feat(onboarding): add "where you found us" referral-source step

Adds a single-select referral-source step between the goals and connect
steps of onboarding. The choice is stored on users.referral_source and
mirrored to PostHog, mirroring the existing persona and goals steps.

- ReferralSource enum (12 sources) + nullable users.referral_source column
- referralSource()/storeReferralSource() controller actions with the same
  self-hosted, subscribed, persona and goals guards as the sibling steps
- connect() now requires a referral source before rendering
- Single-select ReferralSource.vue page mirroring the goals step
- Localized across all 15 locales
This commit is contained in:
Paulo Castellano 2026-07-18 16:07:11 -03:00
parent 7b37190358
commit a78f5a96ae
23 changed files with 668 additions and 12 deletions

View file

@ -0,0 +1,21 @@
<?php
declare(strict_types=1);
namespace App\Enums\User;
enum ReferralSource: string
{
case Google = 'google';
case X = 'x';
case LinkedIn = 'linkedin';
case YouTube = 'youtube';
case TikTok = 'tiktok';
case Instagram = 'instagram';
case Reddit = 'reddit';
case ProductHunt = 'product_hunt';
case AiAssistant = 'ai_assistant';
case Friend = 'friend';
case Blog = 'blog';
case Other = 'other';
}

View file

@ -9,7 +9,9 @@
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\Onboarding\StoreOnboardingGoalsRequest;
use App\Http\Requests\App\Onboarding\StoreOnboardingReferralSourceRequest;
use App\Http\Requests\App\Onboarding\StoreOnboardingRequest;
use App\Http\Resources\App\SocialAccountResource;
use App\Models\Account;
@ -110,6 +112,63 @@ public function storeGoals(StoreOnboardingGoalsRequest $request, PostHogService
'goals' => $goals,
]);
return redirect()->route('app.onboarding.referral-source');
}
public function referralSource(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');
}
if (! $user->goals) {
return redirect()->route('app.onboarding.goals');
}
return Inertia::render('onboarding/ReferralSource', [
'sources' => array_map(fn (ReferralSource $source): string => $source->value, ReferralSource::cases()),
'selected' => $user->referral_source?->value,
]);
}
public function storeReferralSource(StoreOnboardingReferralSourceRequest $request, PostHogService $postHog): RedirectResponse
{
if (config('trypost.self_hosted')) {
return redirect()->route('app.calendar');
}
$user = $request->user();
if ($user->account?->subscribed(Account::SUBSCRIPTION_NAME)) {
return redirect()->route('app.calendar');
}
if (! $user->persona) {
return redirect()->route('app.onboarding');
}
if (! $user->goals) {
return redirect()->route('app.onboarding.goals');
}
$referralSource = (string) $request->validated('referral_source');
$user->update(['referral_source' => $referralSource]);
$postHog->identify($user->id, [
'referral_source' => $referralSource,
]);
return redirect()->route('app.onboarding.connect');
}
@ -133,6 +192,10 @@ public function connect(Request $request): Response|RedirectResponse
return redirect()->route('app.onboarding.goals');
}
if (! $user->referral_source) {
return redirect()->route('app.onboarding.referral-source');
}
$workspace = $user->currentWorkspace;
if (! $workspace) {

View file

@ -0,0 +1,27 @@
<?php
declare(strict_types=1);
namespace App\Http\Requests\App\Onboarding;
use App\Enums\User\ReferralSource;
use Illuminate\Foundation\Http\FormRequest;
use Illuminate\Validation\Rule;
class StoreOnboardingReferralSourceRequest extends FormRequest
{
public function authorize(): bool
{
return true;
}
/**
* @return array<string, mixed>
*/
public function rules(): array
{
return [
'referral_source' => ['required', Rule::enum(ReferralSource::class)],
];
}
}

View file

@ -6,6 +6,7 @@
use App\Enums\Notification\Type as NotificationType;
use App\Enums\User\Persona;
use App\Enums\User\ReferralSource;
use App\Models\Traits\HasMedia;
use App\Models\Traits\HasWorkspace;
use Database\Factories\UserFactory;
@ -45,6 +46,7 @@ class User extends Authenticatable implements MustVerifyEmail, OAuthenticatable
'registration_ip',
'persona',
'goals',
'referral_source',
];
/**
@ -80,6 +82,7 @@ protected function casts(): array
'two_factor_confirmed_at' => 'datetime',
'persona' => Persona::class,
'goals' => 'array',
'referral_source' => ReferralSource::class,
];
}

View file

@ -0,0 +1,28 @@
<?php
use Illuminate\Database\Migrations\Migration;
use Illuminate\Database\Schema\Blueprint;
use Illuminate\Support\Facades\Schema;
return new class extends Migration
{
/**
* Run the migrations.
*/
public function up(): void
{
Schema::table('users', function (Blueprint $table) {
$table->string('referral_source')->nullable()->after('goals');
});
}
/**
* Reverse the migrations.
*/
public function down(): void
{
Schema::table('users', function (Blueprint $table) {
$table->dropColumn('referral_source');
});
}
};

