Merge pull request #106 from trypostit/feat/per-workspace-pricing

feat: per-workspace pricing, onboarding, and billing overhaul
This commit is contained in:
Paulo Castellano 2026-06-22 14:45:02 -03:00 committed by GitHub
commit 11319cb45a
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
162 changed files with 3328 additions and 2990 deletions

View file

@ -1,69 +0,0 @@
---
name: pennant-development
description: "Use when working with Laravel Pennant the official Laravel feature flag package. Trigger whenever the query mentions Pennant by name or involves feature flags or feature toggles in a Laravel project. Tasks include defining feature flags checking whether features are active creating class based features in `app/Features` using Blade `@feature` directives scoping flags to users or teams building custom Pennant storage drivers protecting routes with feature flags testing feature flags with Pest or PHPUnit and implementing A B testing or gradual rollouts with feature flags. Do not trigger for generic Laravel configuration authorization policies authentication or non Pennant feature management systems."
license: MIT
metadata:
author: laravel
---
# Pennant Features
## Documentation
Use `search-docs` for detailed Pennant patterns and documentation.
## Basic Usage
### Defining Features
<!-- Defining Features -->
```php
use Laravel\Pennant\Feature;
Feature::define('new-dashboard', function (User $user) {
return $user->isAdmin();
});
```
### Checking Features
<!-- Checking Features -->
```php
if (Feature::active('new-dashboard')) {
// Feature is active
}
// With scope
if (Feature::for($user)->active('new-dashboard')) {
// Feature is active for this user
}
```
### Blade Directive
<!-- Blade Directive -->
```blade
@feature('new-dashboard')
<x-new-dashboard />
@else
<x-old-dashboard />
@endfeature
```
### Activating / Deactivating
<!-- Activating Features -->
```php
Feature::activate('new-dashboard');
Feature::for($user)->activate('new-dashboard');
```
## Verification
1. Check feature flag is defined
2. Test with different scopes/users
## Common Pitfalls
- Forgetting to scope features for specific users/entities
- Not following existing naming conventions

View file

@ -193,16 +193,12 @@ STRIPE_SECRET=
STRIPE_WEBHOOK_SECRET=
# Set to false to allow generic signup trial without requiring a card.
REQUIRE_CARD_FOR_TRIAL=true
# Free trial length in days (Stripe Checkout shows one day fewer).
CASHIER_TRIAL_DAYS=8
# Stripe Plan Price IDs (one per plan × interval). Used by PlanSeeder.
STRIPE_STARTER_MONTHLY=
STRIPE_STARTER_YEARLY=
STRIPE_PLUS_MONTHLY=
STRIPE_PLUS_YEARLY=
STRIPE_PRO_MONTHLY=
STRIPE_PRO_YEARLY=
STRIPE_MAX_MONTHLY=
STRIPE_MAX_YEARLY=
STRIPE_WORKSPACE_MONTHLY=
STRIPE_WORKSPACE_YEARLY=
# Laravel Nightwatch (production telemetry — disabled by default in dev)
NIGHTWATCH_ENABLED=false

View file

@ -19,7 +19,6 @@ ## Foundational Context
- laravel/mcp (MCP) - v0
- laravel/nightwatch (NIGHTWATCH) - v1
- laravel/passport (PASSPORT) - v13
- laravel/pennant (PENNANT) - v1
- laravel/prompts (PROMPTS) - v0
- laravel/reverb (REVERB) - v1
- laravel/socialite (SOCIALITE) - v5
@ -241,6 +240,13 @@ ## AI agents (`app/Ai/Agents`)
- Put system/instruction text in Blade under `resources/views/prompts/` (e.g. `prompts.post_content.generator`, `prompts.post_image.regenerator`).
- In `instructions()`, return `view('prompts....', [...])->render()` and pass only the variables the Blade file needs — same pattern as `PostContentStreamer`, `PostContentReviewer`, and `BrandAnalyzer`.
## System AI (always allowed, never metered)
- The brand analyzer / workspace autofill (`App\Services\Brand\BrandAnalyzerRunner`, `App\Actions\Ai\AutofillBrand`, `WorkspaceController::autofillBrand`) is a **system** feature, not the user's AI usage. It runs during workspace creation, before the user has AI access.
- It MUST always be allowed: NEVER gate it behind the `useAi` policy, an active subscription, or a credit check.
- It MUST NOT deduct anything: NEVER call `RecordAiUsage` (or otherwise consume the account's credits) for brand analysis. Cost is the platform's, not the user's.
- Any future "system" AI helper (runs as part of the platform, not on behalf of a workspace's metered quota) follows the same rule: ungated and unmetered.
## Icons (@tabler/icons-vue)
- This project uses `@tabler/icons-vue` for all icons. NEVER use `lucide-vue-next`.

View file

@ -4,7 +4,6 @@
namespace App\Actions\Ai;
use App\Models\Workspace;
use App\Services\Brand\BrandAnalyzerRunner;
use App\Services\Brand\BrandMetadata;
use App\Services\Brand\HomepageMetaExtractor;
@ -20,7 +19,7 @@ public function __construct(
private readonly LogoColorExtractor $logoColors,
) {}
public function __invoke(string $url, ?Workspace $workspace = null, ?string $userId = null): BrandMetadata
public function __invoke(string $url): BrandMetadata
{
$url = $this->fetcher->normalizeUrl($url);
@ -54,7 +53,7 @@ public function __invoke(string $url, ?Workspace $workspace = null, ?string $use
return $metadata;
}
$analysis = $this->analyzer->analyze($this->extractor->extractBodyHtml($html), $workspace, $userId);
$analysis = $this->analyzer->analyze($this->extractor->extractBodyHtml($html));
return $analysis === null
? $metadata

View file

@ -0,0 +1,40 @@
<?php
declare(strict_types=1);
namespace App\Actions\Billing;
use App\Models\Account;
use Inertia\Inertia;
use Symfony\Component\HttpFoundation\Response;
class StartSubscriptionCheckout
{
/**
* Create a Stripe Checkout session for the given price and return an Inertia
* redirect to it. Quantity tracks the account's workspace count; a trial is
* attached when the instance requires a card up front.
*/
public function redirect(Account $account, string $priceId, string $cancelUrl): Response
{
$account->createOrGetStripeCustomer([
'email' => $account->stripeEmail(),
'name' => $account->stripeName(),
]);
$subscription = $account->newSubscription(Account::SUBSCRIPTION_NAME, $priceId)
->quantity(max(1, $account->workspaces()->count()))
->allowPromotionCodes();
if ((bool) config('trypost.billing.require_card_for_trial', true)) {
$subscription->trialDays(config('cashier.trial_days'));
}
$session = $subscription->checkout([
'success_url' => route('app.billing.processing').'?session_id={CHECKOUT_SESSION_ID}',
'cancel_url' => $cancelUrl,
]);
return Inertia::location($session->url);
}
}

View file

@ -14,14 +14,11 @@ class CreateInvite
{
public static function execute(Workspace $workspace, array $data): Invite
{
$role = WorkspaceRole::tryFrom((string) data_get($data, 'role', WorkspaceRole::Member->value))
?? WorkspaceRole::Member;
$invite = Invite::create([
'account_id' => $workspace->account_id,
'invited_by' => auth()->id(),
'email' => data_get($data, 'email'),
'role' => $role,
'role' => WorkspaceRole::from((string) data_get($data, 'role')),
'workspaces' => [$workspace->id],
]);

View file

@ -7,13 +7,12 @@
use App\Enums\SocialAccount\Platform;
use App\Enums\SocialAccount\Status;
use App\Events\TelegramChannelConnected;
use App\Features\SocialAccountLimit;
use App\Events\TelegramConnectFailed;
use App\Models\SocialAccount;
use App\Models\Workspace;
use App\Services\Social\Telegram\TelegramApi;
use Illuminate\Support\Facades\Cache;
use Illuminate\Support\Facades\Http;
use Laravel\Pennant\Feature;
use Throwable;
class ConnectTelegramChannel
@ -30,13 +29,14 @@ public static function execute(Workspace $workspace, array $chat, string $nonce)
$chatId = (string) data_get($chat, 'id');
$username = data_get($chat, 'username');
// Block only brand-new accounts against the plan limit, never reconnects.
$isNewAccount = ! $workspace->socialAccounts()
->where('platform', Platform::Telegram->value)
->where('platform_user_id', $chatId)
->exists();
if ($isNewAccount && self::workspaceAtAccountLimit($workspace)) {
if ($isNewAccount && self::networkAlreadyConnected($workspace, $chatId)) {
TelegramConnectFailed::dispatch($workspace->id, $nonce, 'network_taken');
return null;
}
@ -103,14 +103,15 @@ private static function fetchChannelAvatar(string $chatId): ?string
}
}
private static function workspaceAtAccountLimit(Workspace $workspace): bool
private static function networkAlreadyConnected(Workspace $workspace, string $chatId): bool
{
if (config('trypost.self_hosted')) {
return false;
}
$limit = Feature::for($workspace->account)->value(SocialAccountLimit::class);
return $workspace->socialAccounts()->count() >= $limit;
return $workspace->socialAccounts()
->whereIn('platform', Platform::Telegram->networkPlatformValues())
->where('platform_user_id', '!=', $chatId)
->exists();
}
}

View file

@ -4,6 +4,7 @@
namespace App\Actions\User;
use App\Actions\Workspace\CreateWorkspace;
use App\Enums\Plan\Slug;
use App\Jobs\PostHog\SyncUser;
use App\Models\Account;
@ -29,8 +30,8 @@ public static function execute(array $data, array $utmParameters = []): User
];
if (! $requiresCardForTrial) {
$accountAttributes['plan_id'] = Plan::where('slug', Slug::Starter)->value('id');
$accountAttributes['trial_ends_at'] = now()->addDays(config('cashier.trial_days', 7));
$accountAttributes['plan_id'] = Plan::where('slug', Slug::Workspace)->value('id');
$accountAttributes['trial_ends_at'] = now()->addDays(config('cashier.trial_days'));
}
$account = Account::create($accountAttributes);
@ -48,6 +49,10 @@ public static function execute(array $data, array $utmParameters = []): User
$account->update(['owner_id' => $user->id]);
if (! $isInviteRegistration) {
CreateWorkspace::execute($user, ['name' => data_get($data, 'name')."'s Workspace"]);
}
return $user;
});

View file

@ -7,6 +7,7 @@
use App\Enums\UserWorkspace\Role;
use App\Models\User;
use App\Models\Workspace;
use Illuminate\Support\Facades\DB;
class CreateWorkspace
{
@ -26,16 +27,22 @@ public static function execute(User $user, array $data): Workspace
'content_language' => data_get($data, 'content_language'),
], static fn ($value): bool => $value !== null);
$workspace = Workspace::create([
...$attributes,
'account_id' => $user->account_id,
'user_id' => $user->id,
]);
$workspace = DB::transaction(function () use ($user, $attributes): Workspace {
$workspace = Workspace::create([
...$attributes,
'account_id' => $user->account_id,
'user_id' => $user->id,
]);
// Creator becomes Admin of the workspace they made. The Account Owner
// is resolved separately (via account.owner_id) and outranks this role.
$workspace->members()->attach($user->id, ['role' => Role::Admin->value]);
$user->switchWorkspace($workspace);
// Creator becomes Admin of the workspace they made. The Account Owner
// is resolved separately (via account.owner_id) and outranks this role.
$workspace->members()->attach($user->id, ['role' => Role::Admin->value]);
$user->switchWorkspace($workspace);
return $workspace;
});
$user->account?->syncWorkspaceQuantity();
return $workspace;
}

View file

@ -15,10 +15,13 @@ public static function execute(User $user, Workspace $workspace): void
{
User::where('current_workspace_id', $workspace->id)->update(['current_workspace_id' => null]);
$account = $workspace->account;
$accountId = (string) $workspace->account_id;
$workspace->delete();
$account?->syncWorkspaceQuantity();
if (PostHogService::isEnabled()) {
SyncAccountUsage::dispatch($accountId, null);
}

View file

@ -10,6 +10,7 @@ enum Slug: string
case Plus = 'plus';
case Pro = 'pro';
case Max = 'max';
case Workspace = 'workspace';
public function label(): string
{
@ -18,6 +19,7 @@ public function label(): string
self::Plus => 'Plus',
self::Pro => 'Pro',
self::Max => 'Max',
self::Workspace => 'Workspace',
};
}
}

View file

