From 09d4e9d6b127f2bc1bf2664ca81f365668f6562a Mon Sep 17 00:00:00 2001 From: Paulo Castellano Date: Mon, 22 Jun 2026 12:31:44 -0300 Subject: [PATCH] feat(onboarding): require connecting a social network before checkout Add a connect step between persona selection and Stripe checkout: persona -> connect >=1 network (server-enforced) -> checkout. Extract the network grid into a shared NetworkConnectGrid used by both onboarding and the accounts page, which is redesigned from a table into the same grid (one account per network, with per-card connect / connected+disconnect / reconnect states). Drop the openDialog auto-open flow now that networks are shown inline. --- .../Controllers/App/OnboardingController.php | 75 +++- .../Controllers/App/WorkspaceController.php | 2 +- .../Controllers/Auth/SocialController.php | 23 +- lang/en/accounts.php | 24 +- lang/en/onboarding.php | 6 +- lang/es/accounts.php | 24 +- lang/es/onboarding.php | 6 +- lang/pt-BR/accounts.php | 24 +- lang/pt-BR/onboarding.php | 6 +- .../components/accounts/AddSocialDialog.vue | 208 ---------- .../accounts/NetworkConnectGrid.vue | 356 ++++++++++++++++++ resources/js/composables/useOAuthPopup.ts | 12 +- resources/js/pages/accounts/Index.vue | 279 +------------- resources/js/pages/onboarding/Connect.vue | 78 ++++ resources/js/pages/onboarding/Index.vue | 35 +- routes/app.php | 8 +- .../Onboarding/OnboardingControllerTest.php | 161 ++++++-- tests/Feature/SocialControllerTest.php | 14 + tests/Feature/WorkspaceBillingTest.php | 2 +- tests/Feature/WorkspaceControllerTest.php | 12 +- 20 files changed, 736 insertions(+), 619 deletions(-) delete mode 100644 resources/js/components/accounts/AddSocialDialog.vue create mode 100644 resources/js/components/accounts/NetworkConnectGrid.vue create mode 100644 resources/js/pages/onboarding/Connect.vue diff --git a/app/Http/Controllers/App/OnboardingController.php b/app/Http/Controllers/App/OnboardingController.php index 68ef90da..06410983 100644 --- a/app/Http/Controllers/App/OnboardingController.php +++ b/app/Http/Controllers/App/OnboardingController.php @@ -6,8 +6,10 @@ use App\Actions\Billing\StartSubscriptionCheckout; use App\Enums\Plan\Slug; +use App\Enums\SocialAccount\Platform as SocialPlatform; use App\Enums\User\Persona; use App\Http\Requests\App\Onboarding\StoreOnboardingRequest; +use App\Http\Resources\App\SocialAccountResource; use App\Models\Account; use App\Models\Plan; use App\Services\PostHogService; @@ -37,19 +39,15 @@ public function index(Request $request): Response|RedirectResponse ]); } - public function store( - StoreOnboardingRequest $request, - PostHogService $postHog, - StartSubscriptionCheckout $checkout, - ): SymfonyResponse|RedirectResponse { + public function store(StoreOnboardingRequest $request, PostHogService $postHog): RedirectResponse + { if (config('trypost.self_hosted')) { return redirect()->route('app.calendar'); } $user = $request->user(); - $account = $user->account; - if ($account?->subscribed(Account::SUBSCRIPTION_NAME)) { + if ($user->account?->subscribed(Account::SUBSCRIPTION_NAME)) { return redirect()->route('app.calendar'); } @@ -61,12 +59,73 @@ public function store( 'persona' => $persona, ]); + return redirect()->route('app.onboarding.connect'); + } + + public function connect(Request $request): Response|RedirectResponse + { + if (config('trypost.self_hosted')) { + return redirect()->route('app.calendar'); + } + + $user = $request->user(); + + if ($user->account?->subscribed(Account::SUBSCRIPTION_NAME)) { + return redirect()->route('app.calendar'); + } + + if (! $user->persona) { + return redirect()->route('app.onboarding'); + } + + $workspace = $user->currentWorkspace; + + if (! $workspace) { + return redirect()->route('app.workspaces.create'); + } + + $accounts = $workspace->socialAccounts()->orderBy('id')->get(); + + $platforms = collect(SocialPlatform::enabled())->map(fn (SocialPlatform $platform): array => [ + 'value' => $platform->value, + 'label' => $platform->label(), + 'color' => $platform->color(), + 'network' => $platform->network(), + ])->values(); + + return Inertia::render('onboarding/Connect', [ + 'platforms' => $platforms, + 'accounts' => SocialAccountResource::collection($accounts)->resolve(), + ]); + } + + public function checkout(Request $request, StartSubscriptionCheckout $checkout): SymfonyResponse|RedirectResponse + { + if (config('trypost.self_hosted')) { + return redirect()->route('app.calendar'); + } + + $user = $request->user(); + $account = $user->account; + + if ($account?->subscribed(Account::SUBSCRIPTION_NAME)) { + return redirect()->route('app.calendar'); + } + + $workspace = $user->currentWorkspace; + + if (! $workspace || ! $workspace->socialAccounts()->exists()) { + return redirect()->route('app.onboarding.connect') + ->with('flash.banner', __('onboarding.connect.must_connect')) + ->with('flash.bannerStyle', 'danger'); + } + $plan = Plan::where('slug', Slug::Workspace)->firstOrFail(); return $checkout->redirect( $account, (string) $plan->stripe_monthly_price_id, - route('app.onboarding'), + route('app.onboarding.connect'), ); } } diff --git a/app/Http/Controllers/App/WorkspaceController.php b/app/Http/Controllers/App/WorkspaceController.php index 088e8606..ee500be8 100644 --- a/app/Http/Controllers/App/WorkspaceController.php +++ b/app/Http/Controllers/App/WorkspaceController.php @@ -114,7 +114,7 @@ public function store(StoreWorkspaceRequest $request, LogoAttacher $logoAttacher } } - return redirect()->route('app.accounts', ['openDialog' => 'true']) + return redirect()->route('app.accounts') ->with('success', __('workspaces.create.success')); } diff --git a/app/Http/Controllers/Auth/SocialController.php b/app/Http/Controllers/Auth/SocialController.php index 36a8b859..5ca0d53d 100644 --- a/app/Http/Controllers/Auth/SocialController.php +++ b/app/Http/Controllers/Auth/SocialController.php @@ -43,32 +43,19 @@ public function index(Request $request): Response|RedirectResponse $this->authorize('view', $workspace); - $accounts = $workspace->socialAccounts() - ->when( - $request->input('search'), - fn ($query, $search) => $query->where(function ($q) use ($search): void { - $q->where('display_name', 'ilike', "%{$search}%") - ->orWhere('username', 'ilike', "%{$search}%") - ->orWhere('platform', 'ilike', "%{$search}%"); - }), - ) - ->orderBy('id') - ->paginate(config('app.pagination.default')); - $platforms = collect(SocialPlatform::enabled())->map(fn ($platform) => [ 'value' => $platform->value, 'label' => $platform->label(), 'color' => $platform->color(), + 'network' => $platform->network(), ])->values(); return Inertia::render('accounts/Index', [ 'workspace' => $workspace, - 'accounts' => Inertia::scroll(fn () => SocialAccountResource::collection($accounts)), 'platforms' => $platforms, - 'filters' => [ - 'search' => $request->input('search', ''), - ], - 'openDialog' => $request->boolean('openDialog'), + 'connectedAccounts' => SocialAccountResource::collection( + $workspace->socialAccounts()->orderBy('id')->get(), + )->resolve(), ]); } @@ -204,7 +191,7 @@ protected function forgetSocialConnectSession(): void protected function getRedirectRoute(): string { - return session('social_connect_onboarding', false) ? 'onboarding.connect' : 'accounts'; + return session('social_connect_onboarding', false) ? 'app.onboarding.connect' : 'app.accounts'; } /** diff --git a/lang/en/accounts.php b/lang/en/accounts.php index 44876d8e..8359c84c 100644 --- a/lang/en/accounts.php +++ b/lang/en/accounts.php @@ -4,38 +4,16 @@ 'title' => 'Connections', 'page_title' => 'Social Accounts', 'description' => 'Overview of all your connected social accounts', - 'add_social' => 'Add Social', - 'add_social_title' => 'Connect a Social Account', - 'add_social_description' => 'Connect a social account to TryPost to start posting', 'connect_cta' => 'Connect', - 'no_accounts' => 'No accounts connected yet', - 'no_accounts_description' => 'Connect your social networks to start scheduling and publishing posts', - 'no_search_results' => 'No accounts match your search', - 'try_different_search' => 'Try a different keyword or clear the search.', - 'search' => 'Search accounts...', - 'added' => 'Added :date', 'not_connected' => 'Not connected', 'connect' => 'Connect', 'connection_lost' => 'Connection lost', + 'reconnect' => 'Reconnect', 'reconnect_account' => 'Reconnect account', 'view_profile' => 'View profile', 'disconnect' => 'Disconnect', - 'table' => [ - 'account' => 'Account', - 'platform' => 'Platform', - 'status' => 'Status', - 'last_used' => 'Last used', - 'added' => 'Added', - 'active' => 'Active', - ], - 'never_used' => 'Never used', - 'status' => [ - 'connected' => 'Connected', - 'disconnected' => 'Disconnected', - ], - 'descriptions' => [ 'linkedin' => 'Connect your LinkedIn personal profile', 'linkedin-page' => 'Connect a LinkedIn company page', diff --git a/lang/en/onboarding.php b/lang/en/onboarding.php index c6434d08..071288cf 100644 --- a/lang/en/onboarding.php +++ b/lang/en/onboarding.php @@ -6,7 +6,6 @@ 'title' => 'Welcome to TryPost', 'description' => 'Tell us what best describes you so we can tailor your experience.', 'continue' => 'Continue', - 'trial_note' => 'Try it free for 7 days, no commitment. You won\'t be charged during your trial and can cancel anytime.', 'personas' => [ 'creator' => 'Creator', 'freelancer' => 'Freelancer', @@ -15,4 +14,9 @@ 'small_business' => 'Small business', 'other' => 'Other', ], + 'connect' => [ + 'title' => 'Connect your first network', + 'description' => 'Link at least one social account to start scheduling. You can add more anytime.', + 'must_connect' => 'Connect at least one network to continue.', + ], ]; diff --git a/lang/es/accounts.php b/lang/es/accounts.php index 2f04bd40..3df54c2b 100644 --- a/lang/es/accounts.php +++ b/lang/es/accounts.php @@ -4,38 +4,16 @@ 'title' => 'Conexiones', 'page_title' => 'Cuentas Sociales', 'description' => 'Resumen de todas tus cuentas sociales conectadas', - 'add_social' => 'Agregar Red Social', - 'add_social_title' => 'Conectar una Cuenta Social', - 'add_social_description' => 'Conecta una cuenta social a TryPost para empezar a publicar', 'connect_cta' => 'Conectar', - 'no_accounts' => 'No hay cuentas conectadas todavía', - 'no_accounts_description' => 'Conecta tus redes sociales para empezar a programar y publicar posts', - 'no_search_results' => 'Ninguna cuenta coincide con tu búsqueda', - 'try_different_search' => 'Prueba otra palabra clave o limpia la búsqueda.', - 'search' => 'Buscar cuentas...', - 'added' => 'Agregada :date', 'not_connected' => 'No conectado', 'connect' => 'Conectar', 'connection_lost' => 'Conexión perdida', + 'reconnect' => 'Reconectar', 'reconnect_account' => 'Reconectar cuenta', 'view_profile' => 'Ver perfil', 'disconnect' => 'Desconectar', - 'table' => [ - 'account' => 'Cuenta', - 'platform' => 'Plataforma', - 'status' => 'Estado', - 'last_used' => 'Último uso', - 'added' => 'Añadida', - 'active' => 'Activa', - ], - 'never_used' => 'Nunca usada', - 'status' => [ - 'connected' => 'Conectada', - 'disconnected' => 'Desconectada', - ], - 'descriptions' => [ 'linkedin' => 'Conecta tu perfil personal de LinkedIn', 'linkedin-page' => 'Conecta una página de empresa de LinkedIn', diff --git a/lang/es/onboarding.php b/lang/es/onboarding.php index 96d2d269..eed12c9c 100644 --- a/lang/es/onboarding.php +++ b/lang/es/onboarding.php @@ -6,7 +6,6 @@ 'title' => 'Bienvenido a TryPost', 'description' => 'Cuéntanos qué te describe mejor para personalizar tu experiencia.', 'continue' => 'Continuar', - 'trial_note' => 'Pruébalo gratis por 7 días, sin compromiso. No se te cobrará durante la prueba y puedes cancelar cuando quieras.', 'personas' => [ 'creator' => 'Creador', 'freelancer' => 'Freelancer', @@ -15,4 +14,9 @@ 'small_business' => 'Pequeña empresa', 'other' => 'Otro', ], + 'connect' => [ + 'title' => 'Conecta tu primera red', + 'description' => 'Vincula al menos una cuenta social para empezar a programar. Puedes añadir más cuando quieras.', + 'must_connect' => 'Conecta al menos una red para continuar.', + ], ]; diff --git a/lang/pt-BR/accounts.php b/lang/pt-BR/accounts.php index 8701d934..71baa511 100644 --- a/lang/pt-BR/accounts.php +++ b/lang/pt-BR/accounts.php @@ -4,38 +4,16 @@ 'title' => 'Conexões', 'page_title' => 'Contas Sociais', 'description' => 'Visão geral de todas as suas contas sociais conectadas', - 'add_social' => 'Adicionar Rede Social', - 'add_social_title' => 'Conectar uma Conta Social', - 'add_social_description' => 'Conecte uma conta social ao TryPost para começar a publicar', 'connect_cta' => 'Conectar', - 'no_accounts' => 'Nenhuma conta conectada ainda', - 'no_accounts_description' => 'Conecte suas redes sociais para começar a agendar e publicar posts', - 'no_search_results' => 'Nenhuma conta corresponde à sua busca', - 'try_different_search' => 'Tente outra palavra-chave ou limpe a busca.', - 'search' => 'Buscar contas...', - 'added' => 'Adicionada :date', 'not_connected' => 'Não conectado', 'connect' => 'Conectar', 'connection_lost' => 'Conexão perdida', + 'reconnect' => 'Reconectar', 'reconnect_account' => 'Reconectar conta', 'view_profile' => 'Ver perfil', 'disconnect' => 'Desconectar', - 'table' => [ - 'account' => 'Conta', - 'platform' => 'Plataforma', - 'status' => 'Status', - 'last_used' => 'Último uso', - 'added' => 'Adicionada', - 'active' => 'Ativa', - ], - 'never_used' => 'Nunca usada', - 'status' => [ - 'connected' => 'Conectada', - 'disconnected' => 'Desconectada', - ], - 'descriptions' => [ 'linkedin' => 'Conecte seu perfil pessoal do LinkedIn', 'linkedin-page' => 'Conecte uma página de empresa do LinkedIn', diff --git a/lang/pt-BR/onboarding.php b/lang/pt-BR/onboarding.php index ee3b4e62..408270a8 100644 --- a/lang/pt-BR/onboarding.php +++ b/lang/pt-BR/onboarding.php @@ -6,7 +6,6 @@ 'title' => 'Bem-vindo ao TryPost', 'description' => 'Conte o que melhor te descreve para personalizarmos sua experiência.', 'continue' => 'Continuar', - 'trial_note' => 'Experimente grátis por 7 dias, sem compromisso. Você não será cobrado durante o teste e pode cancelar quando quiser.', 'personas' => [ 'creator' => 'Criador', 'freelancer' => 'Freelancer', @@ -15,4 +14,9 @@ 'small_business' => 'Pequena empresa', 'other' => 'Outro', ], + 'connect' => [ + 'title' => 'Conecte sua primeira rede', + 'description' => 'Vincule pelo menos uma conta social para começar a agendar. Você pode adicionar mais quando quiser.', + 'must_connect' => 'Conecte pelo menos uma rede para continuar.', + ], ]; diff --git a/resources/js/components/accounts/AddSocialDialog.vue b/resources/js/components/accounts/AddSocialDialog.vue deleted file mode 100644 index fdee97f1..00000000 --- a/resources/js/components/accounts/AddSocialDialog.vue +++ /dev/null @@ -1,208 +0,0 @@ - - - diff --git a/resources/js/components/accounts/NetworkConnectGrid.vue b/resources/js/components/accounts/NetworkConnectGrid.vue new file mode 100644 index 00000000..60834915 --- /dev/null +++ b/resources/js/components/accounts/NetworkConnectGrid.vue @@ -0,0 +1,356 @@ + + + diff --git a/resources/js/composables/useOAuthPopup.ts b/resources/js/composables/useOAuthPopup.ts index 9f6e24f3..221629aa 100644 --- a/resources/js/composables/useOAuthPopup.ts +++ b/resources/js/composables/useOAuthPopup.ts @@ -9,12 +9,20 @@ const POPUP_HEIGHT = 700; * message. The listener is wired to the calling component's lifecycle. */ export const useOAuthPopup = (onSuccess: () => void) => { - const openOAuthPopup = (platform: string) => { + const openOAuthPopup = ( + platform: string, + query?: Record, + ) => { const left = window.screenX + (window.outerWidth - POPUP_WIDTH) / 2; const top = window.screenY + (window.outerHeight - POPUP_HEIGHT) / 2; + const search = + query && Object.keys(query).length > 0 + ? `?${new URLSearchParams(query).toString()}` + : ''; + window.open( - `/connect/${platform}`, + `/connect/${platform}${search}`, 'oauth-popup', `width=${POPUP_WIDTH},height=${POPUP_HEIGHT},left=${left},top=${top},scrollbars=yes,resizable=yes`, ); diff --git a/resources/js/pages/accounts/Index.vue b/resources/js/pages/accounts/Index.vue index bc763a5c..fc0ff70c 100644 --- a/resources/js/pages/accounts/Index.vue +++ b/resources/js/pages/accounts/Index.vue @@ -1,119 +1,17 @@ diff --git a/resources/js/pages/onboarding/Connect.vue b/resources/js/pages/onboarding/Connect.vue new file mode 100644 index 00000000..df0030c2 --- /dev/null +++ b/resources/js/pages/onboarding/Connect.vue @@ -0,0 +1,78 @@ + + + diff --git a/resources/js/pages/onboarding/Index.vue b/resources/js/pages/onboarding/Index.vue index 37f6753a..1041930c 100644 --- a/resources/js/pages/onboarding/Index.vue +++ b/resources/js/pages/onboarding/Index.vue @@ -13,6 +13,7 @@ import { import { trans } from 'laravel-vue-i18n'; import type { FunctionalComponent } from 'vue'; +import { Button } from '@/components/ui/button'; import { store } from '@/routes/app/onboarding'; const props = defineProps<{ @@ -22,15 +23,19 @@ const props = defineProps<{ const form = useForm({ persona: props.selected ?? '' }); -const icons: Record = { - creator: IconUser, - freelancer: IconBriefcase, - startup: IconRocket, - agency: IconBuildingSkyscraper, - small_business: IconBuildingStore, - other: IconDots, +const personaMeta: Record = { + creator: { icon: IconUser, color: 'text-rose-600' }, + freelancer: { icon: IconBriefcase, color: 'text-amber-600' }, + startup: { icon: IconRocket, color: 'text-violet-700' }, + agency: { icon: IconBuildingSkyscraper, color: 'text-blue-700' }, + small_business: { icon: IconBuildingStore, color: 'text-emerald-600' }, + other: { icon: IconDots, color: 'text-sky-600' }, }; +const personaIcon = (value: string): FunctionalComponent => personaMeta[value]?.icon ?? IconDots; + +const personaColor = (value: string): string => personaMeta[value]?.color ?? 'text-foreground'; + const personaLabel = (value: string): string => trans(`onboarding.personas.${value}`); const select = (value: string): void => { @@ -81,7 +86,11 @@ const submit = (): void => { @click="select(persona)" > - + {{ personaLabel(persona) }} @@ -96,18 +105,16 @@ const submit = (): void => {
- -

- {{ $t('onboarding.trial_note') }} -

+
diff --git a/routes/app.php b/routes/app.php index c68bd2cf..01253dc2 100644 --- a/routes/app.php +++ b/routes/app.php @@ -58,6 +58,8 @@ Route::get('subscribe', [BillingController::class, 'subscribe'])->name('app.subscribe'); Route::get('onboarding', [OnboardingController::class, 'index'])->name('app.onboarding'); Route::post('onboarding', [OnboardingController::class, 'store'])->name('app.onboarding.store'); + Route::get('onboarding/connect', [OnboardingController::class, 'connect'])->name('app.onboarding.connect'); + Route::post('onboarding/connect', [OnboardingController::class, 'checkout'])->name('app.onboarding.checkout'); Route::get('billing/processing', [BillingController::class, 'processing'])->name('app.billing.processing'); Route::get('workspaces/create', [WorkspaceController::class, 'create'])->name('app.workspaces.create'); @@ -127,6 +129,11 @@ Route::get('connect/discord', [DiscordController::class, 'connect'])->name('app.social.discord.connect'); Route::get('accounts/discord/callback', [DiscordController::class, 'callback'])->name('app.social.discord.callback'); + + // Disconnecting must also work during onboarding (before a subscription + // exists), so it lives here rather than behind EnsureAccountReady — the + // controller still authorizes workspace ownership. + Route::delete('accounts/{account}', [SocialController::class, 'disconnect'])->name('app.accounts.disconnect'); }); // Routes that require active subscription and completed onboarding @@ -156,7 +163,6 @@ // Social Accounts Route::get('accounts', [SocialController::class, 'index'])->name('app.accounts'); - Route::delete('accounts/{account}', [SocialController::class, 'disconnect'])->name('app.accounts.disconnect'); Route::put('accounts/{account}/toggle', [SocialController::class, 'toggleActive'])->name('app.accounts.toggle'); // Analytics diff --git a/tests/Feature/Onboarding/OnboardingControllerTest.php b/tests/Feature/Onboarding/OnboardingControllerTest.php index 914ed270..ba489b97 100644 --- a/tests/Feature/Onboarding/OnboardingControllerTest.php +++ b/tests/Feature/Onboarding/OnboardingControllerTest.php @@ -8,7 +8,9 @@ use App\Jobs\PostHog\SendEvent; use App\Models\Account; use App\Models\Plan; +use App\Models\SocialAccount; use App\Models\User; +use App\Models\Workspace; use Illuminate\Support\Facades\Bus; beforeEach(function () { @@ -16,6 +18,31 @@ $this->user = User::factory()->create(); }); +/** + * Give the acting user a current workspace under their account. + */ +function onboardingWorkspace(User $user): Workspace +{ + $workspace = Workspace::factory()->create([ + 'user_id' => $user->id, + 'account_id' => $user->account_id, + ]); + + $user->update(['current_workspace_id' => $workspace->id]); + + return $workspace; +} + +function subscribeOnboardingAccount(Account $account): void +{ + $account->subscriptions()->create([ + 'type' => Account::SUBSCRIPTION_NAME, + 'stripe_id' => 'sub_'.fake()->uuid(), + 'stripe_status' => 'active', + 'stripe_price' => 'price_123', + ]); +} + test('onboarding renders the persona selection for an unsubscribed account', function () { $response = $this->actingAs($this->user)->get(route('app.onboarding')); @@ -35,12 +62,7 @@ }); test('onboarding redirects to calendar when already subscribed', function () { - $this->user->account->subscriptions()->create([ - 'type' => Account::SUBSCRIPTION_NAME, - 'stripe_id' => 'sub_'.fake()->uuid(), - 'stripe_status' => 'active', - 'stripe_price' => 'price_123', - ]); + subscribeOnboardingAccount($this->user->account); $response = $this->actingAs($this->user->fresh())->get(route('app.onboarding')); @@ -73,10 +95,102 @@ expect($this->user->fresh()->persona)->toBeNull(); }); -test('onboarding store saves the persona, mirrors to PostHog and starts monthly checkout', function () { +test('onboarding store saves the persona, mirrors to PostHog and advances to the connect step', function () { config(['services.posthog.enabled' => true, 'services.posthog.api_key' => 'phc_test']); Bus::fake(); + $response = $this->actingAs($this->user)->post(route('app.onboarding.store'), [ + 'persona' => Persona::Agency->value, + ]); + + $response->assertRedirect(route('app.onboarding.connect')); + expect($this->user->fresh()->persona)->toBe(Persona::Agency); + + Bus::assertDispatched(SendEvent::class); +}); + +test('onboarding store redirects an already-subscribed account to the calendar', function () { + subscribeOnboardingAccount($this->user->account); + + $response = $this->actingAs($this->user->fresh())->post(route('app.onboarding.store'), [ + 'persona' => Persona::Agency->value, + ]); + + $response->assertRedirect(route('app.calendar')); + expect($this->user->fresh()->persona)->toBeNull(); +}); + +test('connect renders the network grid for an unsubscribed account that picked a persona', function () { + $this->user->update(['persona' => Persona::Agency->value]); + onboardingWorkspace($this->user); + + $response = $this->actingAs($this->user->fresh())->get(route('app.onboarding.connect')); + + $response->assertOk(); + $response->assertInertia(fn ($page) => $page + ->component('onboarding/Connect') + ->has('platforms') + ->has('platforms.0.network') + ->has('accounts') + ); +}); + +test('connect lists the workspace social accounts already connected', function () { + $this->user->update(['persona' => Persona::Agency->value]); + $workspace = onboardingWorkspace($this->user); + SocialAccount::factory()->create(['workspace_id' => $workspace->id]); + + $response = $this->actingAs($this->user->fresh())->get(route('app.onboarding.connect')); + + $response->assertOk(); + $response->assertInertia(fn ($page) => $page + ->component('onboarding/Connect') + ->has('accounts', 1) + ); +}); + +test('connect redirects back to the persona step when no persona was chosen', function () { + onboardingWorkspace($this->user); + + $response = $this->actingAs($this->user->fresh())->get(route('app.onboarding.connect')); + + $response->assertRedirect(route('app.onboarding')); +}); + +test('connect redirects to calendar in self-hosted mode', function () { + config(['trypost.self_hosted' => true]); + + $response = $this->actingAs($this->user)->get(route('app.onboarding.connect')); + + $response->assertRedirect(route('app.calendar')); +}); + +test('connect redirects to calendar when already subscribed', function () { + subscribeOnboardingAccount($this->user->account); + + $response = $this->actingAs($this->user->fresh())->get(route('app.onboarding.connect')); + + $response->assertRedirect(route('app.calendar')); +}); + +test('checkout blocks and redirects back when no network is connected', function () { + $this->user->update(['persona' => Persona::Agency->value]); + onboardingWorkspace($this->user); + + $this->mock(StartSubscriptionCheckout::class) + ->shouldReceive('redirect') + ->never(); + + $response = $this->actingAs($this->user->fresh())->post(route('app.onboarding.checkout')); + + $response->assertRedirect(route('app.onboarding.connect')); +}); + +test('checkout starts monthly checkout once at least one network is connected', function () { + $this->user->update(['persona' => Persona::Agency->value]); + $workspace = onboardingWorkspace($this->user); + SocialAccount::factory()->create(['workspace_id' => $workspace->id]); + Plan::where('slug', Slug::Workspace)->firstOrFail()->update([ 'stripe_monthly_price_id' => 'price_monthly_test', 'stripe_yearly_price_id' => 'price_yearly_test', @@ -88,32 +202,31 @@ ->withArgs(fn (Account $account, string $priceId, string $cancelUrl): bool => $priceId === 'price_monthly_test') ->andReturn(redirect()->route('app.calendar')); - $response = $this->actingAs($this->user)->post(route('app.onboarding.store'), [ - 'persona' => Persona::Agency->value, - ]); + $response = $this->actingAs($this->user->fresh())->post(route('app.onboarding.checkout')); $response->assertRedirect(route('app.calendar')); - expect($this->user->fresh()->persona)->toBe(Persona::Agency); - - Bus::assertDispatched(SendEvent::class); }); -test('onboarding store redirects an already-subscribed account to the calendar without starting a second checkout', function () { - $this->user->account->subscriptions()->create([ - 'type' => Account::SUBSCRIPTION_NAME, - 'stripe_id' => 'sub_'.fake()->uuid(), - 'stripe_status' => 'active', - 'stripe_price' => 'price_123', - ]); +test('checkout redirects an already-subscribed account to the calendar', function () { + subscribeOnboardingAccount($this->user->account); $this->mock(StartSubscriptionCheckout::class) ->shouldReceive('redirect') ->never(); - $response = $this->actingAs($this->user->fresh())->post(route('app.onboarding.store'), [ - 'persona' => Persona::Agency->value, - ]); + $response = $this->actingAs($this->user->fresh())->post(route('app.onboarding.checkout')); + + $response->assertRedirect(route('app.calendar')); +}); + +test('checkout does nothing in self-hosted mode', function () { + config(['trypost.self_hosted' => true]); + + $this->mock(StartSubscriptionCheckout::class) + ->shouldReceive('redirect') + ->never(); + + $response = $this->actingAs($this->user)->post(route('app.onboarding.checkout')); $response->assertRedirect(route('app.calendar')); - expect($this->user->fresh()->persona)->toBeNull(); }); diff --git a/tests/Feature/SocialControllerTest.php b/tests/Feature/SocialControllerTest.php index b808b794..a0ed0a12 100644 --- a/tests/Feature/SocialControllerTest.php +++ b/tests/Feature/SocialControllerTest.php @@ -38,9 +38,23 @@ ->component('accounts/Index', false) ->has('workspace') ->has('platforms') + ->has('platforms.0.network') + ->has('connectedAccounts', 1) ); }); +test('an unsubscribed account can disconnect during onboarding (no active subscription required)', function () { + config(['trypost.self_hosted' => false]); + + $account = SocialAccount::factory()->create(['workspace_id' => $this->workspace->id]); + + $response = $this->actingAs($this->user)->delete(route('app.accounts.disconnect', $account)); + + $response->assertRedirect(); + $response->assertSessionMissing('errors'); + expect(SocialAccount::find($account->id))->toBeNull(); +}); + test('accounts index redirects if no workspace', function () { $this->user->update(['current_workspace_id' => null]); diff --git a/tests/Feature/WorkspaceBillingTest.php b/tests/Feature/WorkspaceBillingTest.php index 9cc31c49..5ca5ab89 100644 --- a/tests/Feature/WorkspaceBillingTest.php +++ b/tests/Feature/WorkspaceBillingTest.php @@ -143,6 +143,6 @@ 'name' => 'Second workspace', ]); - $response->assertRedirect(route('app.accounts', ['openDialog' => 'true'])); + $response->assertRedirect(route('app.accounts')); expect($this->account->workspaces()->count())->toBe(2); }); diff --git a/tests/Feature/WorkspaceControllerTest.php b/tests/Feature/WorkspaceControllerTest.php index 85d1f1ed..b568d932 100644 --- a/tests/Feature/WorkspaceControllerTest.php +++ b/tests/Feature/WorkspaceControllerTest.php @@ -89,7 +89,7 @@ 'name' => 'New Workspace', ]); - $response->assertRedirect(route('app.accounts', ['openDialog' => 'true'])); + $response->assertRedirect(route('app.accounts')); $this->assertDatabaseHas('workspaces', [ 'name' => 'New Workspace', @@ -104,7 +104,7 @@ 'name' => 'Second Workspace', ]); - $response->assertRedirect(route('app.accounts', ['openDialog' => 'true'])); + $response->assertRedirect(route('app.accounts')); $this->assertDatabaseHas('workspaces', [ 'name' => 'Second Workspace', @@ -566,7 +566,7 @@ }); // Brand-aware store tests -test('store persists brand fields and redirects to /accounts with openDialog flag', function () { +test('store persists brand fields and redirects to /accounts', function () { $account = Account::factory()->create(); $user = User::factory()->create(['account_id' => $account->id]); $account->update(['owner_id' => $user->id]); @@ -586,7 +586,7 @@ 'content_language' => 'en', ]); - $response->assertRedirect(route('app.accounts', ['openDialog' => 'true'])); + $response->assertRedirect(route('app.accounts')); $workspace = Workspace::where('name', 'Acme Inc')->sole(); expect($workspace->name)->toBe('Acme Inc'); @@ -594,7 +594,7 @@ expect($workspace->brand_description)->toBe('We sell rockets.'); }); -test('store redirects additional workspace to /accounts with openDialog flag', function () { +test('store redirects additional workspace to /accounts', function () { $account = Account::factory()->create(); $user = User::factory()->create(['account_id' => $account->id]); $account->update(['owner_id' => $user->id]); @@ -614,7 +614,7 @@ 'name' => 'Second Workspace', ]); - $response->assertRedirect(route('app.accounts', ['openDialog' => 'true'])); + $response->assertRedirect(route('app.accounts')); expect(Workspace::where('account_id', $account->id)->count())->toBe(2); });