View file

@ -33,6 +33,22 @@
'just_exploring' => 'مجرد استكشاف في الوقت الحالي',
'other' => 'شيء آخر',
],
'referral_source_title' => 'كيف وجدتنا؟',
'referral_source_description' => 'يساعدنا هذا على فهم كيفية اكتشاف الأشخاص لـ TryPost.',
'referral_source' => [
'google' => 'Google أو البحث',
'x' => 'X (Twitter)',
'linkedin' => 'LinkedIn',
'youtube' => 'YouTube',
'tiktok' => 'TikTok',
'instagram' => 'Instagram أو Threads',
'reddit' => 'Reddit',
'product_hunt' => 'Product Hunt',
'ai_assistant' => 'مساعد ذكاء اصطناعي (ChatGPT، Claude…)',
'friend' => 'صديق أو زميل',
'blog' => 'مدونة أو نشرة إخبارية أو مقال',
'other' => 'شيء آخر',
],
'connect' => [
'title' => 'اربط شبكتك الأولى',
'description' => 'اربط حسابًا اجتماعيًا واحدًا على الأقل لبدء الجدولة. يمكنك إضافة المزيد في أي وقت.',

View file

@ -33,6 +33,22 @@
'just_exploring' => 'Ich schaue mich vorerst nur um',
'other' => 'Etwas anderes',
],
'referral_source_title' => 'Wie hast du uns gefunden?',
'referral_source_description' => 'Das hilft uns zu verstehen, wie Menschen TryPost entdecken.',
'referral_source' => [
'google' => 'Google oder Suche',
'x' => 'X (Twitter)',
'linkedin' => 'LinkedIn',
'youtube' => 'YouTube',
'tiktok' => 'TikTok',
'instagram' => 'Instagram oder Threads',
'reddit' => 'Reddit',
'product_hunt' => 'Product Hunt',
'ai_assistant' => 'KI-Assistent (ChatGPT, Claude…)',
'friend' => 'Freund oder Kollege',
'blog' => 'Blog, Newsletter oder Artikel',
'other' => 'Etwas anderes',
],
'connect' => [
'title' => 'Verbinde dein erstes Netzwerk',
'description' => 'Verknüpfe mindestens ein Social-Media-Konto, um mit der Planung zu beginnen. Du kannst jederzeit weitere hinzufügen.',

View file

@ -33,6 +33,22 @@
'just_exploring' => 'Απλώς εξερευνώ προς το παρόν',
'other' => 'Κάτι άλλο',
],
'referral_source_title' => 'Πώς μας βρήκατε;',
'referral_source_description' => 'Αυτό μας βοηθά να καταλάβουμε πώς οι άνθρωποι ανακαλύπτουν το TryPost.',
'referral_source' => [
'google' => 'Google ή αναζήτηση',
'x' => 'X (Twitter)',
'linkedin' => 'LinkedIn',
'youtube' => 'YouTube',
'tiktok' => 'TikTok',
'instagram' => 'Instagram ή Threads',
'reddit' => 'Reddit',
'product_hunt' => 'Product Hunt',
'ai_assistant' => 'Βοηθός AI (ChatGPT, Claude…)',
'friend' => 'Φίλος ή συνάδελφος',
'blog' => 'Blog, newsletter ή άρθρο',
'other' => 'Κάτι άλλο',
],
'connect' => [
'title' => 'Συνδέστε το πρώτο σας δίκτυο',
'description' => 'Συνδέστε τουλάχιστον έναν λογαριασμό κοινωνικού δικτύου για να ξεκινήσετε τον προγραμματισμό. Μπορείτε να προσθέσετε κι άλλους ανά πάσα στιγμή.',

View file

@ -33,6 +33,22 @@
'just_exploring' => 'Just exploring for now',
'other' => 'Something else',
],
'referral_source_title' => 'How did you find us?',
'referral_source_description' => 'This helps us understand how people discover TryPost.',
'referral_source' => [
'google' => 'Google or search',
'x' => 'X (Twitter)',
'linkedin' => 'LinkedIn',
'youtube' => 'YouTube',
'tiktok' => 'TikTok',
'instagram' => 'Instagram or Threads',
'reddit' => 'Reddit',
'product_hunt' => 'Product Hunt',
'ai_assistant' => 'AI assistant (ChatGPT, Claude…)',
'friend' => 'Friend or colleague',
'blog' => 'Blog, newsletter or article',
'other' => 'Something else',
],
'connect' => [
'title' => 'Connect your first network',
'description' => 'Link at least one social account to start scheduling. You can add more anytime.',

View file

@ -33,6 +33,22 @@
'just_exploring' => 'Solo estoy explorando por ahora',
'other' => 'Otra cosa',
],
'referral_source_title' => '¿Cómo nos encontraste?',
'referral_source_description' => 'Esto nos ayuda a entender cómo la gente descubre TryPost.',
'referral_source' => [
'google' => 'Google o búsqueda',
'x' => 'X (Twitter)',
'linkedin' => 'LinkedIn',
'youtube' => 'YouTube',
'tiktok' => 'TikTok',
'instagram' => 'Instagram o Threads',
'reddit' => 'Reddit',
'product_hunt' => 'Product Hunt',
'ai_assistant' => 'Asistente de IA (ChatGPT, Claude…)',
'friend' => 'Amigo o colega',
'blog' => 'Blog, newsletter o artículo',
'other' => 'Otra cosa',
],
'connect' => [
'title' => 'Conecta tu primera red',
'description' => 'Vincula al menos una cuenta social para empezar a programar. Puedes añadir más cuando quieras.',

View file

@ -33,6 +33,22 @@
'just_exploring' => 'Je découvre pour l\'instant',
'other' => 'Autre chose',
],
'referral_source_title' => 'Comment nous avez-vous connus ?',
'referral_source_description' => 'Cela nous aide à comprendre comment les gens découvrent TryPost.',
'referral_source' => [
'google' => 'Google ou recherche',
'x' => 'X (Twitter)',
'linkedin' => 'LinkedIn',
'youtube' => 'YouTube',
'tiktok' => 'TikTok',
'instagram' => 'Instagram ou Threads',
'reddit' => 'Reddit',
'product_hunt' => 'Product Hunt',
'ai_assistant' => 'Assistant IA (ChatGPT, Claude…)',
'friend' => 'Ami ou collègue',
'blog' => 'Blog, newsletter ou article',
'other' => 'Autre chose',
],
'connect' => [
'title' => 'Connectez votre premier réseau',
'description' => 'Associez au moins un compte social pour commencer à programmer. Vous pourrez en ajouter d\'autres à tout moment.',

View file

@ -33,6 +33,22 @@
'just_exploring' => 'Sto solo dando un\'occhiata',
'other' => 'Qualcos\'altro',
],
'referral_source_title' => 'Come ci hai trovato?',
'referral_source_description' => 'Questo ci aiuta a capire come le persone scoprono TryPost.',
'referral_source' => [
'google' => 'Google o ricerca',
'x' => 'X (Twitter)',
'linkedin' => 'LinkedIn',
'youtube' => 'YouTube',
'tiktok' => 'TikTok',
'instagram' => 'Instagram o Threads',
'reddit' => 'Reddit',
'product_hunt' => 'Product Hunt',
'ai_assistant' => 'Assistente IA (ChatGPT, Claude…)',
'friend' => 'Amico o collega',
'blog' => 'Blog, newsletter o articolo',
'other' => 'Qualcos\'altro',
],
'connect' => [
'title' => 'Collega la tua prima rete',
'description' => 'Collega almeno un account social per iniziare a programmare. Puoi aggiungerne altri in qualsiasi momento.',

View file

@ -33,6 +33,22 @@
'just_exploring' => '今はまだ様子を見ている',
'other' => 'その他',
],
'referral_source_title' => 'どこで私たちを知りましたか?',
'referral_source_description' => 'これは、人々がどのように TryPost を見つけるかを理解するのに役立ちます。',
'referral_source' => [
'google' => 'Google または検索',
'x' => 'X (Twitter)',
'linkedin' => 'LinkedIn',
'youtube' => 'YouTube',
'tiktok' => 'TikTok',
'instagram' => 'Instagram または Threads',
'reddit' => 'Reddit',
'product_hunt' => 'Product Hunt',
'ai_assistant' => 'AI アシスタントChatGPT、Claude など)',
'friend' => '友人または同僚',
'blog' => 'ブログ、ニュースレター、記事',
'other' => 'その他',
],
'connect' => [
'title' => '最初のネットワークを接続',
'description' => 'スケジュールを始めるには、少なくとも 1 つのソーシャルアカウントを連携してください。後からいつでも追加できます。',

View file

@ -33,6 +33,22 @@
'just_exploring' => '지금은 둘러보는 중',
'other' => '다른 것',
],
'referral_source_title' => '저희를 어떻게 알게 되셨나요?',
'referral_source_description' => '사람들이 TryPost를 어떻게 발견하는지 파악하는 데 도움이 됩니다.',
'referral_source' => [
'google' => 'Google 또는 검색',
'x' => 'X (Twitter)',
'linkedin' => 'LinkedIn',
'youtube' => 'YouTube',
'tiktok' => 'TikTok',
'instagram' => 'Instagram 또는 Threads',
'reddit' => 'Reddit',
'product_hunt' => 'Product Hunt',
'ai_assistant' => 'AI 어시스턴트 (ChatGPT, Claude 등)',
'friend' => '친구 또는 동료',
'blog' => '블로그, 뉴스레터 또는 기사',
'other' => '기타',
],
'connect' => [
'title' => '첫 네트워크 연결',
'description' => '예약을 시작하려면 소셜 계정을 하나 이상 연결하세요. 언제든지 추가할 수 있습니다.',

View file

@ -33,6 +33,22 @@
'just_exploring' => 'Voorlopig gewoon aan het verkennen',
'other' => 'Iets anders',
],
'referral_source_title' => 'Hoe heb je ons gevonden?',
'referral_source_description' => 'Dit helpt ons te begrijpen hoe mensen TryPost ontdekken.',
'referral_source' => [
'google' => 'Google of zoekmachine',
'x' => 'X (Twitter)',
'linkedin' => 'LinkedIn',
'youtube' => 'YouTube',
'tiktok' => 'TikTok',
'instagram' => 'Instagram of Threads',
'reddit' => 'Reddit',
'product_hunt' => 'Product Hunt',
'ai_assistant' => 'AI-assistent (ChatGPT, Claude…)',
'friend' => 'Vriend of collega',
'blog' => 'Blog, nieuwsbrief of artikel',
'other' => 'Iets anders',
],
'connect' => [
'title' => 'Koppel je eerste netwerk',
'description' => 'Koppel ten minste één social account om te beginnen met plannen. Je kunt er altijd meer toevoegen.',

View file

@ -33,6 +33,22 @@
'just_exploring' => 'Na razie tylko się rozglądam',
'other' => 'Coś innego',
],
'referral_source_title' => 'Jak nas znalazłeś?',
'referral_source_description' => 'To pomaga nam zrozumieć, jak ludzie odkrywają TryPost.',
'referral_source' => [
'google' => 'Google lub wyszukiwarka',
'x' => 'X (Twitter)',
'linkedin' => 'LinkedIn',
'youtube' => 'YouTube',
'tiktok' => 'TikTok',
'instagram' => 'Instagram lub Threads',
'reddit' => 'Reddit',
'product_hunt' => 'Product Hunt',
'ai_assistant' => 'Asystent AI (ChatGPT, Claude…)',
'friend' => 'Znajomy lub współpracownik',
'blog' => 'Blog, newsletter lub artykuł',
'other' => 'Coś innego',
],
'connect' => [
'title' => 'Połącz swoją pierwszą sieć',
'description' => 'Połącz co najmniej jedno konto społecznościowe, aby zacząć planować. Więcej możesz dodać w dowolnym momencie.',

View file

@ -33,6 +33,22 @@
'just_exploring' => 'Só dando uma olhada por enquanto',
'other' => 'Outra coisa',
],
'referral_source_title' => 'Como você nos encontrou?',
'referral_source_description' => 'Isso nos ajuda a entender como as pessoas descobrem o TryPost.',
'referral_source' => [
'google' => 'Google ou busca',
'x' => 'X (Twitter)',
'linkedin' => 'LinkedIn',
'youtube' => 'YouTube',
'tiktok' => 'TikTok',
'instagram' => 'Instagram ou Threads',
'reddit' => 'Reddit',
'product_hunt' => 'Product Hunt',
'ai_assistant' => 'Assistente de IA (ChatGPT, Claude…)',
'friend' => 'Amigo ou colega',
'blog' => 'Blog, newsletter ou artigo',
'other' => 'Outra coisa',
],
'connect' => [
'title' => 'Conecte sua primeira rede',
'description' => 'Vincule pelo menos uma conta social para começar a agendar. Você pode adicionar mais quando quiser.',

View file

@ -33,6 +33,22 @@
'just_exploring' => 'Пока просто знакомлюсь',
'other' => 'Что-то ещё',
],
'referral_source_title' => 'Как вы нас нашли?',
'referral_source_description' => 'Это помогает нам понять, как люди узнают о TryPost.',
'referral_source' => [
'google' => 'Google или поиск',
'x' => 'X (Twitter)',
'linkedin' => 'LinkedIn',
'youtube' => 'YouTube',
'tiktok' => 'TikTok',
'instagram' => 'Instagram или Threads',
'reddit' => 'Reddit',
'product_hunt' => 'Product Hunt',
'ai_assistant' => 'ИИ-ассистент (ChatGPT, Claude…)',
'friend' => 'Друг или коллега',
'blog' => 'Блог, рассылка или статья',
'other' => 'Что-то другое',
],
'connect' => [
'title' => 'Подключите первую сеть',
'description' => 'Привяжите хотя бы один социальный аккаунт, чтобы начать планировать. Вы можете добавить другие в любой момент.',

View file

@ -33,6 +33,22 @@
'just_exploring' => 'Şimdilik sadece keşfetmek',
'other' => 'Başka bir şey',
],
'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' => [
'google' => 'Google veya arama',
'x' => 'X (Twitter)',
'linkedin' => 'LinkedIn',
'youtube' => 'YouTube',
'tiktok' => 'TikTok',
'instagram' => 'Instagram veya Threads',
'reddit' => 'Reddit',
'product_hunt' => 'Product Hunt',
'ai_assistant' => 'Yapay zeka asistanı (ChatGPT, Claude…)',
'friend' => 'Arkadaş veya meslektaş',
'blog' => 'Blog, bülten veya makale',
'other' => 'Başka bir şey',
],
'connect' => [
'title' => 'İlk ağınızı bağlayın',
'description' => 'Zamanlamaya başlamak için en az bir sosyal hesap bağlayın. İstediğiniz zaman daha fazlasını ekleyebilirsiniz.',

View file

@ -33,6 +33,22 @@
'just_exploring' => '目前只是随便看看',
'other' => '其他需求',
],
'referral_source_title' => '您是如何找到我们的?',
'referral_source_description' => '这有助于我们了解人们是如何发现 TryPost 的。',
'referral_source' => [
'google' => 'Google 或搜索',
'x' => 'X (Twitter)',
'linkedin' => 'LinkedIn',
'youtube' => 'YouTube',
'tiktok' => 'TikTok',
'instagram' => 'Instagram 或 Threads',
'reddit' => 'Reddit',
'product_hunt' => 'Product Hunt',
'ai_assistant' => 'AI 助手ChatGPT、Claude 等)',
'friend' => '朋友或同事',
'blog' => '博客、新闻通讯或文章',
'other' => '其他',
],
'connect' => [
'title' => '连接你的第一个平台',
'description' => '至少关联一个社交账号即可开始排期。你可以随时添加更多。',

View file

@ -0,0 +1,135 @@
<script setup lang="ts">
import { Head, useForm } from '@inertiajs/vue3';
import {
IconArrowRight,
IconArticle,
IconBrandGoogle,
IconBrandInstagram,
IconBrandLinkedin,
IconBrandProducthunt,
IconBrandReddit,
IconBrandTiktok,
IconBrandX,
IconBrandYoutube,
IconCheck,
IconDots,
IconSparkles,
IconUsers,
} from '@tabler/icons-vue';
import { trans } from 'laravel-vue-i18n';
import type { FunctionalComponent } from 'vue';
import { Button } from '@/components/ui/button';
import { store } from '@/routes/app/onboarding/referral-source';
const props = defineProps<{
sources: string[];
selected?: string | null;
}>();
const form = useForm<{ referral_source: string }>({ referral_source: props.selected ?? '' });
const sourceMeta: Record<string, { icon: FunctionalComponent; color: string }> = {
google: { icon: IconBrandGoogle, color: 'text-blue-600' },
x: { icon: IconBrandX, color: 'text-foreground' },
linkedin: { icon: IconBrandLinkedin, color: 'text-sky-700' },
youtube: { icon: IconBrandYoutube, color: 'text-red-600' },
tiktok: { icon: IconBrandTiktok, color: 'text-foreground' },
instagram: { icon: IconBrandInstagram, color: 'text-fuchsia-600' },
reddit: { icon: IconBrandReddit, color: 'text-orange-600' },
product_hunt: { icon: IconBrandProducthunt, color: 'text-orange-500' },
ai_assistant: { icon: IconSparkles, color: 'text-violet-700' },
friend: { icon: IconUsers, color: 'text-emerald-600' },
blog: { icon: IconArticle, color: 'text-amber-600' },
other: { icon: IconDots, color: 'text-foreground' },
};
const sourceIcon = (value: string): FunctionalComponent => sourceMeta[value]?.icon ?? IconDots;
const sourceColor = (value: string): string => sourceMeta[value]?.color ?? 'text-foreground';
const sourceLabel = (value: string): string => trans(`onboarding.referral_source.${value}`);
const isSelected = (value: string): boolean => form.referral_source === value;
const select = (value: string): void => {
form.referral_source = value;
};
const submit = (): void => {
if (form.referral_source === '' || form.processing) {
return;
}
form.post(store.url());
};
</script>
<template>
<Head :title="$t('onboarding.referral_source_title')" />
<section class="relative min-h-screen overflow-hidden bg-background">
<div
class="pointer-events-none absolute inset-0 opacity-[0.06]"
style="background-image: radial-gradient(circle, #0a0a0a 1px, transparent 1px); background-size: 28px 28px;"
/>
<div class="pointer-events-none absolute -top-20 right-0 size-[560px] rounded-full bg-violet-200/50 blur-3xl" />
<div class="relative mx-auto flex min-h-screen max-w-3xl flex-col justify-center px-6 py-12">
<div class="mx-auto mb-10 max-w-xl space-y-3 text-center">
<h1
class="text-balance text-3xl font-normal leading-[1.1] tracking-tight text-foreground sm:text-4xl"
style="font-family: var(--font-display);"
>
{{ $t('onboarding.referral_source_title') }}
</h1>
<p class="text-balance text-base text-muted-foreground">
{{ $t('onboarding.referral_source_description') }}
</p>
</div>
<div class="grid grid-cols-1 gap-4 sm:grid-cols-2 lg:grid-cols-3">
<button
v-for="source in sources"
:key="source"
type="button"
:class="[
'relative flex cursor-pointer flex-col items-start gap-3 rounded-2xl border-2 border-foreground p-5 text-left shadow-2xs transition-shadow hover:shadow-md',
isSelected(source) ? 'bg-violet-100' : 'bg-card',
]"
@click="select(source)"
>
<span class="inline-flex size-10 items-center justify-center rounded-2xl border-2 border-foreground bg-card shadow-2xs">
<component
:is="sourceIcon(source)"
:class="[sourceColor(source), 'size-5']"
stroke-width="2.25"
/>
</span>
<span class="text-base font-bold tracking-tight text-foreground">
{{ sourceLabel(source) }}
</span>
<span
v-if="isSelected(source)"
class="absolute right-4 top-4 inline-flex size-5 items-center justify-center rounded-full border-2 border-foreground bg-foreground"
>
<IconCheck class="size-3 text-background" stroke-width="3" />
</span>
</button>
</div>
<div class="mx-auto mt-10 flex w-full max-w-sm flex-col items-center gap-3">
<Button
type="button"
size="lg"
class="w-full rounded-full"
:disabled="form.referral_source === '' || form.processing"
@click="submit"
>
{{ $t('onboarding.continue') }}
<IconArrowRight class="size-4" />
</Button>
</div>
</div>
</section>
</template>