@ -23,6 +23,35 @@ enum Platform: string
case Telegram = 'telegram';
case Discord = 'discord';
/**
* The social network this platform belongs to. Variants that represent the
* same network (LinkedIn profile vs. company page, Instagram standalone vs.
* Facebook-linked) collapse to one key so a workspace may connect only one
* account per network.
*/
public function network(): string
{
return match ($this) {
self::LinkedIn, self::LinkedInPage => 'linkedin',
self::Instagram, self::InstagramFacebook => 'instagram',
default => $this->value,
};
}
/**
* All platform values that share this platform's network, used to enforce
* the one-account-per-network rule across variants.
*
* @return array<int, string>
*/
public function networkPlatformValues(): array
{
return array_values(array_map(
fn (self $platform): string => $platform->value,
array_filter(self::cases(), fn (self $platform): bool => $platform->network() === $this->network()),
));
}
public function label(): string
{
return match ($this) {

View file

@ -0,0 +1,15 @@
<?php
declare(strict_types=1);
namespace App\Enums\User;
enum Persona: string
{
case Creator = 'creator';
case Freelancer = 'freelancer';
case Startup = 'startup';
case Agency = 'agency';
case SmallBusiness = 'small_business';
case Other = 'other';
}

View file

@ -0,0 +1,53 @@
<?php
declare(strict_types=1);
namespace App\Events;
use Illuminate\Broadcasting\InteractsWithSockets;
use Illuminate\Broadcasting\PrivateChannel;
use Illuminate\Contracts\Broadcasting\ShouldBroadcast;
use Illuminate\Foundation\Events\Dispatchable;
use Illuminate\Queue\SerializesModels;
class TelegramConnectFailed implements ShouldBroadcast
{
use Dispatchable, InteractsWithSockets, SerializesModels;
public function __construct(
public string $workspaceId,
public string $nonce,
public string $reason,
) {}
public function broadcastAs(): string
{
return 'telegram.connect.failed';
}
/**
* @return array<int, PrivateChannel>
*/
public function broadcastOn(): array
{
return [
new PrivateChannel("workspace.{$this->workspaceId}"),
];
}
/**
* @return array<string, string>
*/
public function broadcastWith(): array
{
return [
'nonce' => $this->nonce,
'reason' => $this->reason,
];
}
public function broadcastQueue(): string
{
return 'broadcasts';
}
}

View file

@ -0,0 +1,16 @@
<?php
declare(strict_types=1);
namespace App\Exceptions\SocialAccount;
use App\Enums\SocialAccount\Platform;
use RuntimeException;
class NetworkAlreadyConnectedException extends RuntimeException
{
public function __construct(public readonly Platform $platform)
{
parent::__construct("This workspace already has a {$platform->network()} account connected.");
}
}

View file

@ -1,17 +0,0 @@
<?php
declare(strict_types=1);
namespace App\Features;
use App\Models\Account;
class MemberLimit
{
public string $name = 'member-limit';
public function resolve(Account $scope): int
{
return $scope->plan?->member_limit ?? 1;
}
}

View file

@ -1,17 +0,0 @@
<?php
declare(strict_types=1);
namespace App\Features;
use App\Models\Account;
class MonthlyCreditsLimit
{
public string $name = 'monthly-credits-limit';
public function resolve(Account $scope): int
{
return $scope->plan?->monthly_credits_limit ?? 1000;
}
}

View file

@ -1,17 +0,0 @@
<?php
declare(strict_types=1);
namespace App\Features;
use App\Models\Account;
class SocialAccountLimit
{
public string $name = 'social-account-limit';
public function resolve(Account $scope): int
{
return $scope->plan?->social_account_limit ?? 5;
}
}

View file

@ -1,17 +0,0 @@
<?php
declare(strict_types=1);
namespace App\Features;
use App\Models\Account;
class WorkspaceLimit
{
public string $name = 'workspace-limit';
public function resolve(Account $scope): int
{
return $scope->plan?->workspace_limit ?? 1;
}
}

View file

@ -5,7 +5,6 @@
namespace App\Http\Controllers\App;
use App\Models\Account;
use App\Models\Plan;
use Illuminate\Http\RedirectResponse;
use Illuminate\Http\Request;
use Illuminate\Support\Facades\Cache;
@ -17,67 +16,9 @@
class BillingController extends Controller
{
public function subscribe(Request $request): Response|RedirectResponse
public function subscribe(): RedirectResponse
{
if (config('trypost.self_hosted')) {
return redirect()->route('app.calendar');
}
$account = $request->user()->account;
if ($account && $account->hasActiveSubscription()) {
return redirect()->route('app.billing.index');
}
$requiresCardForTrial = (bool) config('trypost.billing.require_card_for_trial', true);
return Inertia::render('billing/Subscribe', [
'plans' => Plan::active()->orderBy('sort')->get(),
'trialDays' => $requiresCardForTrial ? config('cashier.trial_days') : null,
]);
}
public function checkout(Request $request, Plan $plan): SymfonyResponse|RedirectResponse
{
if (config('trypost.self_hosted')) {
return redirect()->route('app.calendar');
}
$user = $request->user();
$account = $user->account;
abort_unless($user->isAccountOwner(), SymfonyResponse::HTTP_FORBIDDEN);
$request->validate([
'price_id' => ['required', 'string'],
]);
$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(),
'name' => $account->stripeName(),
]);
$subscription = $account->newSubscription(Account::SUBSCRIPTION_NAME, $priceId)
->allowPromotionCodes();
if ((bool) config('trypost.billing.require_card_for_trial', true)) {
$subscription->trialDays(config('cashier.trial_days'));
}
$checkoutSession = $subscription->checkout([
'success_url' => route('app.billing.processing').'?session_id={CHECKOUT_SESSION_ID}',
'cancel_url' => route('app.subscribe'),
]);
return Inertia::location($checkoutSession->url);
return redirect()->route('app.onboarding');
}
public function processing(Request $request): Response|RedirectResponse
@ -99,6 +40,7 @@ public function processing(Request $request): Response|RedirectResponse
return Inertia::render('billing/Processing', [
'subscriptionActive' => $account && $account->subscribed(Account::SUBSCRIPTION_NAME),
'fromCheckout' => $fromCheckout,
'persona' => $request->user()->persona?->value,
'conversion' => $fromCheckout && $account?->stripe_id
? fn () => $this->buildConversionData($account, $sessionId)
: null,
@ -159,7 +101,7 @@ public function index(Request $request): Response|RedirectResponse
'ends_at',
]),
'plan' => $account->plan,
'plans' => Plan::active()->orderBy('sort')->get(),
'workspaceCount' => $account->workspaces()->count(),
'invoices' => $account->invoices()->map(fn ($invoice) => [
'id' => $invoice->id,
'date' => $invoice->date(),
@ -171,7 +113,7 @@ public function index(Request $request): Response|RedirectResponse
]);
}
public function swap(Request $request, Plan $plan): RedirectResponse
public function swapToYearly(Request $request): RedirectResponse
{
if (config('trypost.self_hosted')) {
return redirect()->route('app.calendar');
@ -180,33 +122,31 @@ public function swap(Request $request, Plan $plan): RedirectResponse
$account = $request->user()->account;
abort_unless($request->user()->isAccountOwner(), SymfonyResponse::HTTP_FORBIDDEN);
abort_unless($account->subscribed(Account::SUBSCRIPTION_NAME), 422, 'No active subscription');
abort_unless($account->subscribed(Account::SUBSCRIPTION_NAME), SymfonyResponse::HTTP_UNPROCESSABLE_ENTITY, 'No active subscription');
$request->validate([
'price_id' => ['required', 'string'],
]);
$plan = $account->plan;
$yearlyPriceId = $plan?->stripe_yearly_price_id;
abort_if($yearlyPriceId === null, SymfonyResponse::HTTP_UNPROCESSABLE_ENTITY, 'No annual price configured');
$priceId = $request->input('price_id');
$subscription = $account->subscription(Account::SUBSCRIPTION_NAME);
abort_unless(
$priceId === $plan->stripe_monthly_price_id || $priceId === $plan->stripe_yearly_price_id,
422,
'Invalid price for this plan',
);
abort_if($subscription === null, SymfonyResponse::HTTP_UNPROCESSABLE_ENTITY, 'No active subscription');
$authorization = Gate::inspect('swapPlan', [$account, $plan]);
if ($subscription->stripe_price === $yearlyPriceId) {
return redirect()->route('app.billing.index');
}
$authorization = Gate::inspect('swapPlan', [$account]);
if ($authorization->denied()) {
return back()->with('flash.error', $authorization->message());
}
$subscription->swap($priceId);
$account->update(['plan_id' => $plan->id]);
$account->forgetPlanFeatureCache();
$subscription->swap($yearlyPriceId);
return redirect()->route('app.billing.index')
->with('flash.success', __('billing.flash.plan_changed', ['plan' => $plan->name]));
->with('flash.success', __('billing.flash.switched_to_yearly'));
}
public function portal(Request $request): RedirectResponse

View file

@ -0,0 +1,131 @@
<?php
declare(strict_types=1);
namespace App\Http\Controllers\App;
use App\Actions\Billing\StartSubscriptionCheckout;
use App\Enums\Plan\Slug;
use App\Enums\SocialAccount\Platform as SocialPlatform;
use App\Enums\User\Persona;
use App\Http\Requests\App\Onboarding\StoreOnboardingRequest;
use App\Http\Resources\App\SocialAccountResource;
use App\Models\Account;
use App\Models\Plan;
use App\Services\PostHogService;
use Illuminate\Http\RedirectResponse;
use Illuminate\Http\Request;
use Inertia\Inertia;
use Inertia\Response;
use Symfony\Component\HttpFoundation\Response as SymfonyResponse;
class OnboardingController extends Controller
{
public function index(Request $request): Response|RedirectResponse
{
if (config('trypost.self_hosted')) {
return redirect()->route('app.calendar');
}
$user = $request->user();
if ($user->account?->subscribed(Account::SUBSCRIPTION_NAME)) {
return redirect()->route('app.calendar');
}
return Inertia::render('onboarding/Index', [
'personas' => array_map(fn (Persona $persona): string => $persona->value, Persona::cases()),
'selected' => $user->persona?->value,
]);
}
public function store(StoreOnboardingRequest $request, PostHogService $postHog): RedirectResponse
{
if (config('trypost.self_hosted')) {
return redirect()->route('app.calendar');
}
$user = $request->user();
if ($user->account?->subscribed(Account::SUBSCRIPTION_NAME)) {
return redirect()->route('app.calendar');
}
$persona = (string) $request->validated('persona');
$user->update(['persona' => $persona]);
$postHog->identify($user->id, [
'persona' => $persona,
]);
return redirect()->route('app.onboarding.connect');
}
public function connect(Request $request): Response|RedirectResponse
{
if (config('trypost.self_hosted')) {
return redirect()->route('app.calendar');
}
$user = $request->user();
if ($user->account?->subscribed(Account::SUBSCRIPTION_NAME)) {
return redirect()->route('app.calendar');
}
if (! $user->persona) {
return redirect()->route('app.onboarding');
}
$workspace = $user->currentWorkspace;
if (! $workspace) {
return redirect()->route('app.workspaces.create');
}
$accounts = $workspace->socialAccounts()->orderBy('id')->get();
$platforms = collect(SocialPlatform::enabled())->map(fn (SocialPlatform $platform): array => [
'value' => $platform->value,
'label' => $platform->label(),
'color' => $platform->color(),
'network' => $platform->network(),
])->values();
return Inertia::render('onboarding/Connect', [
'platforms' => $platforms,
'accounts' => SocialAccountResource::collection($accounts)->resolve(),
]);
}
public function checkout(Request $request, StartSubscriptionCheckout $checkout): SymfonyResponse|RedirectResponse
{
if (config('trypost.self_hosted')) {
return redirect()->route('app.calendar');
}
$user = $request->user();
$account = $user->account;
if ($account?->subscribed(Account::SUBSCRIPTION_NAME)) {
return redirect()->route('app.calendar');
}
$workspace = $user->currentWorkspace;
if (! $workspace || ! $workspace->socialAccounts()->exists()) {
return redirect()->route('app.onboarding.connect')
->with('flash.banner', __('onboarding.connect.must_connect'))
->with('flash.bannerStyle', 'danger');
}
$plan = Plan::where('slug', Slug::Workspace)->firstOrFail();
return $checkout->redirect(
$account,
(string) $plan->stripe_monthly_price_id,
route('app.onboarding.connect'),
);
}
}

View file

@ -78,9 +78,6 @@ public function updateLanguage(Request $request): RedirectResponse
'locale' => ['required', 'string', 'in:'.implode(',', array_keys(config('languages.available')))],
]);
session()->flash('flash.banner', __('settings.flash.language_updated'));
session()->flash('flash.bannerStyle', 'success');
return back()->withCookie(
cookie()->forever('locale', $request->locale, '/', config('session.domain'))
);

View file

@ -4,17 +4,12 @@
namespace App\Http\Controllers\App\Settings;
use App\Features\MemberLimit;
use App\Features\MonthlyCreditsLimit;
use App\Features\SocialAccountLimit;
use App\Features\WorkspaceLimit;
use App\Http\Controllers\App\Controller;
use App\Models\AiUsageLog;
use App\Support\BillingCycle;
use Illuminate\Http\RedirectResponse;
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
@ -37,16 +32,12 @@ public function index(Request $request): Response|RedirectResponse
}
return Inertia::render('settings/account/Usage', [
'plan' => $account->plan,
'usage' => [
'workspaceCount' => $account->workspaces()->count(),
'workspaceLimit' => Feature::for($account)->value(WorkspaceLimit::class),
'workspaceCount' => $account->workspaces->count(),
'socialAccountCount' => $totalSocialAccounts,
'socialAccountLimit' => Feature::for($account)->value(SocialAccountLimit::class),
'memberCount' => $totalMembers,
'memberLimit' => Feature::for($account)->value(MemberLimit::class),
'creditsUsed' => AiUsageLog::monthlyCredits($account->id),
'monthlyCreditsLimit' => Feature::for($account)->value(MonthlyCreditsLimit::class),
'creditsUsed' => BillingCycle::for($account)->usedCredits(),
'monthlyCreditsLimit' => BillingCycle::for($account)->creditAllotment(),
],
]);
}

View file

@ -10,17 +10,16 @@
use App\Enums\Workspace\BrandFont;
use App\Enums\Workspace\BrandVoiceTrait;
use App\Enums\Workspace\ImageStyle;
use App\Http\Requests\App\Workspace\AutofillBrandRequest;
use App\Http\Requests\App\Workspace\StoreWorkspaceRequest;
use App\Http\Requests\App\Workspace\UpdateWorkspaceRequest;
use App\Http\Resources\App\WorkspaceMemberResource;
use App\Models\Account;
use App\Models\Workspace;
use App\Services\Brand\LogoAttacher;
use Illuminate\Http\JsonResponse;
use Illuminate\Http\RedirectResponse;
use Illuminate\Http\Request;
use Illuminate\Http\Resources\Json\AnonymousResourceCollection;
use Illuminate\Support\Facades\Gate;
use Illuminate\Support\Facades\Log;
use Inertia\Inertia;
use Inertia\Response;
@ -70,15 +69,13 @@ public function create(Request $request): Response|RedirectResponse
{
$user = $request->user();
if ($user->ownedWorkspacesCount() > 0 && ! $user->account?->hasActiveSubscription()) {
if (! config('trypost.self_hosted')
&& $user->ownedWorkspacesCount() > 0
&& ! $user->account?->hasActiveSubscription()) {
return redirect()->route('app.billing.index')
->with('message', 'Subscribe to create more workspaces.');
}
if ($this->hasReachedWorkspaceLimit($user->account)) {
return back()->with('flash.error', __('workspaces.limit_reached'));
}
return Inertia::render('workspaces/Create', [
'availableFonts' => BrandFont::values(),
'availableImageStyles' => ImageStyle::values(),
@ -86,21 +83,10 @@ public function create(Request $request): Response|RedirectResponse
]);
}
public function autofillBrand(Request $request, AutofillBrand $autofill): JsonResponse
public function autofillBrand(AutofillBrandRequest $request, AutofillBrand $autofill): JsonResponse
{
$validated = $request->validate([
'url' => ['required', 'string', 'max:255'],
]);
$user = $request->user();
$gate = Gate::inspect('useAi', $user->account);
if ($gate->denied()) {
return response()->json(['message' => $gate->message()], SymfonyResponse::HTTP_PAYMENT_REQUIRED);
}
try {
$metadata = $autofill(data_get($validated, 'url'), $user->currentWorkspace, $user->id);
$metadata = $autofill($request->validated('url'));
} catch (RuntimeException $e) {
return response()->json(['message' => $e->getMessage()], SymfonyResponse::HTTP_UNPROCESSABLE_ENTITY);
}
@ -112,10 +98,6 @@ public function store(StoreWorkspaceRequest $request, LogoAttacher $logoAttacher
{
$user = $request->user();
if ($this->hasReachedWorkspaceLimit($user->account)) {
abort(SymfonyResponse::HTTP_FORBIDDEN, __('workspaces.limit_reached'));
}
$validated = $request->validated();
$workspace = CreateWorkspace::execute($user, $validated);
@ -132,7 +114,7 @@ public function store(StoreWorkspaceRequest $request, LogoAttacher $logoAttacher
}
}
return redirect()->route('app.accounts', ['openDialog' => 'true'])
return redirect()->route('app.accounts')
->with('success', __('workspaces.create.success'));
}
@ -235,28 +217,13 @@ public function destroy(Request $request, Workspace $workspace): RedirectRespons
$user = $request->user();
if (! config('trypost.self_hosted') && $workspace->account->workspaces()->count() <= 1) {
return back()->with('flash.error', __('workspaces.cannot_delete_last'));
}
DeleteWorkspace::execute($user, $workspace);
return redirect()->route('app.workspaces.index')
->with('flash.success', __('workspaces.flash.deleted'));
}
/**
* Check whether the account has hit its plan's workspace limit.
* Returns false in self-hosted mode (no plan limits apply).
*/
private function hasReachedWorkspaceLimit(?Account $account): bool
{
if (config('trypost.self_hosted')) {
return false;
}
if (! $account) {
return false;
}
$limit = (int) ($account->plan?->workspace_limit ?? 1);
return $account->workspaces()->count() >= $limit;
}
}

View file

