Merge pull request #106 from trypostit/feat/per-workspace-pricing
feat: per-workspace pricing, onboarding, and billing overhaul
This commit is contained in:
commit
11319cb45a
162 changed files with 3328 additions and 2990 deletions
|
|
@ -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
|
||||
12
.env.example
12
.env.example
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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`.
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
40
app/Actions/Billing/StartSubscriptionCheckout.php
Normal file
40
app/Actions/Billing/StartSubscriptionCheckout.php
Normal 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);
|
||||
}
|
||||
}
|
||||
|
|
@ -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],
|
||||
]);
|
||||
|
||||
|
|
|
|||
|
|
@ -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();
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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;
|
||||
});
|
||||
|
||||
|
|
|
|||
|
|
@ -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;
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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);
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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',
|
||||
};
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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) {
|
||||
|
|
|
|||
15
app/Enums/User/Persona.php
Normal file
15
app/Enums/User/Persona.php
Normal 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';
|
||||
}
|
||||
53
app/Events/TelegramConnectFailed.php
Normal file
53
app/Events/TelegramConnectFailed.php
Normal 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';
|
||||
}
|
||||
}
|
||||
|
|
@ -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.");
|
||||
}
|
||||
}
|
||||
|
|
@ -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;
|
||||
}
|
||||
}
|
||||
|
|
@ -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;
|
||||
}
|
||||
}
|
||||
|
|
@ -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;
|
||||
}
|
||||
}
|
||||
|
|
@ -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;
|
||||
}
|
||||
}
|
||||
|
|
@ -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
|
||||
|
|
|
|||
131
app/Http/Controllers/App/OnboardingController.php
Normal file
131
app/Http/Controllers/App/OnboardingController.php
Normal 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'),
|
||||
);
|
||||
}
|
||||
}
|
||||
|
|
@ -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'))
|
||||
);
|
||||
|
|
|
|||
|
|
@ -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(),
|
||||
],
|
||||
]);
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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;
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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.']);
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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() ?? [],
|
||||
|
|
|
|||
|
|
@ -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(),
|
||||
|
|
|
|||
|
|
@ -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(),
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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(),
|
||||
|
|
|
|||
|
|
@ -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(),
|
||||
|
|
|
|||
|
|
@ -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() ?? [],
|
||||
|
|
|
|||
|
|
@ -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);
|
||||
|
|
|
|||
|
|
@ -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');
|
||||
}
|
||||
|
||||
/**
|
||||
|
|
|
|||
|
|
@ -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);
|
||||
|
|
|
|||
|
|
@ -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(),
|
||||
|
|
|
|||
|
|
@ -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(),
|
||||
|
|
|
|||
|
|
@ -32,7 +32,7 @@ public function handle(Request $request, Closure $next): Response
|
|||
);
|
||||
|
||||
if (! $hasAccess) {
|
||||
return redirect()->route('app.subscribe');
|
||||
return redirect()->route('app.onboarding');
|
||||
}
|
||||
}
|
||||
|
||||
|
|
|
|||
|
|
@ -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'),
|
||||
|
|
|
|||
|
|
@ -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'))],
|
||||
];
|
||||
}
|
||||
|
||||
|
|
|
|||
27
app/Http/Requests/App/Onboarding/StoreOnboardingRequest.php
Normal file
27
app/Http/Requests/App/Onboarding/StoreOnboardingRequest.php
Normal 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)],
|
||||
];
|
||||
}
|
||||
}
|
||||
25
app/Http/Requests/App/Workspace/AutofillBrandRequest.php
Normal file
25
app/Http/Requests/App/Workspace/AutofillBrandRequest.php
Normal 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'],
|
||||
];
|
||||
}
|
||||
}
|
||||
|
|
@ -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,
|
||||
);
|
||||
|
|
|
|||
|
|
@ -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);
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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')) {
|
||||
|
|
|
|||
|
|
@ -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');
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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',
|
||||
];
|
||||
|
|
|
|||
|
|
@ -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(),
|
||||
];
|
||||
}
|
||||
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
];
|
||||
}
|
||||
|
||||
|
|
|
|||
|
|
@ -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);
|
||||
|
|
|
|||
|
|
@ -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();
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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();
|
||||
}
|
||||
|
||||
|
|
|
|||
|
|
@ -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.');
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
@ -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);
|
||||
}
|
||||
|
||||
|
|
|
|||
141
app/Support/BillingCycle.php
Normal file
141
app/Support/BillingCycle.php
Normal 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;
|
||||
}
|
||||
}
|
||||
|
|
@ -15,7 +15,6 @@
|
|||
"mcp-development",
|
||||
"configure-nightwatch",
|
||||
"passport-development",
|
||||
"pennant-development",
|
||||
"socialite-development",
|
||||
"wayfinder-development",
|
||||
"pest-testing",
|
||||
|
|
|
|||
|
|
@ -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
78
composer.lock
generated
|
|
@ -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",
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
];
|
||||
|
|
|
|||
|
|
@ -46,6 +46,7 @@ public function definition(): array
|
|||
'utm_term' => null,
|
||||
'utm_content' => null,
|
||||
'registration_ip' => null,
|
||||
'persona' => null,
|
||||
];
|
||||
}
|
||||
|
||||
|
|
|
|||
|
|
@ -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);
|
||||
});
|
||||
}
|
||||
};
|
||||
|
|
@ -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');
|
||||
});
|
||||
}
|
||||
};
|
||||
|
|
@ -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');
|
||||
}
|
||||
};
|
||||
|
|
@ -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,
|
||||
);
|
||||
}
|
||||
);
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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');
|
||||
|
|
|
|||
|
|
@ -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.',
|
||||
|
|
|
|||
|
|
@ -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',
|
||||
|
|
|
|||
|
|
@ -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' => [
|
||||
|
|
|
|||
|
|
@ -4,11 +4,6 @@
|
|||
|
||||
return [
|
||||
|
||||
'discord' => [
|
||||
'label' => 'Discord',
|
||||
'join' => 'Join our Discord community',
|
||||
],
|
||||
|
||||
'back' => 'Back',
|
||||
|
||||
'confirm_modal' => [
|
||||
|
|
|
|||
22
lang/en/onboarding.php
Normal file
22
lang/en/onboarding.php
Normal 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.',
|
||||
],
|
||||
];
|
||||
|
|
@ -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!',
|
||||
|
|
|
|||
|
|
@ -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.',
|
||||
|
||||
|
|
|
|||
|
|
@ -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.',
|
||||
|
|
|
|||
|
|
@ -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.',
|
||||
|
|
|
|||
|
|
@ -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',
|
||||
|
|
|
|||
|
|
@ -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' => [
|
||||
|
|
|
|||
|
|
@ -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
22
lang/es/onboarding.php
Normal 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.',
|
||||
],
|
||||
];
|
||||
|
|
@ -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!',
|
||||
|
|
|
|||
|
|
@ -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.',
|
||||
|
||||
|
|
|
|||
|
|
@ -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.',
|
||||
|
|
|
|||
|
|
@ -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.',
|
||||
|
|
|
|||
|
|
@ -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',
|
||||
|
|
|
|||
|
|
@ -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' => [
|
||||
|
|
|
|||
|
|
@ -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
22
lang/pt-BR/onboarding.php
Normal 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.',
|
||||
],
|
||||
];
|
||||
|
|
@ -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!',
|
||||
|
|
|
|||
|
|
@ -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.',
|
||||
|
||||
|
|
|
|||
|
|
@ -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.',
|
||||
|
|
|
|||
|
|
@ -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>
|
||||
|
|
|
|||
|
|
@ -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>
|
||||
|
|
@ -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>
|
||||
|
|
@ -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>
|
||||
347
resources/js/components/accounts/NetworkConnectGrid.vue
Normal file
347
resources/js/components/accounts/NetworkConnectGrid.vue
Normal 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>
|
||||
|
|
@ -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 = '';
|
||||
|
|
|
|||
|
|
@ -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>
|
||||
|
|
|
|||
|
|
@ -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') }}
|
||||
|
|
|
|||
|
|
@ -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
Loading…
Reference in a new issue