View file

@ -61,6 +61,8 @@
Route::post('onboarding', [OnboardingController::class, 'store'])->name('app.onboarding.store');
Route::get('onboarding/goals', [OnboardingController::class, 'goals'])->name('app.onboarding.goals');
Route::post('onboarding/goals', [OnboardingController::class, 'storeGoals'])->name('app.onboarding.goals.store');
Route::get('onboarding/referral-source', [OnboardingController::class, 'referralSource'])->name('app.onboarding.referral-source');
Route::post('onboarding/referral-source', [OnboardingController::class, 'storeReferralSource'])->name('app.onboarding.referral-source.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');

View file

@ -7,6 +7,7 @@
use App\Enums\SocialAccount\Platform;
use App\Enums\User\Goal;
use App\Enums\User\Persona;
use App\Enums\User\ReferralSource;
use App\Jobs\PostHog\SendEvent;
use App\Models\Account;
use App\Models\Plan;
@ -182,11 +183,11 @@ function subscribeOnboardingAccount(Account $account): void
'goals' => [Goal::JustExploring->value, Goal::SaveTime->value],
]);
$response->assertRedirect(route('app.onboarding.connect'));
$response->assertRedirect(route('app.onboarding.referral-source'));
expect($this->user->fresh()->goals)->toBe([Goal::JustExploring->value, Goal::SaveTime->value]);
});
test('goals store saves the goals, mirrors to PostHog and advances to the connect step', function () {
test('goals store saves the goals, mirrors to PostHog and advances to the referral-source step', function () {
config(['services.posthog.enabled' => true, 'services.posthog.api_key' => 'phc_test']);
Bus::fake();
@ -196,7 +197,7 @@ function subscribeOnboardingAccount(Account $account): void
'goals' => [Goal::SaveTime->value, Goal::AiContent->value],
]);
$response->assertRedirect(route('app.onboarding.connect'));
$response->assertRedirect(route('app.onboarding.referral-source'));
expect($this->user->fresh()->goals)->toBe([Goal::SaveTime->value, Goal::AiContent->value]);
Bus::assertDispatched(SendEvent::class);
@ -212,7 +213,7 @@ function subscribeOnboardingAccount(Account $account): void
'goals' => [Goal::JustExploring->value],
]);
$response->assertRedirect(route('app.onboarding.connect'));
$response->assertRedirect(route('app.onboarding.referral-source'));
expect($this->user->fresh()->goals)->toBe([Goal::JustExploring->value]);
Bus::assertDispatched(SendEvent::class);
@ -239,6 +240,136 @@ function subscribeOnboardingAccount(Account $account): void
expect($this->user->fresh()->goals)->toBeNull();
});
test('referral source renders the source selection for an account that picked persona and goals', function () {
$this->user->update(['persona' => Persona::Agency->value, 'goals' => [Goal::SaveTime->value]]);
$response = $this->actingAs($this->user->fresh())->get(route('app.onboarding.referral-source'));
$response->assertOk();
$response->assertInertia(fn ($page) => $page
->component('onboarding/ReferralSource')
->has('sources', count(ReferralSource::cases()))
);
});
test('referral source redirects to the persona step when no persona was chosen', function () {
$response = $this->actingAs($this->user)->get(route('app.onboarding.referral-source'));
$response->assertRedirect(route('app.onboarding'));
});
test('referral source redirects to the goals step when a persona was chosen but no goals', function () {
$this->user->update(['persona' => Persona::Agency->value]);
$response = $this->actingAs($this->user->fresh())->get(route('app.onboarding.referral-source'));
$response->assertRedirect(route('app.onboarding.goals'));
});
test('referral source redirects to calendar in self-hosted mode', function () {
config(['trypost.self_hosted' => true]);
$response = $this->actingAs($this->user)->get(route('app.onboarding.referral-source'));
$response->assertRedirect(route('app.calendar'));
});
test('referral source redirects to calendar when already subscribed', function () {
subscribeOnboardingAccount($this->user->account);
$response = $this->actingAs($this->user->fresh())->get(route('app.onboarding.referral-source'));
$response->assertRedirect(route('app.calendar'));
});
test('referral source store requires a source', function () {
$this->user->update(['persona' => Persona::Agency->value, 'goals' => [Goal::SaveTime->value]]);
$response = $this->actingAs($this->user->fresh())->post(route('app.onboarding.referral-source.store'), []);
$response->assertSessionHasErrors('referral_source');
expect($this->user->fresh()->referral_source)->toBeNull();
});
test('referral source store rejects an invalid source', function () {
$this->user->update(['persona' => Persona::Agency->value, 'goals' => [Goal::SaveTime->value]]);
$response = $this->actingAs($this->user->fresh())->post(route('app.onboarding.referral-source.store'), [
'referral_source' => 'not-a-source',
]);
$response->assertSessionHasErrors('referral_source');
expect($this->user->fresh()->referral_source)->toBeNull();
});
test('referral source store saves the source, mirrors to PostHog and advances to the connect step', function () {
config(['services.posthog.enabled' => true, 'services.posthog.api_key' => 'phc_test']);
Bus::fake();
$this->user->update(['persona' => Persona::Agency->value, 'goals' => [Goal::SaveTime->value]]);
$response = $this->actingAs($this->user->fresh())->post(route('app.onboarding.referral-source.store'), [
'referral_source' => ReferralSource::ProductHunt->value,
]);
$response->assertRedirect(route('app.onboarding.connect'));
expect($this->user->fresh()->referral_source)->toBe(ReferralSource::ProductHunt);
Bus::assertDispatched(SendEvent::class);
});
test('referral source store redirects to the persona step when no persona was chosen', function () {
$response = $this->actingAs($this->user)->post(route('app.onboarding.referral-source.store'), [
'referral_source' => ReferralSource::Google->value,
]);
$response->assertRedirect(route('app.onboarding'));
expect($this->user->fresh()->referral_source)->toBeNull();
});
test('referral source store redirects to the goals step when no goals were chosen', function () {
$this->user->update(['persona' => Persona::Agency->value]);
$response = $this->actingAs($this->user->fresh())->post(route('app.onboarding.referral-source.store'), [
'referral_source' => ReferralSource::Google->value,
]);
$response->assertRedirect(route('app.onboarding.goals'));
expect($this->user->fresh()->referral_source)->toBeNull();
});
test('referral source store does nothing in self-hosted mode', function () {
config(['trypost.self_hosted' => true]);
$this->user->update(['persona' => Persona::Agency->value, 'goals' => [Goal::SaveTime->value]]);
$response = $this->actingAs($this->user->fresh())->post(route('app.onboarding.referral-source.store'), [
'referral_source' => ReferralSource::Google->value,
]);
$response->assertRedirect(route('app.calendar'));
expect($this->user->fresh()->referral_source)->toBeNull();
});
test('referral source store redirects an already-subscribed account to the calendar', function () {
subscribeOnboardingAccount($this->user->account);
$response = $this->actingAs($this->user->fresh())->post(route('app.onboarding.referral-source.store'), [
'referral_source' => ReferralSource::Google->value,
]);
$response->assertRedirect(route('app.calendar'));
expect($this->user->fresh()->referral_source)->toBeNull();
});
test('connect redirects to the referral-source step when persona and goals chosen but no source', function () {
$this->user->update(['persona' => Persona::Agency->value, 'goals' => [Goal::SaveTime->value]]);
onboardingWorkspace($this->user);
$response = $this->actingAs($this->user->fresh())->get(route('app.onboarding.connect'));
$response->assertRedirect(route('app.onboarding.referral-source'));
});
test('connect redirects to the goals step when a persona was chosen but no goals', function () {
$this->user->update(['persona' => Persona::Agency->value]);
onboardingWorkspace($this->user);
@ -249,7 +380,7 @@ function subscribeOnboardingAccount(Account $account): void
});
test('connect renders the network grid for an unsubscribed account that picked a persona', function () {
$this->user->update(['persona' => Persona::Agency->value, 'goals' => [Goal::SaveTime->value]]);
$this->user->update(['persona' => Persona::Agency->value, 'goals' => [Goal::SaveTime->value], 'referral_source' => ReferralSource::Google->value]);
onboardingWorkspace($this->user);
$response = $this->actingAs($this->user->fresh())->get(route('app.onboarding.connect'));
@ -264,7 +395,7 @@ function subscribeOnboardingAccount(Account $account): void
});
test('connect passes the workspace plan so the client can fire begin_checkout', function () {
$this->user->update(['persona' => Persona::Agency->value, 'goals' => [Goal::SaveTime->value]]);
$this->user->update(['persona' => Persona::Agency->value, 'goals' => [Goal::SaveTime->value], 'referral_source' => ReferralSource::Google->value]);
onboardingWorkspace($this->user);
$plan = Plan::where('slug', Slug::Workspace)->firstOrFail();
@ -280,7 +411,7 @@ function subscribeOnboardingAccount(Account $account): void
});
test('connect offers a single linkedin card and no standalone linkedin page card', function () {
$this->user->update(['persona' => Persona::Agency->value, 'goals' => [Goal::SaveTime->value]]);
$this->user->update(['persona' => Persona::Agency->value, 'goals' => [Goal::SaveTime->value], 'referral_source' => ReferralSource::Google->value]);
onboardingWorkspace($this->user);
$response = $this->actingAs($this->user->fresh())->get(route('app.onboarding.connect'));
@ -295,7 +426,7 @@ function subscribeOnboardingAccount(Account $account): void
});
test('connect lists the workspace social accounts already connected', function () {
$this->user->update(['persona' => Persona::Agency->value, 'goals' => [Goal::SaveTime->value]]);
$this->user->update(['persona' => Persona::Agency->value, 'goals' => [Goal::SaveTime->value], 'referral_source' => ReferralSource::Google->value]);
$workspace = onboardingWorkspace($this->user);
SocialAccount::factory()->create(['workspace_id' => $workspace->id]);
@ -391,7 +522,7 @@ function subscribeOnboardingAccount(Account $account): void
});
test('connect redirects to workspace creation when no workspace exists', function () {
$this->user->update(['persona' => Persona::Agency->value, 'goals' => [Goal::SaveTime->value]]);
$this->user->update(['persona' => Persona::Agency->value, 'goals' => [Goal::SaveTime->value], 'referral_source' => ReferralSource::Google->value]);
$response = $this->actingAs($this->user->fresh())->get(route('app.onboarding.connect'));
@ -410,12 +541,17 @@ function subscribeOnboardingAccount(Account $account): void
$this->actingAs($this->user->fresh())->post(route('app.onboarding.store'), ['persona' => Persona::Creator->value])
->assertRedirect(route('app.onboarding.goals'));
// Goals advances to connect.
// Goals advances to the referral-source step.
$this->actingAs($this->user->fresh())->post(route('app.onboarding.goals.store'), [
'goals' => [Goal::AiContent->value, Goal::SaveTime->value],
])->assertRedirect(route('app.onboarding.referral-source'));
// Referral source advances to connect.
$this->actingAs($this->user->fresh())->post(route('app.onboarding.referral-source.store'), [
'referral_source' => ReferralSource::Friend->value,
])->assertRedirect(route('app.onboarding.connect'));
// Connect renders now that persona, goals and a workspace are present.
// Connect renders now that persona, goals, a source and a workspace are present.
$this->actingAs($this->user->fresh())->get(route('app.onboarding.connect'))->assertOk();
// Checkout blocks until a network is connected.
@ -435,6 +571,7 @@ function subscribeOnboardingAccount(Account $account): void
expect($this->user->fresh()->persona)->toBe(Persona::Creator);
expect($this->user->fresh()->goals)->toBe([Goal::AiContent->value, Goal::SaveTime->value]);
expect($this->user->fresh()->referral_source)->toBe(ReferralSource::Friend);
});
test('a self-hosted user never enters the onboarding flow', function () {
@ -445,7 +582,7 @@ function subscribeOnboardingAccount(Account $account): void
$this->actingAs($this->user->fresh())->get(route('app.calendar'))->assertOk();
// Every onboarding step just bounces to the calendar.
foreach (['app.onboarding', 'app.onboarding.goals', 'app.onboarding.connect'] as $routeName) {
foreach (['app.onboarding', 'app.onboarding.goals', 'app.onboarding.referral-source', 'app.onboarding.connect'] as $routeName) {
$this->actingAs($this->user->fresh())->get(route($routeName))->assertRedirect(route('app.calendar'));
}
});