@ -120,6 +120,11 @@ public function removeMember(Request $request, string $userId): RedirectResponse
$this->authorize('manageTeam', $workspace);
// You cannot remove yourself
if ($userId === $request->user()->id) {
return back()->withErrors(['member' => 'You cannot remove yourself.']);
}
// Account owner cannot be removed
if ($userId === $workspace->account?->owner_id) {
return back()->withErrors(['member' => 'Cannot remove the account owner.']);
@ -143,6 +148,11 @@ public function updateRole(Request $request, string $userId): RedirectResponse
$this->authorize('manageTeam', $workspace);
// You cannot change your own role
if ($userId === $request->user()->id) {
return back()->withErrors(['role' => 'You cannot change your own role.']);
}
// Account owner's role cannot be changed
if ($userId === $workspace->account?->owner_id) {
return back()->withErrors(['role' => 'Cannot change the account owner role.']);

View file

@ -4,7 +4,6 @@
namespace App\Http\Controllers\Auth;
use App\Enums\UserWorkspace\Role;
use App\Http\Controllers\Controller;
use App\Models\Invite;
use App\Models\Workspace;
@ -25,7 +24,7 @@ public function show(Invite $invite): Response
$firstWorkspaceId = collect($invite->workspaces ?? [])->first();
$workspace = $firstWorkspaceId ? Workspace::find($firstWorkspaceId) : null;
$role = $invite->role ?? Role::Member;
$role = $invite->role;
return Inertia::render('auth/AcceptInvite', [
'invite' => [
@ -82,7 +81,7 @@ public function accept(Request $request, Invite $invite): RedirectResponse
if ($workspace && $workspace->account_id === $invite->account_id) {
$workspace->members()->syncWithoutDetaching([
$user->id => ['role' => Role::Member->value],
$user->id => ['role' => $invite->role->value],
]);
// Set first workspace as current

View file

@ -30,7 +30,6 @@ public function connect(Request $request): Response|RedirectResponse
}
$this->authorize('manageAccounts', $workspace);
$this->ensureSocialAccountLimit($workspace);
return Inertia::render('accounts/BlueskyConnect', [
'errors' => session('errors')?->getBag('default')?->toArray() ?? [],

View file

@ -6,6 +6,7 @@
use App\Enums\SocialAccount\Platform as SocialPlatform;
use App\Enums\SocialAccount\Status;
use App\Exceptions\SocialAccount\NetworkAlreadyConnectedException;
use App\Models\Workspace;
use Illuminate\Http\RedirectResponse;
use Illuminate\Http\Request;
@ -42,12 +43,10 @@ public function connect(Request $request): Response|RedirectResponse
}
$this->authorize('manageAccounts', $workspace);
$this->ensureSocialAccountLimit($workspace);
session([
'social_connect_workspace' => $workspace->id,
'social_reconnect_id' => null,
'social_connect_onboarding' => $request->boolean('onboarding'),
]);
return Inertia::location(
@ -132,6 +131,8 @@ public function callback(Request $request): View|RedirectResponse
]);
return redirect()->route('app.social.facebook.select-page');
} catch (NetworkAlreadyConnectedException) {
return $this->popupCallback(false, __('accounts.popup_callback.network_taken'), $this->platform->value);
} catch (\Exception $e) {
Log::error('Facebook OAuth Error', [
'error' => $e->getMessage(),
@ -257,6 +258,8 @@ public function select(Request $request): View
session()->forget(['facebook_oauth', 'social_reconnect_id']);
return $this->popupCallback(true, __('accounts.popup_callback.connected'), $this->platform->value);
} catch (NetworkAlreadyConnectedException) {
return $this->popupCallback(false, __('accounts.popup_callback.network_taken'), $this->platform->value);
} catch (\Exception $e) {
Log::error('Facebook page selection error', [
'error' => $e->getMessage(),

View file

@ -6,6 +6,7 @@
use App\Enums\SocialAccount\Platform as SocialPlatform;
use App\Enums\SocialAccount\Status;
use App\Exceptions\SocialAccount\NetworkAlreadyConnectedException;
use App\Models\Workspace;
use Illuminate\Http\RedirectResponse;
use Illuminate\Http\Request;
@ -38,12 +39,10 @@ public function connect(Request $request): Response|RedirectResponse
}
$this->authorize('manageAccounts', $workspace);
$this->ensureSocialAccountLimit($workspace);
session([
'social_connect_workspace' => $workspace->id,
'social_reconnect_id' => null,
'social_connect_onboarding' => $request->boolean('onboarding'),
]);
$url = Socialite::driver($this->driver)
@ -101,6 +100,8 @@ public function callback(Request $request): View
);
return $this->popupCallback(true, __('accounts.popup_callback.connected'), $this->platform->value);
} catch (NetworkAlreadyConnectedException) {
return $this->popupCallback(false, __('accounts.popup_callback.network_taken'), $this->platform->value);
} catch (\Exception $e) {
Log::error('Instagram OAuth Error', [
'error' => $e->getMessage(),

View file

@ -6,6 +6,7 @@
use App\Enums\SocialAccount\Platform as SocialPlatform;
use App\Enums\SocialAccount\Status;
use App\Exceptions\SocialAccount\NetworkAlreadyConnectedException;
use App\Models\Workspace;
use Illuminate\Http\RedirectResponse;
use Illuminate\Http\Request;
@ -44,12 +45,10 @@ public function connect(Request $request): Response|RedirectResponse
}
$this->authorize('manageAccounts', $workspace);
$this->ensureSocialAccountLimit($workspace);
session([
'social_connect_workspace' => $workspace->id,
'social_reconnect_id' => null,
'social_connect_onboarding' => $request->boolean('onboarding'),
]);
$url = Socialite::driver($this->driver)
@ -111,6 +110,8 @@ public function callback(Request $request): View|RedirectResponse
]);
return redirect()->route('app.social.instagram-facebook.select-page');
} catch (NetworkAlreadyConnectedException) {
return $this->popupCallback(false, __('accounts.popup_callback.network_taken'), $this->platform->value);
} catch (\Exception $e) {
Log::error('Instagram via Facebook OAuth Error', [
'error' => $e->getMessage(),
@ -183,6 +184,8 @@ public function select(Request $request): View
session()->forget(['instagram_facebook_oauth', 'social_reconnect_id']);
return $result;
} catch (NetworkAlreadyConnectedException) {
return $this->popupCallback(false, __('accounts.popup_callback.network_taken'), $this->platform->value);
} catch (\Exception $e) {
Log::error('Instagram via Facebook page selection error', ['error' => $e->getMessage()]);
@ -230,9 +233,7 @@ private function connectInstagramAccount(Workspace $workspace, array $pageData,
]),
);
$isOnboarding = session('social_connect_onboarding', false);
return $this->popupCallback(true, __('accounts.popup_callback.connected'), $this->platform->value, $isOnboarding);
return $this->popupCallback(true, __('accounts.popup_callback.connected'), $this->platform->value);
}
private function fetchPagesWithInstagram(string $userToken): array

View file

@ -6,6 +6,7 @@
use App\Enums\SocialAccount\Platform as SocialPlatform;
use App\Enums\SocialAccount\Status;
use App\Exceptions\SocialAccount\NetworkAlreadyConnectedException;
use App\Models\Workspace;
use App\Services\Social\LinkedInTokenSynchronizer;
use Illuminate\Http\RedirectResponse;
@ -83,6 +84,8 @@ public function callback(Request $request): View
app(LinkedInTokenSynchronizer::class)->syncTokens($account);
return $this->popupCallback(true, __('accounts.popup_callback.connected'), $this->platform->value);
} catch (NetworkAlreadyConnectedException $e) {
return $this->popupCallback(false, __('accounts.popup_callback.network_taken'), $this->platform->value);
} catch (\Exception $e) {
Log::error('LinkedIn OAuth Error', [
'error' => $e->getMessage(),

View file

@ -6,6 +6,7 @@
use App\Enums\SocialAccount\Platform as SocialPlatform;
use App\Enums\SocialAccount\Status;
use App\Exceptions\SocialAccount\NetworkAlreadyConnectedException;
use App\Models\Workspace;
use App\Services\Social\LinkedInTokenSynchronizer;
use Illuminate\Http\RedirectResponse;
@ -35,12 +36,10 @@ public function connect(Request $request): SymfonyResponse|RedirectResponse
}
$this->authorize('manageAccounts', $workspace);
$this->ensureSocialAccountLimit($workspace);
session([
'social_connect_workspace' => $workspace->id,
'linkedin_page_reconnect_id' => null,
'social_connect_onboarding' => $request->boolean('onboarding'),
]);
return Inertia::location(
@ -223,6 +222,8 @@ public function select(Request $request): View
session()->forget(['linkedin_page_pending', 'linkedin_page_reconnect_id']);
return $this->popupCallback(true, __('accounts.popup_callback.connected'), $this->platform->value);
} catch (NetworkAlreadyConnectedException) {
return $this->popupCallback(false, __('accounts.popup_callback.network_taken'), $this->platform->value);
} catch (\Exception $e) {
Log::error('LinkedIn Page selection error', [
'error' => $e->getMessage(),

View file

@ -36,7 +36,6 @@ public function connect(Request $request): Response|RedirectResponse
}
$this->authorize('manageAccounts', $workspace);
$this->ensureSocialAccountLimit($workspace);
return Inertia::render('accounts/MastodonConnect', [
'errors' => session('errors')?->getBag('default')?->toArray() ?? [],

View file

@ -8,7 +8,6 @@
use App\Http\Controllers\Auth\Concerns\PreservesUtmParameters;
use App\Http\Controllers\Controller;
use App\Models\User;
use App\Rules\Timezone;
use Illuminate\Auth\Events\Registered;
use Illuminate\Http\RedirectResponse;
use Illuminate\Http\Request;
@ -37,7 +36,6 @@ public function store(Request $request): RedirectResponse
'name' => ['required', 'string', 'max:255'],
'email' => ['required', 'string', 'lowercase', 'email', 'max:255', 'unique:'.User::class],
'password' => ['required', Rules\Password::defaults()],
'timezone' => ['nullable', 'string', new Timezone],
]);
$isInviteRegistration = str_contains($request->input('redirect', ''), '/invites/');
@ -48,7 +46,6 @@ public function store(Request $request): RedirectResponse
'name' => $request->name,
'email' => $request->email,
'password' => $request->password,
'timezone' => $request->input('timezone', 'UTC'),
'is_invite' => $isInviteRegistration,
'registration_ip' => $request->ip(),
], $utmParameters);

View file

@ -8,7 +8,7 @@
use App\Enums\PostPlatform\Status as PostPlatformStatus;
use App\Enums\SocialAccount\Platform as SocialPlatform;
use App\Enums\SocialAccount\Status;
use App\Features\SocialAccountLimit;
use App\Exceptions\SocialAccount\NetworkAlreadyConnectedException;
use App\Http\Controllers\Controller;
use App\Http\Resources\App\SocialAccountResource;
use App\Models\SocialAccount;
@ -19,7 +19,6 @@
use Illuminate\View\View;
use Inertia\Inertia;
use Inertia\Response;
use Laravel\Pennant\Feature;
use Laravel\Socialite\Facades\Socialite;
use Symfony\Component\HttpFoundation\Response as SymfonyResponse;
@ -34,19 +33,6 @@ protected function ensurePlatformEnabled(): void
}
}
protected function ensureSocialAccountLimit(Workspace $workspace): void
{
if (config('trypost.self_hosted')) {
return;
}
$limit = Feature::for($workspace->account)->value(SocialAccountLimit::class);
if ($workspace->socialAccounts()->count() >= $limit) {
abort(SymfonyResponse::HTTP_FORBIDDEN, __('accounts.limit_reached'));
}
}
public function index(Request $request): Response|RedirectResponse
{
$workspace = $request->user()->currentWorkspace;
@ -57,32 +43,19 @@ public function index(Request $request): Response|RedirectResponse
$this->authorize('view', $workspace);
$accounts = $workspace->socialAccounts()
->when(
$request->input('search'),
fn ($query, $search) => $query->where(function ($q) use ($search): void {
$q->where('display_name', 'ilike', "%{$search}%")
->orWhere('username', 'ilike', "%{$search}%")
->orWhere('platform', 'ilike', "%{$search}%");
}),
)
->orderBy('id')
->paginate(config('app.pagination.default'));
$platforms = collect(SocialPlatform::enabled())->map(fn ($platform) => [
'value' => $platform->value,
'label' => $platform->label(),
'color' => $platform->color(),
'network' => $platform->network(),
])->values();
return Inertia::render('accounts/Index', [
'workspace' => $workspace,
'accounts' => Inertia::scroll(fn () => SocialAccountResource::collection($accounts)),
'platforms' => $platforms,
'filters' => [
'search' => $request->input('search', ''),
],
'openDialog' => $request->boolean('openDialog'),
'connectedAccounts' => SocialAccountResource::collection(
$workspace->socialAccounts()->orderBy('id')->get(),
)->resolve(),
]);
}
@ -146,10 +119,7 @@ protected function redirectToProvider(Request $request, string $driver, array $s
return redirect()->route('app.workspaces.create');
}
$this->ensureSocialAccountLimit($workspace);
session(['social_connect_workspace' => $workspace->id]);
session(['social_connect_onboarding' => $request->boolean('onboarding')]);
return Inertia::location(
Socialite::driver($driver)
@ -201,6 +171,8 @@ protected function handleCallback(
);
return $this->popupCallback(true, __('accounts.popup_callback.connected'), $platform->value);
} catch (NetworkAlreadyConnectedException) {
return $this->popupCallback(false, __('accounts.popup_callback.network_taken'), $platform->value);
} catch (\Exception $e) {
Log::error('Social OAuth Error', [
'platform' => $platform->value,
@ -213,12 +185,7 @@ protected function handleCallback(
protected function forgetSocialConnectSession(): void
{
session()->forget(['social_connect_workspace', 'social_connect_onboarding']);
}
protected function getRedirectRoute(): string
{
return session('social_connect_onboarding', false) ? 'onboarding.connect' : 'accounts';
session()->forget('social_connect_workspace');
}
/**

View file

@ -28,7 +28,6 @@ public function connect(Request $request): JsonResponse
abort_if($workspace === null, SymfonyResponse::HTTP_CONFLICT, 'No active workspace.');
$this->authorize('manageAccounts', $workspace);
$this->ensureSocialAccountLimit($workspace);
$expiresAt = now()->addMinutes(15);
$code = TelegramConnectCode::issue($workspace->id, $expiresAt);

View file

@ -6,6 +6,7 @@
use App\Enums\SocialAccount\Platform as SocialPlatform;
use App\Enums\SocialAccount\Status;
use App\Exceptions\SocialAccount\NetworkAlreadyConnectedException;
use App\Models\Workspace;
use Illuminate\Http\RedirectResponse;
use Illuminate\Http\Request;
@ -36,7 +37,6 @@ public function connect(Request $request): Response|RedirectResponse
}
$this->authorize('manageAccounts', $workspace);
$this->ensureSocialAccountLimit($workspace);
session([
'social_connect_workspace' => $workspace->id,
@ -158,6 +158,8 @@ public function callback(Request $request): View
session()->forget(['threads_oauth_state', 'social_reconnect_id']);
return $this->popupCallback(true, __('accounts.popup_callback.connected'), $this->platform->value);
} catch (NetworkAlreadyConnectedException) {
return $this->popupCallback(false, __('accounts.popup_callback.network_taken'), $this->platform->value);
} catch (\Exception $e) {
Log::error('Threads OAuth Error', [
'error' => $e->getMessage(),

View file

@ -6,6 +6,7 @@
use App\Enums\SocialAccount\Platform as SocialPlatform;
use App\Enums\SocialAccount\Status;
use App\Exceptions\SocialAccount\NetworkAlreadyConnectedException;
use App\Models\Workspace;
use Illuminate\Http\RedirectResponse;
use Illuminate\Http\Request;
@ -40,12 +41,10 @@ public function connect(Request $request): Response|RedirectResponse
}
$this->authorize('manageAccounts', $workspace);
$this->ensureSocialAccountLimit($workspace);
session([
'social_connect_workspace' => $workspace->id,
'social_reconnect_id' => null,
'social_connect_onboarding' => $request->boolean('onboarding'),
]);
return $this->redirectToGoogle();
@ -117,6 +116,8 @@ public function callback(Request $request): View|RedirectResponse
]);
return redirect()->route('app.social.youtube.select-channel');
} catch (NetworkAlreadyConnectedException) {
return $this->popupCallback(false, __('accounts.popup_callback.network_taken'), $this->platform->value);
} catch (\Exception $e) {
Log::error('YouTube OAuth Error', [
'error' => $e->getMessage(),
@ -152,14 +153,13 @@ public function selectChannel(Request $request)
$channels = $this->fetchChannels(data_get($oauthData, 'access_token'));
if (empty($channels)) {
$redirectRoute = $this->getRedirectRoute();
$this->forgetSocialConnectSession();
session()->forget('youtube_oauth');
session()->flash('flash.banner', __('accounts.flash.no_youtube_channels'));
session()->flash('flash.bannerStyle', 'danger');
return redirect()->route($redirectRoute);
return redirect()->route('app.accounts');
}
return inertia('accounts/YouTubeChannelSelect', [
@ -251,6 +251,8 @@ public function select(Request $request): View
session()->forget(['youtube_oauth', 'social_reconnect_id']);
return $this->popupCallback(true, __('accounts.popup_callback.connected'), $this->platform->value);
} catch (NetworkAlreadyConnectedException) {
return $this->popupCallback(false, __('accounts.popup_callback.network_taken'), $this->platform->value);
} catch (\Exception $e) {
Log::error('YouTube channel selection error', [
'error' => $e->getMessage(),

View file

@ -32,7 +32,7 @@ public function handle(Request $request, Closure $next): Response
);
if (! $hasAccess) {
return redirect()->route('app.subscribe');
return redirect()->route('app.onboarding');
}
}

View file

@ -8,7 +8,6 @@
use App\Http\Resources\App\HandleInertiaRequests\AuthPlanResource;
use App\Http\Resources\App\HandleInertiaRequests\AuthUserResource;
use App\Http\Resources\App\HandleInertiaRequests\AuthWorkspaceResource;
use App\Models\Plan;
use Illuminate\Http\Request;
use Inertia\Middleware;
@ -48,7 +47,6 @@ public function share(Request $request): array
],
'usage' => $account && ! $isSelfHosted ? $account->usage() : null,
'features' => $account && ! $isSelfHosted ? $account->featureLimits() : null,
'plans' => $isSelfHosted ? [] : Plan::active()->orderBy('sort')->get(),
'sidebarOpen' => ! $request->hasCookie('sidebar_state') || $request->cookie('sidebar_state') === 'true',
'flash' => $request->session()->get('flash', []),
'applicationUrl' => config('app.url'),

View file

@ -28,7 +28,7 @@ public function rules(): array
{
return [
'email' => ['required', 'email', 'max:255'],
'role' => ['nullable', Rule::in(array_column(WorkspaceRole::cases(), 'value'))],
'role' => ['required', Rule::in(array_column(WorkspaceRole::cases(), 'value'))],
];
}

View file

@ -0,0 +1,27 @@
<?php
declare(strict_types=1);
namespace App\Http\Requests\App\Onboarding;
use App\Enums\User\Persona;
use Illuminate\Foundation\Http\FormRequest;
use Illuminate\Validation\Rule;
class StoreOnboardingRequest extends FormRequest
{
public function authorize(): bool
{
return true;
}
/**
* @return array<string, mixed>
*/
public function rules(): array
{
return [
'persona' => ['required', Rule::enum(Persona::class)],
];
}
}

View file

@ -0,0 +1,25 @@
<?php
declare(strict_types=1);
namespace App\Http\Requests\App\Workspace;
use Illuminate\Foundation\Http\FormRequest;
class AutofillBrandRequest extends FormRequest
{
public function authorize(): bool
{
return true;
}
/**
* @return array<string, mixed>
*/
public function rules(): array
{
return [
'url' => ['required', 'string', 'max:255'],
];
}
}

View file

@ -39,7 +39,7 @@ public function handle(PostHogService $postHog): void
return;
}
$account = Account::with('plan')->find($this->accountId);
$account = Account::with(['plan', 'owner'])->find($this->accountId);
if (! $account || ! $account->owner_id) {
return;
@ -53,6 +53,7 @@ public function handle(PostHogService $postHog): void
'plan' => $account->plan?->name,
'plan_slug' => $account->plan?->slug->value,
'previous_plan' => $this->previousPlan,
'persona' => $account->owner?->persona?->value,
],
$account,
);

View file

@ -56,7 +56,6 @@ protected function handleSubscriptionCreated(Account $account, array $payload):
'plan_id' => $plan->id,
'trial_ends_at' => null,
]);
$account->forgetPlanFeatureCache();
}
$this->trackPlanChange($account, BillingEvent::Created, $previousPlan, $payload);
@ -71,7 +70,6 @@ protected function handleSubscriptionUpdated(Account $account, array $payload):
if ($plan = $this->resolvePlanFromSubscriptionItems($payload, $account)) {
$account->update(['plan_id' => $plan->id]);
$account->forgetPlanFeatureCache();
}
$this->trackPlanChange($account, BillingEvent::Updated, $previousPlan, $payload);
@ -89,7 +87,6 @@ protected function handleSubscriptionDeleted(Account $account, array $payload):
$previousPlan = $account->plan?->name;
$account->update(['plan_id' => null]);
$account->forgetPlanFeatureCache();
$this->trackPlanChange($account, BillingEvent::Cancelled, $previousPlan, $payload);
}

View file

@ -4,10 +4,6 @@
namespace App\Models;
use App\Features\MemberLimit;
use App\Features\MonthlyCreditsLimit;
use App\Features\SocialAccountLimit;
use App\Features\WorkspaceLimit;
use App\Models\Traits\HasUsage;
use Carbon\CarbonInterface;
use Database\Factories\AccountFactory;
@ -16,8 +12,9 @@
use Illuminate\Database\Eloquent\Model;
use Illuminate\Database\Eloquent\Relations\BelongsTo;
use Illuminate\Database\Eloquent\Relations\HasMany;
use Illuminate\Support\Facades\Log;
use Laravel\Cashier\Billable;
use Laravel\Pennant\Feature;
use Throwable;
class Account extends Model
{
@ -47,16 +44,6 @@ public static function postsCountCacheKey(string $accountId): string
'trial_ends_at' => 'datetime',
];
public function forgetPlanFeatureCache(): void
{
Feature::for($this)->forget([
WorkspaceLimit::class,
SocialAccountLimit::class,
MemberLimit::class,
MonthlyCreditsLimit::class,
]);
}
public function owner(): BelongsTo
{
return $this->belongsTo(User::class, 'owner_id');
@ -91,6 +78,33 @@ public function hasActiveSubscription(): bool
return $this->subscribed(self::SUBSCRIPTION_NAME);
}
/**
* Align the Stripe subscription quantity with the number of workspaces the
* account owns. Each workspace is a billed unit. No-op in self-hosted mode
* or when there is no active subscription (e.g. during onboarding).
*/
public function syncWorkspaceQuantity(): void
{
if (config('trypost.self_hosted')) {
return;
}
$subscription = $this->subscription(self::SUBSCRIPTION_NAME);
if (! $subscription || ! $subscription->active()) {
return;
}
try {
$subscription->updateQuantity($this->workspaces()->count());
} catch (Throwable $e) {
Log::warning('Failed to sync workspace quantity to Stripe', [
'account_id' => $this->id,
'error' => $e->getMessage(),
]);
}
}
public function isPastDue(): bool
{
if (config('trypost.self_hosted')) {

View file

@ -5,6 +5,7 @@
namespace App\Models;
use App\Enums\Ai\UsageType;
use Carbon\CarbonInterface;
use Database\Factories\AiUsageLogFactory;
use Illuminate\Database\Eloquent\Concerns\HasUuids;
use Illuminate\Database\Eloquent\Factories\HasFactory;
@ -50,10 +51,11 @@ public function account(): BelongsTo
return $this->belongsTo(Account::class);
}
public static function monthlyCredits(string $accountId): int
public static function creditsUsedBetween(string $accountId, CarbonInterface $start, CarbonInterface $end): int
{
return (int) static::where('account_id', $accountId)
->whereBetween('created_at', [now()->startOfMonth(), now()->endOfMonth()])
->where('created_at', '>=', $start)
->where('created_at', '<', $end)
->sum('credits');
}
}

View file

@ -22,9 +22,6 @@ class Plan extends Model
'name',
'stripe_monthly_price_id',
'stripe_yearly_price_id',
'social_account_limit',
'member_limit',
'workspace_limit',
'monthly_credits_limit',
'sort',
'is_archived',
@ -35,9 +32,6 @@ protected function casts(): array
return [
'slug' => Slug::class,
'is_archived' => 'boolean',
'social_account_limit' => 'integer',
'member_limit' => 'integer',
'workspace_limit' => 'integer',
'monthly_credits_limit' => 'integer',
'sort' => 'integer',
];

View file

@ -4,23 +4,18 @@
namespace App\Models\Traits;
use App\Features\MemberLimit;
use App\Features\MonthlyCreditsLimit;
use App\Features\SocialAccountLimit;
use App\Features\WorkspaceLimit;
use App\Models\Account;
use App\Models\AiUsageLog;
use App\Models\Invite;
use App\Models\Post;
use App\Support\BillingCycle;
use Illuminate\Support\Facades\Cache;
use Laravel\Pennant\Feature;
/**
* Provides account-level usage counts and plan-resolved feature limits.
*
* The `featureLimits()` call resolves Pennant features for the account,
* which writes to the features cache table on first access. Pennant cache
* is invalidated automatically when `plan_id` changes (see Account::booted).
* `featureLimits()` resolves the account's per-cycle credit allotment directly
* from BillingCycle, computed fresh from the plan, workspace count, and billing
* interval — no caching, so there is nothing to invalidate.
*/
trait HasUsage
{
@ -49,20 +44,17 @@ public function usage(): array
->whereNull('accepted_at')
->count(),
'postCount' => $this->cachedPostCount($workspaces->pluck('id')->all()),
'creditsUsed' => AiUsageLog::monthlyCredits($this->id),
'creditsUsed' => BillingCycle::for($this)->usedCredits(),
];
}
/**
* @return array{workspaceLimit: int, socialAccountLimit: int, memberLimit: int, monthlyCreditsLimit: int}
* @return array{monthlyCreditsLimit: int}
*/
public function featureLimits(): array
{
return [
'workspaceLimit' => Feature::for($this)->value(WorkspaceLimit::class),
'socialAccountLimit' => Feature::for($this)->value(SocialAccountLimit::class),
'memberLimit' => Feature::for($this)->value(MemberLimit::class),
'monthlyCreditsLimit' => Feature::for($this)->value(MonthlyCreditsLimit::class),
'monthlyCreditsLimit' => BillingCycle::for($this)->creditAllotment(),
];
}

