From 15f87aebe4fd504934aecd3178c09fcec635bfed Mon Sep 17 00:00:00 2001 From: Paulo Castellano Date: Mon, 17 Aug 2026 19:37:43 -0300 Subject: [PATCH] Add social connect step to welcome before Stripe (#293) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit * feat: add social connect step to welcome before Stripe checkout Ask new owners to connect a network after referral source so we can track welcome.connect in PostHog and still let them continue to checkout without a connection. Co-authored-by: Cursor * Nest welcome connect copy under a connect array. Co-authored-by: Cursor * Require a connected social account before welcome checkout. Skip is no longer allowed, and the welcome layout takes a Tailwind size so the connect grid can sit two rows of six. Co-authored-by: Cursor * Harden the welcome connect step after review. Track connect only after Stripe creates a session, restore a missing workspace before showing networks, and cover the remaining checkout and analytics cases. Co-authored-by: Cursor * Refactor social account status handling across components Updated the SocialAccountsGrid, NetworkConnectGrid, onboarding, and welcome connect components to utilize the new SocialAccountStatus enum for improved clarity and maintainability. This change replaces string literals for account statuses with the enum values, enhancing type safety and consistency throughout the application. Co-authored-by: Cursor * Refactor workspace resolution in WelcomeController and StoreWelcomeConnectRequest Updated the WelcomeController and StoreWelcomeConnectRequest to directly access the user's current workspace, simplifying the code by removing the resolveCurrentWorkspace method. This change enhances readability and maintains functionality by ensuring the current workspace is correctly utilized in the connection process. Additionally, removed outdated test cases related to workspace restoration. * Inline welcome connect PostHog platforms from the current workspace. Drop the extra helper — the grid already loads accounts the same way as onboarding and accounts. Co-authored-by: Cursor * Inline Stripe checkout into the welcome connect store. startCheckout was a one-caller wrapper; storeConnect now matches the other welcome steps. Co-authored-by: Cursor * Move welcome connect validation into the controller. The FormRequest had no input to validate and duplicated step-gating. Require a connected account in storeConnect, and drop the dead owner abort plus the always-true PostHog connected flag. Co-authored-by: Cursor * Show welcome toasts and cover remaining connect cases. Mount the app Toast host on WelcomeLayout so OAuth, Telegram, and disconnect feedback is visible. Add tests for stale goals, an empty workspace grid, accounts on another workspace, and skipped identify when Stripe fails. Co-authored-by: Cursor * Assume a welcome workspace, validate connect in the FormRequest, and add browser tests. Co-authored-by: Cursor * Rename WelcomeEvent::dashboardFunnel() to funnel(). Co-authored-by: Cursor * Identify connected platforms from the social account observer. Co-authored-by: Cursor * Queue connected-platform identify on the posthog queue. Co-authored-by: Cursor * Harden welcome connect: 404 without a workspace, and keep step redirects ahead of connect validation. Co-authored-by: Cursor * Identify connected platforms on workspace and account groups, and keep the account union on the owner. Co-authored-by: Cursor * Share hasCurrentGoals on User and keep Stripe checkout when PostHog capture fails. Co-authored-by: Cursor * Skip welcome connect validation when the controller would redirect the user away. Co-authored-by: Cursor * Move current-goal membership onto the Goal enum. Co-authored-by: Cursor --------- Co-authored-by: Cursor --- app/Enums/PostHog/WelcomeEvent.php | 17 + app/Enums/User/Goal.php | 18 + .../Controllers/App/WelcomeController.php | 104 +++-- .../Welcome/StoreWelcomeConnectRequest.php | 70 +++ .../PostHog/IdentifyConnectedPlatforms.php | 87 ++++ app/Observers/SocialAccountObserver.php | 12 + app/Services/PostHogService.php | 75 ++-- lang/ar/welcome.php | 5 + lang/de/welcome.php | 5 + lang/el/welcome.php | 5 + lang/en/welcome.php | 5 + lang/es/welcome.php | 5 + lang/fr/welcome.php | 5 + lang/it/welcome.php | 5 + lang/ja/welcome.php | 5 + lang/ko/welcome.php | 5 + lang/nl/welcome.php | 5 + lang/pl/welcome.php | 5 + lang/pt-BR/welcome.php | 5 + lang/ru/welcome.php | 5 + lang/tr/welcome.php | 5 + lang/uk/welcome.php | 5 + lang/zh/welcome.php | 5 + .../js/components/SocialAccountsGrid.vue | 9 +- .../accounts/NetworkConnectGrid.vue | 9 +- resources/js/layouts/WelcomeLayout.vue | 48 ++- resources/js/pages/onboarding/Index.vue | 5 +- resources/js/pages/welcome/Connect.vue | 73 ++++ resources/js/pages/welcome/Goals.vue | 2 +- resources/js/pages/welcome/Persona.vue | 2 +- resources/js/pages/welcome/ReferralSource.vue | 6 +- resources/js/types/social-account-status.ts | 8 + routes/app.php | 6 +- tests/Browser/WelcomeConnectTest.php | 120 ++++++ .../IdentifyConnectedPlatformsTest.php | 143 ++++++ .../Observers/SocialAccountObserverTest.php | 59 +++ .../Feature/Welcome/WelcomeControllerTest.php | 406 ++++++++++++++++-- tests/Unit/Enums/GoalTest.php | 15 + tests/Unit/Enums/WelcomeEventTest.php | 16 + 39 files changed, 1255 insertions(+), 135 deletions(-) create mode 100644 app/Http/Requests/App/Welcome/StoreWelcomeConnectRequest.php create mode 100644 app/Jobs/PostHog/IdentifyConnectedPlatforms.php create mode 100644 resources/js/pages/welcome/Connect.vue create mode 100644 resources/js/types/social-account-status.ts create mode 100644 tests/Browser/WelcomeConnectTest.php create mode 100644 tests/Feature/Jobs/PostHog/IdentifyConnectedPlatformsTest.php create mode 100644 tests/Unit/Enums/GoalTest.php create mode 100644 tests/Unit/Enums/WelcomeEventTest.php diff --git a/app/Enums/PostHog/WelcomeEvent.php b/app/Enums/PostHog/WelcomeEvent.php index 934937de..bab4a07c 100644 --- a/app/Enums/PostHog/WelcomeEvent.php +++ b/app/Enums/PostHog/WelcomeEvent.php @@ -9,4 +9,21 @@ enum WelcomeEvent: string case Persona = 'welcome.persona'; case Goals = 'welcome.goals'; case Referral = 'welcome.referral'; + case Connect = 'welcome.connect'; + + /** + * Welcome capture order through Stripe Checkout. + * + * @return list + */ + public static function funnel(): array + { + return [ + self::Persona->value, + self::Goals->value, + self::Referral->value, + self::Connect->value, + CheckoutEvent::Started->value, + ]; + } } diff --git a/app/Enums/User/Goal.php b/app/Enums/User/Goal.php index a2b600b2..93205f8f 100644 --- a/app/Enums/User/Goal.php +++ b/app/Enums/User/Goal.php @@ -16,4 +16,22 @@ enum Goal: string case ManageClients = 'manage_clients'; case JustExploring = 'just_exploring'; case Other = 'other'; + + /** + * True when at least one stored goal still exists as a Goal case. + * Dropped values must not count — users mid-funnel would otherwise + * skip re-selecting after we slim the list. + * + * @param list|null $goals + */ + public static function containsCurrent(?array $goals): bool + { + if (! is_array($goals) || $goals === []) { + return false; + } + + $allowed = array_map(fn (self $goal): string => $goal->value, self::cases()); + + return array_intersect($goals, $allowed) !== []; + } } diff --git a/app/Http/Controllers/App/WelcomeController.php b/app/Http/Controllers/App/WelcomeController.php index 485e1c3a..d4267c7a 100644 --- a/app/Http/Controllers/App/WelcomeController.php +++ b/app/Http/Controllers/App/WelcomeController.php @@ -8,20 +8,23 @@ use App\Enums\Plan\Slug; use App\Enums\PostHog\CheckoutEvent; use App\Enums\PostHog\WelcomeEvent; +use App\Enums\SocialAccount\Platform as SocialPlatform; use App\Enums\User\Goal; use App\Enums\User\Persona; use App\Enums\User\ReferralSource; +use App\Http\Requests\App\Welcome\StoreWelcomeConnectRequest; use App\Http\Requests\App\Welcome\StoreWelcomeGoalsRequest; use App\Http\Requests\App\Welcome\StoreWelcomePersonaRequest; use App\Http\Requests\App\Welcome\StoreWelcomeReferralSourceRequest; +use App\Http\Resources\App\SocialAccountResource; use App\Models\Plan; -use App\Models\User; use App\Services\PostHogService; use Illuminate\Http\RedirectResponse; use Illuminate\Http\Request; use Inertia\Inertia; use Inertia\Response as InertiaResponse; use Symfony\Component\HttpFoundation\Response; +use Throwable; class WelcomeController extends Controller { @@ -106,31 +109,22 @@ public function referralSource(Request $request): InertiaResponse|RedirectRespon } $user = $request->user(); - $plan = Plan::where('slug', Slug::Workspace)->firstOrFail(); return Inertia::render('welcome/ReferralSource', [ 'sources' => array_map(fn (ReferralSource $source): string => $source->value, ReferralSource::cases()), 'selected' => $user->referral_source?->value, - 'plan' => [ - 'name' => $plan->name, - 'interval' => 'monthly', - ], ]); } public function storeReferralSource( StoreWelcomeReferralSourceRequest $request, - StartSubscriptionCheckout $checkout, PostHogService $postHog, - ): Response|RedirectResponse { + ): RedirectResponse { if ($redirect = $this->redirectIfStepIncomplete($request, requireGoals: true)) { return $redirect; } $user = $request->user(); - - abort_unless($user->isAccountOwner(), Response::HTTP_FORBIDDEN); - $referralSource = (string) $request->validated('referral_source'); $user->update(['referral_source' => $referralSource]); @@ -145,6 +139,41 @@ public function storeReferralSource( $user->account, ); + return redirect()->route('app.welcome.connect'); + } + + public function connect(Request $request): InertiaResponse|RedirectResponse + { + if ($redirect = $this->redirectIfStepIncomplete($request, requireGoals: true, requireReferral: true)) { + return $redirect; + } + + $workspace = $request->user()->currentWorkspace; + + abort_unless($workspace !== null, Response::HTTP_NOT_FOUND); + + return Inertia::render('welcome/Connect', [ + 'platforms' => SocialPlatform::connectableOptions(), + 'accounts' => SocialAccountResource::collection( + $workspace->socialAccounts()->orderBy('id')->get(), + )->resolve(), + ]); + } + + public function storeConnect( + StoreWelcomeConnectRequest $request, + StartSubscriptionCheckout $checkout, + PostHogService $postHog, + ): Response|RedirectResponse { + if ($redirect = $this->redirectIfStepIncomplete($request, requireGoals: true, requireReferral: true)) { + return $redirect; + } + + abort_unless($request->user()->currentWorkspace !== null, Response::HTTP_NOT_FOUND); + + $user = $request->user(); + $platforms = $request->connectedPlatforms(); + $plan = Plan::where('slug', Slug::Workspace)->firstOrFail(); $priceId = $plan->stripe_monthly_price_id; @@ -153,15 +182,25 @@ public function storeReferralSource( $response = $checkout->redirect( $user->account, $priceId, - route('app.welcome.referral-source'), + route('app.welcome.connect'), ); - $postHog->capture( - $user->id, - CheckoutEvent::Started->value, - ['plan_name' => $plan->name, 'interval' => 'monthly'], - $user->account, - ); + try { + $postHog->capture( + $user->id, + WelcomeEvent::Connect->value, + ['platforms' => $platforms], + $user->account, + ); + $postHog->capture( + $user->id, + CheckoutEvent::Started->value, + ['plan_name' => $plan->name, 'interval' => 'monthly'], + $user->account, + ); + } catch (Throwable $e) { + report($e); + } return $response; } @@ -183,8 +222,11 @@ public function subscriptionRequired(Request $request): InertiaResponse|Redirect ]); } - private function redirectIfStepIncomplete(Request $request, bool $requireGoals = false): ?RedirectResponse - { + private function redirectIfStepIncomplete( + Request $request, + bool $requireGoals = false, + bool $requireReferral = false, + ): ?RedirectResponse { if ($redirect = $this->redirectIfUnavailable($request)) { return $redirect; } @@ -195,29 +237,15 @@ private function redirectIfStepIncomplete(Request $request, bool $requireGoals = return redirect()->route('app.welcome.persona'); } - if ($requireGoals && ! $this->hasCurrentGoals($user)) { + if ($requireGoals && ! Goal::containsCurrent($user->goals)) { return redirect()->route('app.welcome.goals'); } - return null; - } - - /** - * True when the user has at least one goal that still exists in Goal. - * Dropped enum values must not satisfy the gate or users mid-funnel can - * skip re-selecting after we slim the list. - */ - private function hasCurrentGoals(User $user): bool - { - $goals = $user->goals; - - if (! is_array($goals) || $goals === []) { - return false; + if ($requireReferral && ! $user->referral_source) { + return redirect()->route('app.welcome.referral-source'); } - $allowed = array_map(fn (Goal $goal): string => $goal->value, Goal::cases()); - - return array_intersect($goals, $allowed) !== []; + return null; } private function redirectIfUnavailable(Request $request): ?RedirectResponse diff --git a/app/Http/Requests/App/Welcome/StoreWelcomeConnectRequest.php b/app/Http/Requests/App/Welcome/StoreWelcomeConnectRequest.php new file mode 100644 index 00000000..5cde55a6 --- /dev/null +++ b/app/Http/Requests/App/Welcome/StoreWelcomeConnectRequest.php @@ -0,0 +1,70 @@ +|null + */ + private ?array $connectedPlatforms = null; + + public function authorize(): bool + { + return true; + } + + /** + * @return array + */ + public function rules(): array + { + return []; + } + + /** + * @return list + */ + public function connectedPlatforms(): array + { + return $this->connectedPlatforms ??= $this->user()->currentWorkspace->socialAccounts() + ->where('status', Status::Connected) + ->orderBy('id') + ->get() + ->map(fn (SocialAccount $account): string => $account->platform->value) + ->unique() + ->values() + ->all(); + } + + public function withValidator(Validator $validator): void + { + $validator->after(function (Validator $validator): void { + $user = $this->user(); + + if ($user->currentWorkspace === null) { + return; + } + + if ($user->account?->hasAppAccess() || ! $user->isAccountOwner()) { + return; + } + + if (! $user->persona || ! Goal::containsCurrent($user->goals) || ! $user->referral_source) { + return; + } + + if ($this->connectedPlatforms() === []) { + $validator->errors()->add('connect', __('welcome.connect.required')); + } + }); + } +} diff --git a/app/Jobs/PostHog/IdentifyConnectedPlatforms.php b/app/Jobs/PostHog/IdentifyConnectedPlatforms.php new file mode 100644 index 00000000..d5200fc0 --- /dev/null +++ b/app/Jobs/PostHog/IdentifyConnectedPlatforms.php @@ -0,0 +1,87 @@ +onQueue('posthog'); + } + + public function handle(PostHogService $postHog): void + { + if (! PostHogService::isEnabled()) { + return; + } + + $workspace = Workspace::query() + ->with('account.owner') + ->find($this->workspaceId); + + $account = $workspace?->account; + + if ($account === null) { + return; + } + + $workspacePlatforms = $this->connectedPlatformSlugs( + SocialAccount::query()->where('workspace_id', $workspace->id), + ); + $accountPlatforms = $this->connectedPlatformSlugs( + SocialAccount::query()->whereIn('workspace_id', $account->workspaces()->select('id')), + ); + + $postHog->groupIdentify('workspace', (string) $workspace->id, [ + 'connected_platforms' => $workspacePlatforms, + ]); + $postHog->groupIdentify('account', (string) $account->id, [ + 'connected_platforms' => $accountPlatforms, + ]); + + $owner = $account->owner; + + if ($owner === null) { + return; + } + + $postHog->identify($owner->id, [ + 'connected_platforms' => $accountPlatforms, + ]); + } + + /** + * @param Builder $query + * @return list + */ + private function connectedPlatformSlugs(Builder $query): array + { + return $query + ->where('status', Status::Connected) + ->orderBy('id') + ->get() + ->map(fn (SocialAccount $account): string => $account->platform->value) + ->unique() + ->values() + ->all(); + } +} diff --git a/app/Observers/SocialAccountObserver.php b/app/Observers/SocialAccountObserver.php index 1a8f95fc..13e5c9a6 100644 --- a/app/Observers/SocialAccountObserver.php +++ b/app/Observers/SocialAccountObserver.php @@ -8,6 +8,7 @@ use App\Enums\SocialAccount\Status; use App\Events\OnboardingStatusUpdated; use App\Exceptions\SocialAccount\NetworkAlreadyConnectedException; +use App\Jobs\PostHog\IdentifyConnectedPlatforms; use App\Jobs\PostHog\SyncAccountUsage; use App\Models\SocialAccount; use App\Services\PostHogService; @@ -57,6 +58,7 @@ public function updated(SocialAccount $socialAccount): void $isConnected = $socialAccount->status === Status::Connected; if ($wasConnected !== $isConnected) { + $this->identifyConnectedPlatforms($socialAccount); $this->notifyOnboarding($socialAccount); } } @@ -64,12 +66,22 @@ public function updated(SocialAccount $socialAccount): void private function syncUsageAndOnboarding(SocialAccount $socialAccount): void { $this->syncUsage($socialAccount); + $this->identifyConnectedPlatforms($socialAccount); if ($socialAccount->status === Status::Connected) { $this->notifyOnboarding($socialAccount); } } + private function identifyConnectedPlatforms(SocialAccount $socialAccount): void + { + if (! PostHogService::isEnabled()) { + return; + } + + IdentifyConnectedPlatforms::dispatch((string) $socialAccount->workspace_id); + } + /** * First usable connect / last disconnect for the account. * Actor-less → syncAndNotify falls back to the account owner. diff --git a/app/Services/PostHogService.php b/app/Services/PostHogService.php index 86ced557..1f6753df 100644 --- a/app/Services/PostHogService.php +++ b/app/Services/PostHogService.php @@ -33,22 +33,29 @@ public static function shouldTrack(): bool */ public function capture(string $distinctId, string $event, array $properties = [], ?Account $account = null): void { - $payload = [ - 'distinctId' => $distinctId, - 'event' => $event, - 'properties' => $properties, - ]; + try { + $payload = [ + 'distinctId' => $distinctId, + 'event' => $event, + 'properties' => $properties, + ]; - if ($account) { - $payload['properties']['$groups'] = ['account' => (string) $account->id]; - $payload['properties']['account_id'] = (string) $account->id; - $payload['properties']['plan'] = $account->plan?->name; - } + if ($account) { + $payload['properties']['$groups'] = ['account' => (string) $account->id]; + $payload['properties']['account_id'] = (string) $account->id; + $payload['properties']['plan'] = $account->plan?->name; + } - $this->logLocally('capture', $payload); + $this->logLocally('capture', $payload); - if (self::isEnabled()) { - $this->dispatch('capture', $payload); + if (self::isEnabled()) { + $this->dispatch('capture', $payload); + } + } catch (Throwable $e) { + Log::warning('PostHogService: failed to capture event', [ + 'event' => $event, + 'error' => $e->getMessage(), + ]); } } @@ -57,15 +64,21 @@ public function capture(string $distinctId, string $event, array $properties = [ */ public function identify(string $distinctId, array $properties = []): void { - $payload = [ - 'distinctId' => $distinctId, - 'properties' => $properties, - ]; + try { + $payload = [ + 'distinctId' => $distinctId, + 'properties' => $properties, + ]; - $this->logLocally('identify', $payload); + $this->logLocally('identify', $payload); - if (self::isEnabled()) { - $this->dispatch('identify', $payload); + if (self::isEnabled()) { + $this->dispatch('identify', $payload); + } + } catch (Throwable $e) { + Log::warning('PostHogService: failed to identify', [ + 'error' => $e->getMessage(), + ]); } } @@ -74,16 +87,22 @@ public function identify(string $distinctId, array $properties = []): void */ public function groupIdentify(string $groupType, string $groupKey, array $properties = []): void { - $payload = [ - 'groupType' => $groupType, - 'groupKey' => $groupKey, - 'properties' => $properties, - ]; + try { + $payload = [ + 'groupType' => $groupType, + 'groupKey' => $groupKey, + 'properties' => $properties, + ]; - $this->logLocally('groupIdentify', $payload); + $this->logLocally('groupIdentify', $payload); - if (self::isEnabled()) { - $this->dispatch('groupIdentify', $payload); + if (self::isEnabled()) { + $this->dispatch('groupIdentify', $payload); + } + } catch (Throwable $e) { + Log::warning('PostHogService: failed to group identify', [ + 'error' => $e->getMessage(), + ]); } } diff --git a/lang/ar/welcome.php b/lang/ar/welcome.php index fd9e5616..9b64c5b3 100644 --- a/lang/ar/welcome.php +++ b/lang/ar/welcome.php @@ -59,4 +59,9 @@ 'blog' => 'Blog / newsletter', 'other' => 'شيء آخر', ], + 'connect' => [ + 'title' => 'اربط حسابًا اجتماعيًا', + 'description' => 'اختر شبكة واحدة على الأقل يمكن لـ TryPost النشر عليها.', + 'required' => 'اربط حسابًا اجتماعيًا واحدًا على الأقل للمتابعة.', + ], ]; diff --git a/lang/de/welcome.php b/lang/de/welcome.php index e4f6f39d..4fbdf726 100644 --- a/lang/de/welcome.php +++ b/lang/de/welcome.php @@ -59,4 +59,9 @@ 'blog' => 'Blog / newsletter', 'other' => 'Etwas anderes', ], + 'connect' => [ + 'title' => 'Verbinde ein soziales Konto', + 'description' => 'Wähle mindestens ein Netzwerk, auf dem TryPost deine Inhalte veröffentlichen kann.', + 'required' => 'Verbinde mindestens ein soziales Konto, um fortzufahren.', + ], ]; diff --git a/lang/el/welcome.php b/lang/el/welcome.php index a2801c25..f4ad9557 100644 --- a/lang/el/welcome.php +++ b/lang/el/welcome.php @@ -59,4 +59,9 @@ 'blog' => 'Blog / newsletter', 'other' => 'Κάτι άλλο', ], + 'connect' => [ + 'title' => 'Σύνδεσε έναν λογαριασμό κοινωνικής δικτύωσης', + 'description' => 'Επίλεξε τουλάχιστον ένα δίκτυο όπου το TryPost μπορεί να δημοσιεύει το περιεχόμενό σου.', + 'required' => 'Σύνδεσε τουλάχιστον έναν λογαριασμό κοινωνικής δικτύωσης για να συνεχίσεις.', + ], ]; diff --git a/lang/en/welcome.php b/lang/en/welcome.php index d83a8e37..1664e5ab 100644 --- a/lang/en/welcome.php +++ b/lang/en/welcome.php @@ -59,4 +59,9 @@ 'blog' => 'Blog / newsletter', 'other' => 'Something else', ], + 'connect' => [ + 'title' => 'Connect a social account', + 'description' => 'Choose at least one network where TryPost can publish your content.', + 'required' => 'Connect at least one social account to continue.', + ], ]; diff --git a/lang/es/welcome.php b/lang/es/welcome.php index be2ee737..f6377915 100644 --- a/lang/es/welcome.php +++ b/lang/es/welcome.php @@ -59,4 +59,9 @@ 'blog' => 'Blog / newsletter', 'other' => 'Otra cosa', ], + 'connect' => [ + 'title' => 'Conecta una red social', + 'description' => 'Elige al menos una red donde TryPost pueda publicar tu contenido.', + 'required' => 'Conecta al menos una red social para continuar.', + ], ]; diff --git a/lang/fr/welcome.php b/lang/fr/welcome.php index f7648989..bbc8fddd 100644 --- a/lang/fr/welcome.php +++ b/lang/fr/welcome.php @@ -59,4 +59,9 @@ 'blog' => 'Blog / newsletter', 'other' => 'Autre chose', ], + 'connect' => [ + 'title' => 'Connectez un réseau social', + 'description' => 'Choisissez au moins un réseau sur lequel TryPost peut publier votre contenu.', + 'required' => 'Connectez au moins un réseau social pour continuer.', + ], ]; diff --git a/lang/it/welcome.php b/lang/it/welcome.php index e6f2667a..8f34df08 100644 --- a/lang/it/welcome.php +++ b/lang/it/welcome.php @@ -59,4 +59,9 @@ 'blog' => 'Blog / newsletter', 'other' => 'Qualcos\'altro', ], + 'connect' => [ + 'title' => 'Collega un account social', + 'description' => 'Scegli almeno una rete su cui TryPost può pubblicare i tuoi contenuti.', + 'required' => 'Collega almeno un account social per continuare.', + ], ]; diff --git a/lang/ja/welcome.php b/lang/ja/welcome.php index 8507a5d4..24eaac61 100644 --- a/lang/ja/welcome.php +++ b/lang/ja/welcome.php @@ -59,4 +59,9 @@ 'blog' => 'Blog / newsletter', 'other' => 'その他', ], + 'connect' => [ + 'title' => 'SNSアカウントを接続', + 'description' => 'TryPostが投稿できるネットワークを少なくとも1つ選んでください。', + 'required' => '続けるには、少なくとも1つのSNSアカウントを接続してください。', + ], ]; diff --git a/lang/ko/welcome.php b/lang/ko/welcome.php index 15a08121..8156ae27 100644 --- a/lang/ko/welcome.php +++ b/lang/ko/welcome.php @@ -59,4 +59,9 @@ 'blog' => 'Blog / newsletter', 'other' => '기타', ], + 'connect' => [ + 'title' => '소셜 계정을 연결하세요', + 'description' => 'TryPost가 콘텐츠를 게시할 네트워크를 하나 이상 선택하세요.', + 'required' => '계속하려면 소셜 계정을 하나 이상 연결하세요.', + ], ]; diff --git a/lang/nl/welcome.php b/lang/nl/welcome.php index b51349ff..f6250a22 100644 --- a/lang/nl/welcome.php +++ b/lang/nl/welcome.php @@ -59,4 +59,9 @@ 'blog' => 'Blog / newsletter', 'other' => 'Iets anders', ], + 'connect' => [ + 'title' => 'Verbind een social account', + 'description' => 'Kies minstens één netwerk waarop TryPost je content kan plaatsen.', + 'required' => 'Verbind minstens één social account om door te gaan.', + ], ]; diff --git a/lang/pl/welcome.php b/lang/pl/welcome.php index 87f4bba0..3dbb28db 100644 --- a/lang/pl/welcome.php +++ b/lang/pl/welcome.php @@ -59,4 +59,9 @@ 'blog' => 'Blog / newsletter', 'other' => 'Coś innego', ], + 'connect' => [ + 'title' => 'Połącz konto społecznościowe', + 'description' => 'Wybierz co najmniej jedną sieć, na której TryPost może publikować Twoje treści.', + 'required' => 'Połącz co najmniej jedno konto społecznościowe, aby kontynuować.', + ], ]; diff --git a/lang/pt-BR/welcome.php b/lang/pt-BR/welcome.php index 3e474436..6cca5b66 100644 --- a/lang/pt-BR/welcome.php +++ b/lang/pt-BR/welcome.php @@ -59,4 +59,9 @@ 'blog' => 'Blog / newsletter', 'other' => 'Outra coisa', ], + 'connect' => [ + 'title' => 'Conecte uma rede social', + 'description' => 'Escolha pelo menos uma rede onde o TryPost possa publicar seu conteúdo.', + 'required' => 'Conecte pelo menos uma rede social para continuar.', + ], ]; diff --git a/lang/ru/welcome.php b/lang/ru/welcome.php index 41c41910..4dad73b9 100644 --- a/lang/ru/welcome.php +++ b/lang/ru/welcome.php @@ -59,4 +59,9 @@ 'blog' => 'Blog / newsletter', 'other' => 'Что-то другое', ], + 'connect' => [ + 'title' => 'Подключите соцсеть', + 'description' => 'Выберите хотя бы одну сеть, где TryPost сможет публиковать ваш контент.', + 'required' => 'Подключите хотя бы одну соцсеть, чтобы продолжить.', + ], ]; diff --git a/lang/tr/welcome.php b/lang/tr/welcome.php index 74f9dc6f..9d603e60 100644 --- a/lang/tr/welcome.php +++ b/lang/tr/welcome.php @@ -59,4 +59,9 @@ 'blog' => 'Blog / newsletter', 'other' => 'Başka bir şey', ], + 'connect' => [ + 'title' => 'Bir sosyal hesap bağla', + 'description' => 'TryPost’un içeriğini yayınlayabileceği en az bir ağ seç.', + 'required' => 'Devam etmek için en az bir sosyal hesap bağla.', + ], ]; diff --git a/lang/uk/welcome.php b/lang/uk/welcome.php index 80967c87..cb96dab6 100644 --- a/lang/uk/welcome.php +++ b/lang/uk/welcome.php @@ -59,4 +59,9 @@ 'blog' => 'Blog / newsletter', 'other' => 'Щось інше', ], + 'connect' => [ + 'title' => 'Підключіть соцмережу', + 'description' => 'Оберіть принаймні одну мережу, де TryPost зможе публікувати ваш контент.', + 'required' => 'Підключіть принаймні одну соцмережу, щоб продовжити.', + ], ]; diff --git a/lang/zh/welcome.php b/lang/zh/welcome.php index 06781f02..2d373722 100644 --- a/lang/zh/welcome.php +++ b/lang/zh/welcome.php @@ -59,4 +59,9 @@ 'blog' => 'Blog / newsletter', 'other' => '其他', ], + 'connect' => [ + 'title' => '连接社交账号', + 'description' => '选择至少一个 TryPost 可以发布内容的平台。', + 'required' => '请至少连接一个社交账号后再继续。', + ], ]; diff --git a/resources/js/components/SocialAccountsGrid.vue b/resources/js/components/SocialAccountsGrid.vue index 808b0c5f..e2c661a0 100644 --- a/resources/js/components/SocialAccountsGrid.vue +++ b/resources/js/components/SocialAccountsGrid.vue @@ -23,6 +23,10 @@ import { getInitials } from '@/composables/useInitials'; import { useOAuthPopup } from '@/composables/useOAuthPopup'; import { getPlatformLogo } from '@/composables/usePlatformLogo'; import { toggle as toggleAccount } from '@/routes/app/accounts'; +import { + SocialAccountStatus, + type SocialAccountStatusValue, +} from '@/types/social-account-status'; export interface SocialAccount { id: string; @@ -33,7 +37,7 @@ export interface SocialAccount { display_label: string; handle_label: string; avatar_url: string; - status: 'connected' | 'disconnected' | 'token_expired' | null; + status: SocialAccountStatusValue | null; is_active: boolean; error_message: string | null; } @@ -120,7 +124,8 @@ const getProfileUrl = ( const isDisconnected = (account: SocialAccount | null): boolean => { if (!account) return false; return ( - account.status === 'disconnected' || account.status === 'token_expired' + account.status === SocialAccountStatus.Disconnected || + account.status === SocialAccountStatus.TokenExpired ); }; diff --git a/resources/js/components/accounts/NetworkConnectGrid.vue b/resources/js/components/accounts/NetworkConnectGrid.vue index 314beabd..8c2d7d89 100644 --- a/resources/js/components/accounts/NetworkConnectGrid.vue +++ b/resources/js/components/accounts/NetworkConnectGrid.vue @@ -12,6 +12,10 @@ import { Button } from '@/components/ui/button'; import { useOAuthPopup } from '@/composables/useOAuthPopup'; import { disconnect } from '@/routes/app/accounts'; import { Platform } from '@/types/platform'; +import { + SocialAccountStatus, + type SocialAccountStatusValue, +} from '@/types/social-account-status'; export interface AvailablePlatform { value: string; @@ -30,7 +34,7 @@ export interface ConnectedAccount { display_label: string; handle_label: string; avatar_url: string | null; - status: 'connected' | 'disconnected' | 'token_expired' | null; + status: SocialAccountStatusValue | null; } const props = withDefaults( @@ -177,7 +181,8 @@ const disconnectAccount = (account: ConnectedAccount) => { }; const needsReconnect = (account: ConnectedAccount): boolean => - account.status === 'disconnected' || account.status === 'token_expired'; + account.status === SocialAccountStatus.Disconnected || + account.status === SocialAccountStatus.TokenExpired; const connectEntryFor = (platformValue: string): string => platformValue === Platform.LinkedInPage ? Platform.LinkedIn : platformValue; diff --git a/resources/js/layouts/WelcomeLayout.vue b/resources/js/layouts/WelcomeLayout.vue index 375d2bb4..24e50195 100644 --- a/resources/js/layouts/WelcomeLayout.vue +++ b/resources/js/layouts/WelcomeLayout.vue @@ -2,26 +2,43 @@ import { Link } from '@inertiajs/vue3'; import { computed } from 'vue'; +import Toast from '@/components/Toast.vue'; import { + connect as connectRoute, goals as goalsRoute, persona as personaRoute, referralSource as referralSourceRoute, } from '@/routes/app/welcome'; +const maxWidthClass = { + sm: 'max-w-sm', + md: 'max-w-md', + lg: 'max-w-lg', + xl: 'max-w-xl', + '2xl': 'max-w-2xl', + '3xl': 'max-w-3xl', + '4xl': 'max-w-4xl', + '5xl': 'max-w-5xl', + '6xl': 'max-w-6xl', + '7xl': 'max-w-7xl', +} as const; + +type MaxWidthSize = keyof typeof maxWidthClass; + const props = withDefaults( defineProps<{ title?: string; description?: string; step?: number; totalSteps?: number; - wide?: boolean; + size?: MaxWidthSize; }>(), { title: undefined, description: undefined, step: undefined, - totalSteps: 3, - wide: false, + totalSteps: 4, + size: 'xl', }, ); @@ -29,6 +46,7 @@ const stepRoutes = computed(() => [ personaRoute(), goalsRoute(), referralSourceRoute(), + connectRoute(), ]); const canNavigateTo = (stepNumber: number): boolean => @@ -39,7 +57,7 @@ const canNavigateTo = (stepNumber: number): boolean =>
-
+
}) " :data-testid="`welcome-step-${stepNumber}`" + :dusk="`welcome-step-${stepNumber}`" >
}) : undefined " - /> + > + +
@@ -114,5 +139,6 @@ const canNavigateTo = (stepNumber: number): boolean =>
+
diff --git a/resources/js/pages/onboarding/Index.vue b/resources/js/pages/onboarding/Index.vue index c065b76a..06e3c5f4 100644 --- a/resources/js/pages/onboarding/Index.vue +++ b/resources/js/pages/onboarding/Index.vue @@ -16,6 +16,7 @@ import { copyToClipboard } from '@/lib/utils'; import { complete } from '@/routes/app/onboarding'; import { skip as skipMcpRoute } from '@/routes/app/onboarding/mcp'; import { create as createPost } from '@/routes/app/posts'; +import { SocialAccountStatus } from '@/types/social-account-status'; interface OnboardingStatus { mcp_connected: boolean; @@ -49,7 +50,9 @@ const maxCompleteAttempts = 3; const socialConnectedElsewhere = computed( () => props.status.social_connected && - !props.accounts.some((account) => account.status === 'connected'), + !props.accounts.some( + (account) => account.status === SocialAccountStatus.Connected, + ), ); // Keep listening until completion is stamped — all_complete alone is not enough diff --git a/resources/js/pages/welcome/Connect.vue b/resources/js/pages/welcome/Connect.vue new file mode 100644 index 00000000..c40c6912 --- /dev/null +++ b/resources/js/pages/welcome/Connect.vue @@ -0,0 +1,73 @@ + + + diff --git a/resources/js/pages/welcome/Goals.vue b/resources/js/pages/welcome/Goals.vue index cae52391..108a2b6b 100644 --- a/resources/js/pages/welcome/Goals.vue +++ b/resources/js/pages/welcome/Goals.vue @@ -133,7 +133,7 @@ const submit = (): void => { :title="$t('welcome.goals_title')" :description="$t('welcome.goals_description')" :step="2" - wide + size="4xl" >