diff --git a/app/Enums/PostHog/WelcomeEvent.php b/app/Enums/PostHog/WelcomeEvent.php index 934937def..bab4a07ca 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 a2b600b23..93205f8fe 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 485e1c3a8..d4267c7a8 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 000000000..5cde55a62 --- /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 000000000..d5200fc09 --- /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 1a8f95fc2..13e5c9a6e 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 86ced557d..1f6753df8 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, - ]; - - 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); - - if (self::isEnabled()) { - $this->dispatch('capture', $payload); + 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; + } + + $this->logLocally('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 fd9e5616a..9b64c5b3c 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 e4f6f39de..4fbdf7264 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 a2801c25f..f4ad9557d 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 d83a8e377..1664e5aba 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 be2ee737b..f63779152 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 f7648989f..bbc8fddd5 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 e6f2667a3..8f34df080 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 8507a5d41..24eaac618 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 15a081213..8156ae271 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 b51349ff6..f6250a224 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 87f4bba0f..3dbb28db9 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 3e4744360..6cca5b662 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 41c419100..4dad73b90 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 74f9dc6f6..9d603e60b 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 80967c87c..cb96dab64 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 06781f02f..2d3737224 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 808b0c5f6..e2c661a06 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 314beabd4..8c2d7d897 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 375d2bb4f..24e50195b 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 c065b76a9..06e3c5f41 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 000000000..c40c6912f --- /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 cae523918..108a2b6b1 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" >