View file

@ -5,6 +5,7 @@
namespace App\Models;
use App\Enums\Notification\Type as NotificationType;
use App\Enums\User\Persona;
use App\Models\Traits\HasMedia;
use App\Models\Traits\HasWorkspace;
use Database\Factories\UserFactory;
@ -42,6 +43,7 @@ class User extends Authenticatable implements MustVerifyEmail, OAuthenticatable
'utm_term',
'utm_content',
'registration_ip',
'persona',
];
/**
@ -75,6 +77,7 @@ protected function casts(): array
'email_verified_at' => 'datetime',
'password' => 'hashed',
'two_factor_confirmed_at' => 'datetime',
'persona' => Persona::class,
];
}

View file

@ -4,12 +4,43 @@
namespace App\Observers;
use App\Enums\SocialAccount\Platform;
use App\Exceptions\SocialAccount\NetworkAlreadyConnectedException;
use App\Jobs\PostHog\SyncAccountUsage;
use App\Models\SocialAccount;
use App\Services\PostHogService;
class SocialAccountObserver
{
/**
* Enforce one connected account per social network per workspace. Variants
* of the same network (LinkedIn profile/page, Instagram standalone/Facebook)
* collapse via Platform::network(). Reconnecting an existing account goes
* through updateOrCreate's update path and never reaches this hook. Bypassed
* in self-hosted mode, which has no per-workspace limits.
*/
public function creating(SocialAccount $socialAccount): void
{
if (config('trypost.self_hosted')) {
return;
}
$platform = $socialAccount->platform;
if (! $platform instanceof Platform) {
return;
}
$conflict = SocialAccount::query()
->where('workspace_id', $socialAccount->workspace_id)
->whereIn('platform', $platform->networkPlatformValues())
->exists();
if ($conflict) {
throw new NetworkAlreadyConnectedException($platform);
}
}
public function created(SocialAccount $socialAccount): void
{
$this->syncUsage($socialAccount);

View file

@ -4,13 +4,10 @@
namespace App\Policies;
use App\Features\MonthlyCreditsLimit;
use App\Models\Account;
use App\Models\AiUsageLog;
use App\Models\Plan;
use App\Models\User;
use App\Support\BillingCycle;
use Illuminate\Auth\Access\Response;
use Laravel\Pennant\Feature;
class AccountPolicy
{
@ -25,9 +22,9 @@ public function manageBilling(User $user, Account $account): bool
}
/**
* Authorize using AI features. Allows when the account still has monthly
* credits remaining. Manual post creation is unaffected — only AI calls
* are gated by this check.
* Authorize using AI features. Requires an active subscription (or trial)
* and remaining monthly credits. Manual post creation is unaffected — only
* AI calls are gated by this check.
*/
public function useAi(User $user, Account $account): Response
{
@ -35,10 +32,19 @@ public function useAi(User $user, Account $account): Response
return Response::allow();
}
$limit = (int) Feature::for($account)->value(MonthlyCreditsLimit::class);
$used = AiUsageLog::monthlyCredits($account->id);
$requiresCardForTrial = (bool) config('trypost.billing.require_card_for_trial', true);
if ($used >= $limit) {
$hasAccess = $account->subscribed(Account::SUBSCRIPTION_NAME)
|| (! $requiresCardForTrial && $account->isOnTrial());
if (! $hasAccess) {
return Response::deny(__('billing.flash.subscription_required'));
}
$cycle = BillingCycle::for($account);
$limit = $cycle->creditAllotment();
if ($cycle->usedCredits() >= $limit) {
return Response::deny(__('billing.flash.credits_exhausted', [
'limit' => (string) $limit,
]));
@ -48,44 +54,16 @@ public function useAi(User $user, Account $account): Response
}
/**
* Authorize swapping the account's subscription to the given plan.
*
* Denies if the account's current usage exceeds any of the target plan's
* limits (workspaces, social accounts, members + pending invites).
* Authorize swapping the account's subscription billing interval. Only the
* account owner may change billing; per-workspace pricing has no plan tiers
* to downgrade between, so there are no usage-based restrictions.
*/
public function swapPlan(User $user, Account $account, Plan $plan): Response
public function swapPlan(User $user, Account $account): Response
{
if ($user->id !== $account->owner_id) {
return Response::deny(__('billing.flash.cannot_manage'));
}
$usage = $account->usage();
$checks = [
'workspaces' => [
'count' => $usage['workspaceCount'],
'limit' => (int) $plan->workspace_limit,
],
'social_accounts' => [
'count' => $usage['socialAccountCount'],
'limit' => (int) $plan->social_account_limit,
],
'members' => [
'count' => $usage['memberCount'] + $usage['pendingInviteCount'],
'limit' => (int) $plan->member_limit,
],
];
foreach ($checks as $type => $data) {
if ($data['count'] > $data['limit']) {
return Response::deny(__('billing.flash.cannot_downgrade.'.$type, [
'plan' => $plan->name,
'count' => (string) $data['count'],
'limit' => (string) $data['limit'],
]));
}
}
return Response::allow();
}
}

View file

@ -5,10 +5,8 @@
namespace App\Policies;
use App\Enums\UserWorkspace\Role;
use App\Features\MemberLimit;
use App\Models\User;
use App\Models\Workspace;
use Laravel\Pennant\Feature;
class WorkspacePolicy
{
@ -68,17 +66,7 @@ public function createPost(User $user, Workspace $workspace): bool
public function inviteMember(User $user, Workspace $workspace): bool
{
if (! $this->isOwnerOrWorkspaceAdmin($user, $workspace)) {
return false;
}
if (config('trypost.self_hosted')) {
return true;
}
$limit = Feature::for($user->account)->value(MemberLimit::class);
return $workspace->members()->count() < $limit;
return $this->isOwnerOrWorkspaceAdmin($user, $workspace);
}
public function manageBilling(User $user, Workspace $workspace): bool

View file

@ -54,7 +54,6 @@
use Laravel\Nightwatch\Facades\Nightwatch;
use Laravel\Nightwatch\Records\CacheEvent;
use Laravel\Passport\Passport;
use Laravel\Pennant\Feature;
use Laravel\Socialite\Facades\Socialite;
use Laravel\Socialite\Two\GoogleProvider;
use PostHog\PostHog;
@ -96,10 +95,6 @@ public function boot(): void
Cashier::useSubscriptionItemModel(SubscriptionItem::class);
Cashier::keepPastDueSubscriptionsActive();
Feature::resolveScopeUsing(fn () => auth()->user()?->account);
Feature::useMorphMap();
Feature::discover();
$this->configurePassport();
}

View file

@ -1,24 +0,0 @@
<?php
declare(strict_types=1);
namespace App\Rules;
use Closure;
use Illuminate\Contracts\Validation\ValidationRule;
use Illuminate\Translation\PotentiallyTranslatedString;
class Timezone implements ValidationRule
{
/**
* @param Closure(string, ?string=): PotentiallyTranslatedString $fail
*/
public function validate(string $attribute, mixed $value, Closure $fail): void
{
try {
new \DateTimeZone($value);
} catch (\Exception) {
$fail('The :attribute must be a valid timezone.');
}
}
}

View file

@ -5,8 +5,6 @@
namespace App\Services\Brand;
use App\Ai\Agents\BrandAnalyzer;
use App\Models\Workspace;
use App\Services\Ai\RecordAiUsage;
use Illuminate\Support\Facades\Log;
use Illuminate\Support\Str;
use League\HTMLToMarkdown\HtmlConverter;
@ -25,7 +23,7 @@ public function isAvailable(): bool
};
}
public function analyze(string $bodyHtml, ?Workspace $workspace = null, ?string $userId = null): ?LlmBrandAnalysis
public function analyze(string $bodyHtml): ?LlmBrandAnalysis
{
$markdown = $this->htmlToMarkdown($bodyHtml);
@ -41,21 +39,6 @@ public function analyze(string $bodyHtml, ?Workspace $workspace = null, ?string
return null;
}
// Brand analysis can run during onboarding before the user has a
// workspace; skip usage tracking in that case (the cost is small and
// one-shot). Other AI flows always carry a workspace.
if ($workspace !== null) {
RecordAiUsage::recordText(
workspace: $workspace,
promptTokens: $response->usage->promptTokens,
completionTokens: $response->usage->completionTokens,
provider: (string) config('ai.default'),
model: (string) config('ai.default_text_model'),
userId: $userId,
metadata: ['agent' => 'brand_analyzer'],
);
}
return LlmBrandAnalysis::fromResponse($response);
}

View file

@ -0,0 +1,141 @@
<?php
declare(strict_types=1);
namespace App\Support;
use App\Models\Account;
use App\Models\AiUsageLog;
use Carbon\CarbonImmutable;
use Laravel\Cashier\Subscription;
/**
* Resolves an account's current AI credit cycle: the allotment it is entitled to
* and the time window usage is measured against. The window follows the Stripe
* billing cycle (monthly or yearly), anchored on the subscription date — so an
* annual subscriber receives twelve months of credits upfront and resets on
* their renewal date, while a monthly subscriber resets each anniversary day.
*/
class BillingCycle
{
/** @var array{0: CarbonImmutable, 1: CarbonImmutable}|null */
private ?array $window = null;
private ?Subscription $subscription = null;
private bool $subscriptionResolved = false;
private function __construct(private readonly Account $account) {}
public static function for(Account $account): self
{
return new self($account);
}
public function intervalMonths(): int
{
$subscription = $this->subscription();
$plan = $this->account->plan;
if ($subscription !== null
&& $plan?->stripe_yearly_price_id !== null
&& $subscription->stripe_price === $plan->stripe_yearly_price_id
) {
return 12;
}
return 1;
}
public function creditAllotment(): int
{
$base = (int) ($this->account->plan?->monthly_credits_limit ?? 0);
$months = $this->onTrial() ? 1 : $this->intervalMonths();
return $base * $this->account->workspaces()->count() * $months;
}
public function usedCredits(): int
{
return AiUsageLog::creditsUsedBetween(
(string) $this->account->id,
$this->periodStart(),
$this->periodEnd(),
);
}
public function periodStart(): CarbonImmutable
{
return $this->window()[0];
}
public function periodEnd(): CarbonImmutable
{
return $this->window()[1];
}
/**
* @return array{0: CarbonImmutable, 1: CarbonImmutable}
*/
private function window(): array
{
return $this->window ??= $this->computeWindow();
}
/**
* @return array{0: CarbonImmutable, 1: CarbonImmutable}
*/
private function computeWindow(): array
{
$subscription = $this->subscription();
if ($subscription !== null && $subscription->onTrial()) {
return [
CarbonImmutable::parse($subscription->created_at),
CarbonImmutable::parse($subscription->trial_ends_at),
];
}
$anchor = $this->anchor();
$now = CarbonImmutable::now();
$step = $this->intervalMonths();
$periods = 0;
while ($anchor->addMonthsNoOverflow(($periods + 1) * $step)->lessThanOrEqualTo($now)) {
$periods++;
}
return [
$anchor->addMonthsNoOverflow($periods * $step),
$anchor->addMonthsNoOverflow(($periods + 1) * $step),
];
}
private function anchor(): CarbonImmutable
{
$subscription = $this->subscription();
$anchor = $subscription?->trial_ends_at
?? $subscription?->created_at
?? $this->account->created_at
?? CarbonImmutable::now();
return CarbonImmutable::parse($anchor);
}
private function onTrial(): bool
{
return (bool) $this->subscription()?->onTrial();
}
private function subscription(): ?Subscription
{
if (! $this->subscriptionResolved) {
$this->subscription = $this->account->subscription(Account::SUBSCRIPTION_NAME);
$this->subscriptionResolved = true;
}
return $this->subscription;
}
}

View file

@ -15,7 +15,6 @@
"mcp-development",
"configure-nightwatch",
"passport-development",
"pennant-development",
"socialite-development",
"wayfinder-development",
"pest-testing",

View file

@ -45,7 +45,6 @@
"laravel/mcp": "^0.6.4",
"laravel/nightwatch": "^1.22",
"laravel/passport": "^13.7",
"laravel/pennant": "^1.23",
"laravel/reverb": "^1.0",
"laravel/socialite": "^5.24",
"laravel/tinker": "^3.0",

78
composer.lock generated
View file

@ -4,7 +4,7 @@
"Read more about it at https://getcomposer.org/doc/01-basic-usage.md#installing-dependencies",
"This file is @generated automatically"
],
"content-hash": "19849eb01cb4d8f93733666b53444c5a",
"content-hash": "f2f0d0ee195cb8181f595e304a6cfce0",
"packages": [
{
"name": "aws/aws-crt-php",
@ -2682,82 +2682,6 @@
},
"time": "2026-04-16T14:00:29+00:00"
},
{
"name": "laravel/pennant",
"version": "v1.23.0",
"source": {
"type": "git",
"url": "https://github.com/laravel/pennant.git",
"reference": "d3d531d0ba640f9d0bd3580990fb205244e956ca"
},
"dist": {
"type": "zip",
"url": "https://api.github.com/repos/laravel/pennant/zipball/d3d531d0ba640f9d0bd3580990fb205244e956ca",
"reference": "d3d531d0ba640f9d0bd3580990fb205244e956ca",
"shasum": ""
},
"require": {
"illuminate/console": "^10.0|^11.0|^12.0|^13.0",
"illuminate/container": "^10.0|^11.0|^12.0|^13.0",
"illuminate/contracts": "^10.0|^11.0|^12.0|^13.0",
"illuminate/database": "^10.0|^11.0|^12.0|^13.0",
"illuminate/queue": "^10.0|^11.0|^12.0|^13.0",
"illuminate/support": "^10.0|^11.0|^12.0|^13.0",
"php": "^8.1",
"symfony/console": "^6.0|^7.0|^8.0",
"symfony/finder": "^6.0|^7.0|^8.0"
},
"require-dev": {
"laravel/octane": "^1.4|^2.0",
"orchestra/testbench": "^8.36|^9.15|^10.8|^11.0",
"phpstan/phpstan": "^1.10"
},
"type": "library",
"extra": {
"laravel": {
"aliases": {
"Feature": "Laravel\\Pennant\\Feature"
},
"providers": [
"Laravel\\Pennant\\PennantServiceProvider"
]
},
"branch-alias": {
"dev-master": "1.x-dev"
}
},
"autoload": {
"files": [
"src/helpers.php"
],
"psr-4": {
"Laravel\\Pennant\\": "src/"
}
},
"notification-url": "https://packagist.org/downloads/",
"license": [
"MIT"
],
"authors": [
{
"name": "Taylor Otwell",
"email": "taylor@laravel.com"
}
],
"description": "A simple, lightweight library for managing feature flags.",
"homepage": "https://github.com/laravel/pennant",
"keywords": [
"feature",
"flags",
"laravel",
"pennant"
],
"support": {
"issues": "https://github.com/laravel/pennant/issues",
"source": "https://github.com/laravel/pennant"
},
"time": "2026-03-19T02:27:39+00:00"
},
{
"name": "laravel/prompts",
"version": "v0.3.18",

View file

@ -25,10 +25,7 @@ public function definition(): array
'name' => fake()->word(),
'stripe_monthly_price_id' => null,
'stripe_yearly_price_id' => null,
'social_account_limit' => fake()->randomElement([3, 10, 25, 100]),
'member_limit' => fake()->randomElement([1, 3, 10, 50]),
'workspace_limit' => fake()->randomElement([1, 3, 10, 50]),
'monthly_credits_limit' => fake()->randomElement([1000, 2000, 5000, 15000]),
'monthly_credits_limit' => 2500,
'sort' => 0,
'is_archived' => false,
];

