diff --git a/.claude/skills/pennant-development/SKILL.md b/.claude/skills/pennant-development/SKILL.md deleted file mode 100644 index 85283f5f..00000000 --- a/.claude/skills/pennant-development/SKILL.md +++ /dev/null @@ -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 - - -```php -use Laravel\Pennant\Feature; - -Feature::define('new-dashboard', function (User $user) { - return $user->isAdmin(); -}); -``` - -### 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 -@feature('new-dashboard') - -@else - -@endfeature -``` - -### Activating / Deactivating - - -```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 \ No newline at end of file diff --git a/.env.example b/.env.example index 235552aa..5d6afd4e 100644 --- a/.env.example +++ b/.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 diff --git a/CLAUDE.md b/CLAUDE.md index ce363040..db1556cd 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -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`. diff --git a/app/Actions/Ai/AutofillBrand.php b/app/Actions/Ai/AutofillBrand.php index 6fcf98c5..8654f346 100644 --- a/app/Actions/Ai/AutofillBrand.php +++ b/app/Actions/Ai/AutofillBrand.php @@ -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 diff --git a/app/Actions/Billing/StartSubscriptionCheckout.php b/app/Actions/Billing/StartSubscriptionCheckout.php new file mode 100644 index 00000000..1515ce11 --- /dev/null +++ b/app/Actions/Billing/StartSubscriptionCheckout.php @@ -0,0 +1,40 @@ +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); + } +} diff --git a/app/Actions/Invite/CreateInvite.php b/app/Actions/Invite/CreateInvite.php index 7ee264ef..e3640aee 100644 --- a/app/Actions/Invite/CreateInvite.php +++ b/app/Actions/Invite/CreateInvite.php @@ -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], ]); diff --git a/app/Actions/SocialAccount/ConnectTelegramChannel.php b/app/Actions/SocialAccount/ConnectTelegramChannel.php index 3743a113..63d19f73 100644 --- a/app/Actions/SocialAccount/ConnectTelegramChannel.php +++ b/app/Actions/SocialAccount/ConnectTelegramChannel.php @@ -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(); } } diff --git a/app/Actions/User/CreateUser.php b/app/Actions/User/CreateUser.php index 5f01e207..ead5eca0 100644 --- a/app/Actions/User/CreateUser.php +++ b/app/Actions/User/CreateUser.php @@ -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; }); diff --git a/app/Actions/Workspace/CreateWorkspace.php b/app/Actions/Workspace/CreateWorkspace.php index e6dd18a9..4c42fe67 100644 --- a/app/Actions/Workspace/CreateWorkspace.php +++ b/app/Actions/Workspace/CreateWorkspace.php @@ -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; } diff --git a/app/Actions/Workspace/DeleteWorkspace.php b/app/Actions/Workspace/DeleteWorkspace.php index 84cdacdc..e43e8cbe 100644 --- a/app/Actions/Workspace/DeleteWorkspace.php +++ b/app/Actions/Workspace/DeleteWorkspace.php @@ -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); } diff --git a/app/Enums/Plan/Slug.php b/app/Enums/Plan/Slug.php index 6a9ed87e..7b056c48 100644 --- a/app/Enums/Plan/Slug.php +++ b/app/Enums/Plan/Slug.php @@ -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', }; } } diff --git a/app/Enums/SocialAccount/Platform.php b/app/Enums/SocialAccount/Platform.php index 0d645c97..d2e18c43 100644 --- a/app/Enums/SocialAccount/Platform.php +++ b/app/Enums/SocialAccount/Platform.php @@ -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 + */ + 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) { diff --git a/app/Enums/User/Persona.php b/app/Enums/User/Persona.php new file mode 100644 index 00000000..4b1f6cb6 --- /dev/null +++ b/app/Enums/User/Persona.php @@ -0,0 +1,15 @@ + + */ + public function broadcastOn(): array + { + return [ + new PrivateChannel("workspace.{$this->workspaceId}"), + ]; + } + + /** + * @return array + */ + public function broadcastWith(): array + { + return [ + 'nonce' => $this->nonce, + 'reason' => $this->reason, + ]; + } + + public function broadcastQueue(): string + { + return 'broadcasts'; + } +} diff --git a/app/Exceptions/SocialAccount/NetworkAlreadyConnectedException.php b/app/Exceptions/SocialAccount/NetworkAlreadyConnectedException.php new file mode 100644 index 00000000..af66de5a --- /dev/null +++ b/app/Exceptions/SocialAccount/NetworkAlreadyConnectedException.php @@ -0,0 +1,16 @@ +network()} account connected."); + } +} diff --git a/app/Features/MemberLimit.php b/app/Features/MemberLimit.php deleted file mode 100644 index a1cedb15..00000000 --- a/app/Features/MemberLimit.php +++ /dev/null @@ -1,17 +0,0 @@ -plan?->member_limit ?? 1; - } -} diff --git a/app/Features/MonthlyCreditsLimit.php b/app/Features/MonthlyCreditsLimit.php deleted file mode 100644 index 9b565d68..00000000 --- a/app/Features/MonthlyCreditsLimit.php +++ /dev/null @@ -1,17 +0,0 @@ -plan?->monthly_credits_limit ?? 1000; - } -} diff --git a/app/Features/SocialAccountLimit.php b/app/Features/SocialAccountLimit.php deleted file mode 100644 index 0fa1d460..00000000 --- a/app/Features/SocialAccountLimit.php +++ /dev/null @@ -1,17 +0,0 @@ -plan?->social_account_limit ?? 5; - } -} diff --git a/app/Features/WorkspaceLimit.php b/app/Features/WorkspaceLimit.php deleted file mode 100644 index 64328819..00000000 --- a/app/Features/WorkspaceLimit.php +++ /dev/null @@ -1,17 +0,0 @@ -plan?->workspace_limit ?? 1; - } -} diff --git a/app/Http/Controllers/App/BillingController.php b/app/Http/Controllers/App/BillingController.php index 3ea14e0d..e85d36c5 100644 --- a/app/Http/Controllers/App/BillingController.php +++ b/app/Http/Controllers/App/BillingController.php @@ -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 diff --git a/app/Http/Controllers/App/OnboardingController.php b/app/Http/Controllers/App/OnboardingController.php new file mode 100644 index 00000000..06410983 --- /dev/null +++ b/app/Http/Controllers/App/OnboardingController.php @@ -0,0 +1,131 @@ +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'), + ); + } +} diff --git a/app/Http/Controllers/App/Settings/ProfileController.php b/app/Http/Controllers/App/Settings/ProfileController.php index 880927ab..24d05741 100644 --- a/app/Http/Controllers/App/Settings/ProfileController.php +++ b/app/Http/Controllers/App/Settings/ProfileController.php @@ -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')) ); diff --git a/app/Http/Controllers/App/Settings/UsageController.php b/app/Http/Controllers/App/Settings/UsageController.php index 0aedcab2..7940ef58 100644 --- a/app/Http/Controllers/App/Settings/UsageController.php +++ b/app/Http/Controllers/App/Settings/UsageController.php @@ -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(), ], ]); } diff --git a/app/Http/Controllers/App/WorkspaceController.php b/app/Http/Controllers/App/WorkspaceController.php index d670c8e0..ee500be8 100644 --- a/app/Http/Controllers/App/WorkspaceController.php +++ b/app/Http/Controllers/App/WorkspaceController.php @@ -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; - } } diff --git a/app/Http/Controllers/App/WorkspaceInviteController.php b/app/Http/Controllers/App/WorkspaceInviteController.php index cf43fade..69cab4d2 100644 --- a/app/Http/Controllers/App/WorkspaceInviteController.php +++ b/app/Http/Controllers/App/WorkspaceInviteController.php @@ -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.']); diff --git a/app/Http/Controllers/Auth/AcceptInviteController.php b/app/Http/Controllers/Auth/AcceptInviteController.php index fa7454e9..ea0db7f7 100644 --- a/app/Http/Controllers/Auth/AcceptInviteController.php +++ b/app/Http/Controllers/Auth/AcceptInviteController.php @@ -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 diff --git a/app/Http/Controllers/Auth/BlueskyController.php b/app/Http/Controllers/Auth/BlueskyController.php index e262daaa..6b3630b9 100644 --- a/app/Http/Controllers/Auth/BlueskyController.php +++ b/app/Http/Controllers/Auth/BlueskyController.php @@ -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() ?? [], diff --git a/app/Http/Controllers/Auth/FacebookController.php b/app/Http/Controllers/Auth/FacebookController.php index d91eb252..90d8b3de 100644 --- a/app/Http/Controllers/Auth/FacebookController.php +++ b/app/Http/Controllers/Auth/FacebookController.php @@ -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(), diff --git a/app/Http/Controllers/Auth/InstagramController.php b/app/Http/Controllers/Auth/InstagramController.php index bc667e19..2fddbe2a 100644 --- a/app/Http/Controllers/Auth/InstagramController.php +++ b/app/Http/Controllers/Auth/InstagramController.php @@ -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(), diff --git a/app/Http/Controllers/Auth/InstagramFacebookController.php b/app/Http/Controllers/Auth/InstagramFacebookController.php index 625ec5dc..57eef9ad 100644 --- a/app/Http/Controllers/Auth/InstagramFacebookController.php +++ b/app/Http/Controllers/Auth/InstagramFacebookController.php @@ -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 diff --git a/app/Http/Controllers/Auth/LinkedInController.php b/app/Http/Controllers/Auth/LinkedInController.php index a493d725..05fb0b44 100644 --- a/app/Http/Controllers/Auth/LinkedInController.php +++ b/app/Http/Controllers/Auth/LinkedInController.php @@ -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(), diff --git a/app/Http/Controllers/Auth/LinkedInPageController.php b/app/Http/Controllers/Auth/LinkedInPageController.php index c60c6815..35ec1c59 100644 --- a/app/Http/Controllers/Auth/LinkedInPageController.php +++ b/app/Http/Controllers/Auth/LinkedInPageController.php @@ -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(), diff --git a/app/Http/Controllers/Auth/MastodonController.php b/app/Http/Controllers/Auth/MastodonController.php index c834e690..d42367b6 100644 --- a/app/Http/Controllers/Auth/MastodonController.php +++ b/app/Http/Controllers/Auth/MastodonController.php @@ -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() ?? [], diff --git a/app/Http/Controllers/Auth/RegisteredUserController.php b/app/Http/Controllers/Auth/RegisteredUserController.php index dd13f0f5..a22c77e8 100644 --- a/app/Http/Controllers/Auth/RegisteredUserController.php +++ b/app/Http/Controllers/Auth/RegisteredUserController.php @@ -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); diff --git a/app/Http/Controllers/Auth/SocialController.php b/app/Http/Controllers/Auth/SocialController.php index 6d67ff69..ec9c5009 100644 --- a/app/Http/Controllers/Auth/SocialController.php +++ b/app/Http/Controllers/Auth/SocialController.php @@ -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'); } /** diff --git a/app/Http/Controllers/Auth/TelegramController.php b/app/Http/Controllers/Auth/TelegramController.php index e24fa9cf..309a4606 100644 --- a/app/Http/Controllers/Auth/TelegramController.php +++ b/app/Http/Controllers/Auth/TelegramController.php @@ -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); diff --git a/app/Http/Controllers/Auth/ThreadsController.php b/app/Http/Controllers/Auth/ThreadsController.php index f3c4f418..3455226e 100644 --- a/app/Http/Controllers/Auth/ThreadsController.php +++ b/app/Http/Controllers/Auth/ThreadsController.php @@ -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(), diff --git a/app/Http/Controllers/Auth/YouTubeController.php b/app/Http/Controllers/Auth/YouTubeController.php index 59be9062..a1758a6b 100644 --- a/app/Http/Controllers/Auth/YouTubeController.php +++ b/app/Http/Controllers/Auth/YouTubeController.php @@ -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(), diff --git a/app/Http/Middleware/App/EnsureAccountReady.php b/app/Http/Middleware/App/EnsureAccountReady.php index 20a0900f..775bc3fa 100644 --- a/app/Http/Middleware/App/EnsureAccountReady.php +++ b/app/Http/Middleware/App/EnsureAccountReady.php @@ -32,7 +32,7 @@ public function handle(Request $request, Closure $next): Response ); if (! $hasAccess) { - return redirect()->route('app.subscribe'); + return redirect()->route('app.onboarding'); } } diff --git a/app/Http/Middleware/App/HandleInertiaRequests.php b/app/Http/Middleware/App/HandleInertiaRequests.php index 5d46fa0c..5e51d6d8 100644 --- a/app/Http/Middleware/App/HandleInertiaRequests.php +++ b/app/Http/Middleware/App/HandleInertiaRequests.php @@ -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'), diff --git a/app/Http/Requests/App/Invite/StoreWorkspaceInviteRequest.php b/app/Http/Requests/App/Invite/StoreWorkspaceInviteRequest.php index 5ec895be..4c27759a 100644 --- a/app/Http/Requests/App/Invite/StoreWorkspaceInviteRequest.php +++ b/app/Http/Requests/App/Invite/StoreWorkspaceInviteRequest.php @@ -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'))], ]; } diff --git a/app/Http/Requests/App/Onboarding/StoreOnboardingRequest.php b/app/Http/Requests/App/Onboarding/StoreOnboardingRequest.php new file mode 100644 index 00000000..ee0d7428 --- /dev/null +++ b/app/Http/Requests/App/Onboarding/StoreOnboardingRequest.php @@ -0,0 +1,27 @@ + + */ + public function rules(): array + { + return [ + 'persona' => ['required', Rule::enum(Persona::class)], + ]; + } +} diff --git a/app/Http/Requests/App/Workspace/AutofillBrandRequest.php b/app/Http/Requests/App/Workspace/AutofillBrandRequest.php new file mode 100644 index 00000000..1e63a91e --- /dev/null +++ b/app/Http/Requests/App/Workspace/AutofillBrandRequest.php @@ -0,0 +1,25 @@ + + */ + public function rules(): array + { + return [ + 'url' => ['required', 'string', 'max:255'], + ]; + } +} diff --git a/app/Jobs/PostHog/TrackBilling.php b/app/Jobs/PostHog/TrackBilling.php index 59f2a1a9..5994b0ed 100644 --- a/app/Jobs/PostHog/TrackBilling.php +++ b/app/Jobs/PostHog/TrackBilling.php @@ -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, ); diff --git a/app/Listeners/StripeEventListener.php b/app/Listeners/StripeEventListener.php index 4a701c0b..14e0cd19 100644 --- a/app/Listeners/StripeEventListener.php +++ b/app/Listeners/StripeEventListener.php @@ -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); } diff --git a/app/Models/Account.php b/app/Models/Account.php index eafefef1..b8e31b00 100644 --- a/app/Models/Account.php +++ b/app/Models/Account.php @@ -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')) { diff --git a/app/Models/AiUsageLog.php b/app/Models/AiUsageLog.php index 00207334..b7b3016b 100644 --- a/app/Models/AiUsageLog.php +++ b/app/Models/AiUsageLog.php @@ -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'); } } diff --git a/app/Models/Plan.php b/app/Models/Plan.php index 49088c62..74cc1226 100644 --- a/app/Models/Plan.php +++ b/app/Models/Plan.php @@ -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', ]; diff --git a/app/Models/Traits/HasUsage.php b/app/Models/Traits/HasUsage.php index 099a68c5..f37b2ff2 100644 --- a/app/Models/Traits/HasUsage.php +++ b/app/Models/Traits/HasUsage.php @@ -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(), ]; } diff --git a/app/Models/User.php b/app/Models/User.php index dafee99d..da109ae4 100644 --- a/app/Models/User.php +++ b/app/Models/User.php @@ -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, ]; } diff --git a/app/Observers/SocialAccountObserver.php b/app/Observers/SocialAccountObserver.php index 5ca57281..fb41f597 100644 --- a/app/Observers/SocialAccountObserver.php +++ b/app/Observers/SocialAccountObserver.php @@ -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); diff --git a/app/Policies/AccountPolicy.php b/app/Policies/AccountPolicy.php index a45916c9..b7042f16 100644 --- a/app/Policies/AccountPolicy.php +++ b/app/Policies/AccountPolicy.php @@ -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(); } } diff --git a/app/Policies/WorkspacePolicy.php b/app/Policies/WorkspacePolicy.php index 865b87ab..ad4f40b3 100644 --- a/app/Policies/WorkspacePolicy.php +++ b/app/Policies/WorkspacePolicy.php @@ -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 diff --git a/app/Providers/AppServiceProvider.php b/app/Providers/AppServiceProvider.php index 1ea3193d..960c0244 100644 --- a/app/Providers/AppServiceProvider.php +++ b/app/Providers/AppServiceProvider.php @@ -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(); } diff --git a/app/Rules/Timezone.php b/app/Rules/Timezone.php deleted file mode 100644 index 2432498f..00000000 --- a/app/Rules/Timezone.php +++ /dev/null @@ -1,24 +0,0 @@ -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); } diff --git a/app/Support/BillingCycle.php b/app/Support/BillingCycle.php new file mode 100644 index 00000000..cb4b1c56 --- /dev/null +++ b/app/Support/BillingCycle.php @@ -0,0 +1,141 @@ +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; + } +} diff --git a/boost.json b/boost.json index a4616e75..0703bb2c 100644 --- a/boost.json +++ b/boost.json @@ -15,7 +15,6 @@ "mcp-development", "configure-nightwatch", "passport-development", - "pennant-development", "socialite-development", "wayfinder-development", "pest-testing", diff --git a/composer.json b/composer.json index 644ed3ef..5768bda8 100644 --- a/composer.json +++ b/composer.json @@ -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", diff --git a/composer.lock b/composer.lock index a7d03236..e556996b 100644 --- a/composer.lock +++ b/composer.lock @@ -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", diff --git a/database/factories/PlanFactory.php b/database/factories/PlanFactory.php index c722c81b..522130c1 100644 --- a/database/factories/PlanFactory.php +++ b/database/factories/PlanFactory.php @@ -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, ]; diff --git a/database/factories/UserFactory.php b/database/factories/UserFactory.php index 61edb164..8caa1162 100644 --- a/database/factories/UserFactory.php +++ b/database/factories/UserFactory.php @@ -46,6 +46,7 @@ public function definition(): array 'utm_term' => null, 'utm_content' => null, 'registration_ip' => null, + 'persona' => null, ]; } diff --git a/database/migrations/2026_06_21_184042_drop_limit_columns_from_plans_table.php b/database/migrations/2026_06_21_184042_drop_limit_columns_from_plans_table.php new file mode 100644 index 00000000..d10fc473 --- /dev/null +++ b/database/migrations/2026_06_21_184042_drop_limit_columns_from_plans_table.php @@ -0,0 +1,32 @@ +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); + }); + } +}; diff --git a/database/migrations/2026_06_21_194741_add_persona_to_users_table.php b/database/migrations/2026_06_21_194741_add_persona_to_users_table.php new file mode 100644 index 00000000..ea18c687 --- /dev/null +++ b/database/migrations/2026_06_21_194741_add_persona_to_users_table.php @@ -0,0 +1,30 @@ +string('persona')->nullable()->after('registration_ip'); + }); + } + + /** + * Reverse the migrations. + */ + public function down(): void + { + Schema::table('users', function (Blueprint $table) { + $table->dropColumn('persona'); + }); + } +}; diff --git a/database/migrations/2026_04_14_213055_create_features_table.php b/database/migrations/2026_06_22_162523_drop_features_table.php similarity index 86% rename from database/migrations/2026_04_14_213055_create_features_table.php rename to database/migrations/2026_06_22_162523_drop_features_table.php index a64eea2f..f93f11ba 100644 --- a/database/migrations/2026_04_14_213055_create_features_table.php +++ b/database/migrations/2026_06_22_162523_drop_features_table.php @@ -1,15 +1,23 @@ id(); @@ -21,12 +29,4 @@ public function up(): void $table->unique(['name', 'scope']); }); } - - /** - * Reverse the migrations. - */ - public function down(): void - { - Schema::dropIfExists('features'); - } }; diff --git a/database/seeders/PlanSeeder.php b/database/seeders/PlanSeeder.php index fed08472..65b73134 100644 --- a/database/seeders/PlanSeeder.php +++ b/database/seeders/PlanSeeder.php @@ -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, - ); - } + ); } } diff --git a/database/seeders/UserSeeder.php b/database/seeders/UserSeeder.php index a3a28b5d..0edd6a01 100644 --- a/database/seeders/UserSeeder.php +++ b/database/seeders/UserSeeder.php @@ -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'); diff --git a/lang/en/accounts.php b/lang/en/accounts.php index 52871353..8359c84c 100644 --- a/lang/en/accounts.php +++ b/lang/en/accounts.php @@ -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.', diff --git a/lang/en/auth.php b/lang/en/auth.php index 9662c88d..18dd53c1 100644 --- a/lang/en/auth.php +++ b/lang/en/auth.php @@ -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', diff --git a/lang/en/billing.php b/lang/en/billing.php index 915b0eca..ed4ffa1f 100644 --- a/lang/en/billing.php +++ b/lang/en/billing.php @@ -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' => [ diff --git a/lang/en/common.php b/lang/en/common.php index 7d29a8b0..b90f4a9e 100644 --- a/lang/en/common.php +++ b/lang/en/common.php @@ -4,11 +4,6 @@ return [ - 'discord' => [ - 'label' => 'Discord', - 'join' => 'Join our Discord community', - ], - 'back' => 'Back', 'confirm_modal' => [ diff --git a/lang/en/onboarding.php b/lang/en/onboarding.php new file mode 100644 index 00000000..071288cf --- /dev/null +++ b/lang/en/onboarding.php @@ -0,0 +1,22 @@ + '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.', + ], +]; diff --git a/lang/en/settings.php b/lang/en/settings.php index cb0b29ea..66e184fd 100644 --- a/lang/en/settings.php +++ b/lang/en/settings.php @@ -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!', diff --git a/lang/en/usage.php b/lang/en/usage.php index 17c37d9b..3d348a0e 100644 --- a/lang/en/usage.php +++ b/lang/en/usage.php @@ -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.', diff --git a/lang/en/workspaces.php b/lang/en/workspaces.php index 4b023271..e43c5e8e 100644 --- a/lang/en/workspaces.php +++ b/lang/en/workspaces.php @@ -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.', diff --git a/lang/es/accounts.php b/lang/es/accounts.php index 3a8ed353..3df54c2b 100644 --- a/lang/es/accounts.php +++ b/lang/es/accounts.php @@ -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.', diff --git a/lang/es/auth.php b/lang/es/auth.php index 383fb2ba..4acdad96 100644 --- a/lang/es/auth.php +++ b/lang/es/auth.php @@ -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', diff --git a/lang/es/billing.php b/lang/es/billing.php index 28f6b422..deb54eb1 100644 --- a/lang/es/billing.php +++ b/lang/es/billing.php @@ -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' => [ diff --git a/lang/es/common.php b/lang/es/common.php index 115c0549..c85c9c0f 100644 --- a/lang/es/common.php +++ b/lang/es/common.php @@ -4,11 +4,6 @@ return [ - 'discord' => [ - 'label' => 'Discord', - 'join' => 'Unite a nuestra comunidad en Discord', - ], - 'back' => 'Volver', 'confirm_modal' => [ diff --git a/lang/es/onboarding.php b/lang/es/onboarding.php new file mode 100644 index 00000000..eed12c9c --- /dev/null +++ b/lang/es/onboarding.php @@ -0,0 +1,22 @@ + '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.', + ], +]; diff --git a/lang/es/settings.php b/lang/es/settings.php index a93de88b..5225bd0c 100644 --- a/lang/es/settings.php +++ b/lang/es/settings.php @@ -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!', diff --git a/lang/es/usage.php b/lang/es/usage.php index 6468f119..f71b9264 100644 --- a/lang/es/usage.php +++ b/lang/es/usage.php @@ -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.', diff --git a/lang/es/workspaces.php b/lang/es/workspaces.php index 41b1d087..78789e5f 100644 --- a/lang/es/workspaces.php +++ b/lang/es/workspaces.php @@ -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.', diff --git a/lang/pt-BR/accounts.php b/lang/pt-BR/accounts.php index a4382bb9..71baa511 100644 --- a/lang/pt-BR/accounts.php +++ b/lang/pt-BR/accounts.php @@ -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.', diff --git a/lang/pt-BR/auth.php b/lang/pt-BR/auth.php index 192cbe0b..6ce4dcb4 100644 --- a/lang/pt-BR/auth.php +++ b/lang/pt-BR/auth.php @@ -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', diff --git a/lang/pt-BR/billing.php b/lang/pt-BR/billing.php index 618a9672..52e5e9f7 100644 --- a/lang/pt-BR/billing.php +++ b/lang/pt-BR/billing.php @@ -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' => [ diff --git a/lang/pt-BR/common.php b/lang/pt-BR/common.php index 9826eebc..6cfae2e2 100644 --- a/lang/pt-BR/common.php +++ b/lang/pt-BR/common.php @@ -4,11 +4,6 @@ return [ - 'discord' => [ - 'label' => 'Discord', - 'join' => 'Entre na nossa comunidade no Discord', - ], - 'back' => 'Voltar', 'confirm_modal' => [ diff --git a/lang/pt-BR/onboarding.php b/lang/pt-BR/onboarding.php new file mode 100644 index 00000000..408270a8 --- /dev/null +++ b/lang/pt-BR/onboarding.php @@ -0,0 +1,22 @@ + '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.', + ], +]; diff --git a/lang/pt-BR/settings.php b/lang/pt-BR/settings.php index 5a483f1b..00594e70 100644 --- a/lang/pt-BR/settings.php +++ b/lang/pt-BR/settings.php @@ -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!', diff --git a/lang/pt-BR/usage.php b/lang/pt-BR/usage.php index bd21cee1..cd49ec21 100644 --- a/lang/pt-BR/usage.php +++ b/lang/pt-BR/usage.php @@ -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.', diff --git a/lang/pt-BR/workspaces.php b/lang/pt-BR/workspaces.php index caeabd6f..403d7e4f 100644 --- a/lang/pt-BR/workspaces.php +++ b/lang/pt-BR/workspaces.php @@ -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.', diff --git a/resources/js/components/AppSidebar.vue b/resources/js/components/AppSidebar.vue index e48ce749..f472b314 100644 --- a/resources/js/components/AppSidebar.vue +++ b/resources/js/components/AppSidebar.vue @@ -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()); }; diff --git a/resources/js/components/DiscordWidget.vue b/resources/js/components/DiscordWidget.vue deleted file mode 100644 index 52e3d339..00000000 --- a/resources/js/components/DiscordWidget.vue +++ /dev/null @@ -1,27 +0,0 @@ - - - diff --git a/resources/js/components/UpgradeDialog.vue b/resources/js/components/UpgradeDialog.vue deleted file mode 100644 index b23dc1a8..00000000 --- a/resources/js/components/UpgradeDialog.vue +++ /dev/null @@ -1,319 +0,0 @@ - - - diff --git a/resources/js/components/accounts/AddSocialDialog.vue b/resources/js/components/accounts/AddSocialDialog.vue deleted file mode 100644 index fdee97f1..00000000 --- a/resources/js/components/accounts/AddSocialDialog.vue +++ /dev/null @@ -1,208 +0,0 @@ - - - diff --git a/resources/js/components/accounts/NetworkConnectGrid.vue b/resources/js/components/accounts/NetworkConnectGrid.vue new file mode 100644 index 00000000..9058817b --- /dev/null +++ b/resources/js/components/accounts/NetworkConnectGrid.vue @@ -0,0 +1,347 @@ + + + diff --git a/resources/js/components/accounts/TelegramConnectDialog.vue b/resources/js/components/accounts/TelegramConnectDialog.vue index 7ec1425a..5cec83e6 100644 --- a/resources/js/components/accounts/TelegramConnectDialog.vue +++ b/resources/js/components/accounts/TelegramConnectDialog.vue @@ -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 = ''; diff --git a/resources/js/components/auth/SocialLogin.vue b/resources/js/components/auth/SocialLogin.vue index ca396868..b6d196e6 100644 --- a/resources/js/components/auth/SocialLogin.vue +++ b/resources/js/components/auth/SocialLogin.vue @@ -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);
{{ $t('auth.or_continue_with') }} diff --git a/resources/js/components/settings/UsersTab.vue b/resources/js/components/settings/UsersTab.vue index d9cfd119..5463726e 100644 --- a/resources/js/components/settings/UsersTab.vue +++ b/resources/js/components/settings/UsersTab.vue @@ -1,8 +1,7 @@ diff --git a/resources/js/pages/auth/Register.vue b/resources/js/pages/auth/Register.vue index 9d115483..30c30dcf 100644 --- a/resources/js/pages/auth/Register.vue +++ b/resources/js/pages/auth/Register.vue @@ -1,6 +1,6 @@