From 5b00b0e54cfd42eec522bccb972cf1dfb6408189 Mon Sep 17 00:00:00 2001 From: Paulo Castellano Date: Tue, 8 Sep 2026 14:33:34 -0300 Subject: [PATCH 01/29] Add Socials and Workspaces plans with a workspace limit column --- .env.example | 4 +++ app/Enums/Plan/Slug.php | 4 +++ app/Models/Plan.php | 2 ++ database/factories/PlanFactory.php | 12 +++++-- ...316_add_workspace_limit_to_plans_table.php | 29 +++++++++++++++ database/seeders/PlanSeeder.php | 35 +++++++++++++++++-- tests/Feature/PlanSeederTest.php | 35 ++++++++++++------- tests/Feature/PlanTest.php | 12 +++++-- 8 files changed, 113 insertions(+), 20 deletions(-) create mode 100644 database/migrations/2026_09_08_173316_add_workspace_limit_to_plans_table.php diff --git a/.env.example b/.env.example index f1c92bc44..7a5de38c9 100644 --- a/.env.example +++ b/.env.example @@ -266,6 +266,10 @@ CASHIER_ALLOW_PROMOTION_CODES=false # Stripe Plan Price IDs (one per plan × interval). Used by PlanSeeder. STRIPE_WORKSPACE_MONTHLY= STRIPE_WORKSPACE_YEARLY= +STRIPE_SOCIALS_MONTHLY= +STRIPE_SOCIALS_YEARLY= +STRIPE_WORKSPACES_MONTHLY= +STRIPE_WORKSPACES_YEARLY= # Laravel Nightwatch (production telemetry — disabled by default in dev) NIGHTWATCH_ENABLED=false diff --git a/app/Enums/Plan/Slug.php b/app/Enums/Plan/Slug.php index b750b4bf9..c9e27c298 100644 --- a/app/Enums/Plan/Slug.php +++ b/app/Enums/Plan/Slug.php @@ -7,11 +7,15 @@ enum Slug: string { case Workspace = 'workspace'; + case Socials = 'socials'; + case Workspaces = 'workspaces'; public function label(): string { return match ($this) { self::Workspace => 'Workspace', + self::Socials => 'Socials', + self::Workspaces => 'Workspaces', }; } } diff --git a/app/Models/Plan.php b/app/Models/Plan.php index 74cc1226a..b4108a925 100644 --- a/app/Models/Plan.php +++ b/app/Models/Plan.php @@ -23,6 +23,7 @@ class Plan extends Model 'stripe_monthly_price_id', 'stripe_yearly_price_id', 'monthly_credits_limit', + 'workspace_limit', 'sort', 'is_archived', ]; @@ -33,6 +34,7 @@ protected function casts(): array 'slug' => Slug::class, 'is_archived' => 'boolean', 'monthly_credits_limit' => 'integer', + 'workspace_limit' => 'integer', 'sort' => 'integer', ]; } diff --git a/database/factories/PlanFactory.php b/database/factories/PlanFactory.php index fd3d562fe..e84cf6e00 100644 --- a/database/factories/PlanFactory.php +++ b/database/factories/PlanFactory.php @@ -21,16 +21,24 @@ class PlanFactory extends Factory public function definition(): array { return [ - 'slug' => Slug::Workspace, - 'name' => 'Workspace', + 'slug' => Slug::Socials, + 'name' => 'Socials', 'stripe_monthly_price_id' => null, 'stripe_yearly_price_id' => null, 'monthly_credits_limit' => 2500, + 'workspace_limit' => 1, 'sort' => 0, 'is_archived' => false, ]; } + public function unlimited(): static + { + return $this->state(fn (array $attributes): array => [ + 'workspace_limit' => null, + ]); + } + public function archived(): static { return $this->state(fn (array $attributes): array => [ diff --git a/database/migrations/2026_09_08_173316_add_workspace_limit_to_plans_table.php b/database/migrations/2026_09_08_173316_add_workspace_limit_to_plans_table.php new file mode 100644 index 000000000..bc33d5703 --- /dev/null +++ b/database/migrations/2026_09_08_173316_add_workspace_limit_to_plans_table.php @@ -0,0 +1,29 @@ +unsignedInteger('workspace_limit')->nullable()->after('stripe_yearly_price_id'); + }); + + // null means unlimited. Existing rows (the legacy workspace plan) must + // not read as unlimited between deploy and PlanSeeder. + DB::table('plans')->whereNull('workspace_limit')->update(['workspace_limit' => 1]); + } + + public function down(): void + { + Schema::table('plans', function (Blueprint $table) { + $table->dropColumn('workspace_limit'); + }); + } +}; diff --git a/database/seeders/PlanSeeder.php b/database/seeders/PlanSeeder.php index 65b731347..0181d9e93 100644 --- a/database/seeders/PlanSeeder.php +++ b/database/seeders/PlanSeeder.php @@ -11,10 +11,38 @@ class PlanSeeder extends Seeder { /** - * Run the database seeds. + * Keyed by slug so a production run archives the legacy plan and adds the + * two new ones without touching accounts.plan_id, which references plans + * by UUID. A null workspace_limit means unlimited. */ public function run(): void { + Plan::updateOrCreate( + ['slug' => Slug::Socials], + [ + 'name' => 'Socials', + 'stripe_monthly_price_id' => env('STRIPE_SOCIALS_MONTHLY'), + 'stripe_yearly_price_id' => env('STRIPE_SOCIALS_YEARLY'), + 'monthly_credits_limit' => 2500, + 'workspace_limit' => 1, + 'sort' => 1, + 'is_archived' => false, + ], + ); + + Plan::updateOrCreate( + ['slug' => Slug::Workspaces], + [ + 'name' => 'Workspaces', + 'stripe_monthly_price_id' => env('STRIPE_WORKSPACES_MONTHLY'), + 'stripe_yearly_price_id' => env('STRIPE_WORKSPACES_YEARLY'), + 'monthly_credits_limit' => 2500, + 'workspace_limit' => null, + 'sort' => 2, + 'is_archived' => false, + ], + ); + Plan::updateOrCreate( ['slug' => Slug::Workspace], [ @@ -22,8 +50,9 @@ public function run(): void '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, + 'workspace_limit' => 1, + 'sort' => 3, + 'is_archived' => true, ], ); } diff --git a/tests/Feature/PlanSeederTest.php b/tests/Feature/PlanSeederTest.php index 37167fc38..a8acd9d4d 100644 --- a/tests/Feature/PlanSeederTest.php +++ b/tests/Feature/PlanSeederTest.php @@ -6,23 +6,34 @@ use App\Models\Plan; use Database\Seeders\PlanSeeder; -test('seeder creates only the per-workspace plan', function () { - expect(Plan::count())->toBe(1); - - $workspace = Plan::where('slug', Slug::Workspace)->first(); - - expect($workspace->name)->toBe('Workspace') - ->and($workspace->monthly_credits_limit)->toBe(2500) - ->and($workspace->is_archived)->toBeFalse(); +test('seeder creates the two active plans and the archived legacy plan', function () { + expect(Plan::count())->toBe(3); + + $socials = Plan::where('slug', Slug::Socials)->first(); + $workspaces = Plan::where('slug', Slug::Workspaces)->first(); + $legacy = Plan::where('slug', Slug::Workspace)->first(); + + expect($socials->name)->toBe('Socials') + ->and($socials->workspace_limit)->toBe(1) + ->and($socials->is_archived)->toBeFalse() + ->and($workspaces->name)->toBe('Workspaces') + ->and($workspaces->workspace_limit)->toBeNull() + ->and($workspaces->is_archived)->toBeFalse() + ->and($legacy->name)->toBe('Workspace') + ->and($legacy->workspace_limit)->toBe(1) + ->and($legacy->is_archived)->toBeTrue(); }); test('seeder is idempotent', function () { $this->seed(PlanSeeder::class); - expect(Plan::count())->toBe(1); + expect(Plan::count())->toBe(3); }); -test('the per-workspace plan is active', function () { - expect(Plan::active()->count())->toBe(1) - ->and(Plan::active()->first()->slug)->toBe(Slug::Workspace); +test('only the two new plans are active, ordered by sort', function () { + $active = Plan::active()->orderBy('sort')->get(); + + expect($active)->toHaveCount(2) + ->and($active->first()->slug)->toBe(Slug::Socials) + ->and($active->last()->slug)->toBe(Slug::Workspaces); }); diff --git a/tests/Feature/PlanTest.php b/tests/Feature/PlanTest.php index 032d26453..ebe523b33 100644 --- a/tests/Feature/PlanTest.php +++ b/tests/Feature/PlanTest.php @@ -30,15 +30,21 @@ test('active scope excludes archived plans', function () { $activeBefore = Plan::active()->count(); - $plan = Plan::where('slug', Slug::Workspace)->first(); + $plan = Plan::where('slug', Slug::Socials)->first(); $plan->update(['is_archived' => true]); expect(Plan::active()->count())->toBe($activeBefore - 1); }); test('integer fields are cast correctly', function () { - $plan = Plan::where('slug', Slug::Workspace)->first(); + $plan = Plan::where('slug', Slug::Socials)->first(); - expect($plan->monthly_credits_limit)->toBeInt() + expect($plan->workspace_limit)->toBeInt() ->and($plan->sort)->toBeInt(); }); + +test('an unlimited plan stores a null workspace limit', function () { + $plan = Plan::where('slug', Slug::Workspaces)->first(); + + expect($plan->workspace_limit)->toBeNull(); +}); From 6be2f7aac48334453f4bdefe9453b8f3ac8c23ba Mon Sep 17 00:00:00 2001 From: Paulo Castellano Date: Tue, 8 Sep 2026 14:36:08 -0300 Subject: [PATCH 02/29] Remove the AI credit ceiling, keep usage logging --- .../App/Settings/UsageController.php | 3 -- app/Models/Account.php | 15 ++++-- app/Models/Plan.php | 2 - app/Models/Traits/HasUsage.php | 10 ++-- app/Policies/AccountPolicy.php | 16 ++----- app/Support/BillingCycle.php | 48 ++++++------------- database/factories/PlanFactory.php | 1 - ...monthly_credits_limit_from_plans_table.php | 24 ++++++++++ database/seeders/PlanSeeder.php | 3 -- lang/ar/billing.php | 1 - lang/ar/usage.php | 3 -- lang/de/billing.php | 1 - lang/de/usage.php | 3 -- lang/el/billing.php | 1 - lang/el/usage.php | 3 -- lang/en/billing.php | 1 - lang/en/usage.php | 3 -- lang/es/billing.php | 1 - lang/es/usage.php | 3 -- lang/fr/billing.php | 1 - lang/fr/usage.php | 3 -- lang/it/billing.php | 1 - lang/it/usage.php | 3 -- lang/ja/billing.php | 1 - lang/ja/usage.php | 3 -- lang/ko/billing.php | 1 - lang/ko/usage.php | 3 -- lang/nl/billing.php | 1 - lang/nl/usage.php | 3 -- lang/pl/billing.php | 1 - lang/pl/usage.php | 3 -- lang/pt-BR/billing.php | 1 - lang/pt-BR/usage.php | 3 -- lang/ru/billing.php | 1 - lang/ru/usage.php | 3 -- lang/tr/billing.php | 1 - lang/tr/usage.php | 3 -- lang/uk/billing.php | 1 - lang/uk/usage.php | 3 -- lang/zh/billing.php | 1 - lang/zh/usage.php | 3 -- resources/js/pages/settings/account/Usage.vue | 21 -------- tests/Feature/Billing/BillingCycleTest.php | 41 ---------------- tests/Feature/Models/HasUsageTraitTest.php | 28 ++++++----- tests/Pest.php | 3 +- tests/Unit/Policies/AccountPolicyTest.php | 11 ++--- 46 files changed, 79 insertions(+), 211 deletions(-) create mode 100644 database/migrations/2026_09_08_173445_drop_monthly_credits_limit_from_plans_table.php diff --git a/app/Http/Controllers/App/Settings/UsageController.php b/app/Http/Controllers/App/Settings/UsageController.php index 7940ef58a..d62f36b9c 100644 --- a/app/Http/Controllers/App/Settings/UsageController.php +++ b/app/Http/Controllers/App/Settings/UsageController.php @@ -5,7 +5,6 @@ namespace App\Http\Controllers\App\Settings; use App\Http\Controllers\App\Controller; -use App\Support\BillingCycle; use Illuminate\Http\RedirectResponse; use Illuminate\Http\Request; use Inertia\Inertia; @@ -36,8 +35,6 @@ public function index(Request $request): Response|RedirectResponse 'workspaceCount' => $account->workspaces->count(), 'socialAccountCount' => $totalSocialAccounts, 'memberCount' => $totalMembers, - 'creditsUsed' => BillingCycle::for($account)->usedCredits(), - 'monthlyCreditsLimit' => BillingCycle::for($account)->creditAllotment(), ], ]); } diff --git a/app/Models/Account.php b/app/Models/Account.php index f082e6e39..934c6c91e 100644 --- a/app/Models/Account.php +++ b/app/Models/Account.php @@ -99,10 +99,19 @@ public function hasAppAccess(): bool } /** - * 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). + * Workspaces the account's plan allows. Null means unlimited — both for a + * plan with no cap and for self-hosted. A missing plan_id is not the + * Workspaces plan: `canCreateWorkspace()` treats that as signup-only. */ + public function workspaceLimit(): ?int + { + if (config('trypost.self_hosted')) { + return null; + } + + return $this->plan?->workspace_limit; + } + public function syncWorkspaceQuantity(): void { if (config('trypost.self_hosted')) { diff --git a/app/Models/Plan.php b/app/Models/Plan.php index b4108a925..21904005b 100644 --- a/app/Models/Plan.php +++ b/app/Models/Plan.php @@ -22,7 +22,6 @@ class Plan extends Model 'name', 'stripe_monthly_price_id', 'stripe_yearly_price_id', - 'monthly_credits_limit', 'workspace_limit', 'sort', 'is_archived', @@ -33,7 +32,6 @@ protected function casts(): array return [ 'slug' => Slug::class, 'is_archived' => 'boolean', - 'monthly_credits_limit' => 'integer', 'workspace_limit' => 'integer', 'sort' => 'integer', ]; diff --git a/app/Models/Traits/HasUsage.php b/app/Models/Traits/HasUsage.php index f37b2ff23..2129806d1 100644 --- a/app/Models/Traits/HasUsage.php +++ b/app/Models/Traits/HasUsage.php @@ -13,9 +13,7 @@ /** * Provides account-level usage counts and plan-resolved feature limits. * - * `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. + * `featureLimits()` exposes the plan workspace cap (`null` = unlimited). */ trait HasUsage { @@ -49,13 +47,11 @@ public function usage(): array } /** - * @return array{monthlyCreditsLimit: int} + * @return array{workspaceLimit: int|null} */ public function featureLimits(): array { - return [ - 'monthlyCreditsLimit' => BillingCycle::for($this)->creditAllotment(), - ]; + return ['workspaceLimit' => $this->workspaceLimit()]; } /** diff --git a/app/Policies/AccountPolicy.php b/app/Policies/AccountPolicy.php index 48be159f0..100b2fef7 100644 --- a/app/Policies/AccountPolicy.php +++ b/app/Policies/AccountPolicy.php @@ -6,7 +6,6 @@ use App\Models\Account; use App\Models\User; -use App\Support\BillingCycle; use Illuminate\Auth\Access\Response; class AccountPolicy @@ -22,9 +21,9 @@ public function manageBilling(User $user, Account $account): bool } /** - * 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. + * Authorize using AI features. Requires app access (active subscription or + * trial). There is no usage ceiling: AI usage is recorded for cost + * visibility, never metered against the account. */ public function useAi(User $user, Account $account): Response { @@ -36,15 +35,6 @@ public function useAi(User $user, Account $account): Response 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, - ])); - } - return Response::allow(); } diff --git a/app/Support/BillingCycle.php b/app/Support/BillingCycle.php index cb4b1c566..6255872f2 100644 --- a/app/Support/BillingCycle.php +++ b/app/Support/BillingCycle.php @@ -10,11 +10,9 @@ use Laravel\Cashier\Subscription; /** - * Resolves an account's current AI credit cycle: the allotment it is entitled to - * and the time window usage is measured against. The window follows the Stripe - * billing cycle (monthly or yearly), anchored on the subscription date — so an - * annual subscriber receives twelve months of credits upfront and resets on - * their renewal date, while a monthly subscriber resets each anniversary day. + * Resolves the time window AI usage is measured against. The window follows + * the Stripe billing cycle (monthly or yearly), anchored on the subscription + * date. Usage is recorded for cost visibility, not metered as an entitlement. */ class BillingCycle { @@ -32,29 +30,6 @@ public static function for(Account $account): self return new self($account); } - public function intervalMonths(): int - { - $subscription = $this->subscription(); - $plan = $this->account->plan; - - if ($subscription !== null - && $plan?->stripe_yearly_price_id !== null - && $subscription->stripe_price === $plan->stripe_yearly_price_id - ) { - return 12; - } - - return 1; - } - - public function creditAllotment(): int - { - $base = (int) ($this->account->plan?->monthly_credits_limit ?? 0); - $months = $this->onTrial() ? 1 : $this->intervalMonths(); - - return $base * $this->account->workspaces()->count() * $months; - } - public function usedCredits(): int { return AiUsageLog::creditsUsedBetween( @@ -98,7 +73,7 @@ private function computeWindow(): array $anchor = $this->anchor(); $now = CarbonImmutable::now(); - $step = $this->intervalMonths(); + $step = $this->isYearly() ? 12 : 1; $periods = 0; @@ -112,6 +87,16 @@ private function computeWindow(): array ]; } + private function isYearly(): bool + { + $subscription = $this->subscription(); + $plan = $this->account->plan; + + return $subscription !== null + && $plan?->stripe_yearly_price_id !== null + && $subscription->stripe_price === $plan->stripe_yearly_price_id; + } + private function anchor(): CarbonImmutable { $subscription = $this->subscription(); @@ -124,11 +109,6 @@ private function anchor(): CarbonImmutable return CarbonImmutable::parse($anchor); } - private function onTrial(): bool - { - return (bool) $this->subscription()?->onTrial(); - } - private function subscription(): ?Subscription { if (! $this->subscriptionResolved) { diff --git a/database/factories/PlanFactory.php b/database/factories/PlanFactory.php index e84cf6e00..e8ed8f87e 100644 --- a/database/factories/PlanFactory.php +++ b/database/factories/PlanFactory.php @@ -25,7 +25,6 @@ public function definition(): array 'name' => 'Socials', 'stripe_monthly_price_id' => null, 'stripe_yearly_price_id' => null, - 'monthly_credits_limit' => 2500, 'workspace_limit' => 1, 'sort' => 0, 'is_archived' => false, diff --git a/database/migrations/2026_09_08_173445_drop_monthly_credits_limit_from_plans_table.php b/database/migrations/2026_09_08_173445_drop_monthly_credits_limit_from_plans_table.php new file mode 100644 index 000000000..bc5793f6a --- /dev/null +++ b/database/migrations/2026_09_08_173445_drop_monthly_credits_limit_from_plans_table.php @@ -0,0 +1,24 @@ +dropColumn('monthly_credits_limit'); + }); + } + + public function down(): void + { + Schema::table('plans', function (Blueprint $table) { + $table->integer('monthly_credits_limit')->default(2500); + }); + } +}; diff --git a/database/seeders/PlanSeeder.php b/database/seeders/PlanSeeder.php index 0181d9e93..1c4878412 100644 --- a/database/seeders/PlanSeeder.php +++ b/database/seeders/PlanSeeder.php @@ -23,7 +23,6 @@ public function run(): void 'name' => 'Socials', 'stripe_monthly_price_id' => env('STRIPE_SOCIALS_MONTHLY'), 'stripe_yearly_price_id' => env('STRIPE_SOCIALS_YEARLY'), - 'monthly_credits_limit' => 2500, 'workspace_limit' => 1, 'sort' => 1, 'is_archived' => false, @@ -36,7 +35,6 @@ public function run(): void 'name' => 'Workspaces', 'stripe_monthly_price_id' => env('STRIPE_WORKSPACES_MONTHLY'), 'stripe_yearly_price_id' => env('STRIPE_WORKSPACES_YEARLY'), - 'monthly_credits_limit' => 2500, 'workspace_limit' => null, 'sort' => 2, 'is_archived' => false, @@ -49,7 +47,6 @@ public function run(): void 'name' => 'Workspace', 'stripe_monthly_price_id' => env('STRIPE_WORKSPACE_MONTHLY'), 'stripe_yearly_price_id' => env('STRIPE_WORKSPACE_YEARLY'), - 'monthly_credits_limit' => 2500, 'workspace_limit' => 1, 'sort' => 3, 'is_archived' => true, diff --git a/lang/ar/billing.php b/lang/ar/billing.php index d32643acc..d1032a5f6 100644 --- a/lang/ar/billing.php +++ b/lang/ar/billing.php @@ -59,7 +59,6 @@ 'plan_changed' => 'أنت الآن على خطة :plan.', 'switched_to_yearly' => 'أنت الآن على الفوترة السنوية.', 'cannot_manage' => 'يمكن لمالك الحساب فقط إدارة الفوترة.', - 'credits_exhausted' => 'نفد رصيد الذكاء الاصطناعي — تم استخدام مخصصك الشهري البالغ :limit. رقِّ خطتك أو انتظر حتى الشهر المقبل.', 'subscription_required' => 'يلزم وجود اشتراك نشط لاستخدام ميزات الذكاء الاصطناعي.', ], diff --git a/lang/ar/usage.php b/lang/ar/usage.php index fb709054c..a8665f47b 100644 --- a/lang/ar/usage.php +++ b/lang/ar/usage.php @@ -5,11 +5,8 @@ 'section_account' => 'الحساب', 'section_account_description' => 'الحصص والحدود الخاصة بخطتك.', - 'section_ai' => 'رصيد الذكاء الاصطناعي', - 'section_ai_description' => 'يُخصم الرصيد عند استخدام ميزات الذكاء الاصطناعي. يتم تجديده في اليوم الأول من كل شهر.', 'workspaces' => 'مساحات العمل', 'social_accounts' => 'الحسابات الاجتماعية', 'members' => 'الأعضاء', - 'credits' => 'الرصيد', ]; diff --git a/lang/de/billing.php b/lang/de/billing.php index b977e9f3c..bd708cc84 100644 --- a/lang/de/billing.php +++ b/lang/de/billing.php @@ -61,7 +61,6 @@ 'plan_changed' => 'Du nutzt jetzt den Tarif :plan.', 'switched_to_yearly' => 'Du nutzt jetzt die jährliche Abrechnung.', 'cannot_manage' => 'Nur der Kontoinhaber kann die Abrechnung verwalten.', - 'credits_exhausted' => 'Keine KI-Credits mehr – dein monatliches Kontingent von :limit ist aufgebraucht. Führe ein Upgrade durch oder warte bis zum nächsten Monat.', 'subscription_required' => 'Für die Nutzung der KI-Funktionen ist ein aktives Abonnement erforderlich.', ], diff --git a/lang/de/usage.php b/lang/de/usage.php index fd6cf4de0..c4ae2e8c3 100644 --- a/lang/de/usage.php +++ b/lang/de/usage.php @@ -7,11 +7,8 @@ 'section_account' => 'Konto', 'section_account_description' => 'Kontingente und Limits für deinen Tarif.', - 'section_ai' => 'KI-Credits', - 'section_ai_description' => 'Credits werden bei der Nutzung von KI-Funktionen abgezogen. Sie werden am Ersten jedes Monats zurückgesetzt.', 'workspaces' => 'Workspaces', 'social_accounts' => 'Social-Media-Konten', 'members' => 'Mitglieder', - 'credits' => 'Credits', ]; diff --git a/lang/el/billing.php b/lang/el/billing.php index fa643fe71..c6d2f07b2 100644 --- a/lang/el/billing.php +++ b/lang/el/billing.php @@ -59,7 +59,6 @@ 'plan_changed' => 'Είστε πλέον στο πρόγραμμα :plan.', 'switched_to_yearly' => 'Είστε πλέον σε ετήσια χρέωση.', 'cannot_manage' => 'Μόνο ο κάτοχος του λογαριασμού μπορεί να διαχειρίζεται τη χρέωση.', - 'credits_exhausted' => 'Εξαντλήθηκαν τα credits AI — το μηνιαίο σας όριο των :limit έχει χρησιμοποιηθεί. Αναβαθμίστε το πρόγραμμά σας ή περιμένετε μέχρι τον επόμενο μήνα.', 'subscription_required' => 'Απαιτείται ενεργή συνδρομή για τη χρήση των λειτουργιών AI.', ], diff --git a/lang/el/usage.php b/lang/el/usage.php index 4b6946455..0de620c75 100644 --- a/lang/el/usage.php +++ b/lang/el/usage.php @@ -5,11 +5,8 @@ 'section_account' => 'Λογαριασμός', 'section_account_description' => 'Όρια και ποσοστώσεις για το πρόγραμμά σας.', - 'section_ai' => 'Credits AI', - 'section_ai_description' => 'Τα credits χρεώνονται καθώς χρησιμοποιείτε τις λειτουργίες AI. Μηδενίζονται την πρώτη κάθε μήνα.', 'workspaces' => 'Workspaces', 'social_accounts' => 'Λογαριασμοί κοινωνικών δικτύων', 'members' => 'Μέλη', - 'credits' => 'Credits', ]; diff --git a/lang/en/billing.php b/lang/en/billing.php index ed4ffa1f7..1661b76ef 100644 --- a/lang/en/billing.php +++ b/lang/en/billing.php @@ -59,7 +59,6 @@ '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.', - '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.', ], diff --git a/lang/en/usage.php b/lang/en/usage.php index 3d348a0ec..6de52e927 100644 --- a/lang/en/usage.php +++ b/lang/en/usage.php @@ -5,11 +5,8 @@ 'section_account' => 'Account', '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.', 'workspaces' => 'Workspaces', 'social_accounts' => 'Social Accounts', 'members' => 'Members', - 'credits' => 'Credits', ]; diff --git a/lang/es/billing.php b/lang/es/billing.php index deb54eb12..bdd900837 100644 --- a/lang/es/billing.php +++ b/lang/es/billing.php @@ -59,7 +59,6 @@ '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.', - '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.', ], diff --git a/lang/es/usage.php b/lang/es/usage.php index f71b92642..e3db034b1 100644 --- a/lang/es/usage.php +++ b/lang/es/usage.php @@ -5,11 +5,8 @@ 'section_account' => 'Cuenta', '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.', 'workspaces' => 'Workspaces', 'social_accounts' => 'Cuentas Sociales', 'members' => 'Miembros', - 'credits' => 'Créditos', ]; diff --git a/lang/fr/billing.php b/lang/fr/billing.php index d423e220c..817a5374b 100644 --- a/lang/fr/billing.php +++ b/lang/fr/billing.php @@ -59,7 +59,6 @@ 'plan_changed' => 'Vous êtes maintenant sur le forfait :plan.', 'switched_to_yearly' => 'Vous êtes maintenant en facturation annuelle.', 'cannot_manage' => 'Seul le propriétaire du compte peut gérer la facturation.', - 'credits_exhausted' => 'Crédits IA épuisés — votre quota mensuel de :limit a été utilisé. Améliorez votre forfait ou attendez le mois prochain.', 'subscription_required' => 'Un abonnement actif est requis pour utiliser les fonctionnalités d\'IA.', ], diff --git a/lang/fr/usage.php b/lang/fr/usage.php index 8444698df..20fa7dccf 100644 --- a/lang/fr/usage.php +++ b/lang/fr/usage.php @@ -5,11 +5,8 @@ 'section_account' => 'Compte', 'section_account_description' => 'Quotas et limites de votre forfait.', - 'section_ai' => 'Crédits IA', - 'section_ai_description' => 'Les crédits sont débités à mesure que vous utilisez les fonctionnalités d\'IA. Ils sont réinitialisés le premier de chaque mois.', 'workspaces' => 'Espaces de travail', 'social_accounts' => 'Comptes sociaux', 'members' => 'Membres', - 'credits' => 'Crédits', ]; diff --git a/lang/it/billing.php b/lang/it/billing.php index 2af61f74e..a47d31205 100644 --- a/lang/it/billing.php +++ b/lang/it/billing.php @@ -59,7 +59,6 @@ 'plan_changed' => 'Ora sei sul piano :plan.', 'switched_to_yearly' => 'Ora hai la fatturazione annuale.', 'cannot_manage' => 'Solo il proprietario dell\'account può gestire la fatturazione.', - 'credits_exhausted' => 'Crediti IA esauriti: la tua quota mensile di :limit è stata utilizzata. Aggiorna il tuo piano o attendi il mese prossimo.', 'subscription_required' => 'È richiesto un abbonamento attivo per usare le funzioni IA.', ], diff --git a/lang/it/usage.php b/lang/it/usage.php index 0a03ffeb8..abf5d5c7a 100644 --- a/lang/it/usage.php +++ b/lang/it/usage.php @@ -5,11 +5,8 @@ 'section_account' => 'Account', 'section_account_description' => 'Quote e limiti del tuo piano.', - 'section_ai' => 'Crediti IA', - 'section_ai_description' => 'I crediti vengono scalati man mano che usi le funzioni IA. Si azzerano il primo di ogni mese.', 'workspaces' => 'Workspace', 'social_accounts' => 'Account social', 'members' => 'Membri', - 'credits' => 'Crediti', ]; diff --git a/lang/ja/billing.php b/lang/ja/billing.php index 3b73b4ce2..9a3192473 100644 --- a/lang/ja/billing.php +++ b/lang/ja/billing.php @@ -59,7 +59,6 @@ 'plan_changed' => ':plan プランに変更されました。', 'switched_to_yearly' => '年払いに変更されました。', 'cannot_manage' => 'お支払いを管理できるのはアカウントのオーナーのみです。', - 'credits_exhausted' => 'AI クレジットが不足しています — 今月の :limit の割り当てをすべて使い切りました。プランをアップグレードするか、来月までお待ちください。', 'subscription_required' => 'AI 機能を使用するには有効なサブスクリプションが必要です。', ], diff --git a/lang/ja/usage.php b/lang/ja/usage.php index 07b78ab8a..ca7c0deff 100644 --- a/lang/ja/usage.php +++ b/lang/ja/usage.php @@ -5,11 +5,8 @@ 'section_account' => 'アカウント', 'section_account_description' => 'プランのクォータと上限。', - 'section_ai' => 'AI クレジット', - 'section_ai_description' => 'クレジットは AI 機能の利用に応じて消費されます。毎月 1 日にリセットされます。', 'workspaces' => 'ワークスペース', 'social_accounts' => 'ソーシャルアカウント', 'members' => 'メンバー', - 'credits' => 'クレジット', ]; diff --git a/lang/ko/billing.php b/lang/ko/billing.php index 43b33b21f..fd50999f6 100644 --- a/lang/ko/billing.php +++ b/lang/ko/billing.php @@ -59,7 +59,6 @@ 'plan_changed' => '이제 :plan 요금제를 사용 중입니다.', 'switched_to_yearly' => '이제 연간 결제를 사용 중입니다.', 'cannot_manage' => '계정 소유자만 결제를 관리할 수 있습니다.', - 'credits_exhausted' => 'AI 크레딧 소진 — 월 :limit 한도를 모두 사용했습니다. 요금제를 업그레이드하거나 다음 달까지 기다려 주세요.', 'subscription_required' => 'AI 기능을 사용하려면 활성 구독이 필요합니다.', ], diff --git a/lang/ko/usage.php b/lang/ko/usage.php index b92e7ff79..2db5e0421 100644 --- a/lang/ko/usage.php +++ b/lang/ko/usage.php @@ -5,11 +5,8 @@ 'section_account' => '계정', 'section_account_description' => '요금제의 할당량 및 한도.', - 'section_ai' => 'AI 크레딧', - 'section_ai_description' => '크레딧은 AI 기능을 사용할 때 차감됩니다. 매월 1일에 초기화됩니다.', 'workspaces' => '워크스페이스', 'social_accounts' => '소셜 계정', 'members' => '멤버', - 'credits' => '크레딧', ]; diff --git a/lang/nl/billing.php b/lang/nl/billing.php index 722e46c16..76444e55b 100644 --- a/lang/nl/billing.php +++ b/lang/nl/billing.php @@ -59,7 +59,6 @@ 'plan_changed' => 'Je zit nu op het :plan-abonnement.', 'switched_to_yearly' => 'Je zit nu op jaarlijkse facturatie.', 'cannot_manage' => 'Alleen de accounteigenaar kan de facturatie beheren.', - 'credits_exhausted' => 'Geen AI-credits meer — je maandelijkse tegoed van :limit is opgebruikt. Upgrade je abonnement of wacht tot volgende maand.', 'subscription_required' => 'Een actief abonnement is vereist om AI-functies te gebruiken.', ], diff --git a/lang/nl/usage.php b/lang/nl/usage.php index 12e654287..efa55d39e 100644 --- a/lang/nl/usage.php +++ b/lang/nl/usage.php @@ -5,11 +5,8 @@ 'section_account' => 'Account', 'section_account_description' => 'Quota en limieten voor je abonnement.', - 'section_ai' => 'AI-credits', - 'section_ai_description' => 'Credits worden afgeschreven naarmate AI-functies worden gebruikt. Ze worden op de eerste van elke maand gereset.', 'workspaces' => 'Workspaces', 'social_accounts' => 'Social accounts', 'members' => 'Leden', - 'credits' => 'Credits', ]; diff --git a/lang/pl/billing.php b/lang/pl/billing.php index 4283acf93..b05ae6110 100644 --- a/lang/pl/billing.php +++ b/lang/pl/billing.php @@ -59,7 +59,6 @@ 'plan_changed' => 'Korzystasz teraz z planu :plan.', 'switched_to_yearly' => 'Korzystasz teraz z rozliczenia rocznego.', 'cannot_manage' => 'Tylko właściciel konta może zarządzać rozliczeniami.', - 'credits_exhausted' => 'Brak kredytów AI — Twój miesięczny limit :limit został wykorzystany. Ulepsz plan lub poczekaj do następnego miesiąca.', 'subscription_required' => 'Aby korzystać z funkcji AI, wymagana jest aktywna subskrypcja.', ], diff --git a/lang/pl/usage.php b/lang/pl/usage.php index b6ff40a78..8362b3033 100644 --- a/lang/pl/usage.php +++ b/lang/pl/usage.php @@ -5,11 +5,8 @@ 'section_account' => 'Konto', 'section_account_description' => 'Limity i przydziały dla Twojego planu.', - 'section_ai' => 'Kredyty AI', - 'section_ai_description' => 'Kredyty są pobierane w miarę korzystania z funkcji AI. Odnawiają się pierwszego dnia każdego miesiąca.', 'workspaces' => 'Przestrzenie robocze', 'social_accounts' => 'Konta społecznościowe', 'members' => 'Członkowie', - 'credits' => 'Kredyty', ]; diff --git a/lang/pt-BR/billing.php b/lang/pt-BR/billing.php index 52e5e9f78..8903bd21b 100644 --- a/lang/pt-BR/billing.php +++ b/lang/pt-BR/billing.php @@ -59,7 +59,6 @@ '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.', - '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.', ], diff --git a/lang/pt-BR/usage.php b/lang/pt-BR/usage.php index cd49ec213..431a35cc2 100644 --- a/lang/pt-BR/usage.php +++ b/lang/pt-BR/usage.php @@ -5,11 +5,8 @@ 'section_account' => 'Conta', '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.', 'workspaces' => 'Workspaces', 'social_accounts' => 'Contas Sociais', 'members' => 'Membros', - 'credits' => 'Créditos', ]; diff --git a/lang/ru/billing.php b/lang/ru/billing.php index c6e7d9255..e921aa0c3 100644 --- a/lang/ru/billing.php +++ b/lang/ru/billing.php @@ -59,7 +59,6 @@ 'plan_changed' => 'Вы перешли на тариф :plan.', 'switched_to_yearly' => 'Вы перешли на годовую оплату.', 'cannot_manage' => 'Управлять оплатой может только владелец аккаунта.', - 'credits_exhausted' => 'Кредиты ИИ закончились — ваш месячный лимит :limit исчерпан. Повысьте тариф или подождите до следующего месяца.', 'subscription_required' => 'Для использования функций ИИ требуется активная подписка.', ], diff --git a/lang/ru/usage.php b/lang/ru/usage.php index d0f41c91b..ad26c209d 100644 --- a/lang/ru/usage.php +++ b/lang/ru/usage.php @@ -5,11 +5,8 @@ 'section_account' => 'Аккаунт', 'section_account_description' => 'Квоты и лимиты вашего тарифа.', - 'section_ai' => 'Кредиты ИИ', - 'section_ai_description' => 'Кредиты списываются по мере использования функций ИИ. Они обнуляются первого числа каждого месяца.', 'workspaces' => 'Рабочие пространства', 'social_accounts' => 'Социальные аккаунты', 'members' => 'Участники', - 'credits' => 'Кредиты', ]; diff --git a/lang/tr/billing.php b/lang/tr/billing.php index e50e65562..54953bad7 100644 --- a/lang/tr/billing.php +++ b/lang/tr/billing.php @@ -61,7 +61,6 @@ 'plan_changed' => 'Artık :plan planındasınız.', 'switched_to_yearly' => 'Artık yıllık faturalandırmadasınız.', 'cannot_manage' => 'Faturalandırmayı yalnızca hesap sahibi yönetebilir.', - 'credits_exhausted' => 'AI kredileriniz bitti — aylık :limit hakkınız kullanıldı. Planınızı yükseltin veya gelecek ayı bekleyin.', 'subscription_required' => 'AI özelliklerini kullanmak için etkin bir abonelik gereklidir.', ], diff --git a/lang/tr/usage.php b/lang/tr/usage.php index a707da162..bd6f85b7a 100644 --- a/lang/tr/usage.php +++ b/lang/tr/usage.php @@ -7,11 +7,8 @@ 'section_account' => 'Hesap', 'section_account_description' => 'Planınız için kotalar ve limitler.', - 'section_ai' => 'AI Kredileri', - 'section_ai_description' => 'AI özellikleri kullanıldıkça krediler düşülür. Her ayın ilk günü sıfırlanır.', 'workspaces' => 'Çalışma Alanları', 'social_accounts' => 'Sosyal Hesaplar', 'members' => 'Üyeler', - 'credits' => 'Krediler', ]; diff --git a/lang/uk/billing.php b/lang/uk/billing.php index 055302ffd..bfeea1b83 100644 --- a/lang/uk/billing.php +++ b/lang/uk/billing.php @@ -59,7 +59,6 @@ 'plan_changed' => 'Ви перейшли на план :plan.', 'switched_to_yearly' => 'Тепер у вас річна оплата.', 'cannot_manage' => 'Лише власник облікового запису може керувати оплатою.', - 'credits_exhausted' => 'AI-кредити вичерпано — ваш місячний ліміт :limit використано. Оновіть план або зачекайте до наступного місяця.', 'subscription_required' => 'Для використання AI-функцій потрібна активна підписка.', ], diff --git a/lang/uk/usage.php b/lang/uk/usage.php index 39bf104f7..b978ec22c 100644 --- a/lang/uk/usage.php +++ b/lang/uk/usage.php @@ -5,11 +5,8 @@ 'section_account' => 'Обліковий запис', 'section_account_description' => 'Квоти та обмеження вашого тарифного плану.', - 'section_ai' => 'AI-кредити', - 'section_ai_description' => 'Кредити списуються під час використання AI-функцій. Вони оновлюються першого числа кожного місяця.', 'workspaces' => 'Робочі простори', 'social_accounts' => 'Соціальні акаунти', 'members' => 'Учасники', - 'credits' => 'Кредити', ]; diff --git a/lang/zh/billing.php b/lang/zh/billing.php index c17249c33..54311e0d5 100644 --- a/lang/zh/billing.php +++ b/lang/zh/billing.php @@ -59,7 +59,6 @@ 'plan_changed' => '你现在使用的是 :plan 套餐。', 'switched_to_yearly' => '你现在已切换为按年计费。', 'cannot_manage' => '只有账户所有者才能管理账单。', - 'credits_exhausted' => 'AI 额度已用完——你每月 :limit 的额度已用尽。请升级套餐或等到下个月。', 'subscription_required' => '使用 AI 功能需要有效的订阅。', ], diff --git a/lang/zh/usage.php b/lang/zh/usage.php index dfaa992b3..961c8e9da 100644 --- a/lang/zh/usage.php +++ b/lang/zh/usage.php @@ -5,11 +5,8 @@ 'section_account' => '账户', 'section_account_description' => '你套餐的配额和上限。', - 'section_ai' => 'AI 额度', - 'section_ai_description' => '使用 AI 功能时会扣除额度。额度在每月 1 日重置。', 'workspaces' => '工作区', 'social_accounts' => '社交账号', 'members' => '成员', - 'credits' => '额度', ]; diff --git a/resources/js/pages/settings/account/Usage.vue b/resources/js/pages/settings/account/Usage.vue index e6b5d8ed1..8a7667c98 100644 --- a/resources/js/pages/settings/account/Usage.vue +++ b/resources/js/pages/settings/account/Usage.vue @@ -3,7 +3,6 @@ import { Head } from '@inertiajs/vue3'; import { IconAffiliate, IconBuildingCommunity, - IconSparkles, IconUsers, } from '@tabler/icons-vue'; import { trans } from 'laravel-vue-i18n'; @@ -22,8 +21,6 @@ interface UsageData { workspaceCount: number; socialAccountCount: number; memberCount: number; - creditsUsed: number; - monthlyCreditsLimit: number; } defineProps<{ @@ -82,24 +79,6 @@ const tabs = computed(() => [ /> - -
- - -
- -
-
diff --git a/tests/Feature/Billing/BillingCycleTest.php b/tests/Feature/Billing/BillingCycleTest.php index c4ed42327..d303af977 100644 --- a/tests/Feature/Billing/BillingCycleTest.php +++ b/tests/Feature/Billing/BillingCycleTest.php @@ -2,36 +2,10 @@ declare(strict_types=1); -use App\Models\Account; use App\Models\AiUsageLog; -use App\Models\Plan; -use App\Models\Workspace; use App\Support\BillingCycle; use Illuminate\Support\Carbon; -test('monthly allotment is credits per workspace times workspace count', function () { - $account = billingAccount('price_month', workspaces: 3); - - expect(BillingCycle::for($account)->creditAllotment())->toBe(7500); -}); - -test('yearly allotment grants twelve months upfront per workspace', function () { - $account = billingAccount('price_year', workspaces: 2); - - expect(BillingCycle::for($account)->creditAllotment())->toBe(60000); -}); - -test('during trial the allotment is the monthly amount even on a yearly price', function () { - Carbon::setTestNow('2026-06-20 12:00:00'); - - $account = billingAccount('price_year', [ - 'created_at' => Carbon::parse('2026-06-18'), - 'trial_ends_at' => Carbon::parse('2026-06-25'), - ], workspaces: 1); - - expect(BillingCycle::for($account)->creditAllotment())->toBe(2500); -}); - test('during trial the window spans the subscription creation to the trial end', function () { Carbon::setTestNow('2026-06-20 12:00:00'); @@ -87,14 +61,6 @@ expect(BillingCycle::for($account)->usedCredits())->toBe(10); }); -test('without a subscription the allotment falls back to a monthly amount', function () { - $plan = Plan::query()->firstOrFail(); - $account = Account::factory()->create(['plan_id' => $plan->id, 'trial_ends_at' => null]); - Workspace::factory()->count(2)->create(['account_id' => $account->id]); - - expect(BillingCycle::for($account)->creditAllotment())->toBe(5000); -}); - test('monthly window clamps an end-of-month anchor without drift', function () { Carbon::setTestNow('2026-06-20 12:00:00'); $account = billingAccount('price_month', ['created_at' => Carbon::parse('2026-01-31')]); @@ -114,10 +80,3 @@ expect($cycle->periodStart()->toDateString())->toBe('2026-02-28') ->and($cycle->periodEnd()->toDateString())->toBe('2027-02-28'); }); - -test('allotment is zero when the account has no plan', function () { - $account = Account::factory()->create(['plan_id' => null, 'trial_ends_at' => null]); - Workspace::factory()->count(2)->create(['account_id' => $account->id]); - - expect(BillingCycle::for($account)->creditAllotment())->toBe(0); -}); diff --git a/tests/Feature/Models/HasUsageTraitTest.php b/tests/Feature/Models/HasUsageTraitTest.php index 9d0b48dbf..0829ea2d5 100644 --- a/tests/Feature/Models/HasUsageTraitTest.php +++ b/tests/Feature/Models/HasUsageTraitTest.php @@ -45,20 +45,26 @@ ]); }); -test('featureLimits returns the monthly credits allotment', function () { - $plan = Plan::where('slug', Slug::Workspace)->first(); - $this->account->update(['plan_id' => $plan->id]); +test('featureLimits reports the plan workspace limit', function () { + config()->set('trypost.self_hosted', false); - Workspace::factory()->count(2)->create([ - 'account_id' => $this->account->id, - 'user_id' => $this->owner->id, - ]); + $user = User::factory()->create(); + $account = $user->account; - $limits = $this->account->featureLimits(); + $account->update(['plan_id' => Plan::where('slug', Slug::Socials)->value('id')]); - expect($limits)->toBe([ - 'monthlyCreditsLimit' => 5000, - ]); + expect($account->fresh()->featureLimits())->toBe(['workspaceLimit' => 1]); +}); + +test('featureLimits reports null for an unlimited plan', function () { + config()->set('trypost.self_hosted', false); + + $user = User::factory()->create(); + $account = $user->account; + + $account->update(['plan_id' => Plan::where('slug', Slug::Workspaces)->value('id')]); + + expect($account->fresh()->featureLimits())->toBe(['workspaceLimit' => null]); }); test('pendingInviteCount excludes accepted invites', function () { diff --git a/tests/Pest.php b/tests/Pest.php index 9b4acb440..de5421840 100644 --- a/tests/Pest.php +++ b/tests/Pest.php @@ -2,6 +2,7 @@ declare(strict_types=1); +use App\Enums\Plan\Slug; use App\Enums\UserWorkspace\Role; use App\Models\AccessToken; use App\Models\Account; @@ -134,7 +135,7 @@ function createApiTestToken(array $overrides = []): array */ function billingAccount(string $price, array $subscriptionAttributes = [], int $workspaces = 1): Account { - $plan = Plan::query()->firstOrFail(); + $plan = Plan::where('slug', Slug::Socials)->firstOrFail(); $plan->update([ 'stripe_monthly_price_id' => 'price_month', 'stripe_yearly_price_id' => 'price_year', diff --git a/tests/Unit/Policies/AccountPolicyTest.php b/tests/Unit/Policies/AccountPolicyTest.php index 6ec31e6e5..b66115d7c 100644 --- a/tests/Unit/Policies/AccountPolicyTest.php +++ b/tests/Unit/Policies/AccountPolicyTest.php @@ -34,7 +34,7 @@ expect($response->message())->toBe(__('billing.flash.cannot_manage')); }); -test('useAi allows when subscribed and credits remain', function () { +test('useAi allows when subscribed', function () { config()->set('trypost.self_hosted', false); Workspace::factory()->create([ 'account_id' => $this->account->id, @@ -60,7 +60,7 @@ expect($response->message())->toBe(__('billing.flash.subscription_required')); }); -test('useAi denies when monthly credits are exhausted', function () { +test('useAi allows a subscribed account regardless of recorded AI usage', function () { config()->set('trypost.self_hosted', false); $workspace = Workspace::factory()->create([ 'account_id' => $this->account->id, @@ -68,17 +68,14 @@ ]); subscribeAccount($this->account); - AiUsageLog::factory()->text(credits: 2500)->create([ + AiUsageLog::factory()->text(credits: 999999)->create([ 'account_id' => $this->account->id, 'workspace_id' => $workspace->id, ]); $response = $this->policy->useAi($this->owner, $this->account->fresh()); - expect($response->denied())->toBeTrue(); - expect($response->message())->toBe(__('billing.flash.credits_exhausted', [ - 'limit' => '2500', - ])); + expect($response->allowed())->toBeTrue(); }); test('useAi always allows when self-hosted', function () { From 057366ebdd6f9e7b16c5b1823dadd04f4579eece Mon Sep 17 00:00:00 2001 From: Paulo Castellano Date: Tue, 8 Sep 2026 14:36:57 -0300 Subject: [PATCH 03/29] Drop per-workspace Stripe quantity billing --- .../Billing/StartSubscriptionCheckout.php | 3 +- app/Actions/Workspace/CreateWorkspace.php | 2 - app/Actions/Workspace/DeleteWorkspace.php | 2 - app/Models/Account.php | 24 ------ app/Policies/WorkspacePolicy.php | 2 +- .../Billing/ConfigureSubscriptionCheckout.php | 10 +-- .../Billing/WorkspaceQuantitySyncTest.php | 77 ------------------- .../Billing/StartSubscriptionCheckoutTest.php | 3 - .../ConfigureSubscriptionCheckoutTest.php | 26 ++----- 9 files changed, 14 insertions(+), 135 deletions(-) delete mode 100644 tests/Feature/Billing/WorkspaceQuantitySyncTest.php diff --git a/app/Actions/Billing/StartSubscriptionCheckout.php b/app/Actions/Billing/StartSubscriptionCheckout.php index c7a066b72..873f69f58 100644 --- a/app/Actions/Billing/StartSubscriptionCheckout.php +++ b/app/Actions/Billing/StartSubscriptionCheckout.php @@ -14,7 +14,7 @@ class StartSubscriptionCheckout { /** * Create a Stripe Checkout session for the given price and return an Inertia - * redirect to it. Quantity tracks the account's workspace count. Trial days, + * redirect to it. Trial days, * optional first-month coupon, and promotion codes come from cashier / * trypost billing env config via ConfigureSubscriptionCheckout. The owner's * signup attribution -- UTM parameters and ad click IDs -- and onboarding @@ -50,7 +50,6 @@ public function redirect(Account $account, string $priceId, string $cancelUrl): ]); $subscription = $account->newSubscription(Account::SUBSCRIPTION_NAME, $priceId) - ->quantity(max(1, $account->workspaces()->count())) ->withMetadata(array_map( fn (string $value): string => Str::limit($value, 500, ''), $metadata, diff --git a/app/Actions/Workspace/CreateWorkspace.php b/app/Actions/Workspace/CreateWorkspace.php index 3c7c2df0e..96e8d08e0 100644 --- a/app/Actions/Workspace/CreateWorkspace.php +++ b/app/Actions/Workspace/CreateWorkspace.php @@ -42,8 +42,6 @@ public static function execute(User $user, array $data): Workspace return $workspace; }); - $user->account?->syncWorkspaceQuantity(); - return $workspace; } } diff --git a/app/Actions/Workspace/DeleteWorkspace.php b/app/Actions/Workspace/DeleteWorkspace.php index 1a7e9c27c..ad897b388 100644 --- a/app/Actions/Workspace/DeleteWorkspace.php +++ b/app/Actions/Workspace/DeleteWorkspace.php @@ -78,8 +78,6 @@ public static function execute(Workspace $workspace): bool $settlement->flush(); - $account?->syncWorkspaceQuantity(); - if (PostHogService::isEnabled()) { SyncAccountUsage::dispatch($accountId, null); } diff --git a/app/Models/Account.php b/app/Models/Account.php index 934c6c91e..873052710 100644 --- a/app/Models/Account.php +++ b/app/Models/Account.php @@ -13,9 +13,7 @@ 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 Throwable; class Account extends Model { @@ -112,28 +110,6 @@ public function workspaceLimit(): ?int return $this->plan?->workspace_limit; } - 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/Policies/WorkspacePolicy.php b/app/Policies/WorkspacePolicy.php index 107871649..cb6f3838a 100644 --- a/app/Policies/WorkspacePolicy.php +++ b/app/Policies/WorkspacePolicy.php @@ -32,7 +32,7 @@ public function update(User $user, Workspace $workspace): bool public function delete(User $user, Workspace $workspace): bool { - // Owner-only: deleting a workspace changes Stripe subscription quantity. + // Owner-only: deleting a workspace is an account-level, irreversible action. return $this->isOwner($user, $workspace); } diff --git a/app/Support/Billing/ConfigureSubscriptionCheckout.php b/app/Support/Billing/ConfigureSubscriptionCheckout.php index bcec132c4..2f3b2ca4b 100644 --- a/app/Support/Billing/ConfigureSubscriptionCheckout.php +++ b/app/Support/Billing/ConfigureSubscriptionCheckout.php @@ -62,10 +62,9 @@ public static function apply(SubscriptionBuilder $subscription, Account $account } /** - * Fixed amount_off first-month coupons only fit a new customer checking out a - * single workspace. A subscription that never left incomplete never became - * real, so a retry after a failed first attempt still qualifies; any started - * subscription (even canceled) does not. + * First-month coupons only fit a new customer. A subscription that never + * left incomplete never became real, so a retry after a failed first + * attempt still qualifies; any started subscription (even canceled) does not. */ private static function shouldApplyFirstMonthCoupon(Account $account): bool { @@ -79,8 +78,7 @@ private static function shouldApplyFirstMonthCoupon(Account $account): bool return false; } - return $account->workspaces()->count() === 1 - && self::isFirstTimeSubscriber($account); + return self::isFirstTimeSubscriber($account); } /** diff --git a/tests/Feature/Billing/WorkspaceQuantitySyncTest.php b/tests/Feature/Billing/WorkspaceQuantitySyncTest.php deleted file mode 100644 index 4ea23940e..000000000 --- a/tests/Feature/Billing/WorkspaceQuantitySyncTest.php +++ /dev/null @@ -1,77 +0,0 @@ -set('trypost.self_hosted', true); - - $subscription = mock(Subscription::class); - $subscription->shouldReceive('active')->andReturnTrue(); - $subscription->shouldReceive('updateQuantity')->never(); - - $account = mock(Account::class)->makePartial(); - $account->shouldReceive('subscription')->with(Account::SUBSCRIPTION_NAME)->andReturn($subscription); - $account->shouldReceive('workspaces->count')->andReturn(3); - - $account->syncWorkspaceQuantity(); -}); - -test('syncWorkspaceQuantity does not touch Stripe without an active subscription', function () { - config()->set('trypost.self_hosted', false); - - $subscription = mock(Subscription::class); - $subscription->shouldReceive('active')->andReturnFalse(); - $subscription->shouldReceive('updateQuantity')->never(); - - $account = mock(Account::class)->makePartial(); - $account->shouldReceive('subscription')->with(Account::SUBSCRIPTION_NAME)->andReturn($subscription); - - $account->syncWorkspaceQuantity(); -}); - -test('syncWorkspaceQuantity updates the subscription quantity to the workspace count', function () { - config()->set('trypost.self_hosted', false); - - $subscription = mock(Subscription::class); - $subscription->shouldReceive('active')->andReturnTrue(); - $subscription->shouldReceive('updateQuantity')->once()->with(3); - - $account = mock(Account::class)->makePartial(); - $account->shouldReceive('subscription')->with(Account::SUBSCRIPTION_NAME)->andReturn($subscription); - $account->shouldReceive('workspaces->count')->andReturn(3); - - $account->syncWorkspaceQuantity(); -}); - -test('creating a workspace syncs the stripe quantity', function () { - $user = User::factory()->create(); - - $account = mock(Account::class)->makePartial(); - $account->shouldReceive('syncWorkspaceQuantity')->once(); - $user->setRelation('account', $account); - - CreateWorkspace::execute($user, ['name' => 'Wiring']); -}); - -test('deleting a workspace syncs the stripe quantity', function () { - config()->set('trypost.self_hosted', true); - - $user = User::factory()->create(); - $workspace = Workspace::factory()->create([ - 'account_id' => $user->account_id, - 'user_id' => $user->id, - ]); - - $account = Mockery::mock($user->account)->makePartial(); - $account->shouldReceive('syncWorkspaceQuantity')->once(); - $workspace->setRelation('account', $account); - - expect(DeleteWorkspace::execute($workspace))->toBeTrue(); -}); diff --git a/tests/Unit/Actions/Billing/StartSubscriptionCheckoutTest.php b/tests/Unit/Actions/Billing/StartSubscriptionCheckoutTest.php index f8d37e8bf..f07277286 100644 --- a/tests/Unit/Actions/Billing/StartSubscriptionCheckoutTest.php +++ b/tests/Unit/Actions/Billing/StartSubscriptionCheckoutTest.php @@ -47,7 +47,6 @@ $cancelUrl = route('app.welcome'); $builder = Mockery::mock(SubscriptionBuilder::class); - $builder->shouldReceive('quantity')->once()->with(1)->andReturnSelf(); $builder->shouldReceive('withMetadata') ->once() ->with([ @@ -112,7 +111,6 @@ $cancelUrl = route('app.welcome'); $builder = Mockery::mock(SubscriptionBuilder::class); - $builder->shouldReceive('quantity')->once()->andReturnSelf(); $builder->shouldReceive('withMetadata')->once()->with([])->andReturnSelf(); $builder->shouldReceive('trialDays')->once()->andReturnSelf(); $builder->shouldReceive('checkout') @@ -144,7 +142,6 @@ $account->refresh(); $builder = Mockery::mock(SubscriptionBuilder::class); - $builder->shouldReceive('quantity')->once()->andReturnSelf(); $builder->shouldReceive('withMetadata') ->once() ->with(['fbclid' => str_repeat('a', 500)]) diff --git a/tests/Unit/Support/Billing/ConfigureSubscriptionCheckoutTest.php b/tests/Unit/Support/Billing/ConfigureSubscriptionCheckoutTest.php index 3c6b9ad64..8c80325a0 100644 --- a/tests/Unit/Support/Billing/ConfigureSubscriptionCheckoutTest.php +++ b/tests/Unit/Support/Billing/ConfigureSubscriptionCheckoutTest.php @@ -122,23 +122,17 @@ function trialExpiresAt(SubscriptionBuilder $subscription): ?Carbon ->and(trialExpiresAt($subscription))->toBeNull(); }); -test('does not throw when coupon and promo are both set but multi-workspace skips the coupon', function () { +test('throws when coupon and promo are both set even if the account has several workspaces', function () { config([ 'cashier.first_month_coupon_id' => 'TRIAL1USD', 'cashier.allow_promotion_codes' => true, ]); Workspace::factory()->count(2)->create(['account_id' => $this->account->id]); - Carbon::setTestNow('2026-08-07 12:00:00'); - $subscription = checkoutSubscription($this->account); expect(fn () => ConfigureSubscriptionCheckout::apply($subscription, $this->account)) - ->not->toThrow(RuntimeException::class); - - expect($subscription->couponId)->toBeNull() - ->and($subscription->allowPromotionCodes)->toBeTrue() - ->and(trialExpiresAt($subscription)?->toDateTimeString())->toBe('2026-08-15 12:00:00'); + ->toThrow(RuntimeException::class, 'Cannot apply STRIPE_FIRST_MONTH_COUPON_ID while CASHIER_ALLOW_PROMOTION_CODES is enabled'); }); test('does not throw when coupon and promo are both set but a prior canceled subscription skips the coupon', function () { @@ -194,18 +188,16 @@ function trialExpiresAt(SubscriptionBuilder $subscription): ?Carbon ->and(trialExpiresAt($subscription))->toBeNull(); }); -test('skips the coupon when more than one workspace is billed and still applies trial for first-time', function () { +test('the first-month coupon applies to a first-time subscriber with several workspaces', function () { config(['cashier.first_month_coupon_id' => 'TRIAL1USD']); Workspace::factory()->count(2)->create(['account_id' => $this->account->id]); - Carbon::setTestNow('2026-08-07 12:00:00'); - $subscription = checkoutSubscription($this->account); ConfigureSubscriptionCheckout::apply($subscription, $this->account); - expect($subscription->couponId)->toBeNull() - ->and(trialExpiresAt($subscription)?->toDateTimeString())->toBe('2026-08-15 12:00:00'); + expect($subscription->couponId)->toBe('TRIAL1USD') + ->and(trialExpiresAt($subscription))->toBeNull(); }); test('skips coupon and trial when the account has a prior canceled subscription', function () { @@ -281,15 +273,13 @@ function trialExpiresAt(SubscriptionBuilder $subscription): ?Carbon ->and($subscription->allowPromotionCodes)->toBeFalse(); }); -test('zero workspaces still get a first-time trial without a coupon', function () { +test('zero workspaces still get the first-month coupon when first-time', function () { config(['cashier.first_month_coupon_id' => 'TRIAL1USD']); - Carbon::setTestNow('2026-08-07 12:00:00'); - $subscription = checkoutSubscription($this->account); ConfigureSubscriptionCheckout::apply($subscription, $this->account); - expect($subscription->couponId)->toBeNull() - ->and(trialExpiresAt($subscription)?->toDateTimeString())->toBe('2026-08-15 12:00:00'); + expect($subscription->couponId)->toBe('TRIAL1USD') + ->and(trialExpiresAt($subscription))->toBeNull(); }); From 12b7fa7e20eb1a97e4401316f903cd006b6087e5 Mon Sep 17 00:00:00 2001 From: Paulo Castellano Date: Tue, 8 Sep 2026 14:38:35 -0300 Subject: [PATCH 04/29] Enforce the plan workspace limit --- .../Controllers/App/WorkspaceController.php | 29 +++-- app/Models/Account.php | 26 ++++ lang/ar/workspaces.php | 2 + lang/de/workspaces.php | 2 + lang/el/workspaces.php | 2 + lang/en/workspaces.php | 2 + lang/es/workspaces.php | 2 + lang/fr/workspaces.php | 2 + lang/it/workspaces.php | 2 + lang/ja/workspaces.php | 2 + lang/ko/workspaces.php | 2 + lang/nl/workspaces.php | 2 + lang/pl/workspaces.php | 2 + lang/pt-BR/workspaces.php | 2 + lang/ru/workspaces.php | 2 + lang/tr/workspaces.php | 2 + lang/uk/workspaces.php | 2 + lang/zh/workspaces.php | 2 + resources/js/pages/workspaces/Index.vue | 18 ++- resources/js/types/index.d.ts | 6 + .../Feature/Workspace/WorkspaceLimitTest.php | 114 ++++++++++++++++++ tests/Feature/WorkspaceBillingTest.php | 4 + 22 files changed, 215 insertions(+), 14 deletions(-) create mode 100644 tests/Feature/Workspace/WorkspaceLimitTest.php diff --git a/app/Http/Controllers/App/WorkspaceController.php b/app/Http/Controllers/App/WorkspaceController.php index 74db86f69..5f56696aa 100644 --- a/app/Http/Controllers/App/WorkspaceController.php +++ b/app/Http/Controllers/App/WorkspaceController.php @@ -70,7 +70,7 @@ public function create(Request $request): Response|RedirectResponse { $this->authorize('create', Workspace::class); - if ($redirect = $this->denyAdditionalWorkspaceWithoutSubscription($request->user())) { + if ($redirect = $this->denyAdditionalWorkspace($request->user())) { return $redirect; } @@ -83,12 +83,12 @@ public function create(Request $request): Response|RedirectResponse } /** - * Block creating a paid additional workspace without an active subscription. - * Guards both the form (`create`) and the write (`store`) so a direct POST - * can't bootstrap a second billable workspace — which would also inflate the - * checkout quantity before the first subscription exists. + * Block a second workspace the account cannot have — either because there is + * no active subscription yet, or because the plan's cap is reached. Guards + * both the form (`create`) and the write (`store`) so a direct POST cannot + * bypass the plan. */ - private function denyAdditionalWorkspaceWithoutSubscription(User $user): ?RedirectResponse + private function denyAdditionalWorkspace(User $user): ?RedirectResponse { // An invited member joins exactly one account via the invite. Creating a // workspace on their empty invite-signup shell would leave it non-empty @@ -97,11 +97,18 @@ private function denyAdditionalWorkspaceWithoutSubscription(User $user): ?Redire abort(403); } - if (! config('trypost.self_hosted') - && $user->ownedWorkspacesCount() > 0 - && ! $user->account?->hasActiveSubscription()) { + if (config('trypost.self_hosted') || $user->ownedWorkspacesCount() === 0) { + return null; + } + + if (! $user->account?->hasActiveSubscription()) { + return redirect()->route('app.billing.index') + ->with('flash.error', __('workspaces.subscription_required')); + } + + if (! $user->account->canCreateWorkspace()) { return redirect()->route('app.billing.index') - ->with('message', 'Subscribe to create more workspaces.'); + ->with('flash.error', __('workspaces.limit_reached')); } return null; @@ -122,7 +129,7 @@ public function store(StoreWorkspaceRequest $request, LogoAttacher $logoAttacher { $user = $request->user(); - if ($redirect = $this->denyAdditionalWorkspaceWithoutSubscription($user)) { + if ($redirect = $this->denyAdditionalWorkspace($user)) { return $redirect; } diff --git a/app/Models/Account.php b/app/Models/Account.php index 873052710..30b9d35d0 100644 --- a/app/Models/Account.php +++ b/app/Models/Account.php @@ -110,6 +110,32 @@ public function workspaceLimit(): ?int return $this->plan?->workspace_limit; } + /** + * Whether the account may create another workspace. + * + * Self-hosted and a plan with a null workspace_limit are unlimited. + * An account with no plan may create only the signup workspace + * (`count === 0`) — a missing plan_id is not the Workspaces plan. + */ + public function canCreateWorkspace(): bool + { + if (config('trypost.self_hosted')) { + return true; + } + + if ($this->plan === null) { + return $this->workspaces()->count() === 0; + } + + $limit = $this->workspaceLimit(); + + if ($limit === null) { + return true; + } + + return $this->workspaces()->count() < $limit; + } + public function isPastDue(): bool { if (config('trypost.self_hosted')) { diff --git a/lang/ar/workspaces.php b/lang/ar/workspaces.php index d1347a915..6ba88a718 100644 --- a/lang/ar/workspaces.php +++ b/lang/ar/workspaces.php @@ -9,6 +9,8 @@ 'current' => 'الحالية', 'connections' => ':count اتصال', 'posts' => ':count منشور', + 'subscription_required' => 'اشترك لإنشاء المزيد من مساحات العمل.', + 'limit_reached' => 'تتضمن خطتك مساحة عمل واحدة. قم بالترقية لإضافة المزيد.', 'create' => [ 'page_title' => 'أنشئ مساحة عملك', diff --git a/lang/de/workspaces.php b/lang/de/workspaces.php index f5eac38e4..89ba71637 100644 --- a/lang/de/workspaces.php +++ b/lang/de/workspaces.php @@ -9,6 +9,8 @@ 'current' => 'Aktuell', 'connections' => ':count Verbindungen', 'posts' => ':count Beiträge', + 'subscription_required' => 'Abonniere, um weitere Workspaces zu erstellen.', + 'limit_reached' => 'Dein Tarif umfasst einen Workspace. Upgrade, um weitere hinzuzufügen.', 'create' => [ 'page_title' => 'Erstelle deinen Workspace', diff --git a/lang/el/workspaces.php b/lang/el/workspaces.php index aec30cae8..70bed4017 100644 --- a/lang/el/workspaces.php +++ b/lang/el/workspaces.php @@ -9,6 +9,8 @@ 'current' => 'Τρέχον', 'connections' => ':count συνδέσεις', 'posts' => ':count δημοσιεύσεις', + 'subscription_required' => 'Εγγραφείτε για να δημιουργήσετε περισσότερα workspaces.', + 'limit_reached' => 'Το πλάνο σας περιλαμβάνει ένα workspace. Κάντε αναβάθμιση για να προσθέσετε περισσότερα.', 'create' => [ 'page_title' => 'Δημιουργήστε το workspace σας', diff --git a/lang/en/workspaces.php b/lang/en/workspaces.php index e43c5e8e9..4fa2a3819 100644 --- a/lang/en/workspaces.php +++ b/lang/en/workspaces.php @@ -9,6 +9,8 @@ 'current' => 'Current', 'connections' => ':count connections', 'posts' => ':count posts', + 'subscription_required' => 'Subscribe to create more workspaces.', + 'limit_reached' => 'Your plan includes one workspace. Upgrade to add more.', 'create' => [ 'page_title' => 'Create your workspace', diff --git a/lang/es/workspaces.php b/lang/es/workspaces.php index 78789e5fd..4efcfbc9a 100644 --- a/lang/es/workspaces.php +++ b/lang/es/workspaces.php @@ -9,6 +9,8 @@ 'current' => 'Actual', 'connections' => ':count conexiones', 'posts' => ':count posts', + 'subscription_required' => 'Suscríbete para crear más workspaces.', + 'limit_reached' => 'Tu plan incluye un workspace. Mejora tu plan para añadir más.', 'create' => [ 'page_title' => 'Crea tu workspace', diff --git a/lang/fr/workspaces.php b/lang/fr/workspaces.php index 1bff98114..153919680 100644 --- a/lang/fr/workspaces.php +++ b/lang/fr/workspaces.php @@ -9,6 +9,8 @@ 'current' => 'Actuel', 'connections' => ':count connexions', 'posts' => ':count publications', + 'subscription_required' => 'Abonnez-vous pour créer plus d\'espaces de travail.', + 'limit_reached' => 'Votre offre inclut un espace de travail. Passez à une offre supérieure pour en ajouter.', 'create' => [ 'page_title' => 'Créer votre espace de travail', diff --git a/lang/it/workspaces.php b/lang/it/workspaces.php index 15c2c6597..c9b5143e0 100644 --- a/lang/it/workspaces.php +++ b/lang/it/workspaces.php @@ -9,6 +9,8 @@ 'current' => 'Attuale', 'connections' => ':count connessioni', 'posts' => ':count post', + 'subscription_required' => 'Abbonati per creare altri workspace.', + 'limit_reached' => 'Il tuo piano include un workspace. Fai l\'upgrade per aggiungerne altri.', 'create' => [ 'page_title' => 'Crea il tuo workspace', diff --git a/lang/ja/workspaces.php b/lang/ja/workspaces.php index 013fbfd65..f24490ce2 100644 --- a/lang/ja/workspaces.php +++ b/lang/ja/workspaces.php @@ -9,6 +9,8 @@ 'current' => '現在', 'connections' => ':count 件の接続', 'posts' => ':count 件の投稿', + 'subscription_required' => 'ワークスペースを追加するにはサブスクリプションが必要です。', + 'limit_reached' => 'このプランにはワークスペースが1つ含まれます。追加するにはアップグレードしてください。', 'create' => [ 'page_title' => 'ワークスペースを作成', diff --git a/lang/ko/workspaces.php b/lang/ko/workspaces.php index e1f6b949f..042693b6e 100644 --- a/lang/ko/workspaces.php +++ b/lang/ko/workspaces.php @@ -9,6 +9,8 @@ 'current' => '현재', 'connections' => ':count개 연결', 'posts' => ':count개 게시물', + 'subscription_required' => '워크스페이스를 더 만들려면 구독하세요.', + 'limit_reached' => '이 요금제에는 워크스페이스가 하나 포함됩니다. 더 추가하려면 업그레이드하세요.', 'create' => [ 'page_title' => '워크스페이스 만들기', diff --git a/lang/nl/workspaces.php b/lang/nl/workspaces.php index 973430eb2..07a96754c 100644 --- a/lang/nl/workspaces.php +++ b/lang/nl/workspaces.php @@ -9,6 +9,8 @@ 'current' => 'Huidige', 'connections' => ':count koppelingen', 'posts' => ':count posts', + 'subscription_required' => 'Abonneer je om meer workspaces te maken.', + 'limit_reached' => 'Je plan bevat één workspace. Upgrade om er meer toe te voegen.', 'create' => [ 'page_title' => 'Maak je workspace aan', diff --git a/lang/pl/workspaces.php b/lang/pl/workspaces.php index bd836574e..1b0068609 100644 --- a/lang/pl/workspaces.php +++ b/lang/pl/workspaces.php @@ -9,6 +9,8 @@ 'current' => 'Bieżąca', 'connections' => ':count połączeń', 'posts' => ':count postów', + 'subscription_required' => 'Subskrybuj, aby tworzyć więcej workspace\'ów.', + 'limit_reached' => 'Twój plan obejmuje jeden workspace. Ulepsz plan, aby dodać więcej.', 'create' => [ 'page_title' => 'Utwórz przestrzeń roboczą', diff --git a/lang/pt-BR/workspaces.php b/lang/pt-BR/workspaces.php index 403d7e4f7..f5e13d1a8 100644 --- a/lang/pt-BR/workspaces.php +++ b/lang/pt-BR/workspaces.php @@ -9,6 +9,8 @@ 'current' => 'Atual', 'connections' => ':count conexões', 'posts' => ':count posts', + 'subscription_required' => 'Assine para criar mais workspaces.', + 'limit_reached' => 'Seu plano inclui um workspace. Faça upgrade para adicionar mais.', 'create' => [ 'page_title' => 'Crie seu workspace', diff --git a/lang/ru/workspaces.php b/lang/ru/workspaces.php index 7e1cb4b92..b3fc49197 100644 --- a/lang/ru/workspaces.php +++ b/lang/ru/workspaces.php @@ -9,6 +9,8 @@ 'current' => 'Текущее', 'connections' => ':count подключений', 'posts' => ':count постов', + 'subscription_required' => 'Оформите подписку, чтобы создавать больше workspace.', + 'limit_reached' => 'В вашем плане один workspace. Перейдите на другой план, чтобы добавить больше.', 'create' => [ 'page_title' => 'Создайте рабочее пространство', diff --git a/lang/tr/workspaces.php b/lang/tr/workspaces.php index 5a97bcafc..005a5689e 100644 --- a/lang/tr/workspaces.php +++ b/lang/tr/workspaces.php @@ -9,6 +9,8 @@ 'current' => 'Geçerli', 'connections' => ':count bağlantı', 'posts' => ':count gönderi', + 'subscription_required' => 'Daha fazla workspace oluşturmak için abone olun.', + 'limit_reached' => 'Planınız bir workspace içerir. Daha fazlasını eklemek için yükseltin.', 'create' => [ 'page_title' => 'Çalışma alanınızı oluşturun', diff --git a/lang/uk/workspaces.php b/lang/uk/workspaces.php index 50f2487dc..2250293cf 100644 --- a/lang/uk/workspaces.php +++ b/lang/uk/workspaces.php @@ -9,6 +9,8 @@ 'current' => 'Поточний', 'connections' => ':count підключень', 'posts' => ':count постів', + 'subscription_required' => 'Оформіть підписку, щоб створювати більше workspace.', + 'limit_reached' => 'Ваш план включає один workspace. Оновіть план, щоб додати більше.', 'create' => [ 'page_title' => 'Створіть свій робочий простір', diff --git a/lang/zh/workspaces.php b/lang/zh/workspaces.php index c86b7a6d6..8f644d722 100644 --- a/lang/zh/workspaces.php +++ b/lang/zh/workspaces.php @@ -9,6 +9,8 @@ 'current' => '当前', 'connections' => ':count 个连接', 'posts' => ':count 条帖子', + 'subscription_required' => '订阅后即可创建更多工作区。', + 'limit_reached' => '当前套餐包含一个工作区。升级后可添加更多。', 'create' => [ 'page_title' => '创建你的工作区', diff --git a/resources/js/pages/workspaces/Index.vue b/resources/js/pages/workspaces/Index.vue index f87653cc1..3766df38f 100644 --- a/resources/js/pages/workspaces/Index.vue +++ b/resources/js/pages/workspaces/Index.vue @@ -1,6 +1,7 @@ + + diff --git a/resources/js/layouts/WelcomeLayout.vue b/resources/js/layouts/WelcomeLayout.vue index 056c7bb10..12f8798f8 100644 --- a/resources/js/layouts/WelcomeLayout.vue +++ b/resources/js/layouts/WelcomeLayout.vue @@ -7,6 +7,7 @@ import { connect as connectRoute, goals as goalsRoute, persona as personaRoute, + plan as planRoute, referralSource as referralSourceRoute, } from '@/routes/app/welcome'; @@ -37,7 +38,7 @@ const props = withDefaults( title: undefined, description: undefined, step: undefined, - totalSteps: 4, + totalSteps: 5, size: 'xl', }, ); @@ -47,6 +48,7 @@ const stepRoutes = computed(() => [ goalsRoute(), referralSourceRoute(), connectRoute(), + planRoute(), ]); const canNavigateTo = (stepNumber: number): boolean => diff --git a/resources/js/pages/welcome/Connect.vue b/resources/js/pages/welcome/Connect.vue index fd8797859..c19255d1e 100644 --- a/resources/js/pages/welcome/Connect.vue +++ b/resources/js/pages/welcome/Connect.vue @@ -62,7 +62,7 @@ const submit = (): void => { @@ -138,7 +152,7 @@ const upgradeToAnnual = (): void => { class="text-3xl font-semibold leading-tight text-foreground" style="font-family: var(--font-display)" > - {{ workspacesLabel }} + {{ plan?.name }} {{ $t('billing.plan.trial') }} {{ $t('billing.plan.active') }} @@ -147,7 +161,7 @@ const upgradeToAnnual = (): void => {

{{ displayPrice(plan?.slug) }} - /{{ $t('billing.plan.month') }} {{ $t('billing.plan.per_workspace') }} + /{{ $t('billing.plan.month') }}

{{ isYearly ? $t('billing.subscribe.billed_yearly') : $t('billing.subscribe.billed_monthly') }} @@ -168,6 +182,23 @@ const upgradeToAnnual = (): void => { +

+ + + +
+
name('app.billing.index'); Route::get('settings/account/billing/portal', [BillingController::class, 'portal'])->name('app.billing.portal'); - Route::post('settings/account/billing/swap-to-yearly', [BillingController::class, 'swapToYearly'])->name('app.billing.swap-to-yearly'); + Route::post('settings/account/billing/change-plan', [BillingController::class, 'changePlan'])->name('app.billing.change-plan'); }); diff --git a/tests/Feature/Billing/ChangePlanTest.php b/tests/Feature/Billing/ChangePlanTest.php new file mode 100644 index 000000000..32877d487 --- /dev/null +++ b/tests/Feature/Billing/ChangePlanTest.php @@ -0,0 +1,150 @@ + false]); + + $this->socials = Plan::where('slug', Slug::Socials)->firstOrFail(); + $this->socials->update([ + 'stripe_monthly_price_id' => 'price_socials_monthly', + 'stripe_yearly_price_id' => 'price_socials_yearly', + ]); + + $this->workspaces = Plan::where('slug', Slug::Workspaces)->firstOrFail(); + $this->workspaces->update([ + 'stripe_monthly_price_id' => 'price_workspaces_monthly', + 'stripe_yearly_price_id' => 'price_workspaces_yearly', + ]); +}); + +$withWorkspace = function (User $user): Workspace { + $workspace = Workspace::factory()->create([ + 'account_id' => $user->account_id, + 'user_id' => $user->id, + ]); + $workspace->members()->attach($user->id, ['role' => Role::Admin->value]); + $user->update(['current_workspace_id' => $workspace->id]); + + return $workspace; +}; + +test('a non-owner cannot change the plan', function () use ($withWorkspace) { + $owner = User::factory()->create(); + $workspace = $withWorkspace($owner); + subscribeAccount($owner->account); + + $member = User::factory()->create(['account_id' => $owner->account_id]); + $workspace->members()->attach($member->id, ['role' => Role::Member->value]); + $member->update(['current_workspace_id' => $workspace->id]); + + $this->actingAs($member->fresh()) + ->post(route('app.billing.change-plan'), [ + 'plan_id' => $this->workspaces->id, + 'interval' => 'monthly', + ]) + ->assertForbidden(); +}); + +test('an archived plan is rejected', function () use ($withWorkspace) { + $user = User::factory()->create(); + $withWorkspace($user); + subscribeAccount($user->account); + $legacy = Plan::where('slug', Slug::Workspace)->firstOrFail(); + + $this->actingAs($user->fresh()) + ->post(route('app.billing.change-plan'), [ + 'plan_id' => $legacy->id, + 'interval' => 'monthly', + ]) + ->assertSessionHasErrors('plan_id'); +}); + +test('downgrading is denied while the account holds more workspaces than the target allows', function () { + $user = User::factory()->create(); + $account = $user->account; + $account->update(['plan_id' => $this->workspaces->id]); + + Workspace::factory()->count(2)->create([ + 'account_id' => $account->id, + 'user_id' => $user->id, + ]); + + subscribeAccount($account); + + $response = Gate::forUser($user)->inspect('swapPlan', [$account->fresh(), $this->socials]); + + expect($response->denied())->toBeTrue() + ->and($response->message())->toBe(__('billing.flash.too_many_workspaces', [ + 'count' => 2, + 'limit' => 1, + ])); +}); + +test('downgrading is allowed once the account is inside the target limit', function () { + $user = User::factory()->create(); + $account = $user->account; + $account->update(['plan_id' => $this->workspaces->id]); + + subscribeAccount($account); + + $response = Gate::forUser($user)->inspect('swapPlan', [$account->fresh(), $this->socials]); + + expect($response->allowed())->toBeTrue(); +}); + +test('change-plan flashes when the account has too many workspaces for the target', function () use ($withWorkspace) { + $user = User::factory()->create(); + $account = $user->account; + $account->update(['plan_id' => $this->workspaces->id]); + + $withWorkspace($user); + Workspace::factory()->create([ + 'account_id' => $account->id, + 'user_id' => $user->id, + ]); + + subscribeAccount($account); + + $this->actingAs($user->fresh()) + ->from(route('app.billing.index')) + ->post(route('app.billing.change-plan'), [ + 'plan_id' => $this->socials->id, + 'interval' => 'monthly', + ]) + ->assertRedirect(route('app.billing.index')) + ->assertSessionHas('flash.error', __('billing.flash.too_many_workspaces', [ + 'count' => 2, + 'limit' => 1, + ])); +}); + +test('change-plan is a no-op when the subscription is already on that price', function () use ($withWorkspace) { + $user = User::factory()->create(); + $account = $user->account; + $account->update(['plan_id' => $this->socials->id]); + $withWorkspace($user); + + $account->subscriptions()->create([ + 'type' => Account::SUBSCRIPTION_NAME, + 'stripe_id' => 'sub_'.fake()->uuid(), + 'stripe_status' => 'active', + 'stripe_price' => 'price_socials_yearly', + ]); + + $this->actingAs($user->fresh()) + ->post(route('app.billing.change-plan'), [ + 'plan_id' => $this->socials->id, + 'interval' => 'yearly', + ]) + ->assertRedirect(route('app.billing.index')) + ->assertSessionMissing('flash.success'); +}); diff --git a/tests/Feature/BillingControllerTest.php b/tests/Feature/BillingControllerTest.php index 600025560..9b0859029 100644 --- a/tests/Feature/BillingControllerTest.php +++ b/tests/Feature/BillingControllerTest.php @@ -44,11 +44,14 @@ $response->assertRedirect(route('app.welcome.persona')); }); -test('swapToYearly redirects to calendar in self hosted mode', function () { +test('changePlan redirects to calendar in self hosted mode', function () { config(['trypost.self_hosted' => true]); $response = $this->actingAs($this->user) - ->post(route('app.billing.swap-to-yearly')); + ->post(route('app.billing.change-plan'), [ + 'plan_id' => Plan::where('slug', 'socials')->value('id'), + 'interval' => 'yearly', + ]); $response->assertRedirect(route('app.calendar')); }); @@ -298,8 +301,7 @@ $this->actingAs($member)->get(route('app.billing.index'))->assertForbidden(); }); -// Swap-to-yearly tests -test('swapToYearly forbids a non-owner', function () { +test('changePlan forbids a non-owner', function () { config(['trypost.self_hosted' => false]); $member = User::factory()->create(['account_id' => $this->account->id]); @@ -314,14 +316,17 @@ ]); $this->actingAs($member) - ->post(route('app.billing.swap-to-yearly')) + ->post(route('app.billing.change-plan'), [ + 'plan_id' => Plan::where('slug', 'socials')->value('id'), + 'interval' => 'yearly', + ]) ->assertForbidden(); }); -test('swapToYearly is a no-op when already on annual billing', function () { +test('changePlan is a no-op when already on that price', function () { config(['trypost.self_hosted' => false]); - $plan = Plan::where('slug', 'workspace')->first(); + $plan = Plan::where('slug', 'socials')->first(); $plan->update([ 'stripe_monthly_price_id' => 'price_monthly', 'stripe_yearly_price_id' => 'price_yearly', @@ -337,12 +342,15 @@ $this->user->unsetRelation('account'); $this->actingAs($this->user) - ->post(route('app.billing.swap-to-yearly')) + ->post(route('app.billing.change-plan'), [ + 'plan_id' => $plan->id, + 'interval' => 'yearly', + ]) ->assertRedirect(route('app.billing.index')); }); -test('swapToYearly requires authentication', function () { - $response = $this->post(route('app.billing.swap-to-yearly')); +test('changePlan requires authentication', function () { + $response = $this->post(route('app.billing.change-plan')); $response->assertRedirect(route('login')); }); diff --git a/tests/Unit/Policies/AccountPolicyTest.php b/tests/Unit/Policies/AccountPolicyTest.php index b66115d7c..46fa2c6de 100644 --- a/tests/Unit/Policies/AccountPolicyTest.php +++ b/tests/Unit/Policies/AccountPolicyTest.php @@ -20,15 +20,26 @@ }); test('swapPlan allows the account owner', function () { - $response = $this->policy->swapPlan($this->owner, $this->account); + subscribeAccount($this->account); + + $response = $this->policy->swapPlan( + $this->owner, + $this->account->fresh(), + Plan::where('slug', Slug::Workspaces)->firstOrFail(), + ); expect($response->allowed())->toBeTrue(); }); test('swapPlan denies a non-owner', function () { + subscribeAccount($this->account); $member = User::factory()->create(['account_id' => $this->account->id]); - $response = $this->policy->swapPlan($member, $this->account); + $response = $this->policy->swapPlan( + $member, + $this->account->fresh(), + Plan::where('slug', Slug::Workspaces)->firstOrFail(), + ); expect($response->denied())->toBeTrue(); expect($response->message())->toBe(__('billing.flash.cannot_manage')); From 267584b7cc031f416c70cc8927964bc835e52f5c Mon Sep 17 00:00:00 2001 From: Paulo Castellano Date: Tue, 8 Sep 2026 15:00:00 -0300 Subject: [PATCH 07/29] Document the two-tier pricing model --- AGENTS.md | 35 +++++++++++++++++++++++++++++++++-- CLAUDE.md | 35 +++++++++++++++++++++++++++++++++-- lang/ar/billing.php | 2 -- lang/de/billing.php | 2 -- lang/el/billing.php | 2 -- lang/en/billing.php | 2 -- lang/es/billing.php | 2 -- lang/fr/billing.php | 2 -- lang/it/billing.php | 2 -- lang/ja/billing.php | 2 -- lang/ko/billing.php | 2 -- lang/nl/billing.php | 2 -- lang/pl/billing.php | 2 -- lang/pt-BR/billing.php | 2 -- lang/ru/billing.php | 2 -- lang/tr/billing.php | 2 -- lang/uk/billing.php | 2 -- lang/zh/billing.php | 2 -- 18 files changed, 66 insertions(+), 36 deletions(-) diff --git a/AGENTS.md b/AGENTS.md index 7184a16f3..c9f8c08e6 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -212,16 +212,47 @@ Checkout options are configured only via env — do not hardcode trial/coupon/pr | --- | --- | --- | --- | | `REQUIRE_CARD_FOR_TRIAL` | `trypost.billing.require_card_for_trial` | `true` | `true`: app access only after Stripe Checkout (no generic signup trial). `false`: generic `accounts.trial_ends_at` trial without a card | | `CASHIER_TRIAL_DAYS` | `cashier.trial_days` | `8` | Card-required Checkout: `trialDays(N)` for **first-time** subscribers when no first-month coupon is applied (`0` = off). Re-subscribers skip trial. No-card mode: length of the generic signup trial | -| `STRIPE_FIRST_MONTH_COUPON_ID` | `cashier.first_month_coupon_id` | empty | Optional. When set for a qualifying first-time single-workspace checkout, applies `withCoupon` and **skips** trial. Empty = trial mode | +| `STRIPE_FIRST_MONTH_COUPON_ID` | `cashier.first_month_coupon_id` | empty | Optional. When set for a qualifying first-time checkout, applies `withCoupon` and **skips** trial. Empty = trial mode | | `CASHIER_ALLOW_PROMOTION_CODES` | `cashier.allow_promotion_codes` | `false` | When `true` and no coupon is applied, show the Checkout promo-code field | Standing constraints: - Stripe rejects `discounts` (coupon) and `allow_promotion_codes` on the same session — if both would apply, `ConfigureSubscriptionCheckout` must throw (fail loud). Never “prefer one silently.” Envs may both be set when the account does **not** qualify for the coupon (no throw). - A set first-month coupon wins over trial (`trialDays` is skipped for that checkout). - Empty coupon + card required + first-time must use `trialDays` — do **not** reintroduce a required-coupon throw. -- Coupon qualification stays: card required, exactly one workspace, no prior real subscription (`incomplete` / `incomplete_expired` still qualify). +- Coupon qualification stays: card required, no prior real subscription (`incomplete` / `incomplete_expired` still qualify). Workspace count is irrelevant — Socials is already capped at one, and a first-time Workspaces subscriber qualifies the same way. - Prefer documenting durable billing decisions here (and in `CLAUDE.md`) — do **not** create a `.ai/` rules folder for this project. +## Plans and the workspace limit + +TryPost sells two plans. Both are flat: Stripe subscription **quantity is never +used** — `syncWorkspaceQuantity()` was removed with the per-workspace model. + +| slug | name | price | workspace_limit | +| --- | --- | --- | --- | +| `socials` | Socials | $19/mo, $190/yr | 1 | +| `workspaces` | Workspaces | $99/mo, $990/yr | `null` (unlimited) | +| `workspace` | Workspace (legacy, archived) | $12/mo per workspace | 1 | + +- The cap lives in `plans.workspace_limit`, **not** in code. `null` on that + column means unlimited. Read it through `Account::workspaceLimit()` / + `Account::canCreateWorkspace()` — never compare `plan->slug` to decide what + an account may do. A **missing** `plan_id` is not unlimited: it may create + only the signup workspace (`count === 0`). +- `WorkspacePolicy::create()` is owner-only. The cap is not a permission: an + owner at the limit is redirected to billing by `WorkspaceController`, not 403'd. +- First-month coupon qualification is card required + first-time subscriber. + Workspace count is not part of it. (`incomplete` / `incomplete_expired` still + qualify; coupon + `allow_promotion_codes` still throws.) +- The legacy plan is archived: it never appears in the picker, so nobody can move + back to it. Its `workspace_limit` is 1 because it costs less than Socials. +- Plan choice is a welcome step (`app.welcome.plan`) and the same `PlanPicker` + component drives upgrade/downgrade on the billing page (`app.billing.change-plan`). + A change is a `swap()` to another price id; `accounts.plan_id` is reconciled by + the `customer.subscription.updated` webhook, never written by the controller. +- **There is no AI credit ceiling.** `AiUsageLog` / `RecordAiUsage` still record + every AI call for cost visibility, but nothing meters or blocks a user. + `AccountPolicy::useAi` checks app access and nothing else. + ## Multiple social accounts per network One connected identity per social network per workspace is the Cloud default. This is **not** tied to `SELF_HOSTED` — Cloud cannot flip that flag, but it can flip this one. diff --git a/CLAUDE.md b/CLAUDE.md index 7605cc7aa..d4c075b9a 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -240,16 +240,47 @@ Checkout options are configured only via env — do not hardcode trial/coupon/pr | --- | --- | --- | --- | | `REQUIRE_CARD_FOR_TRIAL` | `trypost.billing.require_card_for_trial` | `true` | `true`: app access only after Stripe Checkout (no generic signup trial). `false`: generic `accounts.trial_ends_at` trial without a card | | `CASHIER_TRIAL_DAYS` | `cashier.trial_days` | `8` | Card-required Checkout: `trialDays(N)` for **first-time** subscribers when no first-month coupon is applied (`0` = off). Re-subscribers skip trial. No-card mode: length of the generic signup trial | -| `STRIPE_FIRST_MONTH_COUPON_ID` | `cashier.first_month_coupon_id` | empty | Optional. When set for a qualifying first-time single-workspace checkout, applies `withCoupon` and **skips** trial. Empty = trial mode | +| `STRIPE_FIRST_MONTH_COUPON_ID` | `cashier.first_month_coupon_id` | empty | Optional. When set for a qualifying first-time checkout, applies `withCoupon` and **skips** trial. Empty = trial mode | | `CASHIER_ALLOW_PROMOTION_CODES` | `cashier.allow_promotion_codes` | `false` | When `true` and no coupon is applied, show the Checkout promo-code field | Standing constraints: - Stripe rejects `discounts` (coupon) and `allow_promotion_codes` on the same session — if both would apply, `ConfigureSubscriptionCheckout` must throw (fail loud). Never “prefer one silently.” Envs may both be set when the account does **not** qualify for the coupon (no throw). - A set first-month coupon wins over trial (`trialDays` is skipped for that checkout). - Empty coupon + card required + first-time must use `trialDays` — do **not** reintroduce a required-coupon throw. -- Coupon qualification stays: card required, exactly one workspace, no prior real subscription (`incomplete` / `incomplete_expired` still qualify). +- Coupon qualification stays: card required, no prior real subscription (`incomplete` / `incomplete_expired` still qualify). Workspace count is irrelevant — Socials is already capped at one, and a first-time Workspaces subscriber qualifies the same way. - Prefer documenting durable billing decisions here (and in `AGENTS.md`) — do **not** create a `.ai/` rules folder for this project. +## Plans and the workspace limit + +TryPost sells two plans. Both are flat: Stripe subscription **quantity is never +used** — `syncWorkspaceQuantity()` was removed with the per-workspace model. + +| slug | name | price | workspace_limit | +| --- | --- | --- | --- | +| `socials` | Socials | $19/mo, $190/yr | 1 | +| `workspaces` | Workspaces | $99/mo, $990/yr | `null` (unlimited) | +| `workspace` | Workspace (legacy, archived) | $12/mo per workspace | 1 | + +- The cap lives in `plans.workspace_limit`, **not** in code. `null` on that + column means unlimited. Read it through `Account::workspaceLimit()` / + `Account::canCreateWorkspace()` — never compare `plan->slug` to decide what + an account may do. A **missing** `plan_id` is not unlimited: it may create + only the signup workspace (`count === 0`). +- `WorkspacePolicy::create()` is owner-only. The cap is not a permission: an + owner at the limit is redirected to billing by `WorkspaceController`, not 403'd. +- First-month coupon qualification is card required + first-time subscriber. + Workspace count is not part of it. (`incomplete` / `incomplete_expired` still + qualify; coupon + `allow_promotion_codes` still throws.) +- The legacy plan is archived: it never appears in the picker, so nobody can move + back to it. Its `workspace_limit` is 1 because it costs less than Socials. +- Plan choice is a welcome step (`app.welcome.plan`) and the same `PlanPicker` + component drives upgrade/downgrade on the billing page (`app.billing.change-plan`). + A change is a `swap()` to another price id; `accounts.plan_id` is reconciled by + the `customer.subscription.updated` webhook, never written by the controller. +- **There is no AI credit ceiling.** `AiUsageLog` / `RecordAiUsage` still record + every AI call for cost visibility, but nothing meters or blocks a user. + `AccountPolicy::useAi` checks app access and nothing else. + ## Multiple social accounts per network One connected identity per social network per workspace is the Cloud default. This is **not** tied to `SELF_HOSTED` — Cloud cannot flip that flag, but it can flip this one. diff --git a/lang/ar/billing.php b/lang/ar/billing.php index 75eca02d1..51a1c9758 100644 --- a/lang/ar/billing.php +++ b/lang/ar/billing.php @@ -42,8 +42,6 @@ 'title' => 'الخطة', 'description' => 'إدارة خطة اشتراكك.', 'label' => 'الخطة', - 'workspaces' => '{1}مساحة عمل واحدة|{2}مساحتا عمل|[3,10]:count مساحات عمل|[11,*]:count مساحة عمل', - 'per_workspace' => 'لكل مساحة عمل', 'price' => 'السعر', 'month' => 'شهر', 'trial' => 'تجريبي', diff --git a/lang/de/billing.php b/lang/de/billing.php index a25ffcd6f..b370ab74f 100644 --- a/lang/de/billing.php +++ b/lang/de/billing.php @@ -44,8 +44,6 @@ 'title' => 'Tarif', 'description' => 'Verwalte deinen Abonnement-Tarif.', 'label' => 'Tarif', - 'workspaces' => '{1}:count Workspace|[2,*]:count Workspaces', - 'per_workspace' => 'pro Workspace', 'price' => 'Preis', 'month' => 'Monat', 'trial' => 'Testphase', diff --git a/lang/el/billing.php b/lang/el/billing.php index 5d44c6281..22be8403a 100644 --- a/lang/el/billing.php +++ b/lang/el/billing.php @@ -42,8 +42,6 @@ 'title' => 'Πρόγραμμα', 'description' => 'Διαχειριστείτε το πρόγραμμα συνδρομής σας.', 'label' => 'Πρόγραμμα', - 'workspaces' => '{1}:count workspace|[2,*]:count workspaces', - 'per_workspace' => 'ανά workspace', 'price' => 'Τιμή', 'month' => 'μήνας', 'trial' => 'Δοκιμαστική περίοδος', diff --git a/lang/en/billing.php b/lang/en/billing.php index 4f5b04435..9ac0e2aa2 100644 --- a/lang/en/billing.php +++ b/lang/en/billing.php @@ -42,8 +42,6 @@ 'title' => 'Plan', 'description' => 'Manage your subscription plan.', 'label' => 'Plan', - 'workspaces' => '{1}:count workspace|[2,*]:count workspaces', - 'per_workspace' => 'per workspace', 'price' => 'Price', 'month' => 'month', 'trial' => 'Trial', diff --git a/lang/es/billing.php b/lang/es/billing.php index 57d89a018..f128bb23d 100644 --- a/lang/es/billing.php +++ b/lang/es/billing.php @@ -42,8 +42,6 @@ 'title' => 'Plan', 'description' => 'Gestiona tu plan de suscripción.', 'label' => 'Plan', - 'workspaces' => '{1}:count workspace|[2,*]:count workspaces', - 'per_workspace' => 'por workspace', 'price' => 'Precio', 'month' => 'mes', 'trial' => 'Prueba', diff --git a/lang/fr/billing.php b/lang/fr/billing.php index b83a06e8f..a6c2c7e30 100644 --- a/lang/fr/billing.php +++ b/lang/fr/billing.php @@ -42,8 +42,6 @@ 'title' => 'Forfait', 'description' => 'Gérez votre forfait d\'abonnement.', 'label' => 'Forfait', - 'workspaces' => '{1}:count espace de travail|[2,*]:count espaces de travail', - 'per_workspace' => 'par espace de travail', 'price' => 'Prix', 'month' => 'mois', 'trial' => 'Essai', diff --git a/lang/it/billing.php b/lang/it/billing.php index 600e90dc9..5c3d8b946 100644 --- a/lang/it/billing.php +++ b/lang/it/billing.php @@ -42,8 +42,6 @@ 'title' => 'Piano', 'description' => 'Gestisci il tuo piano di abbonamento.', 'label' => 'Piano', - 'workspaces' => '{1}:count workspace|[2,*]:count workspace', - 'per_workspace' => 'per workspace', 'price' => 'Prezzo', 'month' => 'mese', 'trial' => 'Prova', diff --git a/lang/ja/billing.php b/lang/ja/billing.php index 30c35ff67..fcbf6f48c 100644 --- a/lang/ja/billing.php +++ b/lang/ja/billing.php @@ -42,8 +42,6 @@ 'title' => 'プラン', 'description' => 'サブスクリプションプランを管理します。', 'label' => 'プラン', - 'workspaces' => '{1}:count 個のワークスペース|[2,*]:count 個のワークスペース', - 'per_workspace' => 'ワークスペースあたり', 'price' => '料金', 'month' => '月', 'trial' => 'トライアル', diff --git a/lang/ko/billing.php b/lang/ko/billing.php index 3ef4054a6..f9527213b 100644 --- a/lang/ko/billing.php +++ b/lang/ko/billing.php @@ -42,8 +42,6 @@ 'title' => '요금제', 'description' => '구독 요금제를 관리하세요.', 'label' => '요금제', - 'workspaces' => '{1}:count개 워크스페이스|[2,*]:count개 워크스페이스', - 'per_workspace' => '워크스페이스당', 'price' => '가격', 'month' => '월', 'trial' => '체험', diff --git a/lang/nl/billing.php b/lang/nl/billing.php index e851cada2..5f9b17bdf 100644 --- a/lang/nl/billing.php +++ b/lang/nl/billing.php @@ -42,8 +42,6 @@ 'title' => 'Abonnement', 'description' => 'Beheer je abonnement.', 'label' => 'Abonnement', - 'workspaces' => '{1}:count workspace|[2,*]:count workspaces', - 'per_workspace' => 'per workspace', 'price' => 'Prijs', 'month' => 'maand', 'trial' => 'Proefperiode', diff --git a/lang/pl/billing.php b/lang/pl/billing.php index 9b2cffe45..5cdeb5767 100644 --- a/lang/pl/billing.php +++ b/lang/pl/billing.php @@ -42,8 +42,6 @@ 'title' => 'Plan', 'description' => 'Zarządzaj swoim planem subskrypcji.', 'label' => 'Plan', - 'workspaces' => ':count przestrzeń robocza|:count przestrzenie robocze|:count przestrzeni roboczych', - 'per_workspace' => 'za przestrzeń roboczą', 'price' => 'Cena', 'month' => 'miesiąc', 'trial' => 'Okres próbny', diff --git a/lang/pt-BR/billing.php b/lang/pt-BR/billing.php index a2bab2cf8..6b2932f4d 100644 --- a/lang/pt-BR/billing.php +++ b/lang/pt-BR/billing.php @@ -42,8 +42,6 @@ 'title' => 'Plano', 'description' => 'Gerencie seu plano de assinatura.', 'label' => 'Plano', - 'workspaces' => '{1}:count workspace|[2,*]:count workspaces', - 'per_workspace' => 'por workspace', 'price' => 'Preço', 'month' => 'mês', 'trial' => 'Trial', diff --git a/lang/ru/billing.php b/lang/ru/billing.php index 3d89b3b0c..f53d98f78 100644 --- a/lang/ru/billing.php +++ b/lang/ru/billing.php @@ -42,8 +42,6 @@ 'title' => 'Тариф', 'description' => 'Управляйте своим тарифом подписки.', 'label' => 'Тариф', - 'workspaces' => '{1}:count рабочее пространство|[2,4]:count рабочих пространства|[5,*]:count рабочих пространств', - 'per_workspace' => 'за рабочее пространство', 'price' => 'Цена', 'month' => 'месяц', 'trial' => 'Пробный период', diff --git a/lang/tr/billing.php b/lang/tr/billing.php index 0101175a3..fade6cca1 100644 --- a/lang/tr/billing.php +++ b/lang/tr/billing.php @@ -44,8 +44,6 @@ 'title' => 'Plan', 'description' => 'Abonelik planınızı yönetin.', 'label' => 'Plan', - 'workspaces' => '{1}:count çalışma alanı|[2,*]:count çalışma alanı', - 'per_workspace' => 'çalışma alanı başına', 'price' => 'Fiyat', 'month' => 'ay', 'trial' => 'Deneme', diff --git a/lang/uk/billing.php b/lang/uk/billing.php index bb5216c42..fa4e95da3 100644 --- a/lang/uk/billing.php +++ b/lang/uk/billing.php @@ -42,8 +42,6 @@ 'title' => 'План', 'description' => 'Керуйте своїм тарифним планом.', 'label' => 'План', - 'workspaces' => '{1}:count робочий простір|[2,*]:count робочих просторів', - 'per_workspace' => 'за робочий простір', 'price' => 'Ціна', 'month' => 'місяць', 'trial' => 'Пробний період', diff --git a/lang/zh/billing.php b/lang/zh/billing.php index 7185cdb73..7d4cb0137 100644 --- a/lang/zh/billing.php +++ b/lang/zh/billing.php @@ -42,8 +42,6 @@ 'title' => '套餐', 'description' => '管理你的订阅套餐。', 'label' => '套餐', - 'workspaces' => '{1}:count 个工作区|[2,*]:count 个工作区', - 'per_workspace' => '每个工作区', 'price' => '价格', 'month' => '月', 'trial' => '试用', From 6aec5ff0f10ee289278d9c7166d5d5cb1f799187 Mon Sep 17 00:00:00 2001 From: Paulo Castellano Date: Tue, 8 Sep 2026 15:36:37 -0300 Subject: [PATCH 08/29] Charge $1 for the first month with per-plan coupons Welcome is monthly only so Socials ($18 off) and Workspaces ($88 off) can land at $1; yearly stays on billing for existing subscribers. --- .env.example | 15 +- AGENTS.md | 16 +- CLAUDE.md | 16 +- .../Billing/StartSubscriptionCheckout.php | 25 +- .../Controllers/App/WelcomeController.php | 7 +- .../App/Welcome/StoreWelcomePlanRequest.php | 2 - .../Billing/ConfigureSubscriptionCheckout.php | 37 +-- config/cashier.php | 20 +- lang/ar/billing.php | 16 ++ lang/ar/welcome.php | 4 +- lang/de/billing.php | 16 ++ lang/de/welcome.php | 4 +- lang/el/billing.php | 16 ++ lang/el/welcome.php | 4 +- lang/en/billing.php | 16 ++ lang/en/welcome.php | 4 +- lang/es/billing.php | 16 ++ lang/es/welcome.php | 4 +- lang/fr/billing.php | 16 ++ lang/fr/welcome.php | 4 +- lang/it/billing.php | 16 ++ lang/it/welcome.php | 4 +- lang/ja/billing.php | 16 ++ lang/ja/welcome.php | 4 +- lang/ko/billing.php | 16 ++ lang/ko/welcome.php | 4 +- lang/nl/billing.php | 16 ++ lang/nl/welcome.php | 4 +- lang/pl/billing.php | 16 ++ lang/pl/welcome.php | 4 +- lang/pt-BR/billing.php | 16 ++ lang/pt-BR/welcome.php | 4 +- lang/ru/billing.php | 16 ++ lang/ru/welcome.php | 4 +- lang/tr/billing.php | 16 ++ lang/tr/welcome.php | 4 +- lang/uk/billing.php | 16 ++ lang/uk/welcome.php | 4 +- lang/zh/billing.php | 16 ++ lang/zh/welcome.php | 4 +- resources/js/components/PlatformLogo.vue | 48 +++- .../js/components/billing/PlanPicker.vue | 235 +++++++++++++++--- .../js/pages/settings/account/Billing.vue | 2 +- resources/js/pages/welcome/Plan.vue | 14 +- tests/Browser/WelcomePlanTest.php | 76 ++++++ tests/Feature/Welcome/PlanSelectionTest.php | 15 +- .../Billing/StartSubscriptionCheckoutTest.php | 94 ++++++- .../ConfigureSubscriptionCheckoutTest.php | 135 +++++++--- 48 files changed, 884 insertions(+), 193 deletions(-) create mode 100644 tests/Browser/WelcomePlanTest.php diff --git a/.env.example b/.env.example index 7a5de38c9..d2985e51f 100644 --- a/.env.example +++ b/.env.example @@ -253,12 +253,15 @@ REQUIRE_CARD_FOR_TRIAL=true # subscribers when no first-month coupon is applied; re-subscribers skip trial. # No-card mode uses this for accounts.trial_ends_at. 0 = off. CASHIER_TRIAL_DAYS=8 -# Optional Stripe Coupon ID (amount_off, duration=once). When set for a qualifying -# first-time single-workspace checkout, applies the coupon and SKIPS trialDays -# (e.g. TRIAL1USD for a $1 first month). Empty = trial mode above. -# XOR with CASHIER_ALLOW_PROMOTION_CODES only when the coupon would apply — -# Stripe forbids both on one session (ConfigureSubscriptionCheckout throws). -STRIPE_FIRST_MONTH_COUPON_ID= +# Per-plan first-month coupons (amount_off, duration=once) so month one is $1. +# Socials $19 − $18, Workspaces $99 − $88. When set for a qualifying first-time +# monthly checkout of that plan, applies the coupon and SKIPS trialDays. +# Empty for that plan = trial mode above. Never reuse one coupon on the other +# plan. Yearly prices never get a coupon. XOR with CASHIER_ALLOW_PROMOTION_CODES +# only when the coupon would apply — Stripe forbids both on one session +# (ConfigureSubscriptionCheckout throws). +STRIPE_SOCIALS_FIRST_MONTH_COUPON_ID= +STRIPE_WORKSPACES_FIRST_MONTH_COUPON_ID= # Show Stripe Checkout promotion-code field when no coupon is applied. # Defaults to false (recipe A). Must be false when a first-month coupon applies. CASHIER_ALLOW_PROMOTION_CODES=false diff --git a/AGENTS.md b/AGENTS.md index c9f8c08e6..2679e5946 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -212,14 +212,17 @@ Checkout options are configured only via env — do not hardcode trial/coupon/pr | --- | --- | --- | --- | | `REQUIRE_CARD_FOR_TRIAL` | `trypost.billing.require_card_for_trial` | `true` | `true`: app access only after Stripe Checkout (no generic signup trial). `false`: generic `accounts.trial_ends_at` trial without a card | | `CASHIER_TRIAL_DAYS` | `cashier.trial_days` | `8` | Card-required Checkout: `trialDays(N)` for **first-time** subscribers when no first-month coupon is applied (`0` = off). Re-subscribers skip trial. No-card mode: length of the generic signup trial | -| `STRIPE_FIRST_MONTH_COUPON_ID` | `cashier.first_month_coupon_id` | empty | Optional. When set for a qualifying first-time checkout, applies `withCoupon` and **skips** trial. Empty = trial mode | +| `STRIPE_SOCIALS_FIRST_MONTH_COUPON_ID` | `cashier.first_month_coupon_ids.socials` | empty | Optional. `$18` off Socials monthly (`$19` → `$1`). When set for a qualifying first-time **monthly** checkout of that plan, applies `withCoupon` and **skips** trial. Empty = trial mode | +| `STRIPE_WORKSPACES_FIRST_MONTH_COUPON_ID` | `cashier.first_month_coupon_ids.workspaces` | empty | Optional. `$88` off Workspaces monthly (`$99` → `$1`). Same qualification as the Socials coupon. Never reuse one coupon on the other plan | | `CASHIER_ALLOW_PROMOTION_CODES` | `cashier.allow_promotion_codes` | `false` | When `true` and no coupon is applied, show the Checkout promo-code field | Standing constraints: - Stripe rejects `discounts` (coupon) and `allow_promotion_codes` on the same session — if both would apply, `ConfigureSubscriptionCheckout` must throw (fail loud). Never “prefer one silently.” Envs may both be set when the account does **not** qualify for the coupon (no throw). - A set first-month coupon wins over trial (`trialDays` is skipped for that checkout). - Empty coupon + card required + first-time must use `trialDays` — do **not** reintroduce a required-coupon throw. -- Coupon qualification stays: card required, no prior real subscription (`incomplete` / `incomplete_expired` still qualify). Workspace count is irrelevant — Socials is already capped at one, and a first-time Workspaces subscriber qualifies the same way. +- Coupon qualification stays: card required, no prior real subscription (`incomplete` / `incomplete_expired` still qualify), **and** the checkout price is that plan's **monthly** price. Workspace count is irrelevant — Socials is already capped at one, and a first-time Workspaces subscriber qualifies the same way. +- First-month coupons are **per plan**. Socials is `$18` off, Workspaces is `$88` off. Never apply one plan's coupon to the other price, and never apply either coupon to a yearly price — `$190 − $18` is not `$1`. +- Welcome checkout (`app.welcome.plan`) is monthly only. Yearly stays on the billing change-plan picker for existing subscribers (they do not get a first-month coupon). - Prefer documenting durable billing decisions here (and in `CLAUDE.md`) — do **not** create a `.ai/` rules folder for this project. ## Plans and the workspace limit @@ -240,9 +243,12 @@ used** — `syncWorkspaceQuantity()` was removed with the per-workspace model. only the signup workspace (`count === 0`). - `WorkspacePolicy::create()` is owner-only. The cap is not a permission: an owner at the limit is redirected to billing by `WorkspaceController`, not 403'd. -- First-month coupon qualification is card required + first-time subscriber. - Workspace count is not part of it. (`incomplete` / `incomplete_expired` still - qualify; coupon + `allow_promotion_codes` still throws.) +- First-month coupon qualification is card required + first-time subscriber + + that plan's monthly price. Workspace count is not part of it. + (`incomplete` / `incomplete_expired` still qualify; coupon + + `allow_promotion_codes` still throws.) Each plan has its own coupon. +- Welcome is monthly only so the `$1` first month can exist. Billing keeps + yearly for subscribers swapping interval. - The legacy plan is archived: it never appears in the picker, so nobody can move back to it. Its `workspace_limit` is 1 because it costs less than Socials. - Plan choice is a welcome step (`app.welcome.plan`) and the same `PlanPicker` diff --git a/CLAUDE.md b/CLAUDE.md index d4c075b9a..f82c90a11 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -240,14 +240,17 @@ Checkout options are configured only via env — do not hardcode trial/coupon/pr | --- | --- | --- | --- | | `REQUIRE_CARD_FOR_TRIAL` | `trypost.billing.require_card_for_trial` | `true` | `true`: app access only after Stripe Checkout (no generic signup trial). `false`: generic `accounts.trial_ends_at` trial without a card | | `CASHIER_TRIAL_DAYS` | `cashier.trial_days` | `8` | Card-required Checkout: `trialDays(N)` for **first-time** subscribers when no first-month coupon is applied (`0` = off). Re-subscribers skip trial. No-card mode: length of the generic signup trial | -| `STRIPE_FIRST_MONTH_COUPON_ID` | `cashier.first_month_coupon_id` | empty | Optional. When set for a qualifying first-time checkout, applies `withCoupon` and **skips** trial. Empty = trial mode | +| `STRIPE_SOCIALS_FIRST_MONTH_COUPON_ID` | `cashier.first_month_coupon_ids.socials` | empty | Optional. `$18` off Socials monthly (`$19` → `$1`). When set for a qualifying first-time **monthly** checkout of that plan, applies `withCoupon` and **skips** trial. Empty = trial mode | +| `STRIPE_WORKSPACES_FIRST_MONTH_COUPON_ID` | `cashier.first_month_coupon_ids.workspaces` | empty | Optional. `$88` off Workspaces monthly (`$99` → `$1`). Same qualification as the Socials coupon. Never reuse one coupon on the other plan | | `CASHIER_ALLOW_PROMOTION_CODES` | `cashier.allow_promotion_codes` | `false` | When `true` and no coupon is applied, show the Checkout promo-code field | Standing constraints: - Stripe rejects `discounts` (coupon) and `allow_promotion_codes` on the same session — if both would apply, `ConfigureSubscriptionCheckout` must throw (fail loud). Never “prefer one silently.” Envs may both be set when the account does **not** qualify for the coupon (no throw). - A set first-month coupon wins over trial (`trialDays` is skipped for that checkout). - Empty coupon + card required + first-time must use `trialDays` — do **not** reintroduce a required-coupon throw. -- Coupon qualification stays: card required, no prior real subscription (`incomplete` / `incomplete_expired` still qualify). Workspace count is irrelevant — Socials is already capped at one, and a first-time Workspaces subscriber qualifies the same way. +- Coupon qualification stays: card required, no prior real subscription (`incomplete` / `incomplete_expired` still qualify), **and** the checkout price is that plan's **monthly** price. Workspace count is irrelevant — Socials is already capped at one, and a first-time Workspaces subscriber qualifies the same way. +- First-month coupons are **per plan**. Socials is `$18` off, Workspaces is `$88` off. Never apply one plan's coupon to the other price, and never apply either coupon to a yearly price — `$190 − $18` is not `$1`. +- Welcome checkout (`app.welcome.plan`) is monthly only. Yearly stays on the billing change-plan picker for existing subscribers (they do not get a first-month coupon). - Prefer documenting durable billing decisions here (and in `AGENTS.md`) — do **not** create a `.ai/` rules folder for this project. ## Plans and the workspace limit @@ -268,9 +271,12 @@ used** — `syncWorkspaceQuantity()` was removed with the per-workspace model. only the signup workspace (`count === 0`). - `WorkspacePolicy::create()` is owner-only. The cap is not a permission: an owner at the limit is redirected to billing by `WorkspaceController`, not 403'd. -- First-month coupon qualification is card required + first-time subscriber. - Workspace count is not part of it. (`incomplete` / `incomplete_expired` still - qualify; coupon + `allow_promotion_codes` still throws.) +- First-month coupon qualification is card required + first-time subscriber + + that plan's monthly price. Workspace count is not part of it. + (`incomplete` / `incomplete_expired` still qualify; coupon + + `allow_promotion_codes` still throws.) Each plan has its own coupon. +- Welcome is monthly only so the `$1` first month can exist. Billing keeps + yearly for subscribers swapping interval. - The legacy plan is archived: it never appears in the picker, so nobody can move back to it. Its `workspace_limit` is 1 because it costs less than Socials. - Plan choice is a welcome step (`app.welcome.plan`) and the same `PlanPicker` diff --git a/app/Actions/Billing/StartSubscriptionCheckout.php b/app/Actions/Billing/StartSubscriptionCheckout.php index 873f69f58..d5ba368c7 100644 --- a/app/Actions/Billing/StartSubscriptionCheckout.php +++ b/app/Actions/Billing/StartSubscriptionCheckout.php @@ -5,6 +5,7 @@ namespace App\Actions\Billing; use App\Models\Account; +use App\Models\Plan; use App\Support\Billing\ConfigureSubscriptionCheckout; use Illuminate\Support\Str; use Inertia\Inertia; @@ -15,7 +16,7 @@ class StartSubscriptionCheckout /** * Create a Stripe Checkout session for the given price and return an Inertia * redirect to it. Trial days, - * optional first-month coupon, and promotion codes come from cashier / + * optional per-plan first-month coupon, and promotion codes come from cashier / * trypost billing env config via ConfigureSubscriptionCheckout. The owner's * signup attribution -- UTM parameters and ad click IDs -- and onboarding * answers ride along as subscription metadata, flattened to the strings @@ -23,7 +24,7 @@ class StartSubscriptionCheckout * rejects a longer value outright rather than truncating it, which would * fail the whole checkout: https://docs.stripe.com/api/metadata */ - public function redirect(Account $account, string $priceId, string $cancelUrl): Response + public function redirect(Account $account, string $priceId, string $cancelUrl, ?Plan $plan = null): Response { $account->createOrGetStripeCustomer([ 'email' => $account->stripeEmail(), @@ -55,7 +56,11 @@ public function redirect(Account $account, string $priceId, string $cancelUrl): $metadata, )); - ConfigureSubscriptionCheckout::apply($subscription, $account); + ConfigureSubscriptionCheckout::apply( + $subscription, + $account, + self::planForFirstMonthCoupon($plan, $priceId), + ); $session = $subscription->checkout([ 'success_url' => route('app.billing.processing').'?session_id={CHECKOUT_SESSION_ID}', @@ -64,4 +69,18 @@ public function redirect(Account $account, string $priceId, string $cancelUrl): return Inertia::location($session->url); } + + /** + * First-month coupons are amount_off against the monthly price ($18 on + * Socials, $88 on Workspaces). A yearly price would leave the customer + * paying almost the full year, so it never qualifies. + */ + private static function planForFirstMonthCoupon(?Plan $plan, string $priceId): ?Plan + { + if ($plan === null || $plan->stripe_monthly_price_id !== $priceId) { + return null; + } + + return $plan; + } } diff --git a/app/Http/Controllers/App/WelcomeController.php b/app/Http/Controllers/App/WelcomeController.php index 5353ac953..a2f14c897 100644 --- a/app/Http/Controllers/App/WelcomeController.php +++ b/app/Http/Controllers/App/WelcomeController.php @@ -211,18 +211,17 @@ public function storePlan( $user = $request->user(); $plan = Plan::active()->findOrFail($request->validated('plan_id')); - $interval = Interval::from($request->validated('interval')); - $priceId = $interval->priceIdFor($plan); + $priceId = Interval::Monthly->priceIdFor($plan); abort_if($priceId === null, Response::HTTP_INTERNAL_SERVER_ERROR, 'Price is not configured.'); - $response = $checkout->redirect($user->account, $priceId, route('app.welcome.plan')); + $response = $checkout->redirect($user->account, $priceId, route('app.welcome.plan'), $plan); try { $postHog->capture( $user->id, CheckoutEvent::Started->value, - ['plan_name' => $plan->name, 'interval' => $interval->value], + ['plan_name' => $plan->name, 'interval' => Interval::Monthly->value], $user->account, ); } catch (Throwable $e) { diff --git a/app/Http/Requests/App/Welcome/StoreWelcomePlanRequest.php b/app/Http/Requests/App/Welcome/StoreWelcomePlanRequest.php index 5b771a750..c548f418e 100644 --- a/app/Http/Requests/App/Welcome/StoreWelcomePlanRequest.php +++ b/app/Http/Requests/App/Welcome/StoreWelcomePlanRequest.php @@ -4,7 +4,6 @@ namespace App\Http\Requests\App\Welcome; -use App\Enums\Billing\Interval; use Illuminate\Foundation\Http\FormRequest; use Illuminate\Validation\Rule; @@ -26,7 +25,6 @@ public function rules(): array 'uuid', Rule::exists('plans', 'id')->where(fn ($query) => $query->where('is_archived', false)), ], - 'interval' => ['required', Rule::enum(Interval::class)], ]; } } diff --git a/app/Support/Billing/ConfigureSubscriptionCheckout.php b/app/Support/Billing/ConfigureSubscriptionCheckout.php index 2f3b2ca4b..62141abfb 100644 --- a/app/Support/Billing/ConfigureSubscriptionCheckout.php +++ b/app/Support/Billing/ConfigureSubscriptionCheckout.php @@ -5,6 +5,7 @@ namespace App\Support\Billing; use App\Models\Account; +use App\Models\Plan; use Laravel\Cashier\SubscriptionBuilder; use RuntimeException; use Stripe\Subscription as StripeSubscription; @@ -20,7 +21,7 @@ final class ConfigureSubscriptionCheckout * Apply env-driven checkout options to a subscription builder. * * Precedence when REQUIRE_CARD_FOR_TRIAL is enabled: - * 1. Qualifying first-month coupon → withCoupon, no trialDays (card charge now). + * 1. Qualifying first-month coupon for this plan → withCoupon, no trialDays. * 2. Else first-time customer + CASHIER_TRIAL_DAYS > 0 → trialDays (clamped to ≥ 2). * 3. Else plain checkout (immediate full price) — including re-subscribers. * @@ -30,17 +31,19 @@ final class ConfigureSubscriptionCheckout * @throws RuntimeException when a coupon would be applied while * allow_promotion_codes is also enabled. */ - public static function apply(SubscriptionBuilder $subscription, Account $account): SubscriptionBuilder + public static function apply(SubscriptionBuilder $subscription, Account $account, ?Plan $plan = null): SubscriptionBuilder { - if (self::shouldApplyFirstMonthCoupon($account)) { + $couponId = self::firstMonthCouponId($account, $plan); + + if ($couponId !== null) { if ((bool) config('cashier.allow_promotion_codes', false)) { throw new RuntimeException( - 'Cannot apply STRIPE_FIRST_MONTH_COUPON_ID while CASHIER_ALLOW_PROMOTION_CODES is enabled: ' + 'Cannot apply a first-month coupon while CASHIER_ALLOW_PROMOTION_CODES is enabled: ' .'Stripe Checkout rejects discounts and allow_promotion_codes on the same session.' ); } - return $subscription->withCoupon((string) config('cashier.first_month_coupon_id')); + return $subscription->withCoupon($couponId); } if ( @@ -62,23 +65,29 @@ public static function apply(SubscriptionBuilder $subscription, Account $account } /** - * First-month coupons only fit a new customer. A subscription that never - * left incomplete never became real, so a retry after a failed first - * attempt still qualifies; any started subscription (even canceled) does not. + * First-month coupons only fit a new customer on a monthly price. A + * subscription that never left incomplete never became real, so a retry + * after a failed first attempt still qualifies; any started subscription + * (even canceled) does not. The coupon is the one for this plan's slug — + * Socials and Workspaces take different amount_off values. */ - private static function shouldApplyFirstMonthCoupon(Account $account): bool + private static function firstMonthCouponId(Account $account, ?Plan $plan): ?string { - if (! (bool) config('trypost.billing.require_card_for_trial', true)) { - return false; + if ($plan === null || ! (bool) config('trypost.billing.require_card_for_trial', true)) { + return null; + } + + if (! self::isFirstTimeSubscriber($account)) { + return null; } - $couponId = config('cashier.first_month_coupon_id'); + $couponId = config('cashier.first_month_coupon_ids.'.$plan->slug->value); if (! is_string($couponId) || $couponId === '') { - return false; + return null; } - return self::isFirstTimeSubscriber($account); + return $couponId; } /** diff --git a/config/cashier.php b/config/cashier.php index 8b4ed78d1..e08fc3b2b 100644 --- a/config/cashier.php +++ b/config/cashier.php @@ -144,18 +144,24 @@ /* |-------------------------------------------------------------------------- - | Paid First Month Coupon + | Paid First Month Coupons |-------------------------------------------------------------------------- | - | Optional Stripe Coupon ID (amount_off, duration=once). When set and the - | account qualifies (card required, single workspace, first-time), checkout - | applies withCoupon and skips trialDays so the first invoice validates the - | card. Empty = no coupon; card-required checkouts use trial_days instead. - | Cannot be combined with allow_promotion_codes on the same checkout. + | Per-plan Stripe Coupon IDs (amount_off, duration=once) so the first + | monthly invoice is $1: Socials $19 − $18, Workspaces $99 − $88. When + | the matching coupon is set and the account qualifies (card required, + | first-time, monthly price), checkout applies withCoupon and skips + | trialDays. Empty for that plan = trial_days instead. Never reuse one + | coupon on the other plan — the amounts are different. Yearly prices + | never get a coupon. Cannot be combined with allow_promotion_codes on + | the same checkout. | */ - 'first_month_coupon_id' => env('STRIPE_FIRST_MONTH_COUPON_ID'), + 'first_month_coupon_ids' => [ + 'socials' => env('STRIPE_SOCIALS_FIRST_MONTH_COUPON_ID'), + 'workspaces' => env('STRIPE_WORKSPACES_FIRST_MONTH_COUPON_ID'), + ], /* |-------------------------------------------------------------------------- diff --git a/lang/ar/billing.php b/lang/ar/billing.php index 51a1c9758..b000bcc55 100644 --- a/lang/ar/billing.php +++ b/lang/ar/billing.php @@ -19,6 +19,7 @@ 'billed_monthly' => 'فوترة شهرية', 'billed_yearly' => 'فوترة سنوية', 'prices' => [ + 'first_month' => '$1', 'workspace' => ['monthly' => '$12', 'yearly_per_month' => '$10', 'yearly' => '$120'], 'socials' => ['monthly' => '$19', 'yearly_per_month' => '$15.83', 'yearly' => '$190'], 'workspaces' => ['monthly' => '$99', 'yearly_per_month' => '$82.50', 'yearly' => '$990'], @@ -36,6 +37,21 @@ 'workspaces_unlimited' => 'مساحات عمل غير محدودة', 'current' => 'الخطة الحالية', 'select' => 'اختر :plan', + 'start_first_month' => 'ابدأ شهري الأول مقابل :price', + 'first_month_then' => 'الشهر الأول :first، ثم :price/شهر', + + 'billed_yearly_total' => 'فوترة سنوية · :price (شهران مجانًا)', + 'socials_tagline' => 'مساحة عمل واحدة. انشر في كل مكان.', + 'workspaces_tagline' => 'مساحة عمل لكل علامة أو عميل.', + 'features' => [ + 'accounts_unlimited' => 'حسابات اجتماعية غير محدودة', + 'calendar' => 'تقويم مرئي مع نشر تلقائي', + 'ai' => 'ذكاء اصطناعي: نصوص وصور وصوت العلامة', + 'mcp' => 'MCP: أنشئ وجدول من Claude أو ChatGPT أو Grok', + 'repurpose' => 'Repurpose: حوّل منشورًا واحدًا إلى عدة منشورات', + 'analytics' => 'تحليلات لكل منشور وحساب', + 'team' => 'فريق وأدوار وموافقات غير محدودة', + ], ], 'plan' => [ diff --git a/lang/ar/welcome.php b/lang/ar/welcome.php index ca682634b..d05f116fe 100644 --- a/lang/ar/welcome.php +++ b/lang/ar/welcome.php @@ -38,8 +38,8 @@ 'just_exploring' => 'مجرد استكشاف في الوقت الحالي', 'other' => 'شيء آخر', ], - 'plan_title' => 'اختر خطتك', - 'plan_description' => 'ابدأ بما تحتاجه اليوم. يمكنك التغيير في أي وقت.', + 'plan_title' => 'اختر خطة. كل الميزات مشمولة.', + 'plan_description' => 'الفرق الوحيد هو عدد مساحات العمل. يمكنك التغيير لاحقًا.', 'referral_source_title' => 'كيف وجدتنا؟', 'referral_source_description' => 'يساعدنا هذا على فهم كيفية اكتشاف الأشخاص لـ TryPost.', 'referral_source' => [ diff --git a/lang/de/billing.php b/lang/de/billing.php index b370ab74f..b52e440d1 100644 --- a/lang/de/billing.php +++ b/lang/de/billing.php @@ -21,6 +21,7 @@ 'billed_monthly' => 'Monatlich abgerechnet', 'billed_yearly' => 'Jährlich abgerechnet', 'prices' => [ + 'first_month' => '$1', 'workspace' => ['monthly' => '$12', 'yearly_per_month' => '$10', 'yearly' => '$120'], 'socials' => ['monthly' => '$19', 'yearly_per_month' => '$15.83', 'yearly' => '$190'], 'workspaces' => ['monthly' => '$99', 'yearly_per_month' => '$82.50', 'yearly' => '$990'], @@ -38,6 +39,21 @@ 'workspaces_unlimited' => 'Unbegrenzte Workspaces', 'current' => 'Aktueller Tarif', 'select' => ':plan wählen', + 'start_first_month' => 'Meinen ersten Monat für :price starten', + 'first_month_then' => 'Erster Monat :first, danach :price/Monat', + + 'billed_yearly_total' => 'Jährlich abgerechnet · :price (2 Monate gratis)', + 'socials_tagline' => 'Ein Workspace. Überall posten.', + 'workspaces_tagline' => 'Ein Workspace für jede Marke oder jeden Kunden.', + 'features' => [ + 'accounts_unlimited' => 'Unbegrenzte Social-Accounts', + 'calendar' => 'Visueller Kalender mit Auto-Publishing', + 'ai' => 'KI: Captions, Bilder und Markenstimme', + 'mcp' => 'MCP: erstellen und planen mit Claude, ChatGPT oder Grok', + 'repurpose' => 'Repurpose: aus einem Post viele machen', + 'analytics' => 'Analytics pro Post und Account', + 'team' => 'Unbegrenztes Team, Rollen und Freigaben', + ], ], 'plan' => [ diff --git a/lang/de/welcome.php b/lang/de/welcome.php index a9baee7da..8d16d8a42 100644 --- a/lang/de/welcome.php +++ b/lang/de/welcome.php @@ -38,8 +38,8 @@ 'just_exploring' => 'Ich schaue mich vorerst nur um', 'other' => 'Etwas anderes', ], - 'plan_title' => 'Wähle deinen Tarif', - 'plan_description' => 'Starte mit dem, was du heute brauchst. Du kannst jederzeit wechseln.', + 'plan_title' => 'Wähle einen Tarif. Alle Funktionen sind enthalten.', + 'plan_description' => 'Der einzige Unterschied ist, wie viele Workspaces du bekommst. Du kannst später wechseln.', 'referral_source_title' => 'Wie hast du uns gefunden?', 'referral_source_description' => 'Das hilft uns zu verstehen, wie Menschen TryPost entdecken.', 'referral_source' => [ diff --git a/lang/el/billing.php b/lang/el/billing.php index 22be8403a..d5fa098b3 100644 --- a/lang/el/billing.php +++ b/lang/el/billing.php @@ -19,6 +19,7 @@ 'billed_monthly' => 'Μηνιαία χρέωση', 'billed_yearly' => 'Ετήσια χρέωση', 'prices' => [ + 'first_month' => '$1', 'workspace' => ['monthly' => '$12', 'yearly_per_month' => '$10', 'yearly' => '$120'], 'socials' => ['monthly' => '$19', 'yearly_per_month' => '$15.83', 'yearly' => '$190'], 'workspaces' => ['monthly' => '$99', 'yearly_per_month' => '$82.50', 'yearly' => '$990'], @@ -36,6 +37,21 @@ 'workspaces_unlimited' => 'Απεριόριστα workspaces', 'current' => 'Τρέχον πλάνο', 'select' => 'Επιλέξτε :plan', + 'start_first_month' => 'Ξεκίνα τον πρώτο μήνα με :price', + 'first_month_then' => 'Πρώτος μήνας :first, μετά :price/μήνα', + + 'billed_yearly_total' => 'Ετήσια χρέωση · :price (2 μήνες δωρεάν)', + 'socials_tagline' => 'Ένα workspace. Δημοσιεύστε παντού.', + 'workspaces_tagline' => 'Ένα workspace για κάθε brand ή πελάτη.', + 'features' => [ + 'accounts_unlimited' => 'Απεριόριστοι λογαριασμοί social', + 'calendar' => 'Οπτικό ημερολόγιο με αυτόματη δημοσίευση', + 'ai' => 'AI: λεζάντες, εικόνες και φωνή brand', + 'mcp' => 'MCP: δημιουργία και προγραμματισμός από Claude, ChatGPT ή Grok', + 'repurpose' => 'Repurpose: ένα post γίνεται πολλά', + 'analytics' => 'Analytics ανά ανάρτηση και λογαριασμό', + 'team' => 'Απεριόριστη ομάδα, ρόλοι και εγκρίσεις', + ], ], 'plan' => [ diff --git a/lang/el/welcome.php b/lang/el/welcome.php index 365176110..391370c8e 100644 --- a/lang/el/welcome.php +++ b/lang/el/welcome.php @@ -38,8 +38,8 @@ 'just_exploring' => 'Απλώς εξερευνώ προς το παρόν', 'other' => 'Κάτι άλλο', ], - 'plan_title' => 'Επιλέξτε το πλάνο σας', - 'plan_description' => 'Ξεκινήστε με ό,τι χρειάζεστε σήμερα. Μπορείτε να το αλλάξετε όποτε θέλετε.', + 'plan_title' => 'Επιλέξτε πλάνο. Όλα τα χαρακτηριστικά περιλαμβάνονται.', + 'plan_description' => 'Η μόνη διαφορά είναι πόσα workspaces παίρνετε. Μπορείτε να αλλάξετε αργότερα.', 'referral_source_title' => 'Πώς μας βρήκατε;', 'referral_source_description' => 'Αυτό μας βοηθά να καταλάβουμε πώς οι άνθρωποι ανακαλύπτουν το TryPost.', 'referral_source' => [ diff --git a/lang/en/billing.php b/lang/en/billing.php index 9ac0e2aa2..57f5771e1 100644 --- a/lang/en/billing.php +++ b/lang/en/billing.php @@ -19,6 +19,7 @@ 'billed_monthly' => 'Billed monthly', 'billed_yearly' => 'Billed annually', 'prices' => [ + 'first_month' => '$1', 'workspace' => ['monthly' => '$12', 'yearly_per_month' => '$10', 'yearly' => '$120'], 'socials' => ['monthly' => '$19', 'yearly_per_month' => '$15.83', 'yearly' => '$190'], 'workspaces' => ['monthly' => '$99', 'yearly_per_month' => '$82.50', 'yearly' => '$990'], @@ -36,6 +37,21 @@ 'workspaces_unlimited' => 'Unlimited workspaces', 'current' => 'Current plan', 'select' => 'Choose :plan', + 'start_first_month' => 'Start my first month for :price', + 'first_month_then' => 'First month :first, then :price/month', + + 'billed_yearly_total' => 'Billed annually · :price (2 months free)', + 'socials_tagline' => 'One workspace. Post everywhere.', + 'workspaces_tagline' => 'A workspace for every brand or client.', + 'features' => [ + 'accounts_unlimited' => 'Unlimited social accounts', + 'calendar' => 'Visual calendar with auto-publishing', + 'ai' => 'AI content: captions, images, brand voice', + 'mcp' => 'MCP: create and schedule from Claude, ChatGPT, or Grok', + 'repurpose' => 'Repurpose: turn one post into many', + 'analytics' => 'Analytics per post and account', + 'team' => 'Unlimited teammates, roles, and approvals', + ], ], 'plan' => [ diff --git a/lang/en/welcome.php b/lang/en/welcome.php index 28594404e..60358fbb5 100644 --- a/lang/en/welcome.php +++ b/lang/en/welcome.php @@ -38,8 +38,8 @@ 'just_exploring' => 'Just exploring for now', 'other' => 'Something else', ], - 'plan_title' => 'Choose your plan', - 'plan_description' => 'Start with what you need today. You can change it whenever you want.', + 'plan_title' => 'Pick a plan. Every feature is included.', + 'plan_description' => 'The only difference is how many workspaces you get. You can switch later.', 'referral_source_title' => 'How did you find us?', 'referral_source_description' => 'This helps us understand how people discover TryPost.', 'referral_source' => [ diff --git a/lang/es/billing.php b/lang/es/billing.php index f128bb23d..538eec698 100644 --- a/lang/es/billing.php +++ b/lang/es/billing.php @@ -19,6 +19,7 @@ 'billed_monthly' => 'Facturado mensualmente', 'billed_yearly' => 'Facturado anualmente', 'prices' => [ + 'first_month' => '$1', 'workspace' => ['monthly' => '$12', 'yearly_per_month' => '$10', 'yearly' => '$120'], 'socials' => ['monthly' => '$19', 'yearly_per_month' => '$15.83', 'yearly' => '$190'], 'workspaces' => ['monthly' => '$99', 'yearly_per_month' => '$82.50', 'yearly' => '$990'], @@ -36,6 +37,21 @@ 'workspaces_unlimited' => 'Workspaces ilimitados', 'current' => 'Plan actual', 'select' => 'Elegir :plan', + 'start_first_month' => 'Empezar mi primer mes por :price', + 'first_month_then' => 'Primer mes :first, luego :price/mes', + + 'billed_yearly_total' => 'Facturación anual · :price (2 meses gratis)', + 'socials_tagline' => 'Un workspace. Publica en todas partes.', + 'workspaces_tagline' => 'Un workspace para cada marca o cliente.', + 'features' => [ + 'accounts_unlimited' => 'Cuentas sociales ilimitadas', + 'calendar' => 'Calendario visual con publicación automática', + 'ai' => 'IA: textos, imágenes y voz de marca', + 'mcp' => 'MCP: crea y programa desde Claude, ChatGPT o Grok', + 'repurpose' => 'Repurpose: convierte un post en muchos', + 'analytics' => 'Analíticas por publicación y por cuenta', + 'team' => 'Equipo, roles y aprobaciones ilimitados', + ], ], 'plan' => [ diff --git a/lang/es/welcome.php b/lang/es/welcome.php index f793ded55..c79be883d 100644 --- a/lang/es/welcome.php +++ b/lang/es/welcome.php @@ -38,8 +38,8 @@ 'just_exploring' => 'Solo estoy explorando por ahora', 'other' => 'Otra cosa', ], - 'plan_title' => 'Elige tu plan', - 'plan_description' => 'Empieza con lo que necesitas hoy. Puedes cambiarlo cuando quieras.', + 'plan_title' => 'Elige un plan. Todas las funciones están incluidas.', + 'plan_description' => 'La única diferencia es cuántos workspaces tienes. Puedes cambiarlo después.', 'referral_source_title' => '¿Cómo nos encontraste?', 'referral_source_description' => 'Esto nos ayuda a entender cómo la gente descubre TryPost.', 'referral_source' => [ diff --git a/lang/fr/billing.php b/lang/fr/billing.php index a6c2c7e30..b85080bad 100644 --- a/lang/fr/billing.php +++ b/lang/fr/billing.php @@ -19,6 +19,7 @@ 'billed_monthly' => 'Facturé mensuellement', 'billed_yearly' => 'Facturé annuellement', 'prices' => [ + 'first_month' => '1 $', 'workspace' => ['monthly' => '12 $', 'yearly_per_month' => '10 $', 'yearly' => '120 $'], 'socials' => ['monthly' => '19 $', 'yearly_per_month' => '15,83 $', 'yearly' => '190 $'], 'workspaces' => ['monthly' => '99 $', 'yearly_per_month' => '82,50 $', 'yearly' => '990 $'], @@ -36,6 +37,21 @@ 'workspaces_unlimited' => 'Espaces de travail illimités', 'current' => 'Offre actuelle', 'select' => 'Choisir :plan', + 'start_first_month' => 'Commencer mon premier mois pour :price', + 'first_month_then' => 'Premier mois :first, puis :price/mois', + + 'billed_yearly_total' => 'Facturé annuellement · :price (2 mois offerts)', + 'socials_tagline' => 'Un espace de travail. Publiez partout.', + 'workspaces_tagline' => 'Un espace de travail pour chaque marque ou client.', + 'features' => [ + 'accounts_unlimited' => 'Comptes sociaux illimités', + 'calendar' => 'Calendrier visuel avec publication automatique', + 'ai' => 'IA : légendes, images et voix de marque', + 'mcp' => 'MCP : créez et planifiez depuis Claude, ChatGPT ou Grok', + 'repurpose' => 'Repurpose : transformez un post en plusieurs', + 'analytics' => 'Analyses par publication et par compte', + 'team' => 'Équipe, rôles et validations illimités', + ], ], 'plan' => [ diff --git a/lang/fr/welcome.php b/lang/fr/welcome.php index 251f6ee34..6d8bf27b5 100644 --- a/lang/fr/welcome.php +++ b/lang/fr/welcome.php @@ -38,8 +38,8 @@ 'just_exploring' => 'Je découvre pour l\'instant', 'other' => 'Autre chose', ], - 'plan_title' => 'Choisissez votre offre', - 'plan_description' => 'Commencez avec ce dont vous avez besoin aujourd\'hui. Vous pourrez changer à tout moment.', + 'plan_title' => 'Choisissez une offre. Toutes les fonctionnalités sont incluses.', + 'plan_description' => 'La seule différence, c\'est le nombre d\'espaces de travail. Vous pourrez changer plus tard.', 'referral_source_title' => 'Comment nous avez-vous connus ?', 'referral_source_description' => 'Cela nous aide à comprendre comment les gens découvrent TryPost.', 'referral_source' => [ diff --git a/lang/it/billing.php b/lang/it/billing.php index 5c3d8b946..54e163774 100644 --- a/lang/it/billing.php +++ b/lang/it/billing.php @@ -19,6 +19,7 @@ 'billed_monthly' => 'Fatturazione mensile', 'billed_yearly' => 'Fatturazione annuale', 'prices' => [ + 'first_month' => '$1', 'workspace' => ['monthly' => '$12', 'yearly_per_month' => '$10', 'yearly' => '$120'], 'socials' => ['monthly' => '$19', 'yearly_per_month' => '$15.83', 'yearly' => '$190'], 'workspaces' => ['monthly' => '$99', 'yearly_per_month' => '$82.50', 'yearly' => '$990'], @@ -36,6 +37,21 @@ 'workspaces_unlimited' => 'Workspace illimitati', 'current' => 'Piano attuale', 'select' => 'Scegli :plan', + 'start_first_month' => 'Inizia il primo mese a :price', + 'first_month_then' => 'Primo mese :first, poi :price/mese', + + 'billed_yearly_total' => 'Fatturato annualmente · :price (2 mesi gratis)', + 'socials_tagline' => 'Un workspace. Pubblica ovunque.', + 'workspaces_tagline' => 'Un workspace per ogni brand o cliente.', + 'features' => [ + 'accounts_unlimited' => 'Account social illimitati', + 'calendar' => 'Calendario visuale con pubblicazione automatica', + 'ai' => 'IA: didascalie, immagini e brand voice', + 'mcp' => 'MCP: crea e programma da Claude, ChatGPT o Grok', + 'repurpose' => 'Repurpose: trasforma un post in tanti', + 'analytics' => 'Analytics per post e per account', + 'team' => 'Team, ruoli e approvazioni illimitati', + ], ], 'plan' => [ diff --git a/lang/it/welcome.php b/lang/it/welcome.php index 73cd2af95..4cf3ba6f6 100644 --- a/lang/it/welcome.php +++ b/lang/it/welcome.php @@ -38,8 +38,8 @@ 'just_exploring' => 'Sto solo dando un\'occhiata', 'other' => 'Qualcos\'altro', ], - 'plan_title' => 'Scegli il tuo piano', - 'plan_description' => 'Inizia con ciò che ti serve oggi. Puoi cambiarlo quando vuoi.', + 'plan_title' => 'Scegli un piano. Tutte le funzionalità sono incluse.', + 'plan_description' => 'L\'unica differenza è quanti workspace hai. Puoi cambiare in seguito.', 'referral_source_title' => 'Come ci hai trovato?', 'referral_source_description' => 'Questo ci aiuta a capire come le persone scoprono TryPost.', 'referral_source' => [ diff --git a/lang/ja/billing.php b/lang/ja/billing.php index fcbf6f48c..d0f0bd339 100644 --- a/lang/ja/billing.php +++ b/lang/ja/billing.php @@ -19,6 +19,7 @@ 'billed_monthly' => '月払い', 'billed_yearly' => '年払い', 'prices' => [ + 'first_month' => '$1', 'workspace' => ['monthly' => '$12', 'yearly_per_month' => '$10', 'yearly' => '$120'], 'socials' => ['monthly' => '$19', 'yearly_per_month' => '$15.83', 'yearly' => '$190'], 'workspaces' => ['monthly' => '$99', 'yearly_per_month' => '$82.50', 'yearly' => '$990'], @@ -36,6 +37,21 @@ 'workspaces_unlimited' => '無制限のワークスペース', 'current' => '現在のプラン', 'select' => ':plan を選ぶ', + 'start_first_month' => '初月を:priceで始める', + 'first_month_then' => '初月:first、その後:price/月', + + 'billed_yearly_total' => '年払い · :price(2か月分無料)', + 'socials_tagline' => 'ワークスペース1つで、すべてのネットワークに投稿。', + 'workspaces_tagline' => 'ブランドやクライアントごとにワークスペースを。', + 'features' => [ + 'accounts_unlimited' => 'ソーシャルアカウント数無制限', + 'calendar' => '自動投稿付きのビジュアルカレンダー', + 'ai' => 'AI:キャプション、画像、ブランドボイス', + 'mcp' => 'MCP:Claude、ChatGPT、Grokから作成・予約', + 'repurpose' => 'Repurpose:1本の投稿を複数に展開', + 'analytics' => '投稿・アカウントごとの分析', + 'team' => 'チーム、役割、承認は無制限', + ], ], 'plan' => [ diff --git a/lang/ja/welcome.php b/lang/ja/welcome.php index 251ac008d..b58e92081 100644 --- a/lang/ja/welcome.php +++ b/lang/ja/welcome.php @@ -38,8 +38,8 @@ 'just_exploring' => '今はまだ様子を見ている', 'other' => 'その他', ], - 'plan_title' => 'プランを選ぶ', - 'plan_description' => '今必要なものから始めましょう。いつでも変更できます。', + 'plan_title' => 'プランを選ぶ。機能はすべて含まれています。', + 'plan_description' => '違いはワークスペースの数だけ。あとから変更できます。', 'referral_source_title' => 'どこで私たちを知りましたか?', 'referral_source_description' => 'これは、人々がどのように TryPost を見つけるかを理解するのに役立ちます。', 'referral_source' => [ diff --git a/lang/ko/billing.php b/lang/ko/billing.php index f9527213b..7d410e448 100644 --- a/lang/ko/billing.php +++ b/lang/ko/billing.php @@ -19,6 +19,7 @@ 'billed_monthly' => '월간 결제', 'billed_yearly' => '연간 결제', 'prices' => [ + 'first_month' => '$1', 'workspace' => ['monthly' => '$12', 'yearly_per_month' => '$10', 'yearly' => '$120'], 'socials' => ['monthly' => '$19', 'yearly_per_month' => '$15.83', 'yearly' => '$190'], 'workspaces' => ['monthly' => '$99', 'yearly_per_month' => '$82.50', 'yearly' => '$990'], @@ -36,6 +37,21 @@ 'workspaces_unlimited' => '무제한 워크스페이스', 'current' => '현재 요금제', 'select' => ':plan 선택', + 'start_first_month' => '첫 달을 :price에 시작하기', + 'first_month_then' => '첫 달 :first, 이후 :price/월', + + 'billed_yearly_total' => '연간 결제 · :price (2개월 무료)', + 'socials_tagline' => '워크스페이스 하나. 모든 네트워크에 게시.', + 'workspaces_tagline' => '브랜드나 클라이언트마다 워크스페이스 하나.', + 'features' => [ + 'accounts_unlimited' => '소셜 계정 무제한', + 'calendar' => '자동 게시가 되는 비주얼 캘린더', + 'ai' => 'AI: 캡션, 이미지, 브랜드 보이스', + 'mcp' => 'MCP: Claude, ChatGPT, Grok에서 만들고 예약', + 'repurpose' => 'Repurpose: 게시물 하나를 여러 개로', + 'analytics' => '게시물·계정별 분석', + 'team' => '팀, 역할, 승인 무제한', + ], ], 'plan' => [ diff --git a/lang/ko/welcome.php b/lang/ko/welcome.php index f9cdf192d..3ba5c7896 100644 --- a/lang/ko/welcome.php +++ b/lang/ko/welcome.php @@ -38,8 +38,8 @@ 'just_exploring' => '지금은 둘러보는 중', 'other' => '다른 것', ], - 'plan_title' => '요금제를 선택하세요', - 'plan_description' => '오늘 필요한 것으로 시작하세요. 언제든지 변경할 수 있습니다.', + 'plan_title' => '요금제를 선택하세요. 모든 기능이 포함됩니다.', + 'plan_description' => '차이는 워크스페이스 수뿐입니다. 나중에 바꿀 수 있습니다.', 'referral_source_title' => '저희를 어떻게 알게 되셨나요?', 'referral_source_description' => '사람들이 TryPost를 어떻게 발견하는지 파악하는 데 도움이 됩니다.', 'referral_source' => [ diff --git a/lang/nl/billing.php b/lang/nl/billing.php index 5f9b17bdf..61c1172b9 100644 --- a/lang/nl/billing.php +++ b/lang/nl/billing.php @@ -19,6 +19,7 @@ 'billed_monthly' => 'Maandelijks gefactureerd', 'billed_yearly' => 'Jaarlijks gefactureerd', 'prices' => [ + 'first_month' => '$1', 'workspace' => ['monthly' => '$12', 'yearly_per_month' => '$10', 'yearly' => '$120'], 'socials' => ['monthly' => '$19', 'yearly_per_month' => '$15.83', 'yearly' => '$190'], 'workspaces' => ['monthly' => '$99', 'yearly_per_month' => '$82.50', 'yearly' => '$990'], @@ -36,6 +37,21 @@ 'workspaces_unlimited' => 'Onbeperkte workspaces', 'current' => 'Huidig plan', 'select' => 'Kies :plan', + 'start_first_month' => 'Start mijn eerste maand voor :price', + 'first_month_then' => 'Eerste maand :first, daarna :price/maand', + + 'billed_yearly_total' => 'Jaarlijks gefactureerd · :price (2 maanden gratis)', + 'socials_tagline' => 'Eén workspace. Overal posten.', + 'workspaces_tagline' => 'Een workspace voor elk merk of elke klant.', + 'features' => [ + 'accounts_unlimited' => 'Onbeperkte social accounts', + 'calendar' => 'Visuele kalender met automatisch publiceren', + 'ai' => 'AI: captions, afbeeldingen en merkstem', + 'mcp' => 'MCP: maak en plan via Claude, ChatGPT of Grok', + 'repurpose' => 'Repurpose: maak van één post er veel', + 'analytics' => 'Analytics per post en per account', + 'team' => 'Onbeperkt team, rollen en goedkeuringen', + ], ], 'plan' => [ diff --git a/lang/nl/welcome.php b/lang/nl/welcome.php index a7beccd64..2ddfae9be 100644 --- a/lang/nl/welcome.php +++ b/lang/nl/welcome.php @@ -38,8 +38,8 @@ 'just_exploring' => 'Voorlopig gewoon aan het verkennen', 'other' => 'Iets anders', ], - 'plan_title' => 'Kies je plan', - 'plan_description' => 'Begin met wat je vandaag nodig hebt. Je kunt het altijd wijzigen.', + 'plan_title' => 'Kies een plan. Elke functie is inbegrepen.', + 'plan_description' => 'Het enige verschil is hoeveel workspaces je krijgt. Je kunt later wisselen.', 'referral_source_title' => 'Hoe heb je ons gevonden?', 'referral_source_description' => 'Dit helpt ons te begrijpen hoe mensen TryPost ontdekken.', 'referral_source' => [ diff --git a/lang/pl/billing.php b/lang/pl/billing.php index 5cdeb5767..a67d9b4fc 100644 --- a/lang/pl/billing.php +++ b/lang/pl/billing.php @@ -19,6 +19,7 @@ 'billed_monthly' => 'Rozliczane miesięcznie', 'billed_yearly' => 'Rozliczane rocznie', 'prices' => [ + 'first_month' => '$1', 'workspace' => ['monthly' => '$12', 'yearly_per_month' => '$10', 'yearly' => '$120'], 'socials' => ['monthly' => '$19', 'yearly_per_month' => '$15.83', 'yearly' => '$190'], 'workspaces' => ['monthly' => '$99', 'yearly_per_month' => '$82.50', 'yearly' => '$990'], @@ -36,6 +37,21 @@ 'workspaces_unlimited' => 'Nielimitowane workspace’y', 'current' => 'Aktualny plan', 'select' => 'Wybierz :plan', + 'start_first_month' => 'Zacznij pierwszy miesiąc za :price', + 'first_month_then' => 'Pierwszy miesiąc :first, potem :price/miesiąc', + + 'billed_yearly_total' => 'Rozliczane rocznie · :price (2 miesiące gratis)', + 'socials_tagline' => 'Jeden workspace. Publikuj wszędzie.', + 'workspaces_tagline' => 'Workspace na każdą markę lub klienta.', + 'features' => [ + 'accounts_unlimited' => 'Nielimitowane konta społecznościowe', + 'calendar' => 'Wizualny kalendarz z automatyczną publikacją', + 'ai' => 'AI: podpisy, obrazy i głos marki', + 'mcp' => 'MCP: twórz i planuj w Claude, ChatGPT lub Grok', + 'repurpose' => 'Repurpose: z jednego posta zrób wiele', + 'analytics' => 'Analityka per post i konto', + 'team' => 'Nielimitowany zespół, role i akceptacje', + ], ], 'plan' => [ diff --git a/lang/pl/welcome.php b/lang/pl/welcome.php index 02081b085..8ca6a6c7c 100644 --- a/lang/pl/welcome.php +++ b/lang/pl/welcome.php @@ -38,8 +38,8 @@ 'just_exploring' => 'Na razie tylko się rozglądam', 'other' => 'Coś innego', ], - 'plan_title' => 'Wybierz swój plan', - 'plan_description' => 'Zacznij od tego, czego potrzebujesz dziś. Możesz zmienić go w każdej chwili.', + 'plan_title' => 'Wybierz plan. Wszystkie funkcje są w cenie.', + 'plan_description' => 'Jedyna różnica to liczba workspace\'ów. Możesz zmienić później.', 'referral_source_title' => 'Jak nas znalazłeś?', 'referral_source_description' => 'To pomaga nam zrozumieć, jak ludzie odkrywają TryPost.', 'referral_source' => [ diff --git a/lang/pt-BR/billing.php b/lang/pt-BR/billing.php index 6b2932f4d..3d0123ea8 100644 --- a/lang/pt-BR/billing.php +++ b/lang/pt-BR/billing.php @@ -19,6 +19,7 @@ 'billed_monthly' => 'Cobrança mensal', 'billed_yearly' => 'Cobrança anual', 'prices' => [ + 'first_month' => 'R$ 5', 'workspace' => ['monthly' => 'R$ 60', 'yearly_per_month' => 'R$ 50', 'yearly' => 'R$ 600'], 'socials' => ['monthly' => 'R$ 95', 'yearly_per_month' => 'R$ 79,17', 'yearly' => 'R$ 950'], 'workspaces' => ['monthly' => 'R$ 495', 'yearly_per_month' => 'R$ 412,50', 'yearly' => 'R$ 4.950'], @@ -36,6 +37,21 @@ 'workspaces_unlimited' => 'Workspaces ilimitados', 'current' => 'Plano atual', 'select' => 'Escolher :plan', + 'start_first_month' => 'Começar meu primeiro mês por :price', + 'first_month_then' => 'Primeiro mês :first, depois :price/mês', + + 'billed_yearly_total' => 'Cobrança anual · :price (2 meses grátis)', + 'socials_tagline' => 'Um workspace. Publique em todas as redes.', + 'workspaces_tagline' => 'Um workspace para cada marca ou cliente.', + 'features' => [ + 'accounts_unlimited' => 'Contas sociais ilimitadas', + 'calendar' => 'Calendário visual com publicação automática', + 'ai' => 'IA: legendas, imagens e voz da marca', + 'mcp' => 'MCP: crie e agende pelo Claude, ChatGPT ou Grok', + 'repurpose' => 'Repurpose: transforme um post em vários', + 'analytics' => 'Analytics por post e por conta', + 'team' => 'Time, papéis e aprovações ilimitados', + ], ], 'plan' => [ diff --git a/lang/pt-BR/welcome.php b/lang/pt-BR/welcome.php index 0403fe4e8..687a71bc7 100644 --- a/lang/pt-BR/welcome.php +++ b/lang/pt-BR/welcome.php @@ -38,8 +38,8 @@ 'just_exploring' => 'Só dando uma olhada por enquanto', 'other' => 'Outra coisa', ], - 'plan_title' => 'Escolha seu plano', - 'plan_description' => 'Comece com o que você precisa hoje. Dá para mudar quando quiser.', + 'plan_title' => 'Escolha um plano. Tudo está incluído.', + 'plan_description' => 'A única diferença é quantos workspaces você tem. Dá para mudar depois.', 'referral_source_title' => 'Como você nos encontrou?', 'referral_source_description' => 'Isso nos ajuda a entender como as pessoas descobrem o TryPost.', 'referral_source' => [ diff --git a/lang/ru/billing.php b/lang/ru/billing.php index f53d98f78..24108356b 100644 --- a/lang/ru/billing.php +++ b/lang/ru/billing.php @@ -19,6 +19,7 @@ 'billed_monthly' => 'Ежемесячная оплата', 'billed_yearly' => 'Годовая оплата', 'prices' => [ + 'first_month' => '$1', 'workspace' => ['monthly' => '$12', 'yearly_per_month' => '$10', 'yearly' => '$120'], 'socials' => ['monthly' => '$19', 'yearly_per_month' => '$15.83', 'yearly' => '$190'], 'workspaces' => ['monthly' => '$99', 'yearly_per_month' => '$82.50', 'yearly' => '$990'], @@ -36,6 +37,21 @@ 'workspaces_unlimited' => 'Неограниченное число workspace', 'current' => 'Текущий план', 'select' => 'Выбрать :plan', + 'start_first_month' => 'Начать первый месяц за :price', + 'first_month_then' => 'Первый месяц :first, затем :price/месяц', + + 'billed_yearly_total' => 'Оплата раз в год · :price (2 месяца бесплатно)', + 'socials_tagline' => 'Одно рабочее пространство. Публикуйте везде.', + 'workspaces_tagline' => 'Рабочее пространство для каждого бренда или клиента.', + 'features' => [ + 'accounts_unlimited' => 'Безлимитные аккаунты', + 'calendar' => 'Визуальный календарь с автопубликацией', + 'ai' => 'ИИ: тексты, изображения и голос бренда', + 'mcp' => 'MCP: создавайте и планируйте в Claude, ChatGPT или Grok', + 'repurpose' => 'Repurpose: из одного поста сделайте много', + 'analytics' => 'Аналитика по постам и аккаунтам', + 'team' => 'Безлимитная команда, роли и согласования', + ], ], 'plan' => [ diff --git a/lang/ru/welcome.php b/lang/ru/welcome.php index 99172a6ca..4b728b31a 100644 --- a/lang/ru/welcome.php +++ b/lang/ru/welcome.php @@ -38,8 +38,8 @@ 'just_exploring' => 'Пока просто знакомлюсь', 'other' => 'Что-то ещё', ], - 'plan_title' => 'Выберите план', - 'plan_description' => 'Начните с того, что нужно сегодня. Изменить можно в любой момент.', + 'plan_title' => 'Выберите план. Все функции включены.', + 'plan_description' => 'Единственная разница — сколько рабочих пространств вы получаете. Позже можно сменить.', 'referral_source_title' => 'Как вы нас нашли?', 'referral_source_description' => 'Это помогает нам понять, как люди узнают о TryPost.', 'referral_source' => [ diff --git a/lang/tr/billing.php b/lang/tr/billing.php index fade6cca1..f064056a2 100644 --- a/lang/tr/billing.php +++ b/lang/tr/billing.php @@ -21,6 +21,7 @@ 'billed_monthly' => 'Aylık faturalandırılır', 'billed_yearly' => 'Yıllık faturalandırılır', 'prices' => [ + 'first_month' => '$1', 'workspace' => ['monthly' => '$12', 'yearly_per_month' => '$10', 'yearly' => '$120'], 'socials' => ['monthly' => '$19', 'yearly_per_month' => '$15.83', 'yearly' => '$190'], 'workspaces' => ['monthly' => '$99', 'yearly_per_month' => '$82.50', 'yearly' => '$990'], @@ -38,6 +39,21 @@ 'workspaces_unlimited' => 'Sınırsız workspace', 'current' => 'Mevcut plan', 'select' => ':plan seç', + 'start_first_month' => 'İlk ayıma :price ile başla', + 'first_month_then' => 'İlk ay :first, sonra :price/ay', + + 'billed_yearly_total' => 'Yıllık faturalandırılır · :price (2 ay bedava)', + 'socials_tagline' => 'Bir çalışma alanı. Her yere yayınlayın.', + 'workspaces_tagline' => 'Her marka veya müşteri için bir çalışma alanı.', + 'features' => [ + 'accounts_unlimited' => 'Sınırsız sosyal hesaplar', + 'calendar' => 'Otomatik yayınlamalı görsel takvim', + 'ai' => 'Yapay zeka: metinler, görseller ve marka sesi', + 'mcp' => 'MCP: Claude, ChatGPT veya Grok ile oluşturun ve planlayın', + 'repurpose' => 'Repurpose: bir gönderiyi çoğaltın', + 'analytics' => 'Gönderi ve hesap analitiği', + 'team' => 'Sınırsız ekip, roller ve onaylar', + ], ], 'plan' => [ diff --git a/lang/tr/welcome.php b/lang/tr/welcome.php index ded4932c0..12df6b981 100644 --- a/lang/tr/welcome.php +++ b/lang/tr/welcome.php @@ -38,8 +38,8 @@ 'just_exploring' => 'Şimdilik sadece keşfetmek', 'other' => 'Başka bir şey', ], - 'plan_title' => 'Planınızı seçin', - 'plan_description' => 'Bugün ihtiyacınız olanla başlayın. İstediğiniz zaman değiştirebilirsiniz.', + 'plan_title' => 'Bir plan seçin. Tüm özellikler dahil.', + 'plan_description' => 'Tek fark aldığınız çalışma alanı sayısı. Daha sonra değiştirebilirsiniz.', 'referral_source_title' => 'Bizi nasıl buldunuz?', 'referral_source_description' => 'İnsanların TryPost\'u nasıl keşfettiğini anlamamıza yardımcı olur.', 'referral_source' => [ diff --git a/lang/uk/billing.php b/lang/uk/billing.php index fa4e95da3..531f96b24 100644 --- a/lang/uk/billing.php +++ b/lang/uk/billing.php @@ -19,6 +19,7 @@ 'billed_monthly' => 'Щомісячна оплата', 'billed_yearly' => 'Річна оплата', 'prices' => [ + 'first_month' => '$1', 'workspace' => ['monthly' => '$12', 'yearly_per_month' => '$10', 'yearly' => '$120'], 'socials' => ['monthly' => '$19', 'yearly_per_month' => '$15.83', 'yearly' => '$190'], 'workspaces' => ['monthly' => '$99', 'yearly_per_month' => '$82.50', 'yearly' => '$990'], @@ -36,6 +37,21 @@ 'workspaces_unlimited' => 'Необмежена кількість workspace', 'current' => 'Поточний план', 'select' => 'Обрати :plan', + 'start_first_month' => 'Почати перший місяць за :price', + 'first_month_then' => 'Перший місяць :first, далі :price/місяць', + + 'billed_yearly_total' => 'Оплата раз на рік · :price (2 місяці безкоштовно)', + 'socials_tagline' => 'Один робочий простір. Публікуйте скрізь.', + 'workspaces_tagline' => 'Робочий простір для кожного бренду чи клієнта.', + 'features' => [ + 'accounts_unlimited' => 'Необмежені акаунти', + 'calendar' => 'Візуальний календар з автопублікацією', + 'ai' => 'ШІ: тексти, зображення та голос бренду', + 'mcp' => 'MCP: створюйте й плануйте в Claude, ChatGPT або Grok', + 'repurpose' => 'Repurpose: з одного поста зробіть багато', + 'analytics' => 'Аналітика за постами та акаунтами', + 'team' => 'Необмежена команда, ролі та погодження', + ], ], 'plan' => [ diff --git a/lang/uk/welcome.php b/lang/uk/welcome.php index 43dad455f..df3a714db 100644 --- a/lang/uk/welcome.php +++ b/lang/uk/welcome.php @@ -38,8 +38,8 @@ 'just_exploring' => 'Поки що просто досліджую', 'other' => 'Щось інше', ], - 'plan_title' => 'Оберіть свій план', - 'plan_description' => 'Почніть з того, що потрібно сьогодні. Змінити можна будь-коли.', + 'plan_title' => 'Оберіть план. Усі функції включені.', + 'plan_description' => 'Єдина різниця — скільки робочих просторів ви отримуєте. Пізніше можна змінити.', 'referral_source_title' => 'Як ви нас знайшли?', 'referral_source_description' => 'Це допомагає нам зрозуміти, як люди дізнаються про TryPost.', 'referral_source' => [ diff --git a/lang/zh/billing.php b/lang/zh/billing.php index 7d4cb0137..bae538696 100644 --- a/lang/zh/billing.php +++ b/lang/zh/billing.php @@ -19,6 +19,7 @@ 'billed_monthly' => '按月计费', 'billed_yearly' => '按年计费', 'prices' => [ + 'first_month' => '$1', 'workspace' => ['monthly' => '$12', 'yearly_per_month' => '$10', 'yearly' => '$120'], 'socials' => ['monthly' => '$19', 'yearly_per_month' => '$15.83', 'yearly' => '$190'], 'workspaces' => ['monthly' => '$99', 'yearly_per_month' => '$82.50', 'yearly' => '$990'], @@ -36,6 +37,21 @@ 'workspaces_unlimited' => '无限工作区', 'current' => '当前套餐', 'select' => '选择 :plan', + 'start_first_month' => '以 :price 开始第一个月', + 'first_month_then' => '首月 :first,之后 :price/月', + + 'billed_yearly_total' => '按年计费 · :price(免两个月)', + 'socials_tagline' => '一个工作区,发到所有网络。', + 'workspaces_tagline' => '每个品牌或客户一个工作区。', + 'features' => [ + 'accounts_unlimited' => '社交账号不限', + 'calendar' => '可视化日历,自动发布', + 'ai' => 'AI:文案、图片和品牌声音', + 'mcp' => 'MCP:用 Claude、ChatGPT 或 Grok 创建并预约', + 'repurpose' => 'Repurpose:一条内容变成多条', + 'analytics' => '按帖子和账号查看分析', + 'team' => '无限团队、角色和审批', + ], ], 'plan' => [ diff --git a/lang/zh/welcome.php b/lang/zh/welcome.php index 196bb3a7b..d3661c707 100644 --- a/lang/zh/welcome.php +++ b/lang/zh/welcome.php @@ -38,8 +38,8 @@ 'just_exploring' => '目前只是随便看看', 'other' => '其他需求', ], - 'plan_title' => '选择套餐', - 'plan_description' => '从今天需要的开始。随时可以更改。', + 'plan_title' => '选择套餐。所有功能都包含在内。', + 'plan_description' => '唯一的区别是工作区数量。之后随时可以换。', 'referral_source_title' => '您是如何找到我们的?', 'referral_source_description' => '这有助于我们了解人们是如何发现 TryPost 的。', 'referral_source' => [ diff --git a/resources/js/components/PlatformLogo.vue b/resources/js/components/PlatformLogo.vue index d72a6df9b..353b25855 100644 --- a/resources/js/components/PlatformLogo.vue +++ b/resources/js/components/PlatformLogo.vue @@ -6,10 +6,11 @@ import { getPlatformLabel, getPlatformTheme } from '@/composables/usePlatformLog const props = withDefaults( defineProps<{ platform: string; - size?: 'sm' | 'md' | 'lg'; + size?: 'xs' | 'sm' | 'md' | 'lg'; tilt?: boolean; + plain?: boolean; }>(), - { size: 'md', tilt: true }, + { size: 'md', tilt: true, plain: false }, ); const theme = computed(() => getPlatformTheme(props.platform)); @@ -17,6 +18,7 @@ const theme = computed(() => getPlatformTheme(props.platform)); const boxClass = computed( () => ({ + xs: 'size-6 rounded-md', sm: 'size-10 rounded-xl', md: 'size-12 rounded-xl', lg: 'size-16 rounded-2xl', @@ -26,23 +28,45 @@ const boxClass = computed( const imageClass = computed( () => ({ + xs: 'size-3.5 rounded-sm', sm: 'size-5 rounded-sm', md: 'size-7 rounded-md', lg: 'size-9 rounded-lg', })[props.size], ); + +const plainImageClass = computed( + () => + ({ + xs: 'size-5', + sm: 'size-6', + md: 'size-8', + lg: 'size-10', + })[props.size], +); diff --git a/resources/js/components/billing/PlanPicker.vue b/resources/js/components/billing/PlanPicker.vue index f27528026..ed6389344 100644 --- a/resources/js/components/billing/PlanPicker.vue +++ b/resources/js/components/billing/PlanPicker.vue @@ -1,9 +1,21 @@ diff --git a/resources/js/pages/settings/account/Billing.vue b/resources/js/pages/settings/account/Billing.vue index e998467b2..dfbd44e98 100644 --- a/resources/js/pages/settings/account/Billing.vue +++ b/resources/js/pages/settings/account/Billing.vue @@ -99,7 +99,7 @@ const upgradeToAnnual = (): void => { -
+
(); -const form = useForm<{ plan_id: string | null; interval: 'monthly' | 'yearly' }>({ +const form = useForm<{ plan_id: string | null }>({ plan_id: null, - interval: 'monthly', }); -const setInterval = (interval: 'monthly' | 'yearly'): void => { - form.interval = interval; -}; - const select = (planId: string): void => { if (form.processing) { return; @@ -35,13 +30,14 @@ const select = (planId: string): void => { :title="$t('welcome.plan_title')" :description="$t('welcome.plan_description')" :step="5" - size="2xl" + size="5xl" > diff --git a/tests/Browser/WelcomePlanTest.php b/tests/Browser/WelcomePlanTest.php new file mode 100644 index 000000000..2188c5b93 --- /dev/null +++ b/tests/Browser/WelcomePlanTest.php @@ -0,0 +1,76 @@ +script(<< { + const sel = '[data-testid="{$testId}"]'; + for (let i = 0; i < 100; i++) { + const el = document.querySelector(sel); + if (el && el.getBoundingClientRect().height > 0) return; + await new Promise((resolve) => setTimeout(resolve, 50)); + } + })(); + JS); +} + +function welcomeOwnerOnPlanStep(): User +{ + $user = User::factory()->create(); + $user->update([ + 'persona' => Persona::Agency->value, + 'goals' => [Goal::SaveTime->value], + 'referral_source' => ReferralSource::ProductHunt->value, + ]); + + $workspace = Workspace::factory()->create([ + 'account_id' => $user->account_id, + 'user_id' => $user->id, + ]); + $workspace->members()->attach($user->id, ['role' => Role::Admin->value]); + $user->update(['current_workspace_id' => $workspace->id]); + + SocialAccount::factory()->linkedin()->create([ + 'workspace_id' => $workspace->id, + ]); + + return $user->fresh(); +} + +test('the plan step shows both plans with networks and no yearly toggle', function () { + config(['trypost.self_hosted' => false]); + + $this->actingAs(welcomeOwnerOnPlanStep()); + + $page = visit(route('app.welcome.plan')); + + waitForWelcomePlanTestId($page, 'plan-networks'); + waitForWelcomePlanTestId($page, 'plan-card-socials'); + waitForWelcomePlanTestId($page, 'plan-card-workspaces'); + + $page->assertRoute('app.welcome.plan') + ->assertVisible('@plan-card-socials') + ->assertVisible('@plan-card-workspaces') + ->assertVisible('@plan-networks') + ->assertVisible('@plan-select-socials') + ->assertVisible('@plan-select-workspaces') + ->assertVisible('@plan-highlight-socials') + ->assertVisible('@plan-highlight-workspaces') + ->assertMissing('@plan-interval-yearly') + ->assertMissing('@plan-interval-monthly') + ->assertNoJavaScriptErrors(); +}); diff --git a/tests/Feature/Welcome/PlanSelectionTest.php b/tests/Feature/Welcome/PlanSelectionTest.php index 42dc7b69f..6976ed161 100644 --- a/tests/Feature/Welcome/PlanSelectionTest.php +++ b/tests/Feature/Welcome/PlanSelectionTest.php @@ -77,18 +77,7 @@ ->assertSessionHasErrors('plan_id'); }); -test('the plan step rejects an unknown interval', function () { - $plan = Plan::where('slug', Slug::Socials)->firstOrFail(); - - $this->actingAs($this->user->fresh()) - ->post(route('app.welcome.plan.store'), [ - 'plan_id' => $plan->id, - 'interval' => 'weekly', - ]) - ->assertSessionHasErrors('interval'); -}); - -test('the plan step starts checkout with the yearly price of the chosen plan', function () { +test('the plan step always checks out the monthly price of the chosen plan', function () { $plan = Plan::where('slug', Slug::Workspaces)->firstOrFail(); $plan->update([ 'stripe_monthly_price_id' => 'price_workspaces_monthly', @@ -98,7 +87,7 @@ $this->mock(StartSubscriptionCheckout::class) ->shouldReceive('redirect') ->once() - ->withArgs(fn ($account, $priceId) => $priceId === 'price_workspaces_yearly') + ->withArgs(fn ($account, $priceId, $cancelUrl, $passedPlan) => $priceId === 'price_workspaces_monthly' && $passedPlan->is($plan)) ->andReturn(redirect('https://checkout.stripe.test/session')); $this->actingAs($this->user->fresh()) diff --git a/tests/Unit/Actions/Billing/StartSubscriptionCheckoutTest.php b/tests/Unit/Actions/Billing/StartSubscriptionCheckoutTest.php index f07277286..747ca8bea 100644 --- a/tests/Unit/Actions/Billing/StartSubscriptionCheckoutTest.php +++ b/tests/Unit/Actions/Billing/StartSubscriptionCheckoutTest.php @@ -3,9 +3,11 @@ declare(strict_types=1); use App\Actions\Billing\StartSubscriptionCheckout; +use App\Enums\Plan\Slug; use App\Enums\User\Persona; use App\Enums\User\ReferralSource; use App\Models\Account; +use App\Models\Plan; use App\Models\User; use App\Models\Workspace; use Illuminate\Foundation\Testing\RefreshDatabase; @@ -19,7 +21,10 @@ config([ 'trypost.billing.require_card_for_trial' => true, 'cashier.trial_days' => 8, - 'cashier.first_month_coupon_id' => '', + 'cashier.first_month_coupon_ids' => [ + 'socials' => '', + 'workspaces' => '', + ], 'cashier.allow_promotion_codes' => false, ]); @@ -99,7 +104,10 @@ config([ 'trypost.billing.require_card_for_trial' => true, 'cashier.trial_days' => 8, - 'cashier.first_month_coupon_id' => '', + 'cashier.first_month_coupon_ids' => [ + 'socials' => '', + 'workspaces' => '', + ], 'cashier.allow_promotion_codes' => false, ]); @@ -129,7 +137,10 @@ config([ 'trypost.billing.require_card_for_trial' => true, 'cashier.trial_days' => 8, - 'cashier.first_month_coupon_id' => '', + 'cashier.first_month_coupon_ids' => [ + 'socials' => '', + 'workspaces' => '', + ], 'cashier.allow_promotion_codes' => false, ]); @@ -158,3 +169,80 @@ app(StartSubscriptionCheckout::class)->redirect($accountMock, 'price_monthly_test', route('app.welcome')); }); + +test('redirect applies the plan first-month coupon on a monthly price', function () { + config([ + 'trypost.billing.require_card_for_trial' => true, + 'cashier.trial_days' => 8, + 'cashier.first_month_coupon_ids.socials' => 'SOCIALS_18USD', + 'cashier.allow_promotion_codes' => false, + ]); + + $plan = Plan::where('slug', Slug::Socials)->firstOrFail(); + $plan->update(['stripe_monthly_price_id' => 'price_socials_monthly']); + + $account = Account::factory()->create(); + Workspace::factory()->create(['account_id' => $account->id]); + User::factory()->create(['account_id' => $account->id]); + $account->refresh(); + + $builder = Mockery::mock(SubscriptionBuilder::class); + $builder->shouldReceive('withMetadata')->once()->andReturnSelf(); + $builder->shouldReceive('withCoupon')->once()->with('SOCIALS_18USD')->andReturnSelf(); + $builder->shouldReceive('trialDays')->never(); + $builder->shouldReceive('checkout') + ->once() + ->andReturn((object) ['url' => 'https://checkout.stripe.test/session']); + + /** @var Account&MockInterface $accountMock */ + $accountMock = Mockery::mock($account)->makePartial(); + $accountMock->shouldReceive('createOrGetStripeCustomer')->once()->andReturnNull(); + $accountMock->shouldReceive('newSubscription')->once()->andReturn($builder); + + app(StartSubscriptionCheckout::class)->redirect( + $accountMock, + 'price_socials_monthly', + route('app.welcome.plan'), + $plan, + ); +}); + +test('redirect skips the first-month coupon on a yearly price', function () { + config([ + 'trypost.billing.require_card_for_trial' => true, + 'cashier.trial_days' => 8, + 'cashier.first_month_coupon_ids.workspaces' => 'WORKSPACES_88USD', + 'cashier.allow_promotion_codes' => false, + ]); + + $plan = Plan::where('slug', Slug::Workspaces)->firstOrFail(); + $plan->update([ + 'stripe_monthly_price_id' => 'price_workspaces_monthly', + 'stripe_yearly_price_id' => 'price_workspaces_yearly', + ]); + + $account = Account::factory()->create(); + Workspace::factory()->create(['account_id' => $account->id]); + User::factory()->create(['account_id' => $account->id]); + $account->refresh(); + + $builder = Mockery::mock(SubscriptionBuilder::class); + $builder->shouldReceive('withMetadata')->once()->andReturnSelf(); + $builder->shouldReceive('withCoupon')->never(); + $builder->shouldReceive('trialDays')->once()->with(8)->andReturnSelf(); + $builder->shouldReceive('checkout') + ->once() + ->andReturn((object) ['url' => 'https://checkout.stripe.test/session']); + + /** @var Account&MockInterface $accountMock */ + $accountMock = Mockery::mock($account)->makePartial(); + $accountMock->shouldReceive('createOrGetStripeCustomer')->once()->andReturnNull(); + $accountMock->shouldReceive('newSubscription')->once()->andReturn($builder); + + app(StartSubscriptionCheckout::class)->redirect( + $accountMock, + 'price_workspaces_yearly', + route('app.welcome.plan'), + $plan, + ); +}); diff --git a/tests/Unit/Support/Billing/ConfigureSubscriptionCheckoutTest.php b/tests/Unit/Support/Billing/ConfigureSubscriptionCheckoutTest.php index 8c80325a0..3514f3e20 100644 --- a/tests/Unit/Support/Billing/ConfigureSubscriptionCheckoutTest.php +++ b/tests/Unit/Support/Billing/ConfigureSubscriptionCheckoutTest.php @@ -2,7 +2,9 @@ declare(strict_types=1); +use App\Enums\Plan\Slug; use App\Models\Account; +use App\Models\Plan; use App\Models\Workspace; use App\Support\Billing\ConfigureSubscriptionCheckout; use Carbon\Carbon; @@ -17,7 +19,10 @@ config([ 'trypost.billing.require_card_for_trial' => true, 'cashier.trial_days' => 8, - 'cashier.first_month_coupon_id' => '', + 'cashier.first_month_coupon_ids' => [ + 'socials' => '', + 'workspaces' => '', + ], 'cashier.allow_promotion_codes' => false, ]); }); @@ -44,6 +49,16 @@ function trialExpiresAt(SubscriptionBuilder $subscription): ?Carbon return $property->getValue($subscription); } +function socialsPlan(): Plan +{ + return Plan::where('slug', Slug::Socials)->firstOrFail(); +} + +function workspacesPlan(): Plan +{ + return Plan::where('slug', Slug::Workspaces)->firstOrFail(); +} + test('recipe A applies an eight-day trial without coupon or promotion codes', function () { Workspace::factory()->create(['account_id' => $this->account->id]); @@ -51,16 +66,16 @@ function trialExpiresAt(SubscriptionBuilder $subscription): ?Carbon $subscription = checkoutSubscription($this->account); - ConfigureSubscriptionCheckout::apply($subscription, $this->account); + ConfigureSubscriptionCheckout::apply($subscription, $this->account, socialsPlan()); expect($subscription->couponId)->toBeNull() ->and($subscription->allowPromotionCodes)->toBeFalse() ->and(trialExpiresAt($subscription)?->toDateTimeString())->toBe('2026-08-15 12:00:00'); }); -test('recipe B applies the first-month coupon and skips trial days', function () { +test('recipe B applies the socials first-month coupon and skips trial days', function () { config([ - 'cashier.first_month_coupon_id' => 'TRIAL1USD', + 'cashier.first_month_coupon_ids.socials' => 'SOCIALS_18USD', 'cashier.allow_promotion_codes' => false, 'cashier.trial_days' => 8, ]); @@ -68,13 +83,61 @@ function trialExpiresAt(SubscriptionBuilder $subscription): ?Carbon $subscription = checkoutSubscription($this->account); - ConfigureSubscriptionCheckout::apply($subscription, $this->account); + ConfigureSubscriptionCheckout::apply($subscription, $this->account, socialsPlan()); - expect($subscription->couponId)->toBe('TRIAL1USD') + expect($subscription->couponId)->toBe('SOCIALS_18USD') ->and($subscription->allowPromotionCodes)->toBeFalse() ->and(trialExpiresAt($subscription))->toBeNull(); }); +test('applies the workspaces first-month coupon for that plan', function () { + config([ + 'cashier.first_month_coupon_ids.socials' => 'SOCIALS_18USD', + 'cashier.first_month_coupon_ids.workspaces' => 'WORKSPACES_88USD', + ]); + Workspace::factory()->create(['account_id' => $this->account->id]); + + $subscription = checkoutSubscription($this->account); + + ConfigureSubscriptionCheckout::apply($subscription, $this->account, workspacesPlan()); + + expect($subscription->couponId)->toBe('WORKSPACES_88USD') + ->and(trialExpiresAt($subscription))->toBeNull(); +}); + +test('does not apply the other plan\'s first-month coupon', function () { + config([ + 'cashier.first_month_coupon_ids.workspaces' => 'WORKSPACES_88USD', + ]); + Workspace::factory()->create(['account_id' => $this->account->id]); + + Carbon::setTestNow('2026-08-07 12:00:00'); + + $subscription = checkoutSubscription($this->account); + + ConfigureSubscriptionCheckout::apply($subscription, $this->account, socialsPlan()); + + expect($subscription->couponId)->toBeNull() + ->and(trialExpiresAt($subscription)?->toDateTimeString())->toBe('2026-08-15 12:00:00'); +}); + +test('does not apply a first-month coupon when no plan is given', function () { + config([ + 'cashier.first_month_coupon_ids.socials' => 'SOCIALS_18USD', + 'cashier.first_month_coupon_ids.workspaces' => 'WORKSPACES_88USD', + ]); + Workspace::factory()->create(['account_id' => $this->account->id]); + + Carbon::setTestNow('2026-08-07 12:00:00'); + + $subscription = checkoutSubscription($this->account); + + ConfigureSubscriptionCheckout::apply($subscription, $this->account); + + expect($subscription->couponId)->toBeNull() + ->and(trialExpiresAt($subscription)?->toDateTimeString())->toBe('2026-08-15 12:00:00'); +}); + test('recipe C applies trial days and allows promotion codes', function () { config(['cashier.allow_promotion_codes' => true]); Workspace::factory()->create(['account_id' => $this->account->id]); @@ -83,7 +146,7 @@ function trialExpiresAt(SubscriptionBuilder $subscription): ?Carbon $subscription = checkoutSubscription($this->account); - ConfigureSubscriptionCheckout::apply($subscription, $this->account); + ConfigureSubscriptionCheckout::apply($subscription, $this->account, socialsPlan()); expect($subscription->couponId)->toBeNull() ->and($subscription->allowPromotionCodes)->toBeTrue() @@ -99,7 +162,7 @@ function trialExpiresAt(SubscriptionBuilder $subscription): ?Carbon $subscription = checkoutSubscription($this->account); - ConfigureSubscriptionCheckout::apply($subscription, $this->account); + ConfigureSubscriptionCheckout::apply($subscription, $this->account, socialsPlan()); expect(trialExpiresAt($subscription))->toBeNull() ->and($subscription->couponId)->toBeNull() @@ -108,15 +171,15 @@ function trialExpiresAt(SubscriptionBuilder $subscription): ?Carbon test('throws when a qualifying coupon would combine with allow promotion codes', function () { config([ - 'cashier.first_month_coupon_id' => 'TRIAL1USD', + 'cashier.first_month_coupon_ids.socials' => 'SOCIALS_18USD', 'cashier.allow_promotion_codes' => true, ]); Workspace::factory()->create(['account_id' => $this->account->id]); $subscription = checkoutSubscription($this->account); - expect(fn () => ConfigureSubscriptionCheckout::apply($subscription, $this->account)) - ->toThrow(RuntimeException::class, 'Cannot apply STRIPE_FIRST_MONTH_COUPON_ID while CASHIER_ALLOW_PROMOTION_CODES is enabled'); + expect(fn () => ConfigureSubscriptionCheckout::apply($subscription, $this->account, socialsPlan())) + ->toThrow(RuntimeException::class, 'Cannot apply a first-month coupon while CASHIER_ALLOW_PROMOTION_CODES is enabled'); expect($subscription->couponId)->toBeNull() ->and(trialExpiresAt($subscription))->toBeNull(); @@ -124,20 +187,20 @@ function trialExpiresAt(SubscriptionBuilder $subscription): ?Carbon test('throws when coupon and promo are both set even if the account has several workspaces', function () { config([ - 'cashier.first_month_coupon_id' => 'TRIAL1USD', + 'cashier.first_month_coupon_ids.socials' => 'SOCIALS_18USD', 'cashier.allow_promotion_codes' => true, ]); Workspace::factory()->count(2)->create(['account_id' => $this->account->id]); $subscription = checkoutSubscription($this->account); - expect(fn () => ConfigureSubscriptionCheckout::apply($subscription, $this->account)) - ->toThrow(RuntimeException::class, 'Cannot apply STRIPE_FIRST_MONTH_COUPON_ID while CASHIER_ALLOW_PROMOTION_CODES is enabled'); + expect(fn () => ConfigureSubscriptionCheckout::apply($subscription, $this->account, socialsPlan())) + ->toThrow(RuntimeException::class, 'Cannot apply a first-month coupon while CASHIER_ALLOW_PROMOTION_CODES is enabled'); }); test('does not throw when coupon and promo are both set but a prior canceled subscription skips the coupon', function () { config([ - 'cashier.first_month_coupon_id' => 'TRIAL1USD', + 'cashier.first_month_coupon_ids.socials' => 'SOCIALS_18USD', 'cashier.allow_promotion_codes' => true, ]); Workspace::factory()->create(['account_id' => $this->account->id]); @@ -145,7 +208,7 @@ function trialExpiresAt(SubscriptionBuilder $subscription): ?Carbon $subscription = checkoutSubscription($this->account); - expect(fn () => ConfigureSubscriptionCheckout::apply($subscription, $this->account)) + expect(fn () => ConfigureSubscriptionCheckout::apply($subscription, $this->account, socialsPlan())) ->not->toThrow(RuntimeException::class); expect($subscription->couponId)->toBeNull() @@ -155,7 +218,7 @@ function trialExpiresAt(SubscriptionBuilder $subscription): ?Carbon test('empty coupon with card required does not throw and still applies trial', function () { config([ - 'cashier.first_month_coupon_id' => '', + 'cashier.first_month_coupon_ids.socials' => '', 'cashier.allow_promotion_codes' => false, ]); Workspace::factory()->create(['account_id' => $this->account->id]); @@ -164,7 +227,7 @@ function trialExpiresAt(SubscriptionBuilder $subscription): ?Carbon $subscription = checkoutSubscription($this->account); - ConfigureSubscriptionCheckout::apply($subscription, $this->account); + ConfigureSubscriptionCheckout::apply($subscription, $this->account, socialsPlan()); expect($subscription->couponId)->toBeNull() ->and(trialExpiresAt($subscription)?->toDateTimeString())->toBe('2026-08-15 12:00:00'); @@ -173,14 +236,14 @@ function trialExpiresAt(SubscriptionBuilder $subscription): ?Carbon test('skips the coupon and allows promotion codes when a card is not required', function () { config([ 'trypost.billing.require_card_for_trial' => false, - 'cashier.first_month_coupon_id' => 'TRIAL1USD', + 'cashier.first_month_coupon_ids.socials' => 'SOCIALS_18USD', 'cashier.allow_promotion_codes' => true, ]); Workspace::factory()->create(['account_id' => $this->account->id]); $subscription = checkoutSubscription($this->account); - expect(fn () => ConfigureSubscriptionCheckout::apply($subscription, $this->account)) + expect(fn () => ConfigureSubscriptionCheckout::apply($subscription, $this->account, socialsPlan())) ->not->toThrow(RuntimeException::class); expect($subscription->allowPromotionCodes)->toBeTrue() @@ -189,25 +252,25 @@ function trialExpiresAt(SubscriptionBuilder $subscription): ?Carbon }); test('the first-month coupon applies to a first-time subscriber with several workspaces', function () { - config(['cashier.first_month_coupon_id' => 'TRIAL1USD']); + config(['cashier.first_month_coupon_ids.socials' => 'SOCIALS_18USD']); Workspace::factory()->count(2)->create(['account_id' => $this->account->id]); $subscription = checkoutSubscription($this->account); - ConfigureSubscriptionCheckout::apply($subscription, $this->account); + ConfigureSubscriptionCheckout::apply($subscription, $this->account, socialsPlan()); - expect($subscription->couponId)->toBe('TRIAL1USD') + expect($subscription->couponId)->toBe('SOCIALS_18USD') ->and(trialExpiresAt($subscription))->toBeNull(); }); test('skips coupon and trial when the account has a prior canceled subscription', function () { - config(['cashier.first_month_coupon_id' => 'TRIAL1USD']); + config(['cashier.first_month_coupon_ids.socials' => 'SOCIALS_18USD']); Workspace::factory()->create(['account_id' => $this->account->id]); givePriorSubscription($this->account); $subscription = checkoutSubscription($this->account); - ConfigureSubscriptionCheckout::apply($subscription, $this->account); + ConfigureSubscriptionCheckout::apply($subscription, $this->account, socialsPlan()); expect($subscription->couponId)->toBeNull() ->and(trialExpiresAt($subscription))->toBeNull() @@ -216,7 +279,7 @@ function trialExpiresAt(SubscriptionBuilder $subscription): ?Carbon test('still applies the coupon when the only prior subscription is incomplete_expired', function () { config([ - 'cashier.first_month_coupon_id' => 'TRIAL1USD', + 'cashier.first_month_coupon_ids.socials' => 'SOCIALS_18USD', 'cashier.allow_promotion_codes' => false, ]); Workspace::factory()->create(['account_id' => $this->account->id]); @@ -224,16 +287,16 @@ function trialExpiresAt(SubscriptionBuilder $subscription): ?Carbon $subscription = checkoutSubscription($this->account); - ConfigureSubscriptionCheckout::apply($subscription, $this->account); + ConfigureSubscriptionCheckout::apply($subscription, $this->account, socialsPlan()); - expect($subscription->couponId)->toBe('TRIAL1USD') + expect($subscription->couponId)->toBe('SOCIALS_18USD') ->and($subscription->allowPromotionCodes)->toBeFalse() ->and(trialExpiresAt($subscription))->toBeNull(); }); test('still applies the coupon when the only prior subscription is incomplete', function () { config([ - 'cashier.first_month_coupon_id' => 'TRIAL1USD', + 'cashier.first_month_coupon_ids.socials' => 'SOCIALS_18USD', 'cashier.allow_promotion_codes' => false, ]); Workspace::factory()->create(['account_id' => $this->account->id]); @@ -241,9 +304,9 @@ function trialExpiresAt(SubscriptionBuilder $subscription): ?Carbon $subscription = checkoutSubscription($this->account); - ConfigureSubscriptionCheckout::apply($subscription, $this->account); + ConfigureSubscriptionCheckout::apply($subscription, $this->account, socialsPlan()); - expect($subscription->couponId)->toBe('TRIAL1USD') + expect($subscription->couponId)->toBe('SOCIALS_18USD') ->and(trialExpiresAt($subscription))->toBeNull(); }); @@ -255,7 +318,7 @@ function trialExpiresAt(SubscriptionBuilder $subscription): ?Carbon $subscription = checkoutSubscription($this->account); - ConfigureSubscriptionCheckout::apply($subscription, $this->account); + ConfigureSubscriptionCheckout::apply($subscription, $this->account, socialsPlan()); expect(trialExpiresAt($subscription)?->toDateTimeString())->toBe('2026-08-09 12:00:00'); }); @@ -266,7 +329,7 @@ function trialExpiresAt(SubscriptionBuilder $subscription): ?Carbon $subscription = checkoutSubscription($this->account); - ConfigureSubscriptionCheckout::apply($subscription, $this->account); + ConfigureSubscriptionCheckout::apply($subscription, $this->account, socialsPlan()); expect(trialExpiresAt($subscription))->toBeNull() ->and($subscription->couponId)->toBeNull() @@ -274,12 +337,12 @@ function trialExpiresAt(SubscriptionBuilder $subscription): ?Carbon }); test('zero workspaces still get the first-month coupon when first-time', function () { - config(['cashier.first_month_coupon_id' => 'TRIAL1USD']); + config(['cashier.first_month_coupon_ids.socials' => 'SOCIALS_18USD']); $subscription = checkoutSubscription($this->account); - ConfigureSubscriptionCheckout::apply($subscription, $this->account); + ConfigureSubscriptionCheckout::apply($subscription, $this->account, socialsPlan()); - expect($subscription->couponId)->toBe('TRIAL1USD') + expect($subscription->couponId)->toBe('SOCIALS_18USD') ->and(trialExpiresAt($subscription))->toBeNull(); }); From e9f3950a51acce1c803031d08c0aac53fce82b48 Mon Sep 17 00:00:00 2001 From: Paulo Castellano Date: Tue, 8 Sep 2026 16:55:57 -0300 Subject: [PATCH 09/29] Redesign welcome onboarding with live workspace preview Split WelcomeLayout into a content column and a sticky preview rail that fills in as the user progresses (persona, goals, connected networks), with step progress in the header, a fixed footer with Back/Continue, and the auth language switcher (persisting locale for signed-in users). Add WelcomeSummaryResource shared across all steps, reusable WelcomeChoicePill and welcomeOptions metadata, and rework the plan step: per-card "everything included" list with all networks, prominent workspace-limit callout, updated taglines and copy, USD-aligned pt-BR prices. Make PlanPicker feature labels reactive so they translate once the language JSON loads. --- .../Controllers/App/WelcomeController.php | 13 +- .../Resources/App/WelcomeSummaryResource.php | 82 ++++++ lang/ar/billing.php | 8 +- lang/ar/welcome.php | 22 +- lang/de/billing.php | 8 +- lang/de/welcome.php | 22 +- lang/el/billing.php | 8 +- lang/el/welcome.php | 22 +- lang/en/billing.php | 8 +- lang/en/welcome.php | 22 +- lang/es/billing.php | 8 +- lang/es/welcome.php | 22 +- lang/fr/billing.php | 8 +- lang/fr/welcome.php | 22 +- lang/it/billing.php | 8 +- lang/it/welcome.php | 22 +- lang/ja/billing.php | 8 +- lang/ja/welcome.php | 22 +- lang/ko/billing.php | 8 +- lang/ko/welcome.php | 22 +- lang/nl/billing.php | 8 +- lang/nl/welcome.php | 22 +- lang/pl/billing.php | 8 +- lang/pl/welcome.php | 22 +- lang/pt-BR/billing.php | 14 +- lang/pt-BR/welcome.php | 22 +- lang/ru/billing.php | 8 +- lang/ru/welcome.php | 22 +- lang/tr/billing.php | 8 +- lang/tr/welcome.php | 22 +- lang/uk/billing.php | 8 +- lang/uk/welcome.php | 22 +- lang/zh/billing.php | 8 +- lang/zh/welcome.php | 22 +- resources/js/components/PlatformLogo.vue | 26 +- .../components/auth/AuthLanguageSwitcher.vue | 32 ++- .../js/components/billing/PlanPicker.vue | 239 +++++++++------- .../components/welcome/WelcomeChoicePill.vue | 67 +++++ .../welcome/WelcomeWorkspacePreview.vue | 270 ++++++++++++++++++ resources/js/layouts/WelcomeLayout.vue | 264 +++++++++++------ resources/js/lib/welcomeOptions.ts | 230 +++++++++++++++ resources/js/pages/welcome/Connect.vue | 42 +-- resources/js/pages/welcome/Goals.vue | 156 ++-------- resources/js/pages/welcome/Persona.vue | 150 ++-------- resources/js/pages/welcome/Plan.vue | 9 +- resources/js/pages/welcome/ReferralSource.vue | 208 ++------------ .../js/pages/welcome/SubscriptionRequired.vue | 1 + resources/js/types/index.d.ts | 15 + tests/Browser/WelcomeConnectTest.php | 4 +- tests/Browser/WelcomePlanTest.php | 7 +- tests/Feature/Welcome/PlanSelectionTest.php | 11 + .../Feature/Welcome/WelcomeControllerTest.php | 40 +++ 52 files changed, 1572 insertions(+), 780 deletions(-) create mode 100644 app/Http/Resources/App/WelcomeSummaryResource.php create mode 100644 resources/js/components/welcome/WelcomeChoicePill.vue create mode 100644 resources/js/components/welcome/WelcomeWorkspacePreview.vue create mode 100644 resources/js/lib/welcomeOptions.ts diff --git a/app/Http/Controllers/App/WelcomeController.php b/app/Http/Controllers/App/WelcomeController.php index a2f14c897..d8dc8a08f 100644 --- a/app/Http/Controllers/App/WelcomeController.php +++ b/app/Http/Controllers/App/WelcomeController.php @@ -20,6 +20,7 @@ use App\Http\Requests\App\Welcome\StoreWelcomeReferralSourceRequest; use App\Http\Resources\App\PlanResource; use App\Http\Resources\App\SocialAccountResource; +use App\Http\Resources\App\WelcomeSummaryResource; use App\Models\Plan; use App\Services\PostHogService; use Illuminate\Http\RedirectResponse; @@ -37,9 +38,12 @@ public function persona(Request $request): InertiaResponse|RedirectResponse return $redirect; } + $user = $request->user(); + return Inertia::render('welcome/Persona', [ 'personas' => array_map(fn (Persona $persona): string => $persona->value, Persona::cases()), - 'selected' => $request->user()->persona?->value, + 'selected' => $user->persona?->value, + 'welcome' => WelcomeSummaryResource::make($user), ]); } @@ -78,6 +82,7 @@ public function goals(Request $request): InertiaResponse|RedirectResponse return Inertia::render('welcome/Goals', [ 'goals' => array_map(fn (Goal $goal): string => $goal->value, Goal::cases()), 'selected' => $user->goals ?? [], + 'welcome' => WelcomeSummaryResource::make($user), ]); } @@ -116,6 +121,7 @@ public function referralSource(Request $request): InertiaResponse|RedirectRespon return Inertia::render('welcome/ReferralSource', [ 'sources' => array_map(fn (ReferralSource $source): string => $source->value, ReferralSource::cases()), 'selected' => $user->referral_source?->value, + 'welcome' => WelcomeSummaryResource::make($user), ]); } @@ -151,7 +157,8 @@ public function connect(Request $request): InertiaResponse|RedirectResponse return $redirect; } - $workspace = $request->user()->currentWorkspace; + $user = $request->user(); + $workspace = $user->currentWorkspace; abort_unless($workspace !== null, Response::HTTP_NOT_FOUND); @@ -160,6 +167,7 @@ public function connect(Request $request): InertiaResponse|RedirectResponse 'accounts' => SocialAccountResource::collection( $workspace->socialAccounts()->orderBy('id')->get(), )->resolve(), + 'welcome' => WelcomeSummaryResource::make($user), ]); } @@ -197,6 +205,7 @@ public function plan(Request $request): InertiaResponse|RedirectResponse 'plans' => PlanResource::collection( Plan::active()->orderBy('sort')->get(), )->resolve(), + 'welcome' => WelcomeSummaryResource::make($request->user()), ]); } diff --git a/app/Http/Resources/App/WelcomeSummaryResource.php b/app/Http/Resources/App/WelcomeSummaryResource.php new file mode 100644 index 000000000..a814297df --- /dev/null +++ b/app/Http/Resources/App/WelcomeSummaryResource.php @@ -0,0 +1,82 @@ +, + * networks: list + * } + */ + public static function make(User $user): array + { + /** @var Persona|null $persona */ + $persona = $user->persona ?? null; + + return [ + 'persona' => $persona?->value, + 'goals' => self::currentGoals($user->goals ?? null), + 'networks' => self::connectedNetworks($user), + ]; + } + + /** + * Only goals that still exist as a case: dropped values would render as + * an unknown chip. + * + * @param list|null $goals + * @return list + */ + private static function currentGoals(?array $goals): array + { + if (! is_array($goals)) { + return []; + } + + $allowed = array_map(fn (Goal $goal): string => $goal->value, Goal::cases()); + + return array_values(array_intersect($goals, $allowed)); + } + + /** + * @return list + */ + private static function connectedNetworks(User $user): array + { + $workspace = $user->currentWorkspace; + + if ($workspace === null) { + return []; + } + + return $workspace->socialAccounts() + ->where('status', Status::Connected) + ->orderBy('id') + ->get() + ->map(fn (SocialAccount $account): array => [ + 'id' => $account->id, + 'platform' => $account->platform->value, + 'display_label' => $account->display_label, + 'username' => $account->username, + 'avatar_url' => $account->avatar_url, + ]) + ->values() + ->all(); + } +} diff --git a/lang/ar/billing.php b/lang/ar/billing.php index b000bcc55..feaea70ae 100644 --- a/lang/ar/billing.php +++ b/lang/ar/billing.php @@ -20,7 +20,6 @@ 'billed_yearly' => 'فوترة سنوية', 'prices' => [ 'first_month' => '$1', - 'workspace' => ['monthly' => '$12', 'yearly_per_month' => '$10', 'yearly' => '$120'], 'socials' => ['monthly' => '$19', 'yearly_per_month' => '$15.83', 'yearly' => '$190'], 'workspaces' => ['monthly' => '$99', 'yearly_per_month' => '$82.50', 'yearly' => '$990'], ], @@ -38,12 +37,13 @@ 'current' => 'الخطة الحالية', 'select' => 'اختر :plan', 'start_first_month' => 'ابدأ شهري الأول مقابل :price', - 'first_month_then' => 'الشهر الأول :first، ثم :price/شهر', 'billed_yearly_total' => 'فوترة سنوية · :price (شهران مجانًا)', - 'socials_tagline' => 'مساحة عمل واحدة. انشر في كل مكان.', - 'workspaces_tagline' => 'مساحة عمل لكل علامة أو عميل.', + 'socials_tagline' => 'مناسب لمنشئي المحتوى والعلامات الصغيرة.', + 'workspaces_tagline' => 'مناسب للوكالات والأعمال الكبيرة.', + 'everything_included' => 'كل شيء مشمول', 'features' => [ + 'networks_all' => 'جميع الشبكات الاجتماعية', 'accounts_unlimited' => 'حسابات اجتماعية غير محدودة', 'calendar' => 'تقويم مرئي مع نشر تلقائي', 'ai' => 'ذكاء اصطناعي: نصوص وصور وصوت العلامة', diff --git a/lang/ar/welcome.php b/lang/ar/welcome.php index d05f116fe..7ea9cabfe 100644 --- a/lang/ar/welcome.php +++ b/lang/ar/welcome.php @@ -11,8 +11,22 @@ 'subscription_required_owner' => 'مالك حسابك هو :name.', 'subscription_required_auto' => 'يتم تحديث هذه الصفحة تلقائيًا — لا حاجة لإعادة التحميل.', 'progress' => 'تقدم الترحيب', - 'go_to_step' => 'الانتقال إلى الخطوة :step', 'step_current' => 'الخطوة :step (الحالية)', + 'step_of' => 'الخطوة :step من :total', + 'back' => 'رجوع', + 'steps' => [ + 'persona' => 'عنك', + 'goals' => 'أهدافك', + 'referral_source' => 'كيف وجدتنا', + 'connect' => 'الشبكات الاجتماعية', + 'plan' => 'الخطة', + ], + 'preview' => [ + 'heading' => 'مساحة عملك تتشكّل.', + 'workspace' => 'مساحة عملك', + 'pending' => 'لم يُختَر بعد', + 'networks_empty' => 'لا توجد شبكات متصلة بعد', + ], 'personas' => [ 'creator' => 'صانع محتوى', 'freelancer' => 'مستقل', @@ -29,7 +43,7 @@ 'goals' => [ 'save_time' => 'توفير الوقت بالنشر في كل مكان دفعة واحدة', 'ai_content' => 'إنشاء منشورات بذكاء TryPost الاصطناعي', - 'use_mcp' => 'إنشاء منشورات عبر Claude أو ChatGPT أو Cursor', + 'use_mcp' => 'إنشاء منشورات عبر Claude أو ChatGPT وغيرها', 'plan_calendar' => 'التخطيط لمنشوراتي على التقويم', 'stay_on_brand' => 'الحفاظ على اتساق كل منشور مع العلامة التجارية', 'grow_audience' => 'تنمية جمهوري وزيادة التفاعل', @@ -38,8 +52,8 @@ 'just_exploring' => 'مجرد استكشاف في الوقت الحالي', 'other' => 'شيء آخر', ], - 'plan_title' => 'اختر خطة. كل الميزات مشمولة.', - 'plan_description' => 'الفرق الوحيد هو عدد مساحات العمل. يمكنك التغيير لاحقًا.', + 'plan_title' => 'اختر خطتك', + 'plan_description' => 'تتضمن الخطتان جميع الميزات وجميع الشبكات الاجتماعية. الفرق الوحيد هو عدد مساحات العمل — ويمكنك التبديل في أي وقت.', 'referral_source_title' => 'كيف وجدتنا؟', 'referral_source_description' => 'يساعدنا هذا على فهم كيفية اكتشاف الأشخاص لـ TryPost.', 'referral_source' => [ diff --git a/lang/de/billing.php b/lang/de/billing.php index b52e440d1..b3429add7 100644 --- a/lang/de/billing.php +++ b/lang/de/billing.php @@ -22,7 +22,6 @@ 'billed_yearly' => 'Jährlich abgerechnet', 'prices' => [ 'first_month' => '$1', - 'workspace' => ['monthly' => '$12', 'yearly_per_month' => '$10', 'yearly' => '$120'], 'socials' => ['monthly' => '$19', 'yearly_per_month' => '$15.83', 'yearly' => '$190'], 'workspaces' => ['monthly' => '$99', 'yearly_per_month' => '$82.50', 'yearly' => '$990'], ], @@ -40,12 +39,13 @@ 'current' => 'Aktueller Tarif', 'select' => ':plan wählen', 'start_first_month' => 'Meinen ersten Monat für :price starten', - 'first_month_then' => 'Erster Monat :first, danach :price/Monat', 'billed_yearly_total' => 'Jährlich abgerechnet · :price (2 Monate gratis)', - 'socials_tagline' => 'Ein Workspace. Überall posten.', - 'workspaces_tagline' => 'Ein Workspace für jede Marke oder jeden Kunden.', + 'socials_tagline' => 'Ideal für Creator und kleine Marken.', + 'workspaces_tagline' => 'Ideal für Agenturen und größere Unternehmen.', + 'everything_included' => 'Alles inklusive', 'features' => [ + 'networks_all' => 'Alle sozialen Netzwerke', 'accounts_unlimited' => 'Unbegrenzte Social-Accounts', 'calendar' => 'Visueller Kalender mit Auto-Publishing', 'ai' => 'KI: Captions, Bilder und Markenstimme', diff --git a/lang/de/welcome.php b/lang/de/welcome.php index 8d16d8a42..bdc884410 100644 --- a/lang/de/welcome.php +++ b/lang/de/welcome.php @@ -11,8 +11,22 @@ 'subscription_required_owner' => 'Der Kontoinhaber ist :name.', 'subscription_required_auto' => 'Diese Seite aktualisiert sich automatisch — kein Neuladen nötig.', 'progress' => 'Willkommensfortschritt', - 'go_to_step' => 'Zu Schritt :step gehen', 'step_current' => 'Schritt :step (aktuell)', + 'step_of' => 'Schritt :step von :total', + 'back' => 'Zurück', + 'steps' => [ + 'persona' => 'Über dich', + 'goals' => 'Deine Ziele', + 'referral_source' => 'Wie du uns gefunden hast', + 'connect' => 'Soziale Netzwerke', + 'plan' => 'Tarif', + ], + 'preview' => [ + 'heading' => 'Dein Workspace nimmt Gestalt an.', + 'workspace' => 'Dein Workspace', + 'pending' => 'Noch nicht gewählt', + 'networks_empty' => 'Noch keine Netzwerke verbunden', + ], 'personas' => [ 'creator' => 'Content Creator', 'freelancer' => 'Freelancer', @@ -29,7 +43,7 @@ 'goals' => [ 'save_time' => 'Zeit sparen, indem ich überall gleichzeitig poste', 'ai_content' => 'Beiträge mit TryPost-KI erstellen', - 'use_mcp' => 'Beiträge über Claude, ChatGPT oder Cursor erstellen', + 'use_mcp' => 'Beiträge über Claude, ChatGPT usw. erstellen', 'plan_calendar' => 'Meine Beiträge in einem Kalender planen', 'stay_on_brand' => 'Jeden Beitrag markenkonform halten', 'grow_audience' => 'Meine Reichweite und mein Engagement steigern', @@ -38,8 +52,8 @@ 'just_exploring' => 'Ich schaue mich vorerst nur um', 'other' => 'Etwas anderes', ], - 'plan_title' => 'Wähle einen Tarif. Alle Funktionen sind enthalten.', - 'plan_description' => 'Der einzige Unterschied ist, wie viele Workspaces du bekommst. Du kannst später wechseln.', + 'plan_title' => 'Wähle deinen Tarif', + 'plan_description' => 'Beide Tarife enthalten alle Funktionen und alle sozialen Netzwerke. Der einzige Unterschied ist die Anzahl der Workspaces — jederzeit wechselbar.', 'referral_source_title' => 'Wie hast du uns gefunden?', 'referral_source_description' => 'Das hilft uns zu verstehen, wie Menschen TryPost entdecken.', 'referral_source' => [ diff --git a/lang/el/billing.php b/lang/el/billing.php index d5fa098b3..e59cad6dc 100644 --- a/lang/el/billing.php +++ b/lang/el/billing.php @@ -20,7 +20,6 @@ 'billed_yearly' => 'Ετήσια χρέωση', 'prices' => [ 'first_month' => '$1', - 'workspace' => ['monthly' => '$12', 'yearly_per_month' => '$10', 'yearly' => '$120'], 'socials' => ['monthly' => '$19', 'yearly_per_month' => '$15.83', 'yearly' => '$190'], 'workspaces' => ['monthly' => '$99', 'yearly_per_month' => '$82.50', 'yearly' => '$990'], ], @@ -38,12 +37,13 @@ 'current' => 'Τρέχον πλάνο', 'select' => 'Επιλέξτε :plan', 'start_first_month' => 'Ξεκίνα τον πρώτο μήνα με :price', - 'first_month_then' => 'Πρώτος μήνας :first, μετά :price/μήνα', 'billed_yearly_total' => 'Ετήσια χρέωση · :price (2 μήνες δωρεάν)', - 'socials_tagline' => 'Ένα workspace. Δημοσιεύστε παντού.', - 'workspaces_tagline' => 'Ένα workspace για κάθε brand ή πελάτη.', + 'socials_tagline' => 'Ιδανικό για creators και μικρά brands.', + 'workspaces_tagline' => 'Ιδανικό για agencies και μεγαλύτερες επιχειρήσεις.', + 'everything_included' => 'Όλα περιλαμβάνονται', 'features' => [ + 'networks_all' => 'Όλα τα κοινωνικά δίκτυα', 'accounts_unlimited' => 'Απεριόριστοι λογαριασμοί social', 'calendar' => 'Οπτικό ημερολόγιο με αυτόματη δημοσίευση', 'ai' => 'AI: λεζάντες, εικόνες και φωνή brand', diff --git a/lang/el/welcome.php b/lang/el/welcome.php index 391370c8e..e8c4ce5bc 100644 --- a/lang/el/welcome.php +++ b/lang/el/welcome.php @@ -11,8 +11,22 @@ 'subscription_required_owner' => 'Ο κάτοχος του λογαριασμού σας είναι ο/η :name.', 'subscription_required_auto' => 'Αυτή η σελίδα ενημερώνεται αυτόματα — δεν χρειάζεται ανανέωση.', 'progress' => 'Πρόοδος καλωσορίσματος', - 'go_to_step' => 'Μετάβαση στο βήμα :step', 'step_current' => 'Βήμα :step (τρέχον)', + 'step_of' => 'Βήμα :step από :total', + 'back' => 'Πίσω', + 'steps' => [ + 'persona' => 'Σχετικά με εσάς', + 'goals' => 'Οι στόχοι σας', + 'referral_source' => 'Πώς μας βρήκατε', + 'connect' => 'Κοινωνικά δίκτυα', + 'plan' => 'Πλάνο', + ], + 'preview' => [ + 'heading' => 'Ο workspace σας παίρνει μορφή.', + 'workspace' => 'Ο workspace σας', + 'pending' => 'Δεν έχει επιλεγεί ακόμα', + 'networks_empty' => 'Δεν έχουν συνδεθεί δίκτυα ακόμα', + ], 'personas' => [ 'creator' => 'Δημιουργός περιεχομένου', 'freelancer' => 'Ελεύθερος επαγγελματίας', @@ -29,7 +43,7 @@ 'goals' => [ 'save_time' => 'Εξοικονόμηση χρόνου δημοσιεύοντας παντού ταυτόχρονα', 'ai_content' => 'Δημιουργία δημοσιεύσεων με το AI του TryPost', - 'use_mcp' => 'Δημιουργία δημοσιεύσεων από Claude, ChatGPT ή Cursor', + 'use_mcp' => 'Δημιουργία δημοσιεύσεων από Claude, ChatGPT κ.λπ.', 'plan_calendar' => 'Προγραμματισμός των δημοσιεύσεών μου σε ημερολόγιο', 'stay_on_brand' => 'Διατήρηση κάθε δημοσίευσης εναρμονισμένης με τη μάρκα', 'grow_audience' => 'Ανάπτυξη του κοινού και της αλληλεπίδρασής μου', @@ -38,8 +52,8 @@ 'just_exploring' => 'Απλώς εξερευνώ προς το παρόν', 'other' => 'Κάτι άλλο', ], - 'plan_title' => 'Επιλέξτε πλάνο. Όλα τα χαρακτηριστικά περιλαμβάνονται.', - 'plan_description' => 'Η μόνη διαφορά είναι πόσα workspaces παίρνετε. Μπορείτε να αλλάξετε αργότερα.', + 'plan_title' => 'Επιλέξτε το πλάνο σας', + 'plan_description' => 'Και τα δύο πλάνα περιλαμβάνουν όλες τις λειτουργίες και όλα τα κοινωνικά δίκτυα. Η μόνη διαφορά είναι πόσα workspaces έχετε — αλλάξτε όποτε θέλετε.', 'referral_source_title' => 'Πώς μας βρήκατε;', 'referral_source_description' => 'Αυτό μας βοηθά να καταλάβουμε πώς οι άνθρωποι ανακαλύπτουν το TryPost.', 'referral_source' => [ diff --git a/lang/en/billing.php b/lang/en/billing.php index 57f5771e1..e125e1f7e 100644 --- a/lang/en/billing.php +++ b/lang/en/billing.php @@ -20,7 +20,6 @@ 'billed_yearly' => 'Billed annually', 'prices' => [ 'first_month' => '$1', - 'workspace' => ['monthly' => '$12', 'yearly_per_month' => '$10', 'yearly' => '$120'], 'socials' => ['monthly' => '$19', 'yearly_per_month' => '$15.83', 'yearly' => '$190'], 'workspaces' => ['monthly' => '$99', 'yearly_per_month' => '$82.50', 'yearly' => '$990'], ], @@ -38,12 +37,13 @@ 'current' => 'Current plan', 'select' => 'Choose :plan', 'start_first_month' => 'Start my first month for :price', - 'first_month_then' => 'First month :first, then :price/month', 'billed_yearly_total' => 'Billed annually · :price (2 months free)', - 'socials_tagline' => 'One workspace. Post everywhere.', - 'workspaces_tagline' => 'A workspace for every brand or client.', + 'socials_tagline' => 'Best for creators and small brands.', + 'workspaces_tagline' => 'Best for agencies and larger businesses.', + 'everything_included' => 'Everything included', 'features' => [ + 'networks_all' => 'All social networks', 'accounts_unlimited' => 'Unlimited social accounts', 'calendar' => 'Visual calendar with auto-publishing', 'ai' => 'AI content: captions, images, brand voice', diff --git a/lang/en/welcome.php b/lang/en/welcome.php index 60358fbb5..228452e66 100644 --- a/lang/en/welcome.php +++ b/lang/en/welcome.php @@ -11,8 +11,22 @@ 'subscription_required_owner' => 'Your account owner is :name.', 'subscription_required_auto' => 'This page updates automatically — no need to refresh.', 'progress' => 'Welcome progress', - 'go_to_step' => 'Go to step :step', 'step_current' => 'Step :step (current)', + 'step_of' => 'Step :step of :total', + 'back' => 'Back', + 'steps' => [ + 'persona' => 'About you', + 'goals' => 'Your goals', + 'referral_source' => 'How you found us', + 'connect' => 'Social networks', + 'plan' => 'Plan', + ], + 'preview' => [ + 'heading' => 'Your workspace is taking shape.', + 'workspace' => 'Your workspace', + 'pending' => 'Not chosen yet', + 'networks_empty' => 'No networks connected yet', + ], 'personas' => [ 'creator' => 'Content creator', 'freelancer' => 'Freelancer', @@ -29,7 +43,7 @@ 'goals' => [ 'save_time' => 'Save time by posting everywhere at once', 'ai_content' => 'Generate posts with TryPost AI', - 'use_mcp' => 'Create posts from Claude, ChatGPT, or Cursor', + 'use_mcp' => 'Create posts from Claude, ChatGPT, etc.', 'plan_calendar' => 'Plan my posts on a calendar', 'stay_on_brand' => 'Keep every post on brand', 'grow_audience' => 'Grow my audience and engagement', @@ -38,8 +52,8 @@ 'just_exploring' => 'Just exploring for now', 'other' => 'Something else', ], - 'plan_title' => 'Pick a plan. Every feature is included.', - 'plan_description' => 'The only difference is how many workspaces you get. You can switch later.', + 'plan_title' => 'Choose your plan', + 'plan_description' => 'Both plans include every feature and every social network. The only difference is how many workspaces you get — switch anytime.', 'referral_source_title' => 'How did you find us?', 'referral_source_description' => 'This helps us understand how people discover TryPost.', 'referral_source' => [ diff --git a/lang/es/billing.php b/lang/es/billing.php index 538eec698..c88c82907 100644 --- a/lang/es/billing.php +++ b/lang/es/billing.php @@ -20,7 +20,6 @@ 'billed_yearly' => 'Facturado anualmente', 'prices' => [ 'first_month' => '$1', - 'workspace' => ['monthly' => '$12', 'yearly_per_month' => '$10', 'yearly' => '$120'], 'socials' => ['monthly' => '$19', 'yearly_per_month' => '$15.83', 'yearly' => '$190'], 'workspaces' => ['monthly' => '$99', 'yearly_per_month' => '$82.50', 'yearly' => '$990'], ], @@ -38,12 +37,13 @@ 'current' => 'Plan actual', 'select' => 'Elegir :plan', 'start_first_month' => 'Empezar mi primer mes por :price', - 'first_month_then' => 'Primer mes :first, luego :price/mes', 'billed_yearly_total' => 'Facturación anual · :price (2 meses gratis)', - 'socials_tagline' => 'Un workspace. Publica en todas partes.', - 'workspaces_tagline' => 'Un workspace para cada marca o cliente.', + 'socials_tagline' => 'Ideal para creadores y marcas pequeñas.', + 'workspaces_tagline' => 'Ideal para agencias y negocios grandes.', + 'everything_included' => 'Todo incluido', 'features' => [ + 'networks_all' => 'Todas las redes sociales', 'accounts_unlimited' => 'Cuentas sociales ilimitadas', 'calendar' => 'Calendario visual con publicación automática', 'ai' => 'IA: textos, imágenes y voz de marca', diff --git a/lang/es/welcome.php b/lang/es/welcome.php index c79be883d..1d645ca43 100644 --- a/lang/es/welcome.php +++ b/lang/es/welcome.php @@ -11,8 +11,22 @@ 'subscription_required_owner' => 'El propietario de tu cuenta es :name.', 'subscription_required_auto' => 'Esta página se actualiza automáticamente, no hace falta recargar.', 'progress' => 'Progreso de bienvenida', - 'go_to_step' => 'Ir al paso :step', 'step_current' => 'Paso :step (actual)', + 'step_of' => 'Paso :step de :total', + 'back' => 'Volver', + 'steps' => [ + 'persona' => 'Sobre ti', + 'goals' => 'Tus objetivos', + 'referral_source' => 'Cómo nos encontraste', + 'connect' => 'Redes sociales', + 'plan' => 'Plan', + ], + 'preview' => [ + 'heading' => 'Tu workspace está tomando forma.', + 'workspace' => 'Tu workspace', + 'pending' => 'Aún sin elegir', + 'networks_empty' => 'Aún no hay redes conectadas', + ], 'personas' => [ 'creator' => 'Creador de contenido', 'freelancer' => 'Freelancer', @@ -29,7 +43,7 @@ 'goals' => [ 'save_time' => 'Ahorrar tiempo publicando en todas mis redes a la vez', 'ai_content' => 'Generar publicaciones con la IA de TryPost', - 'use_mcp' => 'Crear publicaciones desde Claude, ChatGPT o Cursor', + 'use_mcp' => 'Crear publicaciones desde Claude, ChatGPT, etc.', 'plan_calendar' => 'Planificar mis publicaciones en un calendario', 'stay_on_brand' => 'Mantener la coherencia de mi marca', 'grow_audience' => 'Hacer crecer mi audiencia y engagement', @@ -38,8 +52,8 @@ 'just_exploring' => 'Solo estoy explorando por ahora', 'other' => 'Otra cosa', ], - 'plan_title' => 'Elige un plan. Todas las funciones están incluidas.', - 'plan_description' => 'La única diferencia es cuántos workspaces tienes. Puedes cambiarlo después.', + 'plan_title' => 'Elige tu plan', + 'plan_description' => 'Ambos planes incluyen todas las funciones y todas las redes sociales. La única diferencia es cuántos workspaces tienes — cambia cuando quieras.', 'referral_source_title' => '¿Cómo nos encontraste?', 'referral_source_description' => 'Esto nos ayuda a entender cómo la gente descubre TryPost.', 'referral_source' => [ diff --git a/lang/fr/billing.php b/lang/fr/billing.php index b85080bad..974c17484 100644 --- a/lang/fr/billing.php +++ b/lang/fr/billing.php @@ -20,7 +20,6 @@ 'billed_yearly' => 'Facturé annuellement', 'prices' => [ 'first_month' => '1 $', - 'workspace' => ['monthly' => '12 $', 'yearly_per_month' => '10 $', 'yearly' => '120 $'], 'socials' => ['monthly' => '19 $', 'yearly_per_month' => '15,83 $', 'yearly' => '190 $'], 'workspaces' => ['monthly' => '99 $', 'yearly_per_month' => '82,50 $', 'yearly' => '990 $'], ], @@ -38,12 +37,13 @@ 'current' => 'Offre actuelle', 'select' => 'Choisir :plan', 'start_first_month' => 'Commencer mon premier mois pour :price', - 'first_month_then' => 'Premier mois :first, puis :price/mois', 'billed_yearly_total' => 'Facturé annuellement · :price (2 mois offerts)', - 'socials_tagline' => 'Un espace de travail. Publiez partout.', - 'workspaces_tagline' => 'Un espace de travail pour chaque marque ou client.', + 'socials_tagline' => 'Idéal pour les créateurs et petites marques.', + 'workspaces_tagline' => 'Idéal pour les agences et grandes entreprises.', + 'everything_included' => 'Tout est inclus', 'features' => [ + 'networks_all' => 'Tous les réseaux sociaux', 'accounts_unlimited' => 'Comptes sociaux illimités', 'calendar' => 'Calendrier visuel avec publication automatique', 'ai' => 'IA : légendes, images et voix de marque', diff --git a/lang/fr/welcome.php b/lang/fr/welcome.php index 6d8bf27b5..fe00d8b96 100644 --- a/lang/fr/welcome.php +++ b/lang/fr/welcome.php @@ -11,8 +11,22 @@ 'subscription_required_owner' => 'Le propriétaire de votre compte est :name.', 'subscription_required_auto' => 'Cette page se met à jour automatiquement — inutile de la recharger.', 'progress' => 'Progression d’accueil', - 'go_to_step' => 'Aller à l’étape :step', 'step_current' => 'Étape :step (actuelle)', + 'step_of' => 'Étape :step sur :total', + 'back' => 'Retour', + 'steps' => [ + 'persona' => 'À propos de vous', + 'goals' => 'Vos objectifs', + 'referral_source' => 'Comment vous nous avez trouvés', + 'connect' => 'Réseaux sociaux', + 'plan' => 'Forfait', + ], + 'preview' => [ + 'heading' => 'Votre workspace prend forme.', + 'workspace' => 'Votre workspace', + 'pending' => 'Pas encore choisi', + 'networks_empty' => 'Aucun réseau connecté pour l’instant', + ], 'personas' => [ 'creator' => 'Créateur de contenu', 'freelancer' => 'Freelance', @@ -29,7 +43,7 @@ 'goals' => [ 'save_time' => 'Gagner du temps en publiant partout à la fois', 'ai_content' => 'Générer des publications avec l\'IA TryPost', - 'use_mcp' => 'Créer des publications depuis Claude, ChatGPT ou Cursor', + 'use_mcp' => 'Créer des publications depuis Claude, ChatGPT, etc.', 'plan_calendar' => 'Planifier mes publications sur un calendrier', 'stay_on_brand' => 'Garder chaque publication fidèle à ma marque', 'grow_audience' => 'Développer mon audience et mon engagement', @@ -38,8 +52,8 @@ 'just_exploring' => 'Je découvre pour l\'instant', 'other' => 'Autre chose', ], - 'plan_title' => 'Choisissez une offre. Toutes les fonctionnalités sont incluses.', - 'plan_description' => 'La seule différence, c\'est le nombre d\'espaces de travail. Vous pourrez changer plus tard.', + 'plan_title' => 'Choisissez votre forfait', + 'plan_description' => 'Les deux forfaits incluent toutes les fonctionnalités et tous les réseaux sociaux. La seule différence : le nombre de workspaces — changez à tout moment.', 'referral_source_title' => 'Comment nous avez-vous connus ?', 'referral_source_description' => 'Cela nous aide à comprendre comment les gens découvrent TryPost.', 'referral_source' => [ diff --git a/lang/it/billing.php b/lang/it/billing.php index 54e163774..8cef62fa7 100644 --- a/lang/it/billing.php +++ b/lang/it/billing.php @@ -20,7 +20,6 @@ 'billed_yearly' => 'Fatturazione annuale', 'prices' => [ 'first_month' => '$1', - 'workspace' => ['monthly' => '$12', 'yearly_per_month' => '$10', 'yearly' => '$120'], 'socials' => ['monthly' => '$19', 'yearly_per_month' => '$15.83', 'yearly' => '$190'], 'workspaces' => ['monthly' => '$99', 'yearly_per_month' => '$82.50', 'yearly' => '$990'], ], @@ -38,12 +37,13 @@ 'current' => 'Piano attuale', 'select' => 'Scegli :plan', 'start_first_month' => 'Inizia il primo mese a :price', - 'first_month_then' => 'Primo mese :first, poi :price/mese', 'billed_yearly_total' => 'Fatturato annualmente · :price (2 mesi gratis)', - 'socials_tagline' => 'Un workspace. Pubblica ovunque.', - 'workspaces_tagline' => 'Un workspace per ogni brand o cliente.', + 'socials_tagline' => 'Ideale per creator e piccole marche.', + 'workspaces_tagline' => 'Ideale per agenzie e grandi attività.', + 'everything_included' => 'Tutto incluso', 'features' => [ + 'networks_all' => 'Tutti i social network', 'accounts_unlimited' => 'Account social illimitati', 'calendar' => 'Calendario visuale con pubblicazione automatica', 'ai' => 'IA: didascalie, immagini e brand voice', diff --git a/lang/it/welcome.php b/lang/it/welcome.php index 4cf3ba6f6..4d8528e3b 100644 --- a/lang/it/welcome.php +++ b/lang/it/welcome.php @@ -11,8 +11,22 @@ 'subscription_required_owner' => 'Il proprietario del tuo account è :name.', 'subscription_required_auto' => 'Questa pagina si aggiorna automaticamente: non serve ricaricarla.', 'progress' => 'Progresso di benvenuto', - 'go_to_step' => 'Vai al passaggio :step', 'step_current' => 'Passaggio :step (attuale)', + 'step_of' => 'Passaggio :step di :total', + 'back' => 'Indietro', + 'steps' => [ + 'persona' => 'Su di te', + 'goals' => 'I tuoi obiettivi', + 'referral_source' => 'Come ci hai trovato', + 'connect' => 'Social network', + 'plan' => 'Piano', + ], + 'preview' => [ + 'heading' => 'Il tuo workspace sta prendendo forma.', + 'workspace' => 'Il tuo workspace', + 'pending' => 'Non ancora scelto', + 'networks_empty' => 'Nessun social connesso per ora', + ], 'personas' => [ 'creator' => 'Creatore di contenuti', 'freelancer' => 'Freelance', @@ -29,7 +43,7 @@ 'goals' => [ 'save_time' => 'Risparmiare tempo pubblicando ovunque in una volta', 'ai_content' => 'Generare post con l\'IA di TryPost', - 'use_mcp' => 'Creare post da Claude, ChatGPT o Cursor', + 'use_mcp' => 'Creare post da Claude, ChatGPT, ecc.', 'plan_calendar' => 'Pianificare i miei post su un calendario', 'stay_on_brand' => 'Mantenere ogni post in linea con il brand', 'grow_audience' => 'Far crescere il mio pubblico e il coinvolgimento', @@ -38,8 +52,8 @@ 'just_exploring' => 'Sto solo dando un\'occhiata', 'other' => 'Qualcos\'altro', ], - 'plan_title' => 'Scegli un piano. Tutte le funzionalità sono incluse.', - 'plan_description' => 'L\'unica differenza è quanti workspace hai. Puoi cambiare in seguito.', + 'plan_title' => 'Scegli il tuo piano', + 'plan_description' => 'Entrambi i piani includono tutte le funzionalità e tutti i social network. L’unica differenza è quanti workspace hai — cambia quando vuoi.', 'referral_source_title' => 'Come ci hai trovato?', 'referral_source_description' => 'Questo ci aiuta a capire come le persone scoprono TryPost.', 'referral_source' => [ diff --git a/lang/ja/billing.php b/lang/ja/billing.php index d0f0bd339..d69d71cfc 100644 --- a/lang/ja/billing.php +++ b/lang/ja/billing.php @@ -20,7 +20,6 @@ 'billed_yearly' => '年払い', 'prices' => [ 'first_month' => '$1', - 'workspace' => ['monthly' => '$12', 'yearly_per_month' => '$10', 'yearly' => '$120'], 'socials' => ['monthly' => '$19', 'yearly_per_month' => '$15.83', 'yearly' => '$190'], 'workspaces' => ['monthly' => '$99', 'yearly_per_month' => '$82.50', 'yearly' => '$990'], ], @@ -38,12 +37,13 @@ 'current' => '現在のプラン', 'select' => ':plan を選ぶ', 'start_first_month' => '初月を:priceで始める', - 'first_month_then' => '初月:first、その後:price/月', 'billed_yearly_total' => '年払い · :price(2か月分無料)', - 'socials_tagline' => 'ワークスペース1つで、すべてのネットワークに投稿。', - 'workspaces_tagline' => 'ブランドやクライアントごとにワークスペースを。', + 'socials_tagline' => 'クリエイターや小規模ブランド向け。', + 'workspaces_tagline' => '代理店や大規模ビジネス向け。', + 'everything_included' => 'すべて含まれます', 'features' => [ + 'networks_all' => 'すべてのSNS', 'accounts_unlimited' => 'ソーシャルアカウント数無制限', 'calendar' => '自動投稿付きのビジュアルカレンダー', 'ai' => 'AI:キャプション、画像、ブランドボイス', diff --git a/lang/ja/welcome.php b/lang/ja/welcome.php index b58e92081..7adf3cf6f 100644 --- a/lang/ja/welcome.php +++ b/lang/ja/welcome.php @@ -11,8 +11,22 @@ 'subscription_required_owner' => 'アカウントのオーナーは :name です。', 'subscription_required_auto' => 'このページは自動で更新されます — 再読み込みは不要です。', 'progress' => 'ようこそ進捗', - 'go_to_step' => 'ステップ :step へ', 'step_current' => 'ステップ :step(現在)', + 'step_of' => 'ステップ :step / :total', + 'back' => '戻る', + 'steps' => [ + 'persona' => 'あなたについて', + 'goals' => '目標', + 'referral_source' => '知ったきっかけ', + 'connect' => 'SNS', + 'plan' => 'プラン', + ], + 'preview' => [ + 'heading' => 'ワークスペースが形になってきました。', + 'workspace' => 'あなたのワークスペース', + 'pending' => 'まだ未選択', + 'networks_empty' => 'まだSNSが連携されていません', + ], 'personas' => [ 'creator' => 'コンテンツクリエイター', 'freelancer' => 'フリーランス', @@ -29,7 +43,7 @@ 'goals' => [ 'save_time' => 'すべての場所へ一度に投稿して時間を節約する', 'ai_content' => 'TryPost AI で投稿を生成する', - 'use_mcp' => 'Claude・ChatGPT・Cursor から投稿を作成する', + 'use_mcp' => 'Claude・ChatGPT などから投稿を作成する', 'plan_calendar' => 'カレンダーで投稿を計画する', 'stay_on_brand' => 'すべての投稿をブランドに沿ったものにする', 'grow_audience' => 'オーディエンスとエンゲージメントを増やす', @@ -38,8 +52,8 @@ 'just_exploring' => '今はまだ様子を見ている', 'other' => 'その他', ], - 'plan_title' => 'プランを選ぶ。機能はすべて含まれています。', - 'plan_description' => '違いはワークスペースの数だけ。あとから変更できます。', + 'plan_title' => 'プランを選ぶ', + 'plan_description' => 'どちらのプランもすべての機能とすべてのSNSを含みます。違いはワークスペースの数だけ。いつでも変更できます。', 'referral_source_title' => 'どこで私たちを知りましたか?', 'referral_source_description' => 'これは、人々がどのように TryPost を見つけるかを理解するのに役立ちます。', 'referral_source' => [ diff --git a/lang/ko/billing.php b/lang/ko/billing.php index 7d410e448..6cf6ce332 100644 --- a/lang/ko/billing.php +++ b/lang/ko/billing.php @@ -20,7 +20,6 @@ 'billed_yearly' => '연간 결제', 'prices' => [ 'first_month' => '$1', - 'workspace' => ['monthly' => '$12', 'yearly_per_month' => '$10', 'yearly' => '$120'], 'socials' => ['monthly' => '$19', 'yearly_per_month' => '$15.83', 'yearly' => '$190'], 'workspaces' => ['monthly' => '$99', 'yearly_per_month' => '$82.50', 'yearly' => '$990'], ], @@ -38,12 +37,13 @@ 'current' => '현재 요금제', 'select' => ':plan 선택', 'start_first_month' => '첫 달을 :price에 시작하기', - 'first_month_then' => '첫 달 :first, 이후 :price/월', 'billed_yearly_total' => '연간 결제 · :price (2개월 무료)', - 'socials_tagline' => '워크스페이스 하나. 모든 네트워크에 게시.', - 'workspaces_tagline' => '브랜드나 클라이언트마다 워크스페이스 하나.', + 'socials_tagline' => '크리에이터와 소규모 브랜드에 적합.', + 'workspaces_tagline' => '에이전시와 대규모 비즈니스에 적합.', + 'everything_included' => '모두 포함', 'features' => [ + 'networks_all' => '모든 소셜 네트워크', 'accounts_unlimited' => '소셜 계정 무제한', 'calendar' => '자동 게시가 되는 비주얼 캘린더', 'ai' => 'AI: 캡션, 이미지, 브랜드 보이스', diff --git a/lang/ko/welcome.php b/lang/ko/welcome.php index 3ba5c7896..9c2bdf283 100644 --- a/lang/ko/welcome.php +++ b/lang/ko/welcome.php @@ -11,8 +11,22 @@ 'subscription_required_owner' => '계정 소유자는 :name 님입니다.', 'subscription_required_auto' => '이 페이지는 자동으로 업데이트됩니다 — 새로고침할 필요가 없습니다.', 'progress' => '환영 진행률', - 'go_to_step' => ':step단계로 이동', 'step_current' => ':step단계 (현재)', + 'step_of' => ':total단계 중 :step단계', + 'back' => '뒤로', + 'steps' => [ + 'persona' => '나에 대해', + 'goals' => '목표', + 'referral_source' => '알게 된 경로', + 'connect' => '소셜 네트워크', + 'plan' => '요금제', + ], + 'preview' => [ + 'heading' => '워크스페이스가 만들어지고 있어요.', + 'workspace' => '내 워크스페이스', + 'pending' => '아직 선택하지 않음', + 'networks_empty' => '아직 연결된 네트워크가 없어요', + ], 'personas' => [ 'creator' => '콘텐츠 크리에이터', 'freelancer' => '프리랜서', @@ -29,7 +43,7 @@ 'goals' => [ 'save_time' => '한 번에 여러 곳에 게시하여 시간 절약', 'ai_content' => 'TryPost AI로 게시물 생성', - 'use_mcp' => 'Claude, ChatGPT 또는 Cursor에서 게시물 작성', + 'use_mcp' => 'Claude, ChatGPT 등에서 게시물 작성', 'plan_calendar' => '캘린더에서 게시물 계획', 'stay_on_brand' => '모든 게시물을 브랜드에 맞게 유지', 'grow_audience' => '팔로워와 참여 늘리기', @@ -38,8 +52,8 @@ 'just_exploring' => '지금은 둘러보는 중', 'other' => '다른 것', ], - 'plan_title' => '요금제를 선택하세요. 모든 기능이 포함됩니다.', - 'plan_description' => '차이는 워크스페이스 수뿐입니다. 나중에 바꿀 수 있습니다.', + 'plan_title' => '요금제 선택', + 'plan_description' => '두 요금제 모두 모든 기능과 모든 소셜 네트워크를 포함합니다. 차이는 워크스페이스 수뿐이며, 언제든 변경할 수 있어요.', 'referral_source_title' => '저희를 어떻게 알게 되셨나요?', 'referral_source_description' => '사람들이 TryPost를 어떻게 발견하는지 파악하는 데 도움이 됩니다.', 'referral_source' => [ diff --git a/lang/nl/billing.php b/lang/nl/billing.php index 61c1172b9..a69cce91d 100644 --- a/lang/nl/billing.php +++ b/lang/nl/billing.php @@ -20,7 +20,6 @@ 'billed_yearly' => 'Jaarlijks gefactureerd', 'prices' => [ 'first_month' => '$1', - 'workspace' => ['monthly' => '$12', 'yearly_per_month' => '$10', 'yearly' => '$120'], 'socials' => ['monthly' => '$19', 'yearly_per_month' => '$15.83', 'yearly' => '$190'], 'workspaces' => ['monthly' => '$99', 'yearly_per_month' => '$82.50', 'yearly' => '$990'], ], @@ -38,12 +37,13 @@ 'current' => 'Huidig plan', 'select' => 'Kies :plan', 'start_first_month' => 'Start mijn eerste maand voor :price', - 'first_month_then' => 'Eerste maand :first, daarna :price/maand', 'billed_yearly_total' => 'Jaarlijks gefactureerd · :price (2 maanden gratis)', - 'socials_tagline' => 'Eén workspace. Overal posten.', - 'workspaces_tagline' => 'Een workspace voor elk merk of elke klant.', + 'socials_tagline' => 'Ideaal voor creators en kleine merken.', + 'workspaces_tagline' => 'Ideaal voor bureaus en grotere bedrijven.', + 'everything_included' => 'Alles inbegrepen', 'features' => [ + 'networks_all' => 'Alle sociale netwerken', 'accounts_unlimited' => 'Onbeperkte social accounts', 'calendar' => 'Visuele kalender met automatisch publiceren', 'ai' => 'AI: captions, afbeeldingen en merkstem', diff --git a/lang/nl/welcome.php b/lang/nl/welcome.php index 2ddfae9be..34b3f3f83 100644 --- a/lang/nl/welcome.php +++ b/lang/nl/welcome.php @@ -11,8 +11,22 @@ 'subscription_required_owner' => 'De accounteigenaar is :name.', 'subscription_required_auto' => 'Deze pagina vernieuwt automatisch — verversen is niet nodig.', 'progress' => 'Welkomstvoortgang', - 'go_to_step' => 'Ga naar stap :step', 'step_current' => 'Stap :step (huidig)', + 'step_of' => 'Stap :step van :total', + 'back' => 'Terug', + 'steps' => [ + 'persona' => 'Over jou', + 'goals' => 'Je doelen', + 'referral_source' => 'Hoe je ons vond', + 'connect' => 'Sociale netwerken', + 'plan' => 'Abonnement', + ], + 'preview' => [ + 'heading' => 'Je workspace krijgt vorm.', + 'workspace' => 'Je workspace', + 'pending' => 'Nog niet gekozen', + 'networks_empty' => 'Nog geen netwerken verbonden', + ], 'personas' => [ 'creator' => 'Contentmaker', 'freelancer' => 'Freelancer', @@ -29,7 +43,7 @@ 'goals' => [ 'save_time' => 'Tijd besparen door overal tegelijk te posten', 'ai_content' => 'Posts genereren met TryPost AI', - 'use_mcp' => 'Posts maken via Claude, ChatGPT of Cursor', + 'use_mcp' => 'Posts maken via Claude, ChatGPT, enz.', 'plan_calendar' => 'Mijn posts plannen op een kalender', 'stay_on_brand' => 'Elke post in lijn met mijn merk houden', 'grow_audience' => 'Mijn publiek en betrokkenheid laten groeien', @@ -38,8 +52,8 @@ 'just_exploring' => 'Voorlopig gewoon aan het verkennen', 'other' => 'Iets anders', ], - 'plan_title' => 'Kies een plan. Elke functie is inbegrepen.', - 'plan_description' => 'Het enige verschil is hoeveel workspaces je krijgt. Je kunt later wisselen.', + 'plan_title' => 'Kies je abonnement', + 'plan_description' => 'Beide abonnementen bevatten alle functies en alle sociale netwerken. Het enige verschil is hoeveel workspaces je krijgt — wissel wanneer je wilt.', 'referral_source_title' => 'Hoe heb je ons gevonden?', 'referral_source_description' => 'Dit helpt ons te begrijpen hoe mensen TryPost ontdekken.', 'referral_source' => [ diff --git a/lang/pl/billing.php b/lang/pl/billing.php index a67d9b4fc..12a54e38a 100644 --- a/lang/pl/billing.php +++ b/lang/pl/billing.php @@ -20,7 +20,6 @@ 'billed_yearly' => 'Rozliczane rocznie', 'prices' => [ 'first_month' => '$1', - 'workspace' => ['monthly' => '$12', 'yearly_per_month' => '$10', 'yearly' => '$120'], 'socials' => ['monthly' => '$19', 'yearly_per_month' => '$15.83', 'yearly' => '$190'], 'workspaces' => ['monthly' => '$99', 'yearly_per_month' => '$82.50', 'yearly' => '$990'], ], @@ -38,12 +37,13 @@ 'current' => 'Aktualny plan', 'select' => 'Wybierz :plan', 'start_first_month' => 'Zacznij pierwszy miesiąc za :price', - 'first_month_then' => 'Pierwszy miesiąc :first, potem :price/miesiąc', 'billed_yearly_total' => 'Rozliczane rocznie · :price (2 miesiące gratis)', - 'socials_tagline' => 'Jeden workspace. Publikuj wszędzie.', - 'workspaces_tagline' => 'Workspace na każdą markę lub klienta.', + 'socials_tagline' => 'Dla twórców i małych marek.', + 'workspaces_tagline' => 'Dla agencji i większych firm.', + 'everything_included' => 'Wszystko w cenie', 'features' => [ + 'networks_all' => 'Wszystkie sieci społecznościowe', 'accounts_unlimited' => 'Nielimitowane konta społecznościowe', 'calendar' => 'Wizualny kalendarz z automatyczną publikacją', 'ai' => 'AI: podpisy, obrazy i głos marki', diff --git a/lang/pl/welcome.php b/lang/pl/welcome.php index 8ca6a6c7c..2dab2a37b 100644 --- a/lang/pl/welcome.php +++ b/lang/pl/welcome.php @@ -11,8 +11,22 @@ 'subscription_required_owner' => 'Właścicielem Twojego konta jest :name.', 'subscription_required_auto' => 'Ta strona odświeża się automatycznie — nie musisz jej przeładowywać.', 'progress' => 'Postęp powitalny', - 'go_to_step' => 'Przejdź do kroku :step', 'step_current' => 'Krok :step (bieżący)', + 'step_of' => 'Krok :step z :total', + 'back' => 'Wstecz', + 'steps' => [ + 'persona' => 'O Tobie', + 'goals' => 'Twoje cele', + 'referral_source' => 'Jak nas znalazłeś', + 'connect' => 'Sieci społecznościowe', + 'plan' => 'Plan', + ], + 'preview' => [ + 'heading' => 'Twój workspace nabiera kształtu.', + 'workspace' => 'Twój workspace', + 'pending' => 'Jeszcze nie wybrano', + 'networks_empty' => 'Nie połączono jeszcze żadnej sieci', + ], 'personas' => [ 'creator' => 'Twórca treści', 'freelancer' => 'Freelancer', @@ -29,7 +43,7 @@ 'goals' => [ 'save_time' => 'Oszczędzaj czas, publikując wszędzie naraz', 'ai_content' => 'Generuj posty z AI TryPost', - 'use_mcp' => 'Twórz posty w Claude, ChatGPT lub Cursor', + 'use_mcp' => 'Twórz posty w Claude, ChatGPT itd.', 'plan_calendar' => 'Planuj posty w kalendarzu', 'stay_on_brand' => 'Utrzymuj każdy post spójny z marką', 'grow_audience' => 'Powiększaj grono odbiorców i zaangażowanie', @@ -38,8 +52,8 @@ 'just_exploring' => 'Na razie tylko się rozglądam', 'other' => 'Coś innego', ], - 'plan_title' => 'Wybierz plan. Wszystkie funkcje są w cenie.', - 'plan_description' => 'Jedyna różnica to liczba workspace\'ów. Możesz zmienić później.', + 'plan_title' => 'Wybierz plan', + 'plan_description' => 'Oba plany zawierają wszystkie funkcje i wszystkie sieci społecznościowe. Jedyna różnica to liczba workspace’ów — zmień w każdej chwili.', 'referral_source_title' => 'Jak nas znalazłeś?', 'referral_source_description' => 'To pomaga nam zrozumieć, jak ludzie odkrywają TryPost.', 'referral_source' => [ diff --git a/lang/pt-BR/billing.php b/lang/pt-BR/billing.php index 3d0123ea8..3bd4b0f93 100644 --- a/lang/pt-BR/billing.php +++ b/lang/pt-BR/billing.php @@ -19,10 +19,9 @@ 'billed_monthly' => 'Cobrança mensal', 'billed_yearly' => 'Cobrança anual', 'prices' => [ - 'first_month' => 'R$ 5', - 'workspace' => ['monthly' => 'R$ 60', 'yearly_per_month' => 'R$ 50', 'yearly' => 'R$ 600'], - 'socials' => ['monthly' => 'R$ 95', 'yearly_per_month' => 'R$ 79,17', 'yearly' => 'R$ 950'], - 'workspaces' => ['monthly' => 'R$ 495', 'yearly_per_month' => 'R$ 412,50', 'yearly' => 'R$ 4.950'], + 'first_month' => 'R$ 1', + 'socials' => ['monthly' => 'R$ 99', 'yearly_per_month' => 'R$ 82,50', 'yearly' => 'R$ 990'], + 'workspaces' => ['monthly' => 'R$ 499', 'yearly_per_month' => 'R$ 415,83', 'yearly' => 'R$ 4.990'], ], ], @@ -38,12 +37,13 @@ 'current' => 'Plano atual', 'select' => 'Escolher :plan', 'start_first_month' => 'Começar meu primeiro mês por :price', - 'first_month_then' => 'Primeiro mês :first, depois :price/mês', 'billed_yearly_total' => 'Cobrança anual · :price (2 meses grátis)', - 'socials_tagline' => 'Um workspace. Publique em todas as redes.', - 'workspaces_tagline' => 'Um workspace para cada marca ou cliente.', + 'socials_tagline' => 'Indicado para criadores e pequenas marcas.', + 'workspaces_tagline' => 'Indicado para agências e grandes negócios.', + 'everything_included' => 'Tudo incluído', 'features' => [ + 'networks_all' => 'Todas as redes sociais', 'accounts_unlimited' => 'Contas sociais ilimitadas', 'calendar' => 'Calendário visual com publicação automática', 'ai' => 'IA: legendas, imagens e voz da marca', diff --git a/lang/pt-BR/welcome.php b/lang/pt-BR/welcome.php index 687a71bc7..322ca5e01 100644 --- a/lang/pt-BR/welcome.php +++ b/lang/pt-BR/welcome.php @@ -11,8 +11,22 @@ 'subscription_required_owner' => 'O dono da sua conta é :name.', 'subscription_required_auto' => 'Esta página atualiza automaticamente — não precisa recarregar.', 'progress' => 'Progresso das boas-vindas', - 'go_to_step' => 'Ir para a etapa :step', 'step_current' => 'Etapa :step (atual)', + 'step_of' => 'Etapa :step de :total', + 'back' => 'Voltar', + 'steps' => [ + 'persona' => 'Sobre você', + 'goals' => 'Seus objetivos', + 'referral_source' => 'Como nos encontrou', + 'connect' => 'Redes sociais', + 'plan' => 'Plano', + ], + 'preview' => [ + 'heading' => 'Seu workspace está tomando forma.', + 'workspace' => 'Seu workspace', + 'pending' => 'Ainda não escolhido', + 'networks_empty' => 'Nenhuma rede conectada ainda', + ], 'personas' => [ 'creator' => 'Criador de conteúdo', 'freelancer' => 'Freelancer', @@ -29,7 +43,7 @@ 'goals' => [ 'save_time' => 'Economizar tempo postando em todas as redes de uma vez', 'ai_content' => 'Gerar posts com a IA do TryPost', - 'use_mcp' => 'Criar posts pelo Claude, ChatGPT ou Cursor', + 'use_mcp' => 'Criar posts pelo Claude, ChatGPT, etc.', 'plan_calendar' => 'Planejar meus posts num calendário', 'stay_on_brand' => 'Manter a consistência da minha marca', 'grow_audience' => 'Crescer minha audiência e engajamento', @@ -38,8 +52,8 @@ 'just_exploring' => 'Só dando uma olhada por enquanto', 'other' => 'Outra coisa', ], - 'plan_title' => 'Escolha um plano. Tudo está incluído.', - 'plan_description' => 'A única diferença é quantos workspaces você tem. Dá para mudar depois.', + 'plan_title' => 'Escolha seu plano', + 'plan_description' => 'Os dois planos incluem todos os recursos e todas as redes sociais. A única diferença é quantos workspaces você tem — troque quando quiser.', 'referral_source_title' => 'Como você nos encontrou?', 'referral_source_description' => 'Isso nos ajuda a entender como as pessoas descobrem o TryPost.', 'referral_source' => [ diff --git a/lang/ru/billing.php b/lang/ru/billing.php index 24108356b..6d4e8ffd1 100644 --- a/lang/ru/billing.php +++ b/lang/ru/billing.php @@ -20,7 +20,6 @@ 'billed_yearly' => 'Годовая оплата', 'prices' => [ 'first_month' => '$1', - 'workspace' => ['monthly' => '$12', 'yearly_per_month' => '$10', 'yearly' => '$120'], 'socials' => ['monthly' => '$19', 'yearly_per_month' => '$15.83', 'yearly' => '$190'], 'workspaces' => ['monthly' => '$99', 'yearly_per_month' => '$82.50', 'yearly' => '$990'], ], @@ -38,12 +37,13 @@ 'current' => 'Текущий план', 'select' => 'Выбрать :plan', 'start_first_month' => 'Начать первый месяц за :price', - 'first_month_then' => 'Первый месяц :first, затем :price/месяц', 'billed_yearly_total' => 'Оплата раз в год · :price (2 месяца бесплатно)', - 'socials_tagline' => 'Одно рабочее пространство. Публикуйте везде.', - 'workspaces_tagline' => 'Рабочее пространство для каждого бренда или клиента.', + 'socials_tagline' => 'Для авторов и небольших брендов.', + 'workspaces_tagline' => 'Для агентств и крупного бизнеса.', + 'everything_included' => 'Всё включено', 'features' => [ + 'networks_all' => 'Все соцсети', 'accounts_unlimited' => 'Безлимитные аккаунты', 'calendar' => 'Визуальный календарь с автопубликацией', 'ai' => 'ИИ: тексты, изображения и голос бренда', diff --git a/lang/ru/welcome.php b/lang/ru/welcome.php index 4b728b31a..1995c864c 100644 --- a/lang/ru/welcome.php +++ b/lang/ru/welcome.php @@ -11,8 +11,22 @@ 'subscription_required_owner' => 'Владелец вашего аккаунта — :name.', 'subscription_required_auto' => 'Эта страница обновляется автоматически — перезагружать не нужно.', 'progress' => 'Прогресс приветствия', - 'go_to_step' => 'Перейти к шагу :step', 'step_current' => 'Шаг :step (текущий)', + 'step_of' => 'Шаг :step из :total', + 'back' => 'Назад', + 'steps' => [ + 'persona' => 'О вас', + 'goals' => 'Ваши цели', + 'referral_source' => 'Как вы нас нашли', + 'connect' => 'Соцсети', + 'plan' => 'Тариф', + ], + 'preview' => [ + 'heading' => 'Ваш workspace обретает форму.', + 'workspace' => 'Ваш workspace', + 'pending' => 'Ещё не выбрано', + 'networks_empty' => 'Соцсети пока не подключены', + ], 'personas' => [ 'creator' => 'Автор контента', 'freelancer' => 'Фрилансер', @@ -29,7 +43,7 @@ 'goals' => [ 'save_time' => 'Экономить время, публикуя всюду сразу', 'ai_content' => 'Генерировать посты с ИИ TryPost', - 'use_mcp' => 'Создавать посты через Claude, ChatGPT или Cursor', + 'use_mcp' => 'Создавать посты через Claude, ChatGPT и др.', 'plan_calendar' => 'Планировать посты в календаре', 'stay_on_brand' => 'Держать каждый пост в стиле бренда', 'grow_audience' => 'Наращивать аудиторию и вовлечённость', @@ -38,8 +52,8 @@ 'just_exploring' => 'Пока просто знакомлюсь', 'other' => 'Что-то ещё', ], - 'plan_title' => 'Выберите план. Все функции включены.', - 'plan_description' => 'Единственная разница — сколько рабочих пространств вы получаете. Позже можно сменить.', + 'plan_title' => 'Выберите тариф', + 'plan_description' => 'Оба тарифа включают все функции и все соцсети. Единственная разница — количество workspace. Сменить можно в любой момент.', 'referral_source_title' => 'Как вы нас нашли?', 'referral_source_description' => 'Это помогает нам понять, как люди узнают о TryPost.', 'referral_source' => [ diff --git a/lang/tr/billing.php b/lang/tr/billing.php index f064056a2..1245e52ea 100644 --- a/lang/tr/billing.php +++ b/lang/tr/billing.php @@ -22,7 +22,6 @@ 'billed_yearly' => 'Yıllık faturalandırılır', 'prices' => [ 'first_month' => '$1', - 'workspace' => ['monthly' => '$12', 'yearly_per_month' => '$10', 'yearly' => '$120'], 'socials' => ['monthly' => '$19', 'yearly_per_month' => '$15.83', 'yearly' => '$190'], 'workspaces' => ['monthly' => '$99', 'yearly_per_month' => '$82.50', 'yearly' => '$990'], ], @@ -40,12 +39,13 @@ 'current' => 'Mevcut plan', 'select' => ':plan seç', 'start_first_month' => 'İlk ayıma :price ile başla', - 'first_month_then' => 'İlk ay :first, sonra :price/ay', 'billed_yearly_total' => 'Yıllık faturalandırılır · :price (2 ay bedava)', - 'socials_tagline' => 'Bir çalışma alanı. Her yere yayınlayın.', - 'workspaces_tagline' => 'Her marka veya müşteri için bir çalışma alanı.', + 'socials_tagline' => 'İçerik üreticileri ve küçük markalar için.', + 'workspaces_tagline' => 'Ajanslar ve büyük işletmeler için.', + 'everything_included' => 'Her şey dahil', 'features' => [ + 'networks_all' => 'Tüm sosyal ağlar', 'accounts_unlimited' => 'Sınırsız sosyal hesaplar', 'calendar' => 'Otomatik yayınlamalı görsel takvim', 'ai' => 'Yapay zeka: metinler, görseller ve marka sesi', diff --git a/lang/tr/welcome.php b/lang/tr/welcome.php index 12df6b981..9fb47fc1a 100644 --- a/lang/tr/welcome.php +++ b/lang/tr/welcome.php @@ -11,8 +11,22 @@ 'subscription_required_owner' => 'Hesap sahibiniz :name.', 'subscription_required_auto' => 'Bu sayfa otomatik olarak güncellenir — yenilemenize gerek yok.', 'progress' => 'Karşılama ilerlemesi', - 'go_to_step' => ':step. adıma git', 'step_current' => 'Adım :step (şu anki)', + 'step_of' => 'Adım :step / :total', + 'back' => 'Geri', + 'steps' => [ + 'persona' => 'Senin hakkında', + 'goals' => 'Hedeflerin', + 'referral_source' => 'Bizi nasıl buldun', + 'connect' => 'Sosyal ağlar', + 'plan' => 'Plan', + ], + 'preview' => [ + 'heading' => 'Workspace’in şekilleniyor.', + 'workspace' => 'Workspace’in', + 'pending' => 'Henüz seçilmedi', + 'networks_empty' => 'Henüz bağlı ağ yok', + ], 'personas' => [ 'creator' => 'İçerik üreticisi', 'freelancer' => 'Serbest çalışan', @@ -29,7 +43,7 @@ 'goals' => [ 'save_time' => 'Her yere aynı anda paylaşarak zaman kazanmak', 'ai_content' => 'TryPost AI ile gönderi oluşturmak', - 'use_mcp' => 'Claude, ChatGPT veya Cursor ile gönderi oluşturmak', + 'use_mcp' => 'Claude, ChatGPT vb. ile gönderi oluşturmak', 'plan_calendar' => 'Gönderilerimi bir takvimde planlamak', 'stay_on_brand' => 'Her gönderiyi marka çizgisinde tutmak', 'grow_audience' => 'Kitlemi ve etkileşimimi büyütmek', @@ -38,8 +52,8 @@ 'just_exploring' => 'Şimdilik sadece keşfetmek', 'other' => 'Başka bir şey', ], - 'plan_title' => 'Bir plan seçin. Tüm özellikler dahil.', - 'plan_description' => 'Tek fark aldığınız çalışma alanı sayısı. Daha sonra değiştirebilirsiniz.', + 'plan_title' => 'Planını seç', + 'plan_description' => 'Her iki plan da tüm özellikleri ve tüm sosyal ağları içerir. Tek fark kaç workspace aldığın — istediğin zaman değiştir.', 'referral_source_title' => 'Bizi nasıl buldunuz?', 'referral_source_description' => 'İnsanların TryPost\'u nasıl keşfettiğini anlamamıza yardımcı olur.', 'referral_source' => [ diff --git a/lang/uk/billing.php b/lang/uk/billing.php index 531f96b24..5bed24f95 100644 --- a/lang/uk/billing.php +++ b/lang/uk/billing.php @@ -20,7 +20,6 @@ 'billed_yearly' => 'Річна оплата', 'prices' => [ 'first_month' => '$1', - 'workspace' => ['monthly' => '$12', 'yearly_per_month' => '$10', 'yearly' => '$120'], 'socials' => ['monthly' => '$19', 'yearly_per_month' => '$15.83', 'yearly' => '$190'], 'workspaces' => ['monthly' => '$99', 'yearly_per_month' => '$82.50', 'yearly' => '$990'], ], @@ -38,12 +37,13 @@ 'current' => 'Поточний план', 'select' => 'Обрати :plan', 'start_first_month' => 'Почати перший місяць за :price', - 'first_month_then' => 'Перший місяць :first, далі :price/місяць', 'billed_yearly_total' => 'Оплата раз на рік · :price (2 місяці безкоштовно)', - 'socials_tagline' => 'Один робочий простір. Публікуйте скрізь.', - 'workspaces_tagline' => 'Робочий простір для кожного бренду чи клієнта.', + 'socials_tagline' => 'Для авторів і невеликих брендів.', + 'workspaces_tagline' => 'Для агентств і великого бізнесу.', + 'everything_included' => 'Усе включено', 'features' => [ + 'networks_all' => 'Усі соцмережі', 'accounts_unlimited' => 'Необмежені акаунти', 'calendar' => 'Візуальний календар з автопублікацією', 'ai' => 'ШІ: тексти, зображення та голос бренду', diff --git a/lang/uk/welcome.php b/lang/uk/welcome.php index df3a714db..e07fef0e5 100644 --- a/lang/uk/welcome.php +++ b/lang/uk/welcome.php @@ -11,8 +11,22 @@ 'subscription_required_owner' => 'Власник вашого акаунта — :name.', 'subscription_required_auto' => 'Ця сторінка оновлюється автоматично — оновлювати вручну не потрібно.', 'progress' => 'Прогрес привітання', - 'go_to_step' => 'Перейти до кроку :step', 'step_current' => 'Крок :step (поточний)', + 'step_of' => 'Крок :step з :total', + 'back' => 'Назад', + 'steps' => [ + 'persona' => 'Про вас', + 'goals' => 'Ваші цілі', + 'referral_source' => 'Як ви нас знайшли', + 'connect' => 'Соцмережі', + 'plan' => 'Тариф', + ], + 'preview' => [ + 'heading' => 'Ваш workspace набуває форми.', + 'workspace' => 'Ваш workspace', + 'pending' => 'Ще не обрано', + 'networks_empty' => 'Соцмережі ще не підключено', + ], 'personas' => [ 'creator' => 'Автор контенту', 'freelancer' => 'Фрілансер', @@ -29,7 +43,7 @@ 'goals' => [ 'save_time' => 'Економити час, публікуючи всюди одразу', 'ai_content' => 'Генерувати пости з AI TryPost', - 'use_mcp' => 'Створювати пости через Claude, ChatGPT або Cursor', + 'use_mcp' => 'Створювати пости через Claude, ChatGPT тощо', 'plan_calendar' => 'Планувати пости в календарі', 'stay_on_brand' => 'Тримати кожен пост у стилі бренду', 'grow_audience' => 'Збільшувати аудиторію та залучення', @@ -38,8 +52,8 @@ 'just_exploring' => 'Поки що просто досліджую', 'other' => 'Щось інше', ], - 'plan_title' => 'Оберіть план. Усі функції включені.', - 'plan_description' => 'Єдина різниця — скільки робочих просторів ви отримуєте. Пізніше можна змінити.', + 'plan_title' => 'Оберіть тариф', + 'plan_description' => 'Обидва тарифи включають усі функції та всі соцмережі. Єдина різниця — кількість workspace. Змінити можна будь-коли.', 'referral_source_title' => 'Як ви нас знайшли?', 'referral_source_description' => 'Це допомагає нам зрозуміти, як люди дізнаються про TryPost.', 'referral_source' => [ diff --git a/lang/zh/billing.php b/lang/zh/billing.php index bae538696..6d75bb634 100644 --- a/lang/zh/billing.php +++ b/lang/zh/billing.php @@ -20,7 +20,6 @@ 'billed_yearly' => '按年计费', 'prices' => [ 'first_month' => '$1', - 'workspace' => ['monthly' => '$12', 'yearly_per_month' => '$10', 'yearly' => '$120'], 'socials' => ['monthly' => '$19', 'yearly_per_month' => '$15.83', 'yearly' => '$190'], 'workspaces' => ['monthly' => '$99', 'yearly_per_month' => '$82.50', 'yearly' => '$990'], ], @@ -38,12 +37,13 @@ 'current' => '当前套餐', 'select' => '选择 :plan', 'start_first_month' => '以 :price 开始第一个月', - 'first_month_then' => '首月 :first,之后 :price/月', 'billed_yearly_total' => '按年计费 · :price(免两个月)', - 'socials_tagline' => '一个工作区,发到所有网络。', - 'workspaces_tagline' => '每个品牌或客户一个工作区。', + 'socials_tagline' => '适合创作者和小品牌。', + 'workspaces_tagline' => '适合代理商和大型业务。', + 'everything_included' => '全部包含', 'features' => [ + 'networks_all' => '所有社交网络', 'accounts_unlimited' => '社交账号不限', 'calendar' => '可视化日历,自动发布', 'ai' => 'AI:文案、图片和品牌声音', diff --git a/lang/zh/welcome.php b/lang/zh/welcome.php index d3661c707..a4b0da593 100644 --- a/lang/zh/welcome.php +++ b/lang/zh/welcome.php @@ -11,8 +11,22 @@ 'subscription_required_owner' => '您的账户所有者是 :name。', 'subscription_required_auto' => '此页面会自动更新 — 无需刷新。', 'progress' => '欢迎进度', - 'go_to_step' => '前往第 :step 步', 'step_current' => '第 :step 步(当前)', + 'step_of' => '第 :step 步,共 :total 步', + 'back' => '返回', + 'steps' => [ + 'persona' => '关于你', + 'goals' => '你的目标', + 'referral_source' => '如何找到我们', + 'connect' => '社交网络', + 'plan' => '套餐', + ], + 'preview' => [ + 'heading' => '你的工作区正在成形。', + 'workspace' => '你的工作区', + 'pending' => '尚未选择', + 'networks_empty' => '尚未连接任何网络', + ], 'personas' => [ 'creator' => '内容创作者', 'freelancer' => '自由职业者', @@ -29,7 +43,7 @@ 'goals' => [ 'save_time' => '一次发布到所有平台,节省时间', 'ai_content' => '用 TryPost AI 生成帖子', - 'use_mcp' => '通过 Claude、ChatGPT 或 Cursor 创建帖子', + 'use_mcp' => '通过 Claude、ChatGPT 等创建帖子', 'plan_calendar' => '在日历上规划我的帖子', 'stay_on_brand' => '让每一条帖子都符合品牌调性', 'grow_audience' => '增长我的受众和互动', @@ -38,8 +52,8 @@ 'just_exploring' => '目前只是随便看看', 'other' => '其他需求', ], - 'plan_title' => '选择套餐。所有功能都包含在内。', - 'plan_description' => '唯一的区别是工作区数量。之后随时可以换。', + 'plan_title' => '选择你的套餐', + 'plan_description' => '两个套餐都包含全部功能和全部社交网络。唯一的区别是工作区数量——随时可以切换。', 'referral_source_title' => '您是如何找到我们的?', 'referral_source_description' => '这有助于我们了解人们是如何发现 TryPost 的。', 'referral_source' => [ diff --git a/resources/js/components/PlatformLogo.vue b/resources/js/components/PlatformLogo.vue index 353b25855..849e776f6 100644 --- a/resources/js/components/PlatformLogo.vue +++ b/resources/js/components/PlatformLogo.vue @@ -1,7 +1,10 @@