View file

@ -46,6 +46,7 @@ public function definition(): array
'utm_term' => null,
'utm_content' => null,
'registration_ip' => null,
'persona' => null,
];
}

View file

@ -0,0 +1,32 @@
<?php
declare(strict_types=1);
use Illuminate\Database\Migrations\Migration;
use Illuminate\Database\Schema\Blueprint;
use Illuminate\Support\Facades\Schema;
return new class extends Migration
{
/**
* Run the migrations.
*/
public function up(): void
{
Schema::table('plans', function (Blueprint $table) {
$table->dropColumn(['social_account_limit', 'member_limit', 'workspace_limit']);
});
}
/**
* Reverse the migrations.
*/
public function down(): void
{
Schema::table('plans', function (Blueprint $table) {
$table->integer('social_account_limit')->default(0);
$table->integer('member_limit')->default(0);
$table->integer('workspace_limit')->default(0);
});
}
};

View file

@ -0,0 +1,30 @@
<?php
declare(strict_types=1);
use Illuminate\Database\Migrations\Migration;
use Illuminate\Database\Schema\Blueprint;
use Illuminate\Support\Facades\Schema;
return new class extends Migration
{
/**
* Run the migrations.
*/
public function up(): void
{
Schema::table('users', function (Blueprint $table) {
$table->string('persona')->nullable()->after('registration_ip');
});
}
/**
* Reverse the migrations.
*/
public function down(): void
{
Schema::table('users', function (Blueprint $table) {
$table->dropColumn('persona');
});
}
};

View file

@ -1,15 +1,23 @@
<?php
use Illuminate\Database\Migrations\Migration;
use Illuminate\Database\Schema\Blueprint;
use Illuminate\Support\Facades\Schema;
use Laravel\Pennant\Migrations\PennantMigration;
return new class extends PennantMigration
return new class extends Migration
{
/**
* Run the migrations.
*/
public function up(): void
{
Schema::dropIfExists('features');
}
/**
* Reverse the migrations.
*/
public function down(): void
{
Schema::create('features', function (Blueprint $table) {
$table->id();
@ -21,12 +29,4 @@ public function up(): void
$table->unique(['name', 'scope']);
});
}
/**
* Reverse the migrations.
*/
public function down(): void
{
Schema::dropIfExists('features');
}
};

View file

@ -15,58 +15,16 @@ class PlanSeeder extends Seeder
*/
public function run(): void
{
$plans = [
Plan::updateOrCreate(
['slug' => Slug::Workspace],
[
'slug' => Slug::Starter,
'name' => 'Starter',
'stripe_monthly_price_id' => env('STRIPE_STARTER_MONTHLY'),
'stripe_yearly_price_id' => env('STRIPE_STARTER_YEARLY'),
'social_account_limit' => 5,
'member_limit' => 1,
'workspace_limit' => 1,
'monthly_credits_limit' => 1000,
'name' => 'Workspace',
'stripe_monthly_price_id' => env('STRIPE_WORKSPACE_MONTHLY'),
'stripe_yearly_price_id' => env('STRIPE_WORKSPACE_YEARLY'),
'monthly_credits_limit' => 2500,
'sort' => 1,
'is_archived' => false,
],
[
'slug' => Slug::Plus,
'name' => 'Plus',
'stripe_monthly_price_id' => env('STRIPE_PLUS_MONTHLY'),
'stripe_yearly_price_id' => env('STRIPE_PLUS_YEARLY'),
'social_account_limit' => 10,
'member_limit' => 5,
'workspace_limit' => 5,
'monthly_credits_limit' => 2000,
'sort' => 2,
],
[
'slug' => Slug::Pro,
'name' => 'Pro',
'stripe_monthly_price_id' => env('STRIPE_PRO_MONTHLY'),
'stripe_yearly_price_id' => env('STRIPE_PRO_YEARLY'),
'social_account_limit' => 30,
'member_limit' => 15,
'workspace_limit' => 15,
'monthly_credits_limit' => 5000,
'sort' => 3,
],
[
'slug' => Slug::Max,
'name' => 'Max',
'stripe_monthly_price_id' => env('STRIPE_MAX_MONTHLY'),
'stripe_yearly_price_id' => env('STRIPE_MAX_YEARLY'),
'social_account_limit' => 100,
'member_limit' => 20,
'workspace_limit' => 50,
'monthly_credits_limit' => 15000,
'sort' => 4,
],
];
foreach ($plans as $plan) {
Plan::updateOrCreate(
['slug' => $plan['slug']],
$plan,
);
}
);
}
}

View file

@ -5,7 +5,6 @@
namespace Database\Seeders;
use App\Actions\User\CreateUser;
use App\Actions\Workspace\CreateWorkspace;
use App\Models\User;
use Illuminate\Database\Seeder;
@ -19,16 +18,13 @@ public function run(): void
return;
}
$user = CreateUser::execute([
CreateUser::execute([
'name' => 'Admin',
'email' => 'admin@trypost.it',
'password' => 'password',
'email_verified_at' => now(),
'timezone' => 'UTC',
]);
CreateWorkspace::execute($user, ['name' => 'My Workspace']);
$this->command->info('Admin account created — change the password on first login:');
$this->command->line(' email: admin@trypost.it');
$this->command->line(' password: password');

View file

@ -4,40 +4,16 @@
'title' => 'Connections',
'page_title' => 'Social Accounts',
'description' => 'Overview of all your connected social accounts',
'add_social' => 'Add Social',
'add_social_title' => 'Connect a Social Account',
'add_social_description' => 'Connect a social account to TryPost to start posting',
'connect_cta' => 'Connect',
'no_accounts' => 'No accounts connected yet',
'no_accounts_description' => 'Connect your social networks to start scheduling and publishing posts',
'no_search_results' => 'No accounts match your search',
'try_different_search' => 'Try a different keyword or clear the search.',
'search' => 'Search accounts...',
'added' => 'Added :date',
'limit_reached' => 'You have reached your plan limit for social accounts.',
'not_connected' => 'Not connected',
'connect' => 'Connect',
'connection_lost' => 'Connection lost',
'reconnect' => 'Reconnect',
'reconnect_account' => 'Reconnect account',
'view_profile' => 'View profile',
'disconnect' => 'Disconnect',
'table' => [
'account' => 'Account',
'platform' => 'Platform',
'status' => 'Status',
'last_used' => 'Last used',
'added' => 'Added',
'active' => 'Active',
],
'never_used' => 'Never used',
'status' => [
'connected' => 'Connected',
'disconnected' => 'Disconnected',
],
'descriptions' => [
'linkedin' => 'Connect your LinkedIn personal profile',
'linkedin-page' => 'Connect a LinkedIn company page',
@ -98,6 +74,7 @@
'new_code' => 'Generate a new code',
'retry' => 'Try again',
'error_generic' => 'Could not start the connection. Please try again.',
'network_taken' => 'This workspace already has a Telegram channel connected. Disconnect it first.',
],
'facebook' => [
@ -142,6 +119,7 @@
'connected' => 'Account connected!',
'reconnected' => 'Account reconnected!',
'error_connecting' => 'Error connecting account. Please try again.',
'network_taken' => 'This workspace already has an account for this network. Disconnect it first.',
'error_connecting_page' => 'Error connecting page. Please try again.',
'error_connecting_channel' => 'Error connecting channel. Please try again.',
'session_expired' => 'Session expired. Please try again.',

View file

@ -52,6 +52,7 @@
],
'or_continue_with' => 'Or continue with',
'or_continue_with_email' => 'Or continue with email',
'google_login' => 'Log in with Google',
'google_signup' => 'Sign up with Google',
'github_login' => 'Log in with GitHub',
@ -78,9 +79,10 @@
],
'register' => [
'title' => 'Create an account',
'description' => 'Enter your details below to create your account',
'title' => 'Your whole social calendar, in one place',
'description' => 'Create your account and start scheduling posts across every network.',
'page_title' => 'Register',
'signup_with_email' => 'Sign up with email',
'name' => 'Name',
'name_placeholder' => 'Full name',
'email' => 'Email address',

View file

@ -9,68 +9,26 @@
'cta' => 'Update payment',
],
'upgrade_dialog' => [
'title' => 'Upgrade your plan',
'description' => 'Pick a plan that fits your needs.',
'current_plan' => 'Current plan',
'current_short' => 'Current',
'current_badge' => 'Current',
'subscribe' => 'Subscribe',
'switch' => 'Switch to this plan',
'switch_short' => 'Switch',
'switch_to_yearly' => 'Switch to yearly',
'switch_to_monthly' => 'Switch to monthly',
'unavailable' => 'Unavailable',
'reasons' => [
'workspace_limit' => 'You\'ve reached the workspace limit on your current plan. Upgrade to create more workspaces.',
'social_account_limit' => 'You\'ve reached the social account limit on your current plan. Upgrade to connect more accounts.',
'member_limit' => 'You\'ve reached the team member limit on your current plan. Upgrade to invite more people.',
],
'annual_banner' => [
'title' => 'Get 2 months free',
'description' => 'Switch to annual billing and pay less every month — same plan, nothing else changes.',
'cta' => 'Upgrade to annual',
],
'subscribe' => [
'page_title' => 'Choose your plan',
'eyebrow' => 'Pricing',
'title' => 'Choose the right plan for you',
'description' => 'Pick the plan that fits you. Billed monthly or annually.',
'trial_info' => ':days-day free trial, then billed automatically',
'monthly' => 'Monthly',
'yearly' => 'Yearly',
'per_month' => 'monthly',
'per_year' => 'yearly',
'billed_monthly' => 'Billed monthly',
'billed_yearly' => 'Billed annually',
'features_included' => "What's included:",
'everything_in' => 'Everything in :plan, plus:',
'save_months' => '2 months free',
'popular' => 'Most popular',
'start_trial' => 'Start :days-day free trial',
'subscribe_cta' => 'Subscribe',
'prices' => [
'starter' => ['monthly' => '$19', 'yearly_per_month' => '$16', 'yearly' => '$190'],
'plus' => ['monthly' => '$29', 'yearly_per_month' => '$24', 'yearly' => '$290'],
'pro' => ['monthly' => '$49', 'yearly_per_month' => '$41', 'yearly' => '$490'],
'max' => ['monthly' => '$99', 'yearly_per_month' => '$83', 'yearly' => '$990'],
],
'features' => [
'social_accounts' => ':count social accounts',
'workspaces' => ':count workspaces',
'members' => ':count team members',
'credits' => ':count AI credits/mo',
],
'credit_tooltips' => [
'starter' => 'Roughly 150 medium-length posts plus 5 AI images per month.',
'plus' => 'Roughly 300 medium-length posts plus 10 AI images per month.',
'pro' => 'Roughly 700 medium-length posts plus 30 AI images per month.',
'max' => 'Roughly 2,000 medium-length posts plus 100 AI images per month.',
'workspace' => ['monthly' => '$12', 'yearly_per_month' => '$10', 'yearly' => '$120'],
],
],
'plan' => [
'title' => 'Plan',
'description' => 'Manage your subscription plan.',
'change' => 'Change plan',
'label' => 'Plan',
'workspaces' => '{1}:count workspace|[2,*]:count workspaces',
'per_workspace' => 'per workspace',
'price' => 'Price',
'month' => 'month',
'trial' => 'Trial',
@ -99,13 +57,10 @@
'flash' => [
'plan_changed' => 'You are now on the :plan plan.',
'switched_to_yearly' => 'You\'re now on annual billing.',
'cannot_manage' => 'Only the account owner can manage billing.',
'cannot_downgrade' => [
'workspaces' => 'Cannot switch to :plan: you have :count workspaces but the plan only allows :limit.',
'social_accounts' => 'Cannot switch to :plan: you have :count social accounts but the plan only allows :limit.',
'members' => 'Cannot switch to :plan: you have :count team members (including invites) but the plan only allows :limit.',
],
'credits_exhausted' => 'Out of AI credits — your monthly :limit allowance has been used. Upgrade your plan or wait until next month.',
'subscription_required' => 'An active subscription is required to use AI features.',
],
'processing' => [

View file

@ -4,11 +4,6 @@
return [
'discord' => [
'label' => 'Discord',
'join' => 'Join our Discord community',
],
'back' => 'Back',
'confirm_modal' => [

22
lang/en/onboarding.php Normal file
View file

@ -0,0 +1,22 @@
<?php
declare(strict_types=1);
return [
'title' => 'Welcome to TryPost',
'description' => 'Tell us what best describes you so we can tailor your experience.',
'continue' => 'Continue',
'personas' => [
'creator' => 'Creator',
'freelancer' => 'Freelancer',
'startup' => 'Startup',
'agency' => 'Agency',
'small_business' => 'Small business',
'other' => 'Other',
],
'connect' => [
'title' => 'Connect your first network',
'description' => 'Link at least one social account to start scheduling. You can add more anytime.',
'must_connect' => 'Connect at least one network to continue.',
],
];

View file

@ -225,8 +225,7 @@
'cancel' => 'Cancel',
'remove' => 'Remove',
'make_admin' => 'Make admin',
'make_member' => 'Make member',
'make_role' => 'Make :role',
'invite' => [
'title' => 'Invite Member',
@ -300,7 +299,6 @@
'flash' => [
'account_updated' => 'Account updated successfully!',
'profile_updated' => 'Profile updated successfully!',
'language_updated' => 'Language updated successfully!',
'password_updated' => 'Password updated successfully!',
'workspace_updated' => 'Settings updated successfully!',
'photo_updated' => 'Photo updated successfully!',

View file

@ -4,7 +4,7 @@
'title' => 'Usage',
'section_account' => 'Account',
'section_account_description' => 'Quotas and limits for your :plan plan.',
'section_account_description' => 'Quotas and limits for your plan.',
'section_ai' => 'AI Credits',
'section_ai_description' => 'Credits are debited as AI features are used. They reset on the first of every month.',

View file

@ -42,7 +42,7 @@
'success' => 'Workspace created. Connect a social account to start posting.',
],
'limit_reached' => 'You have reached your plan limit for workspaces.',
'cannot_delete_last' => 'You cannot delete your only workspace. Cancel your subscription in billing settings to close your account.',
'flash' => [
'deleted' => 'Workspace deleted successfully.',

View file

@ -4,40 +4,16 @@
'title' => 'Conexiones',
'page_title' => 'Cuentas Sociales',
'description' => 'Resumen de todas tus cuentas sociales conectadas',
'add_social' => 'Agregar Red Social',
'add_social_title' => 'Conectar una Cuenta Social',
'add_social_description' => 'Conecta una cuenta social a TryPost para empezar a publicar',
'connect_cta' => 'Conectar',
'no_accounts' => 'No hay cuentas conectadas todavía',
'no_accounts_description' => 'Conecta tus redes sociales para empezar a programar y publicar posts',
'no_search_results' => 'Ninguna cuenta coincide con tu búsqueda',
'try_different_search' => 'Prueba otra palabra clave o limpia la búsqueda.',
'search' => 'Buscar cuentas...',
'added' => 'Agregada :date',
'limit_reached' => 'Has alcanzado el límite de cuentas sociales de tu plan.',
'not_connected' => 'No conectado',
'connect' => 'Conectar',
'connection_lost' => 'Conexión perdida',
'reconnect' => 'Reconectar',
'reconnect_account' => 'Reconectar cuenta',
'view_profile' => 'Ver perfil',
'disconnect' => 'Desconectar',
'table' => [
'account' => 'Cuenta',
'platform' => 'Plataforma',
'status' => 'Estado',
'last_used' => 'Último uso',
'added' => 'Añadida',
'active' => 'Activa',
],
'never_used' => 'Nunca usada',
'status' => [
'connected' => 'Conectada',
'disconnected' => 'Desconectada',
],
'descriptions' => [
'linkedin' => 'Conecta tu perfil personal de LinkedIn',
'linkedin-page' => 'Conecta una página de empresa de LinkedIn',
@ -98,6 +74,7 @@
'new_code' => 'Generar un nuevo código',
'retry' => 'Reintentar',
'error_generic' => 'No se pudo iniciar la conexión. Inténtalo de nuevo.',
'network_taken' => 'Este workspace ya tiene un canal de Telegram conectado. Desconéctalo primero.',
],
'facebook' => [
@ -142,6 +119,7 @@
'connected' => '¡Cuenta conectada!',
'reconnected' => '¡Cuenta reconectada!',
'error_connecting' => 'Error al conectar la cuenta. Inténtalo de nuevo.',
'network_taken' => 'Este workspace ya tiene una cuenta para esta red. Desconéctala primero.',
'error_connecting_page' => 'Error al conectar la página. Inténtalo de nuevo.',
'error_connecting_channel' => 'Error al conectar el canal. Inténtalo de nuevo.',
'session_expired' => 'Sesión expirada. Inténtalo de nuevo.',

View file

@ -40,6 +40,7 @@
],
'or_continue_with' => 'O continuar con',
'or_continue_with_email' => 'O continúa con correo',
'google_login' => 'Iniciar sesión con Google',
'google_signup' => 'Registrarse con Google',
'github_login' => 'Iniciar sesión con GitHub',
@ -66,9 +67,10 @@
],
'register' => [
'title' => 'Crear una cuenta',
'description' => 'Introduce tus datos para crear tu cuenta',
'title' => 'Todo tu calendario social en un solo lugar',
'description' => 'Crea tu cuenta y empieza a programar publicaciones en todas las redes.',
'page_title' => 'Registro',
'signup_with_email' => 'Registrarse con correo',
'name' => 'Nombre',
'name_placeholder' => 'Nombre completo',
'email' => 'Correo electrónico',

View file

@ -9,68 +9,26 @@
'cta' => 'Actualizar pago',
],
'upgrade_dialog' => [
'title' => 'Actualiza tu plan',
'description' => 'Elige un plan que se adapte a tus necesidades.',
'current_plan' => 'Plan actual',
'current_short' => 'Actual',
'current_badge' => 'Actual',
'subscribe' => 'Suscribirse',
'switch' => 'Cambiar a este plan',
'switch_short' => 'Cambiar',
'switch_to_yearly' => 'Cambiar a anual',
'switch_to_monthly' => 'Cambiar a mensual',
'unavailable' => 'No disponible',
'reasons' => [
'workspace_limit' => 'Has alcanzado el límite de workspaces de tu plan. Actualiza para crear más.',
'social_account_limit' => 'Has alcanzado el límite de cuentas sociales de tu plan. Actualiza para conectar más.',
'member_limit' => 'Has alcanzado el límite de miembros de tu plan. Actualiza para invitar a más personas.',
],
'annual_banner' => [
'title' => 'Consigue 2 meses gratis',
'description' => 'Cambia a la facturación anual y paga menos al mes — el mismo plan, sin cambios.',
'cta' => 'Cambiar a anual',
],
'subscribe' => [
'page_title' => 'Elige tu plan',
'eyebrow' => 'Precios',
'title' => 'Elige el plan ideal para ti',
'description' => 'Elige el plan que te queda. Facturación mensual o anual.',
'trial_info' => 'Prueba gratuita de :days días, luego se cobra automáticamente',
'monthly' => 'Mensual',
'yearly' => 'Anual',
'per_month' => 'mensual',
'per_year' => 'anual',
'billed_monthly' => 'Facturado mensualmente',
'billed_yearly' => 'Facturado anualmente',
'features_included' => 'Qué incluye:',
'everything_in' => 'Todo lo de :plan, más:',
'save_months' => '2 meses gratis',
'popular' => 'Más popular',
'start_trial' => 'Comenzar prueba de :days días',
'subscribe_cta' => 'Suscribirse',
'prices' => [
'starter' => ['monthly' => '$19', 'yearly_per_month' => '$16', 'yearly' => '$190'],
'plus' => ['monthly' => '$29', 'yearly_per_month' => '$24', 'yearly' => '$290'],
'pro' => ['monthly' => '$49', 'yearly_per_month' => '$41', 'yearly' => '$490'],
'max' => ['monthly' => '$99', 'yearly_per_month' => '$83', 'yearly' => '$990'],
],
'features' => [
'social_accounts' => ':count cuentas sociales',
'workspaces' => ':count workspaces',
'members' => ':count miembros del equipo',
'credits' => ':count créditos IA/mes',
],
'credit_tooltips' => [
'starter' => 'En promedio 150 posts de largo medio + 5 imágenes IA por mes.',
'plus' => 'En promedio 300 posts de largo medio + 10 imágenes IA por mes.',
'pro' => 'En promedio 700 posts de largo medio + 30 imágenes IA por mes.',
'max' => 'En promedio 2.000 posts de largo medio + 100 imágenes IA por mes.',
'workspace' => ['monthly' => '$12', 'yearly_per_month' => '$10', 'yearly' => '$120'],
],
],
'plan' => [
'title' => 'Plan',
'description' => 'Gestiona tu plan de suscripción.',
'change' => 'Cambiar plan',
'label' => 'Plan',
'workspaces' => '{1}:count workspace|[2,*]:count workspaces',
'per_workspace' => 'por workspace',
'price' => 'Precio',
'month' => 'mes',
'trial' => 'Prueba',
@ -99,13 +57,10 @@
'flash' => [
'plan_changed' => 'Ahora estás en el plan :plan.',
'switched_to_yearly' => 'Ahora tienes facturación anual.',
'cannot_manage' => 'Solo el propietario de la cuenta puede gestionar la facturación.',
'cannot_downgrade' => [
'workspaces' => 'No puedes cambiar a :plan: tienes :count workspaces pero el plan solo permite :limit.',
'social_accounts' => 'No puedes cambiar a :plan: tienes :count cuentas sociales pero el plan solo permite :limit.',
'members' => 'No puedes cambiar a :plan: tienes :count miembros (incluyendo invitaciones) pero el plan solo permite :limit.',
],
'credits_exhausted' => 'Sin créditos de IA — has usado tus :limit créditos mensuales. Mejora tu plan o espera hasta el próximo mes.',
'subscription_required' => 'Se requiere una suscripción activa para usar las funciones de IA.',
],
'processing' => [

View file

@ -4,11 +4,6 @@
return [
'discord' => [
'label' => 'Discord',
'join' => 'Unite a nuestra comunidad en Discord',
],
'back' => 'Volver',
'confirm_modal' => [

22
lang/es/onboarding.php Normal file
View file

@ -0,0 +1,22 @@
<?php
declare(strict_types=1);
return [
'title' => 'Bienvenido a TryPost',
'description' => 'Cuéntanos qué te describe mejor para personalizar tu experiencia.',
'continue' => 'Continuar',
'personas' => [
'creator' => 'Creador',
'freelancer' => 'Freelancer',
'startup' => 'Startup',
'agency' => 'Agencia',
'small_business' => 'Pequeña empresa',
'other' => 'Otro',
],
'connect' => [
'title' => 'Conecta tu primera red',
'description' => 'Vincula al menos una cuenta social para empezar a programar. Puedes añadir más cuando quieras.',
'must_connect' => 'Conecta al menos una red para continuar.',
],
];

View file

@ -225,8 +225,7 @@
'cancel' => 'Cancelar',
'remove' => 'Eliminar',
'make_admin' => 'Hacer administrador',
'make_member' => 'Hacer miembro',
'make_role' => 'Hacer :role',
'invite' => [
'title' => 'Invitar miembro',
@ -300,7 +299,6 @@
'flash' => [
'account_updated' => '¡Cuenta actualizada correctamente!',
'profile_updated' => '¡Perfil actualizado correctamente!',
'language_updated' => '¡Idioma actualizado correctamente!',
'password_updated' => '¡Contraseña actualizada correctamente!',
'workspace_updated' => '¡Configuración actualizada correctamente!',
'photo_updated' => '¡Foto actualizada correctamente!',

View file

@ -4,7 +4,7 @@
'title' => 'Uso',
'section_account' => 'Cuenta',
'section_account_description' => 'Cuotas y límites de tu plan :plan.',
'section_account_description' => 'Cuotas y límites de tu plan.',
'section_ai' => 'Créditos AI',
'section_ai_description' => 'Los créditos se debitan a medida que usas las funciones de AI. Se renuevan el día 1 de cada mes.',

View file

@ -42,7 +42,7 @@
'success' => 'Workspace creado. Conecta una cuenta social para empezar a publicar.',
],
'limit_reached' => 'Has alcanzado el límite de workspaces de tu plan.',
'cannot_delete_last' => 'No puedes eliminar tu único workspace. Cancela tu suscripción en la configuración de facturación para cerrar tu cuenta.',
'flash' => [
'deleted' => 'Workspace eliminado correctamente.',

View file

@ -4,40 +4,16 @@
'title' => 'Conexões',
'page_title' => 'Contas Sociais',
'description' => 'Visão geral de todas as suas contas sociais conectadas',
'add_social' => 'Adicionar Rede Social',
'add_social_title' => 'Conectar uma Conta Social',
'add_social_description' => 'Conecte uma conta social ao TryPost para começar a publicar',
'connect_cta' => 'Conectar',
'no_accounts' => 'Nenhuma conta conectada ainda',
'no_accounts_description' => 'Conecte suas redes sociais para começar a agendar e publicar posts',
'no_search_results' => 'Nenhuma conta corresponde à sua busca',
'try_different_search' => 'Tente outra palavra-chave ou limpe a busca.',
'search' => 'Buscar contas...',
'added' => 'Adicionada :date',
'limit_reached' => 'Você atingiu o limite de contas sociais do seu plano.',
'not_connected' => 'Não conectado',
'connect' => 'Conectar',
'connection_lost' => 'Conexão perdida',
'reconnect' => 'Reconectar',
'reconnect_account' => 'Reconectar conta',
'view_profile' => 'Ver perfil',
'disconnect' => 'Desconectar',
'table' => [
'account' => 'Conta',
'platform' => 'Plataforma',
'status' => 'Status',
'last_used' => 'Último uso',
'added' => 'Adicionada',
'active' => 'Ativa',
],
'never_used' => 'Nunca usada',
'status' => [
'connected' => 'Conectada',
'disconnected' => 'Desconectada',
],
'descriptions' => [
'linkedin' => 'Conecte seu perfil pessoal do LinkedIn',
'linkedin-page' => 'Conecte uma página de empresa do LinkedIn',
@ -98,6 +74,7 @@
'new_code' => 'Gerar um novo código',
'retry' => 'Tentar novamente',
'error_generic' => 'Não foi possível iniciar a conexão. Tente novamente.',
'network_taken' => 'Este workspace já tem um canal de Telegram conectado. Desconecte-o primeiro.',
],
'facebook' => [
@ -142,6 +119,7 @@
'connected' => 'Conta conectada!',
'reconnected' => 'Conta reconectada!',
'error_connecting' => 'Erro ao conectar conta. Por favor, tente novamente.',
'network_taken' => 'Este workspace já tem uma conta para esta rede. Desconecte-a primeiro.',
'error_connecting_page' => 'Erro ao conectar página. Por favor, tente novamente.',
'error_connecting_channel' => 'Erro ao conectar canal. Por favor, tente novamente.',
'session_expired' => 'Sessão expirada. Por favor, tente novamente.',

View file

@ -52,6 +52,7 @@
],
'or_continue_with' => 'Ou continue com',
'or_continue_with_email' => 'Ou continue com e-mail',
'google_login' => 'Entrar com Google',
'google_signup' => 'Cadastrar com Google',
'github_login' => 'Entrar com GitHub',
@ -78,9 +79,10 @@
],
'register' => [
'title' => 'Criar uma conta',
'description' => 'Digite seus dados abaixo para criar sua conta',
'title' => 'Todo o seu calendário social num só lugar',
'description' => 'Crie sua conta e comece a agendar posts em todas as redes.',
'page_title' => 'Cadastro',
'signup_with_email' => 'Cadastrar com e-mail',
'name' => 'Nome',
'name_placeholder' => 'Nome completo',
'email' => 'Endereço de email',

View file

@ -9,68 +9,26 @@
'cta' => 'Atualizar pagamento',
],
'upgrade_dialog' => [
'title' => 'Faça upgrade do seu plano',
'description' => 'Escolha um plano que se encaixe nas suas necessidades.',
'current_plan' => 'Plano atual',
'current_short' => 'Atual',
'current_badge' => 'Atual',
'subscribe' => 'Assinar',
'switch' => 'Mudar para este plano',
'switch_short' => 'Mudar',
'switch_to_yearly' => 'Mudar para anual',
'switch_to_monthly' => 'Mudar para mensal',
'unavailable' => 'Indisponível',
'reasons' => [
'workspace_limit' => 'Você atingiu o limite de workspaces do seu plano. Faça upgrade pra criar mais.',
'social_account_limit' => 'Você atingiu o limite de contas sociais do seu plano. Faça upgrade pra conectar mais.',
'member_limit' => 'Você atingiu o limite de membros do seu plano. Faça upgrade pra convidar mais pessoas.',
],
'annual_banner' => [
'title' => 'Ganhe 2 meses grátis',
'description' => 'Mude para a cobrança anual e pague menos por mês — sem mudar nada do seu plano.',
'cta' => 'Mudar para anual',
],
'subscribe' => [
'page_title' => 'Escolha seu plano',
'eyebrow' => 'Preços',
'title' => 'Escolha o plano ideal pra você',
'description' => 'Escolha o plano que combina com você. Cobrança mensal ou anual.',
'trial_info' => ':days dias grátis, depois cobrança automática',
'monthly' => 'Mensal',
'yearly' => 'Anual',
'per_month' => 'mensal',
'per_year' => 'anual',
'billed_monthly' => 'Cobrança mensal',
'billed_yearly' => 'Cobrança anual',
'features_included' => 'O que está incluído:',
'everything_in' => 'Tudo do :plan, mais:',
'save_months' => '2 meses grátis',
'popular' => 'Mais popular',
'start_trial' => 'Iniciar teste de :days dias',
'subscribe_cta' => 'Assinar',
'prices' => [
'starter' => ['monthly' => 'R$ 95', 'yearly_per_month' => 'R$ 79', 'yearly' => 'R$ 950'],
'plus' => ['monthly' => 'R$ 145', 'yearly_per_month' => 'R$ 121', 'yearly' => 'R$ 1450'],
'pro' => ['monthly' => 'R$ 245', 'yearly_per_month' => 'R$ 204', 'yearly' => 'R$ 2450'],
'max' => ['monthly' => 'R$ 495', 'yearly_per_month' => 'R$ 413', 'yearly' => 'R$ 4950'],
],
'features' => [
'social_accounts' => ':count contas sociais',
'workspaces' => ':count workspaces',
'members' => ':count membros da equipe',
'credits' => ':count créditos IA/mês',
],
'credit_tooltips' => [
'starter' => 'Em média 150 posts de tamanho médio + 5 imagens de IA por mês.',
'plus' => 'Em média 300 posts de tamanho médio + 10 imagens de IA por mês.',
'pro' => 'Em média 700 posts de tamanho médio + 30 imagens de IA por mês.',
'max' => 'Em média 2.000 posts de tamanho médio + 100 imagens de IA por mês.',
'workspace' => ['monthly' => 'R$ 60', 'yearly_per_month' => 'R$ 50', 'yearly' => 'R$ 600'],
],
],
'plan' => [
'title' => 'Plano',
'description' => 'Gerencie seu plano de assinatura.',
'change' => 'Mudar plano',
'label' => 'Plano',
'workspaces' => '{1}:count workspace|[2,*]:count workspaces',
'per_workspace' => 'por workspace',
'price' => 'Preço',
'month' => 'mês',
'trial' => 'Trial',
@ -99,13 +57,10 @@
'flash' => [
'plan_changed' => 'Você está agora no plano :plan.',
'switched_to_yearly' => 'Você está agora na cobrança anual.',
'cannot_manage' => 'Apenas o owner da conta pode gerenciar a cobrança.',
'cannot_downgrade' => [
'workspaces' => 'Não é possível mudar para :plan: você tem :count workspaces mas o plano só permite :limit.',
'social_accounts' => 'Não é possível mudar para :plan: você tem :count contas sociais mas o plano só permite :limit.',
'members' => 'Não é possível mudar para :plan: você tem :count membros (incluindo convites) mas o plano só permite :limit.',
],
'credits_exhausted' => 'Sem créditos de IA — você usou seus :limit créditos mensais. Faça upgrade do plano ou aguarde até o próximo mês.',
'subscription_required' => 'É necessária uma assinatura ativa para usar os recursos de IA.',
],
'processing' => [

View file

@ -4,11 +4,6 @@
return [
'discord' => [
'label' => 'Discord',
'join' => 'Entre na nossa comunidade no Discord',
],
'back' => 'Voltar',
'confirm_modal' => [

22
lang/pt-BR/onboarding.php Normal file
View file

@ -0,0 +1,22 @@
<?php
declare(strict_types=1);
return [
'title' => 'Bem-vindo ao TryPost',
'description' => 'Conte o que melhor te descreve para personalizarmos sua experiência.',
'continue' => 'Continuar',
'personas' => [
'creator' => 'Criador',
'freelancer' => 'Freelancer',
'startup' => 'Startup',
'agency' => 'Agência',
'small_business' => 'Pequena empresa',
'other' => 'Outro',
],
'connect' => [
'title' => 'Conecte sua primeira rede',
'description' => 'Vincule pelo menos uma conta social para começar a agendar. Você pode adicionar mais quando quiser.',
'must_connect' => 'Conecte pelo menos uma rede para continuar.',
],
];

View file

@ -225,8 +225,7 @@
'cancel' => 'Cancelar',
'remove' => 'Remover',
'make_admin' => 'Tornar administrador',
'make_member' => 'Tornar membro',
'make_role' => 'Tornar :role',
'invite' => [
'title' => 'Convidar Membro',
@ -300,7 +299,6 @@
'flash' => [
'account_updated' => 'Conta atualizada com sucesso!',
'profile_updated' => 'Perfil atualizado com sucesso!',
'language_updated' => 'Idioma atualizado com sucesso!',
'password_updated' => 'Senha atualizada com sucesso!',
'workspace_updated' => 'Configurações atualizadas com sucesso!',
'photo_updated' => 'Foto atualizada com sucesso!',

View file

@ -4,7 +4,7 @@
'title' => 'Uso',
'section_account' => 'Conta',
'section_account_description' => 'Cotas e limites do seu plano :plan.',
'section_account_description' => 'Cotas e limites do seu plano.',
'section_ai' => 'Créditos AI',
'section_ai_description' => 'Os créditos são debitados conforme você usa os recursos de AI. Eles são renovados no dia 1 de cada mês.',

View file

@ -42,7 +42,7 @@
'success' => 'Workspace criado. Conecte uma conta social para começar a postar.',
],
'limit_reached' => 'Você atingiu o limite de workspaces do seu plano.',
'cannot_delete_last' => 'Você não pode excluir seu único workspace. Cancele sua assinatura nas configurações de cobrança para encerrar sua conta.',
'flash' => [
'deleted' => 'Workspace excluído com sucesso.',

View file

@ -43,12 +43,10 @@ import {
SidebarMenuItem,
} from '@/components/ui/sidebar';
import { useActiveUrl } from '@/composables/useActiveUrl';
import { useFeatureAccess } from '@/composables/useFeatureAccess';
import { useUpgradeDialog } from '@/composables/useUpgradeDialog';
import { accounts, analytics, calendar, settings as settingsHub } from '@/routes/app';
import { portal } from '@/routes/app/billing';
import { index as assets } from '@/routes/app/assets';
import { index as automations } from '@/routes/app/automations';
import { portal } from '@/routes/app/billing';
import { index as labels } from '@/routes/app/labels';
import { index as signatures } from '@/routes/app/signatures';
import { create as createWorkspaceRoute, switchMethod } from '@/routes/app/workspaces';
@ -137,15 +135,9 @@ const switchWorkspace = (workspaceId: string) => {
});
};
const { canCreateWorkspace } = useFeatureAccess();
const { openUpgrade } = useUpgradeDialog();
const { urlIsActive } = useActiveUrl();
const handleCreateWorkspace = () => {
if (!canCreateWorkspace.value) {
openUpgrade(trans('billing.upgrade_dialog.reasons.workspace_limit'));
return;
}
router.visit(createWorkspaceRoute.url());
};
</script>

View file

@ -1,27 +0,0 @@
<script setup lang="ts">
import { IconBrandDiscord } from '@tabler/icons-vue';
import { captureEvent } from '@/posthog';
const DISCORD_URL = 'https://trypost.it/discord';
// Distinct from the marketing site's site_discord_clicked, following the
// app's noun.verb event convention.
const trackClick = () => {
captureEvent('discord.clicked', { placement: 'floating_widget' });
};
</script>
<template>
<a
:href="DISCORD_URL"
target="_blank"
rel="noopener noreferrer"
:aria-label="$t('common.discord.join')"
class="group fixed bottom-5 right-5 z-50 inline-flex items-center gap-2 rounded-full border-2 border-foreground bg-[#5865F2] py-2.5 pl-3 pr-4 text-sm font-bold text-white shadow-md transition-all hover:-translate-y-0.5 hover:shadow-lg"
@click="trackClick"
>
<IconBrandDiscord class="size-5 shrink-0" stroke-width="2" />
<span>{{ $t('common.discord.label') }}</span>
</a>
</template>

View file

@ -1,319 +0,0 @@
<script setup lang="ts">
import { router, usePage } from '@inertiajs/vue3';
import { IconCheck, IconInfoCircle, IconLoader2 } from '@tabler/icons-vue';
import { trans } from 'laravel-vue-i18n';
import { computed, ref, watch } from 'vue';
import { Dialog, DialogContent, DialogDescription, DialogTitle } from '@/components/ui/dialog';
import { Tooltip, TooltipContent, TooltipProvider, TooltipTrigger } from '@/components/ui/tooltip';
import { useFeatureAccess } from '@/composables/useFeatureAccess';
import { useUpgradeDialog } from '@/composables/useUpgradeDialog';
import { checkout, swap } from '@/routes/app/billing';
import type { AuthPlan } from '@/types';
type PlanSlug = 'starter' | 'plus' | 'pro' | 'max';
interface Plan {
id: string;
slug: PlanSlug;
name: string;
stripe_monthly_price_id: string | null;
stripe_yearly_price_id: string | null;
social_account_limit: number;
member_limit: number;
workspace_limit: number;
monthly_credits_limit: number;
}
interface Highlight {
text: string;
tooltip?: string;
}
const { open, reason, closeUpgrade } = useUpgradeDialog();
const { usage } = useFeatureAccess();
const page = usePage();
const plans = computed<Plan[]>(() => (page.props.plans as Plan[]) ?? []);
const currentPlan = computed<AuthPlan | null>(() => (page.props.auth as { plan: AuthPlan | null }).plan ?? null);
const hasActiveSubscription = computed<boolean>(() => Boolean((page.props.auth as { hasActiveSubscription?: boolean }).hasActiveSubscription));
const isOnYearly = computed(() => currentPlan.value?.interval === 'yearly');
const isYearly = ref(true);
const processing = ref<string | null>(null);
watch(open, (isOpen) => {
if (isOpen) {
isYearly.value = true;
}
});
// Mirrors Subscribe.vue: headline price = the per-month figure regardless
// of toggle. When yearly is selected, we read the pre-computed per-month
// rate so the price stays comparable visually.
const getDisplayPrice = (plan: Plan): string => {
const key = `billing.subscribe.prices.${plan.slug}.${isYearly.value ? 'yearly_per_month' : 'monthly'}`;
return trans(key);
};
const isSamePlan = (plan: Plan): boolean => currentPlan.value?.id === plan.id;
const isCurrent = (plan: Plan): boolean => {
if (!isSamePlan(plan)) return false;
return isYearly.value === isOnYearly.value;
};
const isPopular = (plan: Plan): boolean => plan.slug === 'pro';
const inheritsFrom: Partial<Record<PlanSlug, PlanSlug>> = {
plus: 'starter',
pro: 'plus',
max: 'pro',
};
const planNameBySlug = (list: Plan[], slug: PlanSlug): string =>
list.find((plan) => plan.slug === slug)?.name ?? slug;
const planTones: Record<PlanSlug, string> = {
starter: 'bg-amber-50',
plus: 'bg-rose-50',
pro: 'bg-violet-100',
max: 'bg-emerald-50',
};
const highlights = (plan: Plan): Highlight[] => [
{ text: trans('billing.subscribe.features.social_accounts', { count: String(plan.social_account_limit) }) },
{
text: trans('billing.subscribe.features.credits', { count: plan.monthly_credits_limit.toLocaleString() }),
tooltip: trans(`billing.subscribe.credit_tooltips.${plan.slug}`),
},
{ text: trans('billing.subscribe.features.workspaces', { count: String(plan.workspace_limit) }) },
{ text: trans('billing.subscribe.features.members', { count: String(plan.member_limit) }) },
];
const downgradeBlocker = (plan: Plan): string | null => {
if (!usage.value) return null;
if (usage.value.workspaceCount > plan.workspace_limit) {
return trans('billing.flash.cannot_downgrade.workspaces', {
plan: plan.name,
count: String(usage.value.workspaceCount),
limit: String(plan.workspace_limit),
});
}
if (usage.value.socialAccountCount > plan.social_account_limit) {
return trans('billing.flash.cannot_downgrade.social_accounts', {
plan: plan.name,
count: String(usage.value.socialAccountCount),
limit: String(plan.social_account_limit),
});
}
const totalMembers = usage.value.memberCount + usage.value.pendingInviteCount;
if (totalMembers > plan.member_limit) {
return trans('billing.flash.cannot_downgrade.members', {
plan: plan.name,
count: String(totalMembers),
limit: String(plan.member_limit),
});
}
return null;
};
const isBlocked = (plan: Plan): boolean => !isCurrent(plan) && downgradeBlocker(plan) !== null;
const ctaLabel = (plan: Plan): string => {
if (isCurrent(plan)) return trans('billing.upgrade_dialog.current_plan');
if (isBlocked(plan)) return trans('billing.upgrade_dialog.unavailable');
if (isSamePlan(plan)) {
return isYearly.value
? trans('billing.upgrade_dialog.switch_to_yearly')
: trans('billing.upgrade_dialog.switch_to_monthly');
}
if (hasActiveSubscription.value) return trans('billing.upgrade_dialog.switch');
return trans('billing.upgrade_dialog.subscribe');
};
const handleSelect = (plan: Plan) => {
if (isCurrent(plan)) return;
processing.value = plan.id;
const priceId = isYearly.value ? plan.stripe_yearly_price_id : plan.stripe_monthly_price_id;
const url = hasActiveSubscription.value ? swap.url(plan.id) : checkout.url(plan.id);
router.post(url, { price_id: priceId }, {
onSuccess: () => {
closeUpgrade();
},
onFinish: () => {
processing.value = null;
},
});
};
const onOpenChange = (value: boolean) => {
if (!value) closeUpgrade();
};
</script>
<template>
<Dialog :open="open" @update:open="onOpenChange">
<DialogContent class="w-[95vw] gap-0 p-0 sm:max-w-6xl">
<TooltipProvider :delay-duration="150">
<div class="px-6 pt-6 text-center">
<DialogTitle
class="text-3xl font-semibold leading-tight"
style="font-family: var(--font-display)"
>
{{ $t('billing.upgrade_dialog.title') }}
</DialogTitle>
<DialogDescription class="mx-auto mt-2 max-w-2xl text-balance text-base text-foreground/70">
{{ reason ?? $t('billing.upgrade_dialog.description') }}
</DialogDescription>
</div>
<!-- Billing toggle pill -->
<div class="flex justify-center pt-6 pb-2">
<div class="relative">
<div
class="inline-flex items-center gap-1 rounded-full border-2 border-foreground bg-card p-1 shadow-xs"
role="tablist"
>
<button
type="button"
role="tab"
:aria-selected="!isYearly"
:class="[
'cursor-pointer whitespace-nowrap rounded-full px-5 py-1.5 text-sm font-semibold transition-colors',
!isYearly ? 'bg-foreground text-background' : 'text-foreground hover:bg-foreground/5',
]"
@click="isYearly = false"
>
{{ $t('billing.subscribe.monthly') }}
</button>
<button
type="button"
role="tab"
:aria-selected="isYearly"
:class="[
'cursor-pointer whitespace-nowrap rounded-full px-5 py-1.5 text-sm font-semibold transition-colors',
isYearly ? 'bg-foreground text-background' : 'text-foreground hover:bg-foreground/5',
]"
@click="isYearly = true"
>
{{ $t('billing.subscribe.yearly') }}
</button>
</div>
<span
:class="[
'pointer-events-none absolute top-1/2 left-full ml-3 -translate-y-1/2 -rotate-3 inline-flex items-center gap-1 whitespace-nowrap rounded-md border-2 border-foreground bg-amber-200 px-2.5 py-1 text-[10px] font-black uppercase tracking-widest text-foreground shadow-xs transition-all',
isYearly ? 'opacity-100' : 'opacity-30 grayscale',
]"
>
<span>←</span>
{{ $t('billing.subscribe.save_months') }}
</span>
</div>
</div>
<!-- Plan cards -->
<div class="grid grid-cols-1 gap-5 px-6 pt-8 pb-6 md:grid-cols-2 xl:grid-cols-4">
<div
v-for="plan in plans"
:key="plan.id"
:class="[
planTones[plan.slug],
'relative flex flex-col rounded-2xl border-2 border-foreground p-6 transition-shadow',
isPopular(plan) ? 'shadow-xl' : 'shadow-sm hover:shadow-md',
]"
>
<span
v-if="isPopular(plan)"
class="absolute -top-3.5 left-1/2 inline-flex -translate-x-1/2 items-center gap-1 whitespace-nowrap rounded-md border-2 border-foreground bg-foreground px-3 py-1 text-[11px] font-black uppercase tracking-widest text-background shadow-2xs"
>
⭐ {{ $t('billing.subscribe.popular') }}
</span>
<span
v-else-if="isCurrent(plan)"
class="absolute -top-3.5 left-1/2 inline-flex -translate-x-1/2 items-center whitespace-nowrap rounded-md border-2 border-foreground bg-card px-3 py-1 text-[11px] font-black uppercase tracking-widest text-foreground shadow-2xs"
>
{{ $t('billing.upgrade_dialog.current_badge') }}
</span>
<div class="mb-4">
<h3 class="text-xl font-bold tracking-tight text-foreground">
{{ plan.name }}
</h3>
</div>
<div class="mb-2">
<div class="flex items-baseline gap-1">
<span class="text-4xl font-black tabular-nums text-foreground">
{{ getDisplayPrice(plan) }}
</span>
<span class="text-sm font-semibold text-foreground/60">
{{ $t('billing.subscribe.per_month') }}
</span>
</div>
<p class="mt-1 text-xs leading-tight text-foreground/60">
{{ isYearly ? $t('billing.subscribe.billed_yearly') : $t('billing.subscribe.billed_monthly') }}
</p>
</div>
<button
type="button"
:disabled="processing !== null || isCurrent(plan) || isBlocked(plan)"
:title="downgradeBlocker(plan) ?? undefined"
:class="[
'my-5 inline-flex w-full cursor-pointer items-center justify-center gap-1.5 whitespace-nowrap rounded-full border-2 border-foreground px-4 py-2.5 text-sm font-semibold shadow-2xs transition-shadow hover:shadow-xs disabled:cursor-not-allowed disabled:opacity-60',
isPopular(plan) ? 'bg-foreground text-background' : 'bg-card text-foreground',
]"
@click="handleSelect(plan)"
>
<IconLoader2 v-if="processing === plan.id" class="size-4 animate-spin" />
{{ ctaLabel(plan) }}
</button>
<div class="flex flex-col gap-3">
<p class="text-sm font-bold leading-snug text-foreground">
<template v-if="inheritsFrom[plan.slug]">
{{ trans('billing.subscribe.everything_in', { plan: planNameBySlug(plans, inheritsFrom[plan.slug]!) }) }}
</template>
<template v-else>
{{ $t('billing.subscribe.features_included') }}
</template>
</p>
<div
v-for="highlight in highlights(plan)"
:key="highlight.text"
class="flex items-start gap-2.5"
>
<div class="mt-0.5 flex size-5 shrink-0 items-center justify-center rounded-full border-2 border-foreground bg-card shadow-2xs">
<IconCheck class="size-3 text-foreground" stroke-width="3" />
</div>
<span class="inline-flex items-center gap-1.5 text-sm leading-snug text-foreground/80">
{{ highlight.text }}
<Tooltip v-if="highlight.tooltip">
<TooltipTrigger as-child>
<button
type="button"
class="inline-flex items-center justify-center text-foreground/40 transition-colors hover:text-foreground"
:aria-label="highlight.tooltip"
>
<IconInfoCircle class="size-3.5" stroke-width="2" />
</button>
</TooltipTrigger>
<TooltipContent class="max-w-xs text-sm leading-snug">
{{ highlight.tooltip }}
</TooltipContent>
</Tooltip>
</span>
</div>
</div>
</div>
</div>
</TooltipProvider>
</DialogContent>
</Dialog>
</template>

View file

@ -1,208 +0,0 @@
<script setup lang="ts">
import { router } from '@inertiajs/vue3';
import { IconPlus } from '@tabler/icons-vue';
import { trans } from 'laravel-vue-i18n';
import { ref } from 'vue';
import TelegramConnectDialog from '@/components/accounts/TelegramConnectDialog.vue';
import { Button } from '@/components/ui/button';
import {
Dialog,
DialogContent,
DialogDescription,
DialogHeader,
DialogTitle,
} from '@/components/ui/dialog';
import { useOAuthPopup } from '@/composables/useOAuthPopup';
import { Platform } from '@/types/platform';
export interface AvailablePlatform {
value: string;
label: string;
color: string;
}
defineProps<{
platforms: AvailablePlatform[];
}>();
const open = defineModel<boolean>('open', { default: false });
const getPlatformDescription = (platform: string): string =>
trans(`accounts.descriptions.${platform}`);
// Mirrors `NetworksGrid.vue` from the marketing site — pastel tile bg
// + ink 2px border + slight rotation per platform, real PNG logo inside.
// `linkedin-page` / `instagram-facebook` fall back to the base brand
// image and same color since they're variants of the same network.
const platformTheme: Record<
string,
{ bg: string; rotate: string; image: string }
> = {
instagram: {
bg: 'bg-pink-200',
rotate: '-rotate-2',
image: '/images/accounts/instagram.png',
},
'instagram-facebook': {
bg: 'bg-pink-200',
rotate: '-rotate-2',
image: '/images/accounts/instagram.png',
},
facebook: {
bg: 'bg-sky-200',
rotate: 'rotate-1',
image: '/images/accounts/facebook.png',
},
linkedin: {
bg: 'bg-blue-200',
rotate: '-rotate-1',
image: '/images/accounts/linkedin.png',
},
'linkedin-page': {
bg: 'bg-blue-200',
rotate: '-rotate-1',
image: '/images/accounts/linkedin.png',
},
x: {
bg: 'bg-amber-200',
rotate: 'rotate-2',
image: '/images/accounts/x.png',
},
tiktok: {
bg: 'bg-fuchsia-200',
rotate: '-rotate-1',
image: '/images/accounts/tiktok.png',
},
youtube: {
bg: 'bg-red-200',
rotate: 'rotate-1',
image: '/images/accounts/youtube.png',
},
pinterest: {
bg: 'bg-rose-200',
rotate: '-rotate-2',
image: '/images/accounts/pinterest.png',
},
threads: {
bg: 'bg-emerald-200',
rotate: 'rotate-2',
image: '/images/accounts/threads.png',
},
bluesky: {
bg: 'bg-cyan-200',
rotate: '-rotate-1',
image: '/images/accounts/bluesky.png',
},
mastodon: {
bg: 'bg-violet-200',
rotate: 'rotate-1',
image: '/images/accounts/mastodon.png',
},
telegram: {
bg: 'bg-sky-200',
rotate: '-rotate-2',
image: '/images/accounts/telegram.png',
},
discord: {
bg: 'bg-indigo-200',
rotate: 'rotate-1',
image: '/images/accounts/discord.png',
},
};
const themeFor = (value: string) =>
platformTheme[value] ?? { bg: 'bg-muted', rotate: '', image: '' };
const telegramOpen = ref(false);
const { openOAuthPopup } = useOAuthPopup(() => {
open.value = false;
router.reload();
});
const connectPlatform = (platformValue: string) => {
open.value = false;
if (platformValue === Platform.Telegram) {
telegramOpen.value = true;
return;
}
openOAuthPopup(platformValue);
};
</script>
<template>
<div>
<Dialog v-model:open="open">
<DialogContent class="sm:max-w-5xl">
<DialogHeader>
<DialogTitle>{{
$t('accounts.add_social_title')
}}</DialogTitle>
<DialogDescription>
{{ $t('accounts.add_social_description') }}
</DialogDescription>
</DialogHeader>
<div
class="grid grid-cols-2 gap-4 sm:grid-cols-3 lg:grid-cols-5"
>
<div
v-for="platform in platforms"
:key="platform.value"
class="group relative flex flex-col items-center gap-3 rounded-xl border-2 border-foreground bg-card p-4 text-center shadow-xs transition-shadow hover:shadow-md"
>
<span
class="pointer-events-none absolute -top-2 -right-2 inline-flex size-6 items-center justify-center rounded-full border-2 border-foreground bg-violet-200 text-foreground opacity-0 shadow-2xs transition-all group-hover:scale-110 group-hover:rotate-90 group-hover:opacity-100"
aria-hidden="true"
>
<IconPlus class="size-3.5" stroke-width="3" />
</span>
<div
:class="[
themeFor(platform.value).bg,
themeFor(platform.value).rotate,
'inline-flex size-16 items-center justify-center rounded-2xl border-2 border-foreground shadow-sm transition-transform group-hover:!rotate-0',
]"
>
<img
:src="themeFor(platform.value).image"
:alt="platform.label"
class="size-9 rounded-lg"
loading="lazy"
/>
</div>
<div class="flex-1">
<span
class="block text-sm font-semibold text-foreground"
>
<template v-if="platform.label.includes('(')">
{{ platform.label.split('(')[0].trim() }}
</template>
<template v-else>{{ platform.label }}</template>
</span>
<p
class="mt-0.5 line-clamp-2 text-xs leading-tight text-foreground/60"
>
{{ getPlatformDescription(platform.value) }}
</p>
</div>
<Button
size="sm"
class="mt-auto w-full"
@click="connectPlatform(platform.value)"
>
{{ $t('accounts.connect_cta') }}
</Button>
</div>
</div>
</DialogContent>
</Dialog>
<TelegramConnectDialog v-model:open="telegramOpen" />
</div>
</template>

View file

@ -0,0 +1,347 @@
<script setup lang="ts">
import { router } from '@inertiajs/vue3';
import { IconAlertTriangle, IconCheck, IconPlus } from '@tabler/icons-vue';
import { trans } from 'laravel-vue-i18n';
import { computed, ref } from 'vue';
import TelegramConnectDialog from '@/components/accounts/TelegramConnectDialog.vue';
import ConfirmDeleteModal from '@/components/ConfirmDeleteModal.vue';
import { Button } from '@/components/ui/button';
import { useOAuthPopup } from '@/composables/useOAuthPopup';
import { disconnect } from '@/routes/app/accounts';
import { Platform } from '@/types/platform';
export interface AvailablePlatform {
value: string;
label: string;
color: string;
network: string;
}
export interface ConnectedAccount {
id: string;
platform: string;
username: string;
display_name: string;
avatar_url: string | null;
status: 'connected' | 'disconnected' | 'token_expired' | null;
}
const props = withDefaults(
defineProps<{
platforms: AvailablePlatform[];
connectedAccounts?: ConnectedAccount[];
gridClass?: string;
}>(),
{
connectedAccounts: () => [],
gridClass: 'grid-cols-2 sm:grid-cols-3 lg:grid-cols-5',
},
);
const getPlatformDescription = (platform: string): string =>
trans(`accounts.descriptions.${platform}`);
// Mirrors `NetworksGrid.vue` from the marketing site — pastel tile bg
// + ink 2px border + slight rotation per platform, real PNG logo inside.
// `linkedin-page` / `instagram-facebook` fall back to the base brand
// image and same color since they're variants of the same network.
const platformTheme: Record<
string,
{ bg: string; rotate: string; image: string }
> = {
instagram: {
bg: 'bg-pink-200',
rotate: '-rotate-2',
image: '/images/accounts/instagram.png',
},
'instagram-facebook': {
bg: 'bg-pink-200',
rotate: '-rotate-2',
image: '/images/accounts/instagram.png',
},
facebook: {
bg: 'bg-sky-200',
rotate: 'rotate-1',
image: '/images/accounts/facebook.png',
},
linkedin: {
bg: 'bg-blue-200',
rotate: '-rotate-1',
image: '/images/accounts/linkedin.png',
},
'linkedin-page': {
bg: 'bg-blue-200',
rotate: '-rotate-1',
image: '/images/accounts/linkedin.png',
},
x: {
bg: 'bg-amber-200',
rotate: 'rotate-2',
image: '/images/accounts/x.png',
},
tiktok: {
bg: 'bg-fuchsia-200',
rotate: '-rotate-1',
image: '/images/accounts/tiktok.png',
},
youtube: {
bg: 'bg-red-200',
rotate: 'rotate-1',
image: '/images/accounts/youtube.png',
},
pinterest: {
bg: 'bg-rose-200',
rotate: '-rotate-2',
image: '/images/accounts/pinterest.png',
},
threads: {
bg: 'bg-emerald-200',
rotate: 'rotate-2',
image: '/images/accounts/threads.png',
},
bluesky: {
bg: 'bg-cyan-200',
rotate: '-rotate-1',
image: '/images/accounts/bluesky.png',
},
mastodon: {
bg: 'bg-violet-200',
rotate: 'rotate-1',
image: '/images/accounts/mastodon.png',
},
telegram: {
bg: 'bg-sky-200',
rotate: '-rotate-2',
image: '/images/accounts/telegram.png',
},
discord: {
bg: 'bg-indigo-200',
rotate: 'rotate-1',
image: '/images/accounts/discord.png',
},
};
const themeFor = (value: string) =>
platformTheme[value] ?? { bg: 'bg-muted', rotate: '', image: '' };
const networkOf = (value: string): string =>
props.platforms.find((platform) => platform.value === value)?.network ??
value;
// One account per network: map each connected network to its account so every
// platform card belonging to that network reflects the connection.
const connectedByNetwork = computed((): Record<string, ConnectedAccount> => {
const map: Record<string, ConnectedAccount> = {};
for (const account of props.connectedAccounts) {
const network = networkOf(account.platform);
if (!map[network]) {
map[network] = account;
}
}
return map;
});
// platform value -> the account occupying its network (if any).
const cardConnection = computed(
(): Record<string, ConnectedAccount | undefined> => {
const map: Record<string, ConnectedAccount | undefined> = {};
for (const platform of props.platforms) {
map[platform.value] = connectedByNetwork.value[platform.network];
}
return map;
},
);
const telegramOpen = ref(false);
const disconnectModal = ref<InstanceType<typeof ConfirmDeleteModal> | null>(
null,
);
const { openOAuthPopup } = useOAuthPopup(() => {
router.reload();
});
const disconnectAccount = (account: ConnectedAccount) => {
disconnectModal.value?.open({
url: disconnect.url(account.id),
confirmText: account.username || account.display_name,
});
};
const needsReconnect = (account: ConnectedAccount): boolean =>
account.status === 'disconnected' || account.status === 'token_expired';
const openConnect = (platformValue: string) => {
if (platformValue === Platform.Telegram) {
telegramOpen.value = true;
return;
}
openOAuthPopup(platformValue);
};
const connectPlatform = (platformValue: string) => {
if (cardConnection.value[platformValue]) {
return;
}
openConnect(platformValue);
};
const reconnectAccount = (account: ConnectedAccount) => {
openConnect(account.platform);
};
const CardState = {
Connect: 'connect',
Connected: 'connected',
Reconnect: 'reconnect',
} as const;
type CardStateValue = (typeof CardState)[keyof typeof CardState];
const cardState = computed((): Record<string, CardStateValue> => {
const map: Record<string, CardStateValue> = {};
for (const platform of props.platforms) {
const account = connectedByNetwork.value[platform.network];
map[platform.value] = !account
? CardState.Connect
: needsReconnect(account)
? CardState.Reconnect
: CardState.Connected;
}
return map;
});
</script>
<template>
<div>
<div :class="['grid gap-4', gridClass]">
<div
v-for="platform in platforms"
:key="platform.value"
:class="[
'group relative flex flex-col items-center gap-3 rounded-xl border-2 border-foreground p-4 text-center shadow-xs transition-shadow',
cardState[platform.value] === CardState.Connected
? 'bg-emerald-50'
: cardState[platform.value] === CardState.Reconnect
? 'bg-amber-50'
: 'bg-card hover:shadow-md',
]"
>
<span
v-if="cardState[platform.value] === CardState.Connected"
class="absolute -top-2 -right-2 inline-flex size-6 items-center justify-center rounded-full border-2 border-foreground bg-emerald-200 text-emerald-700 shadow-2xs"
aria-hidden="true"
>
<IconCheck class="size-3.5" stroke-width="3" />
</span>
<span
v-else-if="cardState[platform.value] === CardState.Reconnect"
class="absolute -top-2 -right-2 inline-flex size-6 items-center justify-center rounded-full border-2 border-foreground bg-amber-200 text-amber-700 shadow-2xs"
aria-hidden="true"
>
<IconAlertTriangle class="size-3.5" stroke-width="2.5" />
</span>
<span
v-else
class="pointer-events-none absolute -top-2 -right-2 inline-flex size-6 items-center justify-center rounded-full border-2 border-foreground bg-violet-200 text-foreground opacity-0 shadow-2xs transition-all group-hover:scale-110 group-hover:rotate-90 group-hover:opacity-100"
aria-hidden="true"
>
<IconPlus class="size-3.5" stroke-width="3" />
</span>
<div
:class="[
themeFor(platform.value).bg,
themeFor(platform.value).rotate,
'inline-flex size-16 items-center justify-center rounded-2xl border-2 border-foreground shadow-sm transition-transform group-hover:!rotate-0',
]"
>
<img
:src="themeFor(platform.value).image"
:alt="platform.label"
class="size-9 rounded-lg"
loading="lazy"
/>
</div>
<div class="w-full min-w-0 flex-1">
<span
class="block truncate text-sm font-semibold text-foreground"
>
<template v-if="platform.label.includes('(')">
{{ platform.label.split('(')[0].trim() }}
</template>
<template v-else>{{ platform.label }}</template>
</span>
<p
v-if="cardState[platform.value] === CardState.Connect"
class="mt-0.5 line-clamp-2 text-xs leading-tight text-foreground/60"
>
{{ getPlatformDescription(platform.value) }}
</p>
<p
v-else-if="cardState[platform.value] === CardState.Reconnect"
class="mt-0.5 truncate text-xs leading-tight font-medium text-amber-700"
>
{{ $t('accounts.connection_lost') }}
</p>
<p
v-else
class="mt-0.5 truncate text-xs leading-tight text-foreground/70"
>
{{
cardConnection[platform.value]?.display_name ||
cardConnection[platform.value]?.username
}}
</p>
</div>
<Button
v-if="cardState[platform.value] === CardState.Reconnect"
size="sm"
class="mt-auto w-full"
@click="reconnectAccount(cardConnection[platform.value]!)"
>
{{ $t('accounts.reconnect') }}
</Button>
<Button
v-else-if="cardState[platform.value] === CardState.Connected"
variant="destructive"
size="sm"
class="mt-auto w-full"
@click="disconnectAccount(cardConnection[platform.value]!)"
>
{{ $t('accounts.disconnect') }}
</Button>
<Button
v-else
size="sm"
class="mt-auto w-full"
@click="connectPlatform(platform.value)"
>
{{ $t('accounts.connect_cta') }}
</Button>
</div>
</div>
<TelegramConnectDialog v-model:open="telegramOpen" />
<ConfirmDeleteModal
ref="disconnectModal"
:title="$t('accounts.disconnect_modal.title')"
:description="$t('accounts.disconnect_modal.description')"
:action="$t('accounts.disconnect_modal.confirm')"
:cancel="$t('accounts.disconnect_modal.cancel')"
/>
</div>
</template>

View file

@ -72,6 +72,22 @@ useWorkspaceEcho<{ nonce: string }>(
},
);
useWorkspaceEcho<{ nonce: string; reason: string }>(
'.telegram.connect.failed',
(payload) => {
if (phase.value !== 'ready' || payload.nonce !== nonce.value) {
return;
}
phase.value = 'error';
clearExpiry();
errorMessage.value =
payload.reason === 'network_taken'
? trans('accounts.telegram.network_taken')
: trans('accounts.telegram.error_generic');
},
);
const start = async () => {
phase.value = 'loading';
errorMessage.value = '';

View file

@ -6,9 +6,13 @@ import { Button } from '@/components/ui/button';
import { redirect as githubRedirect } from '@/routes/auth/github';
import { redirect as googleRedirect } from '@/routes/auth/google';
defineProps<{
mode: 'login' | 'signup';
}>();
withDefaults(
defineProps<{
mode: 'login' | 'signup';
hideDivider?: boolean;
}>(),
{ hideDivider: false },
);
const page = usePage();
const googleEnabled = computed(() => Boolean(page.props.googleAuthEnabled));
@ -31,6 +35,7 @@ const hasSocial = computed(() => googleEnabled.value || githubEnabled.value);
</div>
<div
v-if="!hideDivider"
class="relative text-center text-sm after:absolute after:inset-0 after:top-1/2 after:z-0 after:flex after:items-center after:border-t after:border-border"
>
<span class="relative z-10 bg-background px-2 text-muted-foreground">{{ $t('auth.or_continue_with') }}</span>

View file

@ -1,8 +1,7 @@
<script setup lang="ts">
import { router } from '@inertiajs/vue3';
import { IconClock, IconDots, IconShield, IconTrash, IconUser } from '@tabler/icons-vue';
import { trans } from 'laravel-vue-i18n';
import { ref } from 'vue';
import { router, usePage } from '@inertiajs/vue3';
import { IconClock, IconDots, IconEye, IconShield, IconTrash, IconUser } from '@tabler/icons-vue';
import { computed, ref } from 'vue';
import ConfirmDeleteModal from '@/components/ConfirmDeleteModal.vue';
import HeadingSmall from '@/components/HeadingSmall.vue';
@ -23,11 +22,9 @@ import {
TableHeader,
TableRow,
} from '@/components/ui/table';
import { useFeatureAccess } from '@/composables/useFeatureAccess';
import { useUpgradeDialog } from '@/composables/useUpgradeDialog';
import { WorkspaceRole } from '@/types/workspace-role';
import { destroy as destroyInvite } from '@/routes/app/invites';
import { remove as removeMemberRoute, updateRole } from '@/routes/app/members';
import { WorkspaceRole } from '@/types/workspace-role';
interface Member {
id: string;
@ -42,23 +39,37 @@ interface Invitation {
role: string;
}
interface Role {
value: string;
label: string;
}
defineProps<{
members: Member[];
invitations: Invitation[];
roles: Role[];
}>();
const roleIcon = (role: string) => {
if (role === WorkspaceRole.Admin) {
return IconShield;
}
if (role === WorkspaceRole.Viewer) {
return IconEye;
}
return IconUser;
};
const page = usePage();
const currentUserId = computed(() => page.props.auth.user.id);
const inviteDialogOpen = ref(false);
const removeMemberModal = ref<InstanceType<typeof ConfirmDeleteModal> | null>(null);
const cancelInvitationModal = ref<InstanceType<typeof ConfirmDeleteModal> | null>(null);
const { canInviteMember } = useFeatureAccess();
const { openUpgrade } = useUpgradeDialog();
const handleInviteClick = () => {
if (!canInviteMember.value) {
openUpgrade(trans('billing.upgrade_dialog.reasons.member_limit'));
return;
}
inviteDialogOpen.value = true;
};
@ -99,7 +110,7 @@ const changeRole = (member: Member, role: string) => {
</Badge>
</TableCell>
<TableCell>
<DropdownMenu>
<DropdownMenu v-if="member.id !== currentUserId">
<DropdownMenuTrigger as-child>
<Button variant="outline" size="icon" class="size-8">
<IconDots class="size-4" />
@ -107,22 +118,16 @@ const changeRole = (member: Member, role: string) => {
</DropdownMenuTrigger>
<DropdownMenuContent align="end">
<DropdownMenuItem
v-if="member.role === WorkspaceRole.Member"
@click="changeRole(member, WorkspaceRole.Admin)"
v-for="role in roles.filter((r) => r.value !== member.role)"
:key="role.value"
@click="changeRole(member, role.value)"
>
<IconShield class="size-4" />
{{ $t('settings.members.make_admin') }}
</DropdownMenuItem>
<DropdownMenuItem
v-if="member.role === WorkspaceRole.Admin"
@click="changeRole(member, WorkspaceRole.Member)"
>
<IconUser class="size-4" />
{{ $t('settings.members.make_member') }}
<component :is="roleIcon(role.value)" class="size-4" />
{{ $t('settings.members.make_role', { role: $t(`settings.members.roles.${role.value}`) }) }}
</DropdownMenuItem>
<DropdownMenuItem
variant="destructive"
@click="removeMemberModal?.open({ url: removeMemberRoute.url(member.id) })"
@click="removeMemberModal?.open({ url: removeMemberRoute.url(member.id), confirmText: member.email })"
>
<IconTrash class="size-4" />
{{ $t('settings.members.remove') }}

View file

@ -1,70 +0,0 @@
import { usePage } from '@inertiajs/vue3';
import { computed } from 'vue';
import type { Usage } from '@/types';
interface Plan {
id: string;
slug: string;
name: string;
}
interface Features {
workspaceLimit: number;
socialAccountLimit: number;
memberLimit: number;
monthlyCreditsLimit: number;
}
export const useFeatureAccess = () => {
const page = usePage();
const isSelfHosted = computed(() => page.props.selfHosted as boolean);
const plan = computed<Plan | null>(() => (page.props.auth as { plan: Plan | null }).plan ?? null);
const usage = computed<Usage | null>(() => (page.props.usage as Usage | null) ?? null);
const features = computed<Features | null>(() => (page.props.features as Features | null) ?? null);
const workspaceLimit = computed(() => features.value?.workspaceLimit ?? 1);
const socialAccountLimit = computed(() => features.value?.socialAccountLimit ?? 1);
const memberLimit = computed(() => features.value?.memberLimit ?? 1);
const monthlyCreditsLimit = computed(() => features.value?.monthlyCreditsLimit ?? 0);
const canCreateWorkspace = computed(() => {
if (isSelfHosted.value) return true;
if (!usage.value) return true;
return usage.value.workspaceCount < workspaceLimit.value;
});
const canConnectSocialAccount = computed(() => {
if (isSelfHosted.value) return true;
if (!usage.value) return true;
return usage.value.socialAccountCount < socialAccountLimit.value;
});
const canInviteMember = computed(() => {
if (isSelfHosted.value) return true;
if (!usage.value) return true;
return usage.value.memberCount + usage.value.pendingInviteCount < memberLimit.value;
});
const hasCreditsLeft = computed(() => {
if (isSelfHosted.value) return true;
if (!usage.value) return true;
return usage.value.creditsUsed < monthlyCreditsLimit.value;
});
return {
plan,
usage,
features,
isSelfHosted,
workspaceLimit,
socialAccountLimit,
memberLimit,
monthlyCreditsLimit,
canCreateWorkspace,
canConnectSocialAccount,
canInviteMember,
hasCreditsLeft,
};
};

Some files were not shown because too many files have changed in this diff Show more