Welcome: pre-subscription funnel and member subscription-required screen (#243)

* Rename pre-subscription onboarding funnel to Welcome.

Move the ICP steps to /welcome, drop the social-connect checkout gate, hold unpaid members on a subscription-required screen, and keep legacy /onboarding URLs working until the post-subscription checklist lands.

Closes #237

Co-authored-by: Cursor <cursoragent@cursor.com>

* Drop legacy /onboarding ICP URL aliases.

Unfinished users re-enter Welcome via EnsureAccountReady on next login; /onboarding stays free for the post-subscription checklist.

Co-authored-by: Cursor <cursoragent@cursor.com>

* Simplify Welcome PostHog event names.

Use welcome.persona/goals/referral and drop the unused checkout case — begin checkout stays on the frontend as checkout.started / begin_checkout.

Co-authored-by: Cursor <cursoragent@cursor.com>

* Slim welcome goal options to match the #204 set.

Drop team_collaboration, automate_api, and track_performance so the goals step stays at nine choices.

Co-authored-by: Cursor <cursoragent@cursor.com>

* Split Welcome AI goals into TryPost AI and MCP assistants.

Rewrite ai_content for in-app generation and add use_mcp so Claude/ChatGPT/Cursor intent is captured separately across locales.

Co-authored-by: Cursor <cursoragent@cursor.com>

* Harden Welcome goals gate and drop dead checkout UI.

Treat removed goal values as incomplete so mid-funnel users re-select, remove the unused canCheckout branch, and fix the pt-BR welcome progress label.

Co-authored-by: Cursor <cursoragent@cursor.com>

* Add password visibility toggle to the login form.

Match the register eye control so users can reveal their password while signing in.

Co-authored-by: Cursor <cursoragent@cursor.com>

* Point the sidebar community link to Discord.

Replace the X stay-updated entry with Join Discord and the trypost.it/discord invite.

Co-authored-by: Cursor <cursoragent@cursor.com>

* Rename the sidebar Discord link to Discord community.

Softer label that matches the other support nav items.

Co-authored-by: Cursor <cursoragent@cursor.com>

* Fix broken Turkish Discord community translation.

An unescaped apostrophe left a parse error in lang/tr/sidebar.php.

Co-authored-by: Cursor <cursoragent@cursor.com>

---------

Co-authored-by: Cursor <cursoragent@cursor.com>
This commit is contained in:
Paulo Castellano 2026-08-06 10:34:50 -04:00 committed by GitHub
parent 90a32659c5
commit b4f61be6ef
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
93 changed files with 2582 additions and 1514 deletions

View file

@ -0,0 +1,12 @@
<?php
declare(strict_types=1);
namespace App\Enums\PostHog;
enum WelcomeEvent: string
{
case Persona = 'welcome.persona';
case Goals = 'welcome.goals';
case Referral = 'welcome.referral';
}

View file

@ -8,14 +8,12 @@ enum Goal: string
{ {
case SaveTime = 'save_time'; case SaveTime = 'save_time';
case AiContent = 'ai_content'; case AiContent = 'ai_content';
case UseMcp = 'use_mcp';
case PlanCalendar = 'plan_calendar'; case PlanCalendar = 'plan_calendar';
case StayOnBrand = 'stay_on_brand'; case StayOnBrand = 'stay_on_brand';
case GrowAudience = 'grow_audience'; case GrowAudience = 'grow_audience';
case DriveSales = 'drive_sales'; case DriveSales = 'drive_sales';
case ManageClients = 'manage_clients'; case ManageClients = 'manage_clients';
case TeamCollaboration = 'team_collaboration';
case AutomateApi = 'automate_api';
case TrackPerformance = 'track_performance';
case JustExploring = 'just_exploring'; case JustExploring = 'just_exploring';
case Other = 'other'; case Other = 'other';
} }

View file

@ -18,7 +18,7 @@ class BillingController extends Controller
{ {
public function subscribe(): RedirectResponse public function subscribe(): RedirectResponse
{ {
return redirect()->route('app.onboarding'); return redirect()->route('app.welcome.persona');
} }
public function processing(Request $request): Response|RedirectResponse public function processing(Request $request): Response|RedirectResponse

View file

@ -1,257 +0,0 @@
<?php
declare(strict_types=1);
namespace App\Http\Controllers\App;
use App\Actions\Billing\StartSubscriptionCheckout;
use App\Enums\Plan\Slug;
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;
use App\Models\Plan;
use App\Services\PostHogService;
use Illuminate\Http\RedirectResponse;
use Illuminate\Http\Request;
use Inertia\Inertia;
use Inertia\Response;
use Symfony\Component\HttpFoundation\Response as SymfonyResponse;
class OnboardingController extends Controller
{
public function index(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');
}
return Inertia::render('onboarding/Index', [
'personas' => array_map(fn (Persona $persona): string => $persona->value, Persona::cases()),
'selected' => $user->persona?->value,
]);
}
public function store(StoreOnboardingRequest $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');
}
$persona = $request->validated('persona');
$user->update(['persona' => $persona]);
$postHog->identify($user->id, [
'persona' => $persona,
]);
return redirect()->route('app.onboarding.goals');
}
public function goals(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');
}
return Inertia::render('onboarding/Goals', [
'goals' => array_map(fn (Goal $goal): string => $goal->value, Goal::cases()),
'selected' => $user->goals ?? [],
]);
}
public function storeGoals(StoreOnboardingGoalsRequest $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');
}
$goals = array_values($request->validated('goals'));
$user->update(['goals' => $goals]);
$postHog->identify($user->id, [
'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 = $request->validated('referral_source');
$user->update(['referral_source' => $referralSource]);
$postHog->identify($user->id, [
'referral_source' => $referralSource,
]);
return redirect()->route('app.onboarding.connect');
}
public function connect(Request $request): Response|RedirectResponse
{
if (config('trypost.self_hosted')) {
return redirect()->route('app.calendar');
}
$user = $request->user();
if ($user->account?->subscribed(Account::SUBSCRIPTION_NAME)) {
return redirect()->route('app.calendar');
}
if (! $user->persona) {
return redirect()->route('app.onboarding');
}
if (! $user->goals) {
return redirect()->route('app.onboarding.goals');
}
if (! $user->referral_source) {
return redirect()->route('app.onboarding.referral-source');
}
$workspace = $user->currentWorkspace;
if (! $workspace) {
return redirect()->route('app.workspaces.create');
}
$accounts = $workspace->socialAccounts()->orderBy('id')->get();
$platforms = collect(SocialPlatform::cases())
->filter(fn (SocialPlatform $platform): bool => $platform->isConnectable())
->map(fn (SocialPlatform $platform): array => [
'value' => $platform->value,
'label' => $platform->label(),
'color' => $platform->color(),
'network' => $platform->network(),
])->values();
$plan = Plan::where('slug', Slug::Workspace)->firstOrFail();
return Inertia::render('onboarding/Connect', [
'platforms' => $platforms,
'accounts' => SocialAccountResource::collection($accounts)->resolve(),
'plan' => [
'name' => $plan->name,
'interval' => 'monthly',
],
]);
}
public function checkout(Request $request, StartSubscriptionCheckout $checkout): SymfonyResponse|RedirectResponse
{
if (config('trypost.self_hosted')) {
return redirect()->route('app.calendar');
}
$user = $request->user();
$account = $user->account;
if ($account?->subscribed(Account::SUBSCRIPTION_NAME)) {
return redirect()->route('app.calendar');
}
$workspace = $user->currentWorkspace;
if (! $workspace || ! $workspace->socialAccounts()->exists()) {
return redirect()->route('app.onboarding.connect')
->with('flash.banner', __('onboarding.connect.must_connect'))
->with('flash.bannerStyle', 'danger');
}
$plan = Plan::where('slug', Slug::Workspace)->firstOrFail();
return $checkout->redirect(
$account,
(string) $plan->stripe_monthly_price_id,
route('app.onboarding.connect'),
);
}
}

View file

@ -0,0 +1,240 @@
<?php
declare(strict_types=1);
namespace App\Http\Controllers\App;
use App\Actions\Billing\StartSubscriptionCheckout;
use App\Enums\Plan\Slug;
use App\Enums\PostHog\WelcomeEvent;
use App\Enums\User\Goal;
use App\Enums\User\Persona;
use App\Enums\User\ReferralSource;
use App\Http\Requests\App\Welcome\StoreWelcomeGoalsRequest;
use App\Http\Requests\App\Welcome\StoreWelcomePersonaRequest;
use App\Http\Requests\App\Welcome\StoreWelcomeReferralSourceRequest;
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;
class WelcomeController extends Controller
{
public function persona(Request $request): InertiaResponse|RedirectResponse
{
if ($redirect = $this->redirectIfUnavailable($request)) {
return $redirect;
}
return Inertia::render('welcome/Persona', [
'personas' => array_map(fn (Persona $persona): string => $persona->value, Persona::cases()),
'selected' => $request->user()->persona?->value,
]);
}
public function storePersona(StoreWelcomePersonaRequest $request, PostHogService $postHog): RedirectResponse
{
if ($redirect = $this->redirectIfUnavailable($request)) {
return $redirect;
}
$user = $request->user();
$persona = (string) $request->validated('persona');
$user->update(['persona' => $persona]);
$postHog->identify($user->id, [
'persona' => $persona,
]);
$postHog->capture(
$user->id,
WelcomeEvent::Persona->value,
['persona' => $persona],
$user->account,
);
return redirect()->route('app.welcome.goals');
}
public function goals(Request $request): InertiaResponse|RedirectResponse
{
if ($redirect = $this->redirectIfStepIncomplete($request)) {
return $redirect;
}
$user = $request->user();
return Inertia::render('welcome/Goals', [
'goals' => array_map(fn (Goal $goal): string => $goal->value, Goal::cases()),
'selected' => $user->goals ?? [],
]);
}
public function storeGoals(StoreWelcomeGoalsRequest $request, PostHogService $postHog): RedirectResponse
{
if ($redirect = $this->redirectIfStepIncomplete($request)) {
return $redirect;
}
$user = $request->user();
$goals = array_values($request->validated('goals'));
$user->update(['goals' => $goals]);
$postHog->identify($user->id, [
'goals' => $goals,
]);
$postHog->capture(
$user->id,
WelcomeEvent::Goals->value,
['goals' => $goals],
$user->account,
);
return redirect()->route('app.welcome.referral-source');
}
public function referralSource(Request $request): InertiaResponse|RedirectResponse
{
if ($redirect = $this->redirectIfStepIncomplete($request, requireGoals: true)) {
return $redirect;
}
$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 {
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]);
$postHog->identify($user->id, [
'referral_source' => $referralSource,
]);
$postHog->capture(
$user->id,
WelcomeEvent::Referral->value,
['referral_source' => $referralSource],
$user->account,
);
return $this->startCheckout($request, $checkout);
}
private function startCheckout(
Request $request,
StartSubscriptionCheckout $checkout,
): Response|RedirectResponse {
$user = $request->user();
$plan = Plan::where('slug', Slug::Workspace)->firstOrFail();
$priceId = $plan->stripe_monthly_price_id;
abort_if($priceId === null, Response::HTTP_INTERNAL_SERVER_ERROR, 'Monthly price is not configured.');
return $checkout->redirect(
$user->account,
$priceId,
route('app.welcome.referral-source'),
);
}
public function subscriptionRequired(Request $request): InertiaResponse|RedirectResponse
{
$user = $request->user();
if ($user->account?->hasAppAccess()) {
return redirect()->route('app.calendar');
}
if ($user->isAccountOwner()) {
return redirect()->route('app.welcome.persona');
}
return Inertia::render('welcome/SubscriptionRequired', [
'ownerName' => $user->account?->owner?->name,
]);
}
private function redirectIfStepIncomplete(Request $request, bool $requireGoals = false): ?RedirectResponse
{
if ($redirect = $this->redirectIfUnavailable($request)) {
return $redirect;
}
$user = $request->user();
if (! $user->persona) {
return redirect()->route('app.welcome.persona');
}
if ($requireGoals && ! $this->hasCurrentGoals($user)) {
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;
}
$allowed = array_map(fn (Goal $goal): string => $goal->value, Goal::cases());
return array_intersect($goals, $allowed) !== [];
}
private function redirectIfUnavailable(Request $request): ?RedirectResponse
{
$user = $request->user();
// Match EnsureAccountReady — generic-trial (no-card) users already have
// app access and must not be sent through Stripe checkout again.
// Self-hosted always has app access, so welcome/checkout is skipped too.
if ($user->account?->hasAppAccess()) {
return redirect()->route('app.calendar');
}
// Members can't check out — hold them on a dedicated screen instead of
// walking an ICP flow they can never finish.
if (! $user->isAccountOwner()) {
return redirect()->route('app.welcome.subscription-required');
}
return null;
}
}

View file

@ -25,7 +25,13 @@ public function handle(Request $request, Closure $next): Response
$account = $user->account; $account = $user->account;
if (! $account?->hasAppAccess()) { if (! $account?->hasAppAccess()) {
return redirect()->route('app.onboarding'); // Members can't finish Welcome checkout — send them straight to
// the hold screen instead of hopping through persona first.
if (! $user->isAccountOwner()) {
return redirect()->route('app.welcome.subscription-required');
}
return redirect()->route('app.welcome.persona');
} }
} }

View file

@ -2,13 +2,13 @@
declare(strict_types=1); declare(strict_types=1);
namespace App\Http\Requests\App\Onboarding; namespace App\Http\Requests\App\Welcome;
use App\Enums\User\Goal; use App\Enums\User\Goal;
use Illuminate\Foundation\Http\FormRequest; use Illuminate\Foundation\Http\FormRequest;
use Illuminate\Validation\Rule; use Illuminate\Validation\Rule;
class StoreOnboardingGoalsRequest extends FormRequest class StoreWelcomeGoalsRequest extends FormRequest
{ {
public function authorize(): bool public function authorize(): bool
{ {

View file

@ -2,13 +2,13 @@
declare(strict_types=1); declare(strict_types=1);
namespace App\Http\Requests\App\Onboarding; namespace App\Http\Requests\App\Welcome;
use App\Enums\User\Persona; use App\Enums\User\Persona;
use Illuminate\Foundation\Http\FormRequest; use Illuminate\Foundation\Http\FormRequest;
use Illuminate\Validation\Rule; use Illuminate\Validation\Rule;
class StoreOnboardingRequest extends FormRequest class StoreWelcomePersonaRequest extends FormRequest
{ {
public function authorize(): bool public function authorize(): bool
{ {

View file

@ -2,13 +2,13 @@
declare(strict_types=1); declare(strict_types=1);
namespace App\Http\Requests\App\Onboarding; namespace App\Http\Requests\App\Welcome;
use App\Enums\User\ReferralSource; use App\Enums\User\ReferralSource;
use Illuminate\Foundation\Http\FormRequest; use Illuminate\Foundation\Http\FormRequest;
use Illuminate\Validation\Rule; use Illuminate\Validation\Rule;
class StoreOnboardingReferralSourceRequest extends FormRequest class StoreWelcomeReferralSourceRequest extends FormRequest
{ {
public function authorize(): bool public function authorize(): bool
{ {

View file

@ -71,6 +71,8 @@
'page_title' => 'تسجيل الدخول', 'page_title' => 'تسجيل الدخول',
'email' => 'البريد الإلكتروني', 'email' => 'البريد الإلكتروني',
'password' => 'كلمة المرور', 'password' => 'كلمة المرور',
'show_password' => 'إظهار كلمة المرور',
'hide_password' => 'إخفاء كلمة المرور',
'forgot_password' => 'نسيت كلمة المرور؟', 'forgot_password' => 'نسيت كلمة المرور؟',
'remember_me' => 'تذكّرني', 'remember_me' => 'تذكّرني',
'submit' => 'تسجيل الدخول', 'submit' => 'تسجيل الدخول',

View file

@ -21,15 +21,13 @@
'goals_description' => 'اختر كل ما يناسبك وسنقوم بإعداد TryPost من أجلك.', 'goals_description' => 'اختر كل ما يناسبك وسنقوم بإعداد TryPost من أجلك.',
'goals' => [ 'goals' => [
'save_time' => 'توفير الوقت بالنشر في كل مكان دفعة واحدة', 'save_time' => 'توفير الوقت بالنشر في كل مكان دفعة واحدة',
'ai_content' => 'إنشاء منشورات أسرع بالذكاء الاصطناعي', 'ai_content' => 'إنشاء منشورات بذكاء TryPost الاصطناعي',
'use_mcp' => 'إنشاء منشورات عبر Claude أو ChatGPT أو Cursor',
'plan_calendar' => 'التخطيط لمنشوراتي على التقويم', 'plan_calendar' => 'التخطيط لمنشوراتي على التقويم',
'stay_on_brand' => 'الحفاظ على اتساق كل منشور مع العلامة التجارية', 'stay_on_brand' => 'الحفاظ على اتساق كل منشور مع العلامة التجارية',
'grow_audience' => 'تنمية جمهوري وزيادة التفاعل', 'grow_audience' => 'تنمية جمهوري وزيادة التفاعل',
'drive_sales' => 'الحصول على المزيد من الزيارات والمبيعات', 'drive_sales' => 'الحصول على المزيد من الزيارات والمبيعات',
'manage_clients' => 'إدارة عدة علامات تجارية أو عملاء', 'manage_clients' => 'إدارة عدة علامات تجارية أو عملاء',
'team_collaboration' => 'العمل مع فريقي',
'automate_api' => 'أتمتة النشر عبر الواجهة البرمجية أو MCP أو الكود',
'track_performance' => 'معرفة أداء منشوراتي',
'just_exploring' => 'مجرد استكشاف في الوقت الحالي', 'just_exploring' => 'مجرد استكشاف في الوقت الحالي',
'other' => 'شيء آخر', 'other' => 'شيء آخر',
], ],

View file

@ -60,6 +60,6 @@
'support' => [ 'support' => [
'docs' => 'التوثيق', 'docs' => 'التوثيق',
'referral' => 'اربح عمولة إحالة 30%', 'referral' => 'اربح عمولة إحالة 30%',
'stay_updated' => 'ابقَ على اطلاع', 'discord' => 'مجتمع Discord',
], ],
]; ];

57
lang/ar/welcome.php Normal file
View file

@ -0,0 +1,57 @@
<?php
declare(strict_types=1);
return [
'title' => 'ما الذي يصفك بشكل أفضل؟',
'description' => 'اختر الخيار الأقرب وسنخصص تجربتك.',
'continue' => 'متابعة',
'subscription_required_title' => 'في انتظار مالك الحساب',
'subscription_required_description' => 'هذا الحساب لا يملك اشتراكًا نشطًا بعد. اطلب من مالك الحساب إكمال الدفع — ستحصل على وصول كامل فور تفعيل الاشتراك.',
'subscription_required_owner' => 'مالك حسابك هو :name.',
'subscription_required_auto' => 'يتم تحديث هذه الصفحة تلقائيًا — لا حاجة لإعادة التحميل.',
'progress' => 'تقدم الترحيب',
'go_to_step' => 'الانتقال إلى الخطوة :step',
'step_current' => 'الخطوة :step (الحالية)',
'personas' => [
'creator' => 'صانع محتوى',
'freelancer' => 'مستقل',
'developer' => 'مطوّر',
'startup' => 'شركة ناشئة',
'agency' => 'وكالة',
'small_business' => 'نشاط تجاري صغير',
'marketer' => 'مسوّق',
'online_store' => 'متجر إلكتروني',
'other' => 'أخرى',
],
'goals_title' => 'ما هدفك؟',
'goals_description' => 'اختر كل ما يناسبك وسنقوم بإعداد TryPost من أجلك.',
'goals' => [
'save_time' => 'توفير الوقت بالنشر في كل مكان دفعة واحدة',
'ai_content' => 'إنشاء منشورات بذكاء TryPost الاصطناعي',
'use_mcp' => 'إنشاء منشورات عبر Claude أو ChatGPT أو Cursor',
'plan_calendar' => 'التخطيط لمنشوراتي على التقويم',
'stay_on_brand' => 'الحفاظ على اتساق كل منشور مع العلامة التجارية',
'grow_audience' => 'تنمية جمهوري وزيادة التفاعل',
'drive_sales' => 'الحصول على المزيد من الزيارات والمبيعات',
'manage_clients' => 'إدارة عدة علامات تجارية أو عملاء',
'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' => 'شيء آخر',
],
];

View file

@ -73,6 +73,8 @@
'page_title' => 'Anmelden', 'page_title' => 'Anmelden',
'email' => 'E-Mail-Adresse', 'email' => 'E-Mail-Adresse',
'password' => 'Passwort', 'password' => 'Passwort',
'show_password' => 'Passwort anzeigen',
'hide_password' => 'Passwort verbergen',
'forgot_password' => 'Passwort vergessen?', 'forgot_password' => 'Passwort vergessen?',
'remember_me' => 'Angemeldet bleiben', 'remember_me' => 'Angemeldet bleiben',
'submit' => 'Anmelden', 'submit' => 'Anmelden',

View file

@ -21,15 +21,13 @@
'goals_description' => 'Wähle alles aus, was passt, und wir richten TryPost für dich ein.', 'goals_description' => 'Wähle alles aus, was passt, und wir richten TryPost für dich ein.',
'goals' => [ 'goals' => [
'save_time' => 'Zeit sparen, indem ich überall gleichzeitig poste', 'save_time' => 'Zeit sparen, indem ich überall gleichzeitig poste',
'ai_content' => 'Beiträge schneller mit KI erstellen', 'ai_content' => 'Beiträge mit TryPost-KI erstellen',
'use_mcp' => 'Beiträge über Claude, ChatGPT oder Cursor erstellen',
'plan_calendar' => 'Meine Beiträge in einem Kalender planen', 'plan_calendar' => 'Meine Beiträge in einem Kalender planen',
'stay_on_brand' => 'Jeden Beitrag markenkonform halten', 'stay_on_brand' => 'Jeden Beitrag markenkonform halten',
'grow_audience' => 'Meine Reichweite und mein Engagement steigern', 'grow_audience' => 'Meine Reichweite und mein Engagement steigern',
'drive_sales' => 'Mehr Traffic und Verkäufe erzielen', 'drive_sales' => 'Mehr Traffic und Verkäufe erzielen',
'manage_clients' => 'Mehrere Marken oder Kunden verwalten', 'manage_clients' => 'Mehrere Marken oder Kunden verwalten',
'team_collaboration' => 'Mit meinem Team arbeiten',
'automate_api' => 'Das Posten per API, MCP oder Code automatisieren',
'track_performance' => 'Sehen, wie meine Beiträge performen',
'just_exploring' => 'Ich schaue mich vorerst nur um', 'just_exploring' => 'Ich schaue mich vorerst nur um',
'other' => 'Etwas anderes', 'other' => 'Etwas anderes',
], ],

View file

@ -60,6 +60,6 @@
'support' => [ 'support' => [
'docs' => 'Dokumentation', 'docs' => 'Dokumentation',
'referral' => '30% Provision verdienen', 'referral' => '30% Provision verdienen',
'stay_updated' => 'Auf dem Laufenden bleiben', 'discord' => 'Discord-Community',
], ],
]; ];

57
lang/de/welcome.php Normal file
View file

@ -0,0 +1,57 @@
<?php
declare(strict_types=1);
return [
'title' => 'Was beschreibt dich am besten?',
'description' => 'Wähle die passende Option, und wir passen dein Erlebnis an.',
'continue' => 'Weiter',
'subscription_required_title' => 'Warten auf den Kontoinhaber',
'subscription_required_description' => 'Dieses Konto hat noch kein aktives Abo. Bitte den Kontoinhaber, den Checkout abzuschließen — du erhältst vollen Zugriff, sobald es aktiv ist.',
'subscription_required_owner' => 'Der Kontoinhaber ist :name.',
'subscription_required_auto' => 'Diese Seite aktualisiert sich automatisch — kein Neuladen nötig.',
'progress' => 'Willkommensfortschritt',
'go_to_step' => 'Zu Schritt :step gehen',
'step_current' => 'Schritt :step (aktuell)',
'personas' => [
'creator' => 'Content Creator',
'freelancer' => 'Freelancer',
'developer' => 'Entwickler',
'startup' => 'Startup',
'agency' => 'Agentur',
'small_business' => 'Kleinunternehmen',
'marketer' => 'Marketer',
'online_store' => 'Onlineshop',
'other' => 'Sonstiges',
],
'goals_title' => 'Was ist dein Ziel?',
'goals_description' => 'Wähle alles aus, was passt, und wir richten TryPost für dich ein.',
'goals' => [
'save_time' => 'Zeit sparen, indem ich überall gleichzeitig poste',
'ai_content' => 'Beiträge mit TryPost-KI erstellen',
'use_mcp' => 'Beiträge über Claude, ChatGPT oder Cursor erstellen',
'plan_calendar' => 'Meine Beiträge in einem Kalender planen',
'stay_on_brand' => 'Jeden Beitrag markenkonform halten',
'grow_audience' => 'Meine Reichweite und mein Engagement steigern',
'drive_sales' => 'Mehr Traffic und Verkäufe erzielen',
'manage_clients' => 'Mehrere Marken oder Kunden verwalten',
'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',
],
];

View file

@ -71,6 +71,8 @@
'page_title' => 'Σύνδεση', 'page_title' => 'Σύνδεση',
'email' => 'Διεύθυνση email', 'email' => 'Διεύθυνση email',
'password' => 'Κωδικός πρόσβασης', 'password' => 'Κωδικός πρόσβασης',
'show_password' => 'Εμφάνιση κωδικού',
'hide_password' => 'Απόκρυψη κωδικού',
'forgot_password' => 'Ξεχάσατε τον κωδικό;', 'forgot_password' => 'Ξεχάσατε τον κωδικό;',
'remember_me' => 'Να με θυμάσαι', 'remember_me' => 'Να με θυμάσαι',
'submit' => 'Σύνδεση', 'submit' => 'Σύνδεση',

View file

@ -21,15 +21,13 @@
'goals_description' => 'Επιλέξτε ό,τι σας ταιριάζει και θα ρυθμίσουμε το TryPost για εσάς.', 'goals_description' => 'Επιλέξτε ό,τι σας ταιριάζει και θα ρυθμίσουμε το TryPost για εσάς.',
'goals' => [ 'goals' => [
'save_time' => 'Εξοικονόμηση χρόνου δημοσιεύοντας παντού ταυτόχρονα', 'save_time' => 'Εξοικονόμηση χρόνου δημοσιεύοντας παντού ταυτόχρονα',
'ai_content' => 'Δημιουργία δημοσιεύσεων ταχύτερα με AI', 'ai_content' => 'Δημιουργία δημοσιεύσεων με το AI του TryPost',
'use_mcp' => 'Δημιουργία δημοσιεύσεων από Claude, ChatGPT ή Cursor',
'plan_calendar' => 'Προγραμματισμός των δημοσιεύσεών μου σε ημερολόγιο', 'plan_calendar' => 'Προγραμματισμός των δημοσιεύσεών μου σε ημερολόγιο',
'stay_on_brand' => 'Διατήρηση κάθε δημοσίευσης εναρμονισμένης με τη μάρκα', 'stay_on_brand' => 'Διατήρηση κάθε δημοσίευσης εναρμονισμένης με τη μάρκα',
'grow_audience' => 'Ανάπτυξη του κοινού και της αλληλεπίδρασής μου', 'grow_audience' => 'Ανάπτυξη του κοινού και της αλληλεπίδρασής μου',
'drive_sales' => 'Περισσότερη επισκεψιμότητα και πωλήσεις', 'drive_sales' => 'Περισσότερη επισκεψιμότητα και πωλήσεις',
'manage_clients' => 'Διαχείριση πολλών μαρκών ή πελατών', 'manage_clients' => 'Διαχείριση πολλών μαρκών ή πελατών',
'team_collaboration' => 'Συνεργασία με την ομάδα μου',
'automate_api' => 'Αυτοματοποίηση δημοσιεύσεων με το API, το MCP ή κώδικα',
'track_performance' => 'Παρακολούθηση της απόδοσης των δημοσιεύσεών μου',
'just_exploring' => 'Απλώς εξερευνώ προς το παρόν', 'just_exploring' => 'Απλώς εξερευνώ προς το παρόν',
'other' => 'Κάτι άλλο', 'other' => 'Κάτι άλλο',
], ],

View file

@ -60,6 +60,6 @@
'support' => [ 'support' => [
'docs' => 'Τεκμηρίωση', 'docs' => 'Τεκμηρίωση',
'referral' => 'Κερδίστε 30% από συστάσεις', 'referral' => 'Κερδίστε 30% από συστάσεις',
'stay_updated' => 'Μείνετε ενημερωμένοι', 'discord' => 'Κοινότητα Discord',
], ],
]; ];

57
lang/el/welcome.php Normal file
View file

@ -0,0 +1,57 @@
<?php
declare(strict_types=1);
return [
'title' => 'Τι σας περιγράφει καλύτερα;',
'description' => 'Επιλέξτε την πιο κοντινή επιλογή και θα προσαρμόσουμε την εμπειρία σας.',
'continue' => 'Συνέχεια',
'subscription_required_title' => 'Αναμονή για τον κάτοχο του λογαριασμού',
'subscription_required_description' => 'Αυτός ο λογαριασμός δεν έχει ακόμη ενεργή συνδρομή. Ζητήστε από τον κάτοχο να ολοκληρώσει την πληρωμή — θα έχετε πλήρη πρόσβαση μόλις ενεργοποιηθεί.',
'subscription_required_owner' => 'Ο κάτοχος του λογαριασμού σας είναι ο/η :name.',
'subscription_required_auto' => 'Αυτή η σελίδα ενημερώνεται αυτόματα — δεν χρειάζεται ανανέωση.',
'progress' => 'Πρόοδος καλωσορίσματος',
'go_to_step' => 'Μετάβαση στο βήμα :step',
'step_current' => 'Βήμα :step (τρέχον)',
'personas' => [
'creator' => 'Δημιουργός περιεχομένου',
'freelancer' => 'Ελεύθερος επαγγελματίας',
'developer' => 'Προγραμματιστής',
'startup' => 'Startup',
'agency' => 'Πρακτορείο',
'small_business' => 'Μικρή επιχείρηση',
'marketer' => 'Marketer',
'online_store' => 'Ηλεκτρονικό κατάστημα',
'other' => 'Άλλο',
],
'goals_title' => 'Ποιος είναι ο στόχος σας;',
'goals_description' => 'Επιλέξτε ό,τι σας ταιριάζει και θα ρυθμίσουμε το TryPost για εσάς.',
'goals' => [
'save_time' => 'Εξοικονόμηση χρόνου δημοσιεύοντας παντού ταυτόχρονα',
'ai_content' => 'Δημιουργία δημοσιεύσεων με το AI του TryPost',
'use_mcp' => 'Δημιουργία δημοσιεύσεων από Claude, ChatGPT ή Cursor',
'plan_calendar' => 'Προγραμματισμός των δημοσιεύσεών μου σε ημερολόγιο',
'stay_on_brand' => 'Διατήρηση κάθε δημοσίευσης εναρμονισμένης με τη μάρκα',
'grow_audience' => 'Ανάπτυξη του κοινού και της αλληλεπίδρασής μου',
'drive_sales' => 'Περισσότερη επισκεψιμότητα και πωλήσεις',
'manage_clients' => 'Διαχείριση πολλών μαρκών ή πελατών',
'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' => 'Κάτι άλλο',
],
];

View file

@ -71,6 +71,8 @@
'page_title' => 'Log in', 'page_title' => 'Log in',
'email' => 'Email address', 'email' => 'Email address',
'password' => 'Password', 'password' => 'Password',
'show_password' => 'Show password',
'hide_password' => 'Hide password',
'forgot_password' => 'Forgot password?', 'forgot_password' => 'Forgot password?',
'remember_me' => 'Remember me', 'remember_me' => 'Remember me',
'submit' => 'Log in', 'submit' => 'Log in',

View file

@ -21,15 +21,13 @@
'goals_description' => 'Pick everything that fits and we\'ll set TryPost up for you.', 'goals_description' => 'Pick everything that fits and we\'ll set TryPost up for you.',
'goals' => [ 'goals' => [
'save_time' => 'Save time by posting everywhere at once', 'save_time' => 'Save time by posting everywhere at once',
'ai_content' => 'Create posts faster with AI', 'ai_content' => 'Generate posts with TryPost AI',
'use_mcp' => 'Create posts from Claude, ChatGPT, or Cursor',
'plan_calendar' => 'Plan my posts on a calendar', 'plan_calendar' => 'Plan my posts on a calendar',
'stay_on_brand' => 'Keep every post on brand', 'stay_on_brand' => 'Keep every post on brand',
'grow_audience' => 'Grow my audience and engagement', 'grow_audience' => 'Grow my audience and engagement',
'drive_sales' => 'Get more traffic and sales', 'drive_sales' => 'Get more traffic and sales',
'manage_clients' => 'Manage several brands or clients', 'manage_clients' => 'Manage several brands or clients',
'team_collaboration' => 'Work with my team',
'automate_api' => 'Automate posting with the API, MCP or code',
'track_performance' => 'See how my posts perform',
'just_exploring' => 'Just exploring for now', 'just_exploring' => 'Just exploring for now',
'other' => 'Something else', 'other' => 'Something else',
], ],

View file

@ -60,6 +60,6 @@
'support' => [ 'support' => [
'docs' => 'Documentation', 'docs' => 'Documentation',
'referral' => 'Earn 30% referral', 'referral' => 'Earn 30% referral',
'stay_updated' => 'Stay updated', 'discord' => 'Discord community',
], ],
]; ];

57
lang/en/welcome.php Normal file
View file

@ -0,0 +1,57 @@
<?php
declare(strict_types=1);
return [
'title' => 'What best describes you?',
'description' => 'Choose the closest match and we\'ll tailor your experience.',
'continue' => 'Continue',
'subscription_required_title' => 'Waiting for the account owner',
'subscription_required_description' => 'This account doesn\'t have an active subscription yet. Ask the account owner to finish checkout — you\'ll get full access as soon as it is active.',
'subscription_required_owner' => 'Your account owner is :name.',
'subscription_required_auto' => 'This page updates automatically — no need to refresh.',
'progress' => 'Welcome progress',
'go_to_step' => 'Go to step :step',
'step_current' => 'Step :step (current)',
'personas' => [
'creator' => 'Content creator',
'freelancer' => 'Freelancer',
'developer' => 'Developer',
'startup' => 'Startup',
'agency' => 'Agency',
'small_business' => 'Small business',
'marketer' => 'Marketer',
'online_store' => 'Online store',
'other' => 'Other',
],
'goals_title' => 'What\'s your goal?',
'goals_description' => 'Pick everything that fits and we\'ll set TryPost up for you.',
'goals' => [
'save_time' => 'Save time by posting everywhere at once',
'ai_content' => 'Generate posts with TryPost AI',
'use_mcp' => 'Create posts from Claude, ChatGPT, or Cursor',
'plan_calendar' => 'Plan my posts on a calendar',
'stay_on_brand' => 'Keep every post on brand',
'grow_audience' => 'Grow my audience and engagement',
'drive_sales' => 'Get more traffic and sales',
'manage_clients' => 'Manage several brands or clients',
'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',
],
];

View file

@ -59,6 +59,8 @@
'page_title' => 'Iniciar sesión', 'page_title' => 'Iniciar sesión',
'email' => 'Correo electrónico', 'email' => 'Correo electrónico',
'password' => 'Contraseña', 'password' => 'Contraseña',
'show_password' => 'Mostrar contraseña',
'hide_password' => 'Ocultar contraseña',
'forgot_password' => '¿Olvidaste tu contraseña?', 'forgot_password' => '¿Olvidaste tu contraseña?',
'remember_me' => 'Recuérdame', 'remember_me' => 'Recuérdame',
'submit' => 'Iniciar sesión', 'submit' => 'Iniciar sesión',

View file

@ -21,15 +21,13 @@
'goals_description' => 'Marca todo lo que encaje y adaptamos TryPost a ti.', 'goals_description' => 'Marca todo lo que encaje y adaptamos TryPost a ti.',
'goals' => [ 'goals' => [
'save_time' => 'Ahorrar tiempo publicando en todas mis redes a la vez', 'save_time' => 'Ahorrar tiempo publicando en todas mis redes a la vez',
'ai_content' => 'Crear publicaciones más rápido con IA', 'ai_content' => 'Generar publicaciones con la IA de TryPost',
'use_mcp' => 'Crear publicaciones desde Claude, ChatGPT o Cursor',
'plan_calendar' => 'Planificar mis publicaciones en un calendario', 'plan_calendar' => 'Planificar mis publicaciones en un calendario',
'stay_on_brand' => 'Mantener la coherencia de mi marca', 'stay_on_brand' => 'Mantener la coherencia de mi marca',
'grow_audience' => 'Hacer crecer mi audiencia y engagement', 'grow_audience' => 'Hacer crecer mi audiencia y engagement',
'drive_sales' => 'Conseguir más tráfico y ventas', 'drive_sales' => 'Conseguir más tráfico y ventas',
'manage_clients' => 'Gestionar varias marcas o clientes', 'manage_clients' => 'Gestionar varias marcas o clientes',
'team_collaboration' => 'Trabajar con mi equipo',
'automate_api' => 'Automatizar publicaciones con la API, MCP o código',
'track_performance' => 'Ver cómo rinden mis publicaciones',
'just_exploring' => 'Solo estoy explorando por ahora', 'just_exploring' => 'Solo estoy explorando por ahora',
'other' => 'Otra cosa', 'other' => 'Otra cosa',
], ],

View file

@ -60,6 +60,6 @@
'support' => [ 'support' => [
'docs' => 'Documentación', 'docs' => 'Documentación',
'referral' => 'Gana 30% de comisión', 'referral' => 'Gana 30% de comisión',
'stay_updated' => 'Mantente al día', 'discord' => 'Comunidad de Discord',
], ],
]; ];

57
lang/es/welcome.php Normal file
View file

@ -0,0 +1,57 @@
<?php
declare(strict_types=1);
return [
'title' => '¿Qué te describe mejor?',
'description' => 'Elige la opción más cercana y personalizaremos tu experiencia.',
'continue' => 'Continuar',
'subscription_required_title' => 'Esperando al propietario de la cuenta',
'subscription_required_description' => 'Esta cuenta aún no tiene una suscripción activa. Pide al propietario que complete el checkout: tendrás acceso total en cuanto esté activa.',
'subscription_required_owner' => 'El propietario de tu cuenta es :name.',
'subscription_required_auto' => 'Esta página se actualiza automáticamente, no hace falta recargar.',
'progress' => 'Progreso de bienvenida',
'go_to_step' => 'Ir al paso :step',
'step_current' => 'Paso :step (actual)',
'personas' => [
'creator' => 'Creador de contenido',
'freelancer' => 'Freelancer',
'developer' => 'Desarrollador',
'startup' => 'Startup',
'agency' => 'Agencia',
'small_business' => 'Pequeña empresa',
'marketer' => 'Profesional de marketing',
'online_store' => 'Tienda online',
'other' => 'Otro',
],
'goals_title' => '¿Cuál es tu objetivo?',
'goals_description' => 'Marca todo lo que encaje y adaptamos TryPost a ti.',
'goals' => [
'save_time' => 'Ahorrar tiempo publicando en todas mis redes a la vez',
'ai_content' => 'Generar publicaciones con la IA de TryPost',
'use_mcp' => 'Crear publicaciones desde Claude, ChatGPT o Cursor',
'plan_calendar' => 'Planificar mis publicaciones en un calendario',
'stay_on_brand' => 'Mantener la coherencia de mi marca',
'grow_audience' => 'Hacer crecer mi audiencia y engagement',
'drive_sales' => 'Conseguir más tráfico y ventas',
'manage_clients' => 'Gestionar varias marcas o clientes',
'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',
],
];

View file

@ -71,6 +71,8 @@
'page_title' => 'Connexion', 'page_title' => 'Connexion',
'email' => 'Adresse e-mail', 'email' => 'Adresse e-mail',
'password' => 'Mot de passe', 'password' => 'Mot de passe',
'show_password' => 'Afficher le mot de passe',
'hide_password' => 'Masquer le mot de passe',
'forgot_password' => 'Mot de passe oublié ?', 'forgot_password' => 'Mot de passe oublié ?',
'remember_me' => 'Se souvenir de moi', 'remember_me' => 'Se souvenir de moi',
'submit' => 'Se connecter', 'submit' => 'Se connecter',

View file

@ -21,15 +21,13 @@
'goals_description' => 'Choisissez tout ce qui vous correspond et nous configurerons TryPost pour vous.', 'goals_description' => 'Choisissez tout ce qui vous correspond et nous configurerons TryPost pour vous.',
'goals' => [ 'goals' => [
'save_time' => 'Gagner du temps en publiant partout à la fois', 'save_time' => 'Gagner du temps en publiant partout à la fois',
'ai_content' => 'Créer des publications plus vite avec l\'IA', 'ai_content' => 'Générer des publications avec l\'IA TryPost',
'use_mcp' => 'Créer des publications depuis Claude, ChatGPT ou Cursor',
'plan_calendar' => 'Planifier mes publications sur un calendrier', 'plan_calendar' => 'Planifier mes publications sur un calendrier',
'stay_on_brand' => 'Garder chaque publication fidèle à ma marque', 'stay_on_brand' => 'Garder chaque publication fidèle à ma marque',
'grow_audience' => 'Développer mon audience et mon engagement', 'grow_audience' => 'Développer mon audience et mon engagement',
'drive_sales' => 'Obtenir plus de trafic et de ventes', 'drive_sales' => 'Obtenir plus de trafic et de ventes',
'manage_clients' => 'Gérer plusieurs marques ou clients', 'manage_clients' => 'Gérer plusieurs marques ou clients',
'team_collaboration' => 'Travailler avec mon équipe',
'automate_api' => 'Automatiser la publication avec l\'API, le MCP ou du code',
'track_performance' => 'Voir les performances de mes publications',
'just_exploring' => 'Je découvre pour l\'instant', 'just_exploring' => 'Je découvre pour l\'instant',
'other' => 'Autre chose', 'other' => 'Autre chose',
], ],

View file

@ -60,6 +60,6 @@
'support' => [ 'support' => [
'docs' => 'Documentation', 'docs' => 'Documentation',
'referral' => 'Gagnez 30 % de parrainage', 'referral' => 'Gagnez 30 % de parrainage',
'stay_updated' => 'Rester informé', 'discord' => 'Communauté Discord',
], ],
]; ];

57
lang/fr/welcome.php Normal file
View file

@ -0,0 +1,57 @@
<?php
declare(strict_types=1);
return [
'title' => 'Qu\'est-ce qui vous décrit le mieux ?',
'description' => 'Choisissez l\'option la plus proche et nous personnaliserons votre expérience.',
'continue' => 'Continuer',
'subscription_required_title' => 'En attente du propriétaire du compte',
'subscription_required_description' => 'Ce compte n\'a pas encore d\'abonnement actif. Demandez au propriétaire de finaliser le paiement — vous aurez un accès complet dès qu\'il sera actif.',
'subscription_required_owner' => 'Le propriétaire de votre compte est :name.',
'subscription_required_auto' => 'Cette page se met à jour automatiquement — inutile de la recharger.',
'progress' => 'Progression daccueil',
'go_to_step' => 'Aller à létape :step',
'step_current' => 'Étape :step (actuelle)',
'personas' => [
'creator' => 'Créateur de contenu',
'freelancer' => 'Freelance',
'developer' => 'Développeur',
'startup' => 'Startup',
'agency' => 'Agence',
'small_business' => 'Petite entreprise',
'marketer' => 'Marketeur',
'online_store' => 'Boutique en ligne',
'other' => 'Autre',
],
'goals_title' => 'Quel est votre objectif ?',
'goals_description' => 'Choisissez tout ce qui vous correspond et nous configurerons TryPost pour vous.',
'goals' => [
'save_time' => 'Gagner du temps en publiant partout à la fois',
'ai_content' => 'Générer des publications avec l\'IA TryPost',
'use_mcp' => 'Créer des publications depuis Claude, ChatGPT ou Cursor',
'plan_calendar' => 'Planifier mes publications sur un calendrier',
'stay_on_brand' => 'Garder chaque publication fidèle à ma marque',
'grow_audience' => 'Développer mon audience et mon engagement',
'drive_sales' => 'Obtenir plus de trafic et de ventes',
'manage_clients' => 'Gérer plusieurs marques ou clients',
'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',
],
];

View file

@ -71,6 +71,8 @@
'page_title' => 'Accedi', 'page_title' => 'Accedi',
'email' => 'Indirizzo email', 'email' => 'Indirizzo email',
'password' => 'Password', 'password' => 'Password',
'show_password' => 'Mostra password',
'hide_password' => 'Nascondi password',
'forgot_password' => 'Password dimenticata?', 'forgot_password' => 'Password dimenticata?',
'remember_me' => 'Ricordami', 'remember_me' => 'Ricordami',
'submit' => 'Accedi', 'submit' => 'Accedi',

View file

@ -21,15 +21,13 @@
'goals_description' => 'Scegli tutto ciò che fa per te e configureremo TryPost per te.', 'goals_description' => 'Scegli tutto ciò che fa per te e configureremo TryPost per te.',
'goals' => [ 'goals' => [
'save_time' => 'Risparmiare tempo pubblicando ovunque in una volta', 'save_time' => 'Risparmiare tempo pubblicando ovunque in una volta',
'ai_content' => 'Creare post più velocemente con l\'IA', 'ai_content' => 'Generare post con l\'IA di TryPost',
'use_mcp' => 'Creare post da Claude, ChatGPT o Cursor',
'plan_calendar' => 'Pianificare i miei post su un calendario', 'plan_calendar' => 'Pianificare i miei post su un calendario',
'stay_on_brand' => 'Mantenere ogni post in linea con il brand', 'stay_on_brand' => 'Mantenere ogni post in linea con il brand',
'grow_audience' => 'Far crescere il mio pubblico e il coinvolgimento', 'grow_audience' => 'Far crescere il mio pubblico e il coinvolgimento',
'drive_sales' => 'Ottenere più traffico e vendite', 'drive_sales' => 'Ottenere più traffico e vendite',
'manage_clients' => 'Gestire più brand o clienti', 'manage_clients' => 'Gestire più brand o clienti',
'team_collaboration' => 'Lavorare con il mio team',
'automate_api' => 'Automatizzare la pubblicazione con API, MCP o codice',
'track_performance' => 'Vedere come vanno i miei post',
'just_exploring' => 'Sto solo dando un\'occhiata', 'just_exploring' => 'Sto solo dando un\'occhiata',
'other' => 'Qualcos\'altro', 'other' => 'Qualcos\'altro',
], ],

View file

@ -60,6 +60,6 @@
'support' => [ 'support' => [
'docs' => 'Documentazione', 'docs' => 'Documentazione',
'referral' => 'Guadagna il 30% di referral', 'referral' => 'Guadagna il 30% di referral',
'stay_updated' => 'Resta aggiornato', 'discord' => 'Community Discord',
], ],
]; ];

57
lang/it/welcome.php Normal file
View file

@ -0,0 +1,57 @@
<?php
declare(strict_types=1);
return [
'title' => 'Cosa ti descrive meglio?',
'description' => 'Scegli l\'opzione più vicina e personalizzeremo la tua esperienza.',
'continue' => 'Continua',
'subscription_required_title' => 'In attesa del proprietario dell\'account',
'subscription_required_description' => 'Questo account non ha ancora un abbonamento attivo. Chiedi al proprietario di completare il checkout: avrai accesso completo non appena sarà attivo.',
'subscription_required_owner' => 'Il proprietario del tuo account è :name.',
'subscription_required_auto' => 'Questa pagina si aggiorna automaticamente: non serve ricaricarla.',
'progress' => 'Progresso di benvenuto',
'go_to_step' => 'Vai al passaggio :step',
'step_current' => 'Passaggio :step (attuale)',
'personas' => [
'creator' => 'Creatore di contenuti',
'freelancer' => 'Freelance',
'developer' => 'Sviluppatore',
'startup' => 'Startup',
'agency' => 'Agenzia',
'small_business' => 'Piccola impresa',
'marketer' => 'Marketer',
'online_store' => 'Negozio online',
'other' => 'Altro',
],
'goals_title' => 'Qual è il tuo obiettivo?',
'goals_description' => 'Scegli tutto ciò che fa per te e configureremo TryPost per te.',
'goals' => [
'save_time' => 'Risparmiare tempo pubblicando ovunque in una volta',
'ai_content' => 'Generare post con l\'IA di TryPost',
'use_mcp' => 'Creare post da Claude, ChatGPT o Cursor',
'plan_calendar' => 'Pianificare i miei post su un calendario',
'stay_on_brand' => 'Mantenere ogni post in linea con il brand',
'grow_audience' => 'Far crescere il mio pubblico e il coinvolgimento',
'drive_sales' => 'Ottenere più traffico e vendite',
'manage_clients' => 'Gestire più brand o clienti',
'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',
],
];

View file

@ -71,6 +71,8 @@
'page_title' => 'ログイン', 'page_title' => 'ログイン',
'email' => 'メールアドレス', 'email' => 'メールアドレス',
'password' => 'パスワード', 'password' => 'パスワード',
'show_password' => 'パスワードを表示',
'hide_password' => 'パスワードを隠す',
'forgot_password' => 'パスワードをお忘れですか?', 'forgot_password' => 'パスワードをお忘れですか?',
'remember_me' => 'ログイン状態を保持', 'remember_me' => 'ログイン状態を保持',
'submit' => 'ログイン', 'submit' => 'ログイン',

View file

@ -21,15 +21,13 @@
'goals_description' => '当てはまるものをすべて選んでください。TryPost をあなた向けに設定します。', 'goals_description' => '当てはまるものをすべて選んでください。TryPost をあなた向けに設定します。',
'goals' => [ 'goals' => [
'save_time' => 'すべての場所へ一度に投稿して時間を節約する', 'save_time' => 'すべての場所へ一度に投稿して時間を節約する',
'ai_content' => 'AI でより速く投稿を作成する', 'ai_content' => 'TryPost AI で投稿を生成する',
'use_mcp' => 'Claude・ChatGPT・Cursor から投稿を作成する',
'plan_calendar' => 'カレンダーで投稿を計画する', 'plan_calendar' => 'カレンダーで投稿を計画する',
'stay_on_brand' => 'すべての投稿をブランドに沿ったものにする', 'stay_on_brand' => 'すべての投稿をブランドに沿ったものにする',
'grow_audience' => 'オーディエンスとエンゲージメントを増やす', 'grow_audience' => 'オーディエンスとエンゲージメントを増やす',
'drive_sales' => 'トラフィックと売上を増やす', 'drive_sales' => 'トラフィックと売上を増やす',
'manage_clients' => '複数のブランドやクライアントを管理する', 'manage_clients' => '複数のブランドやクライアントを管理する',
'team_collaboration' => 'チームで作業する',
'automate_api' => 'API、MCP、コードで投稿を自動化する',
'track_performance' => '投稿のパフォーマンスを確認する',
'just_exploring' => '今はまだ様子を見ている', 'just_exploring' => '今はまだ様子を見ている',
'other' => 'その他', 'other' => 'その他',
], ],

View file

@ -60,6 +60,6 @@
'support' => [ 'support' => [
'docs' => 'ドキュメント', 'docs' => 'ドキュメント',
'referral' => '30% の紹介報酬を獲得', 'referral' => '30% の紹介報酬を獲得',
'stay_updated' => '最新情報を受け取る', 'discord' => 'Discord コミュニティ',
], ],
]; ];

57
lang/ja/welcome.php Normal file
View file

@ -0,0 +1,57 @@
<?php
declare(strict_types=1);
return [
'title' => 'あなたに一番近いのは?',
'description' => '近いものを選ぶと、体験を最適化します。',
'continue' => '続ける',
'subscription_required_title' => 'アカウントのオーナーを待っています',
'subscription_required_description' => 'このアカウントにはまだ有効なサブスクリプションがありません。オーナーにチェックアウトの完了を依頼してください — 有効になり次第、フルアクセスできます。',
'subscription_required_owner' => 'アカウントのオーナーは :name です。',
'subscription_required_auto' => 'このページは自動で更新されます — 再読み込みは不要です。',
'progress' => 'ようこそ進捗',
'go_to_step' => 'ステップ :step へ',
'step_current' => 'ステップ :step現在',
'personas' => [
'creator' => 'コンテンツクリエイター',
'freelancer' => 'フリーランス',
'developer' => '開発者',
'startup' => 'スタートアップ',
'agency' => '代理店',
'small_business' => '中小企業',
'marketer' => 'マーケター',
'online_store' => 'オンラインストア',
'other' => 'その他',
],
'goals_title' => '目標は何ですか?',
'goals_description' => '当てはまるものをすべて選んでください。TryPost をあなた向けに設定します。',
'goals' => [
'save_time' => 'すべての場所へ一度に投稿して時間を節約する',
'ai_content' => 'TryPost AI で投稿を生成する',
'use_mcp' => 'Claude・ChatGPT・Cursor から投稿を作成する',
'plan_calendar' => 'カレンダーで投稿を計画する',
'stay_on_brand' => 'すべての投稿をブランドに沿ったものにする',
'grow_audience' => 'オーディエンスとエンゲージメントを増やす',
'drive_sales' => 'トラフィックと売上を増やす',
'manage_clients' => '複数のブランドやクライアントを管理する',
'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' => 'その他',
],
];

View file

@ -71,6 +71,8 @@
'page_title' => '로그인', 'page_title' => '로그인',
'email' => '이메일 주소', 'email' => '이메일 주소',
'password' => '비밀번호', 'password' => '비밀번호',
'show_password' => '비밀번호 표시',
'hide_password' => '비밀번호 숨기기',
'forgot_password' => '비밀번호를 잊으셨나요?', 'forgot_password' => '비밀번호를 잊으셨나요?',
'remember_me' => '로그인 상태 유지', 'remember_me' => '로그인 상태 유지',
'submit' => '로그인', 'submit' => '로그인',

View file

@ -21,15 +21,13 @@
'goals_description' => '해당되는 항목을 모두 선택하면 TryPost를 맞춤 설정해 드립니다.', 'goals_description' => '해당되는 항목을 모두 선택하면 TryPost를 맞춤 설정해 드립니다.',
'goals' => [ 'goals' => [
'save_time' => '한 번에 여러 곳에 게시하여 시간 절약', 'save_time' => '한 번에 여러 곳에 게시하여 시간 절약',
'ai_content' => 'AI로 더 빠르게 게시물 작성', 'ai_content' => 'TryPost AI로 게시물 생성',
'use_mcp' => 'Claude, ChatGPT 또는 Cursor에서 게시물 작성',
'plan_calendar' => '캘린더에서 게시물 계획', 'plan_calendar' => '캘린더에서 게시물 계획',
'stay_on_brand' => '모든 게시물을 브랜드에 맞게 유지', 'stay_on_brand' => '모든 게시물을 브랜드에 맞게 유지',
'grow_audience' => '팔로워와 참여 늘리기', 'grow_audience' => '팔로워와 참여 늘리기',
'drive_sales' => '더 많은 트래픽과 판매 유도', 'drive_sales' => '더 많은 트래픽과 판매 유도',
'manage_clients' => '여러 브랜드 또는 클라이언트 관리', 'manage_clients' => '여러 브랜드 또는 클라이언트 관리',
'team_collaboration' => '팀과 협업',
'automate_api' => 'API, MCP 또는 코드로 게시 자동화',
'track_performance' => '게시물 성과 확인',
'just_exploring' => '지금은 둘러보는 중', 'just_exploring' => '지금은 둘러보는 중',
'other' => '다른 것', 'other' => '다른 것',
], ],

View file

@ -60,6 +60,6 @@
'support' => [ 'support' => [
'docs' => '문서', 'docs' => '문서',
'referral' => '30% 추천 수익 받기', 'referral' => '30% 추천 수익 받기',
'stay_updated' => '최신 소식 받기', 'discord' => 'Discord 커뮤니티',
], ],
]; ];

57
lang/ko/welcome.php Normal file
View file

@ -0,0 +1,57 @@
<?php
declare(strict_types=1);
return [
'title' => '무엇을 가장 잘 설명하나요?',
'description' => '가장 가까운 항목을 선택하면 맞춤 경험을 제공해 드립니다.',
'continue' => '계속',
'subscription_required_title' => '계정 소유자를 기다리는 중',
'subscription_required_description' => '이 계정에는 아직 활성 구독이 없습니다. 소유자에게 결제 완료를 요청하세요 — 활성화되는 즉시 모든 기능을 사용할 수 있습니다.',
'subscription_required_owner' => '계정 소유자는 :name 님입니다.',
'subscription_required_auto' => '이 페이지는 자동으로 업데이트됩니다 — 새로고침할 필요가 없습니다.',
'progress' => '환영 진행률',
'go_to_step' => ':step단계로 이동',
'step_current' => ':step단계 (현재)',
'personas' => [
'creator' => '콘텐츠 크리에이터',
'freelancer' => '프리랜서',
'developer' => '개발자',
'startup' => '스타트업',
'agency' => '에이전시',
'small_business' => '소상공인',
'marketer' => '마케터',
'online_store' => '온라인 스토어',
'other' => '기타',
],
'goals_title' => '목표가 무엇인가요?',
'goals_description' => '해당되는 항목을 모두 선택하면 TryPost를 맞춤 설정해 드립니다.',
'goals' => [
'save_time' => '한 번에 여러 곳에 게시하여 시간 절약',
'ai_content' => 'TryPost AI로 게시물 생성',
'use_mcp' => 'Claude, ChatGPT 또는 Cursor에서 게시물 작성',
'plan_calendar' => '캘린더에서 게시물 계획',
'stay_on_brand' => '모든 게시물을 브랜드에 맞게 유지',
'grow_audience' => '팔로워와 참여 늘리기',
'drive_sales' => '더 많은 트래픽과 판매 유도',
'manage_clients' => '여러 브랜드 또는 클라이언트 관리',
'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' => '기타',
],
];

View file

@ -71,6 +71,8 @@
'page_title' => 'Inloggen', 'page_title' => 'Inloggen',
'email' => 'E-mailadres', 'email' => 'E-mailadres',
'password' => 'Wachtwoord', 'password' => 'Wachtwoord',
'show_password' => 'Wachtwoord tonen',
'hide_password' => 'Wachtwoord verbergen',
'forgot_password' => 'Wachtwoord vergeten?', 'forgot_password' => 'Wachtwoord vergeten?',
'remember_me' => 'Ingelogd blijven', 'remember_me' => 'Ingelogd blijven',
'submit' => 'Inloggen', 'submit' => 'Inloggen',

View file

@ -21,15 +21,13 @@
'goals_description' => 'Kies alles wat past en we stellen TryPost voor je in.', 'goals_description' => 'Kies alles wat past en we stellen TryPost voor je in.',
'goals' => [ 'goals' => [
'save_time' => 'Tijd besparen door overal tegelijk te posten', 'save_time' => 'Tijd besparen door overal tegelijk te posten',
'ai_content' => 'Sneller posts maken met AI', 'ai_content' => 'Posts genereren met TryPost AI',
'use_mcp' => 'Posts maken via Claude, ChatGPT of Cursor',
'plan_calendar' => 'Mijn posts plannen op een kalender', 'plan_calendar' => 'Mijn posts plannen op een kalender',
'stay_on_brand' => 'Elke post in lijn met mijn merk houden', 'stay_on_brand' => 'Elke post in lijn met mijn merk houden',
'grow_audience' => 'Mijn publiek en betrokkenheid laten groeien', 'grow_audience' => 'Mijn publiek en betrokkenheid laten groeien',
'drive_sales' => 'Meer verkeer en verkopen krijgen', 'drive_sales' => 'Meer verkeer en verkopen krijgen',
'manage_clients' => 'Meerdere merken of klanten beheren', 'manage_clients' => 'Meerdere merken of klanten beheren',
'team_collaboration' => 'Samenwerken met mijn team',
'automate_api' => 'Posten automatiseren met de API, MCP of code',
'track_performance' => 'Zien hoe mijn posts presteren',
'just_exploring' => 'Voorlopig gewoon aan het verkennen', 'just_exploring' => 'Voorlopig gewoon aan het verkennen',
'other' => 'Iets anders', 'other' => 'Iets anders',
], ],

View file

@ -60,6 +60,6 @@
'support' => [ 'support' => [
'docs' => 'Documentatie', 'docs' => 'Documentatie',
'referral' => 'Verdien 30% referral', 'referral' => 'Verdien 30% referral',
'stay_updated' => 'Blijf op de hoogte', 'discord' => 'Discord-community',
], ],
]; ];

57
lang/nl/welcome.php Normal file
View file

@ -0,0 +1,57 @@
<?php
declare(strict_types=1);
return [
'title' => 'Wat omschrijft jou het beste?',
'description' => 'Kies de dichtstbijzijnde optie, dan stemmen we je ervaring af.',
'continue' => 'Doorgaan',
'subscription_required_title' => 'Wachten op de accounteigenaar',
'subscription_required_description' => 'Dit account heeft nog geen actief abonnement. Vraag de eigenaar om de checkout af te ronden — je krijgt volledige toegang zodra het actief is.',
'subscription_required_owner' => 'De accounteigenaar is :name.',
'subscription_required_auto' => 'Deze pagina vernieuwt automatisch — verversen is niet nodig.',
'progress' => 'Welkomstvoortgang',
'go_to_step' => 'Ga naar stap :step',
'step_current' => 'Stap :step (huidig)',
'personas' => [
'creator' => 'Contentmaker',
'freelancer' => 'Freelancer',
'developer' => 'Ontwikkelaar',
'startup' => 'Startup',
'agency' => 'Bureau',
'small_business' => 'Klein bedrijf',
'marketer' => 'Marketeer',
'online_store' => 'Webshop',
'other' => 'Anders',
],
'goals_title' => 'Wat is je doel?',
'goals_description' => 'Kies alles wat past en we stellen TryPost voor je in.',
'goals' => [
'save_time' => 'Tijd besparen door overal tegelijk te posten',
'ai_content' => 'Posts genereren met TryPost AI',
'use_mcp' => 'Posts maken via Claude, ChatGPT of Cursor',
'plan_calendar' => 'Mijn posts plannen op een kalender',
'stay_on_brand' => 'Elke post in lijn met mijn merk houden',
'grow_audience' => 'Mijn publiek en betrokkenheid laten groeien',
'drive_sales' => 'Meer verkeer en verkopen krijgen',
'manage_clients' => 'Meerdere merken of klanten beheren',
'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',
],
];

View file

@ -71,6 +71,8 @@
'page_title' => 'Zaloguj się', 'page_title' => 'Zaloguj się',
'email' => 'Adres e-mail', 'email' => 'Adres e-mail',
'password' => 'Hasło', 'password' => 'Hasło',
'show_password' => 'Pokaż hasło',
'hide_password' => 'Ukryj hasło',
'forgot_password' => 'Nie pamiętasz hasła?', 'forgot_password' => 'Nie pamiętasz hasła?',
'remember_me' => 'Zapamiętaj mnie', 'remember_me' => 'Zapamiętaj mnie',
'submit' => 'Zaloguj się', 'submit' => 'Zaloguj się',

View file

@ -21,15 +21,13 @@
'goals_description' => 'Wybierz wszystko, co pasuje, a my skonfigurujemy TryPost dla Ciebie.', 'goals_description' => 'Wybierz wszystko, co pasuje, a my skonfigurujemy TryPost dla Ciebie.',
'goals' => [ 'goals' => [
'save_time' => 'Oszczędzaj czas, publikując wszędzie naraz', 'save_time' => 'Oszczędzaj czas, publikując wszędzie naraz',
'ai_content' => 'Twórz posty szybciej dzięki AI', 'ai_content' => 'Generuj posty z AI TryPost',
'use_mcp' => 'Twórz posty w Claude, ChatGPT lub Cursor',
'plan_calendar' => 'Planuj posty w kalendarzu', 'plan_calendar' => 'Planuj posty w kalendarzu',
'stay_on_brand' => 'Utrzymuj każdy post spójny z marką', 'stay_on_brand' => 'Utrzymuj każdy post spójny z marką',
'grow_audience' => 'Powiększaj grono odbiorców i zaangażowanie', 'grow_audience' => 'Powiększaj grono odbiorców i zaangażowanie',
'drive_sales' => 'Zdobywaj więcej ruchu i sprzedaży', 'drive_sales' => 'Zdobywaj więcej ruchu i sprzedaży',
'manage_clients' => 'Zarządzaj wieloma markami lub klientami', 'manage_clients' => 'Zarządzaj wieloma markami lub klientami',
'team_collaboration' => 'Pracuj z moim zespołem',
'automate_api' => 'Automatyzuj publikowanie za pomocą API, MCP lub kodu',
'track_performance' => 'Sprawdzaj, jak radzą sobie moje posty',
'just_exploring' => 'Na razie tylko się rozglądam', 'just_exploring' => 'Na razie tylko się rozglądam',
'other' => 'Coś innego', 'other' => 'Coś innego',
], ],

View file

@ -60,6 +60,6 @@
'support' => [ 'support' => [
'docs' => 'Dokumentacja', 'docs' => 'Dokumentacja',
'referral' => 'Zarabiaj 30% z poleceń', 'referral' => 'Zarabiaj 30% z poleceń',
'stay_updated' => 'Bądź na bieżąco', 'discord' => 'Społeczność Discord',
], ],
]; ];

57
lang/pl/welcome.php Normal file
View file

@ -0,0 +1,57 @@
<?php
declare(strict_types=1);
return [
'title' => 'Co najlepiej Cię opisuje?',
'description' => 'Wybierz najbliższą opcję, a my dopasujemy Twoje doświadczenie.',
'continue' => 'Kontynuuj',
'subscription_required_title' => 'Oczekiwanie na właściciela konta',
'subscription_required_description' => 'To konto nie ma jeszcze aktywnej subskrypcji. Poproś właściciela o dokończenie płatności — uzyskasz pełny dostęp, gdy tylko będzie aktywna.',
'subscription_required_owner' => 'Właścicielem Twojego konta jest :name.',
'subscription_required_auto' => 'Ta strona odświeża się automatycznie — nie musisz jej przeładowywać.',
'progress' => 'Postęp powitalny',
'go_to_step' => 'Przejdź do kroku :step',
'step_current' => 'Krok :step (bieżący)',
'personas' => [
'creator' => 'Twórca treści',
'freelancer' => 'Freelancer',
'developer' => 'Programista',
'startup' => 'Startup',
'agency' => 'Agencja',
'small_business' => 'Mała firma',
'marketer' => 'Marketingowiec',
'online_store' => 'Sklep internetowy',
'other' => 'Inne',
],
'goals_title' => 'Jaki jest Twój cel?',
'goals_description' => 'Wybierz wszystko, co pasuje, a my skonfigurujemy TryPost dla Ciebie.',
'goals' => [
'save_time' => 'Oszczędzaj czas, publikując wszędzie naraz',
'ai_content' => 'Generuj posty z AI TryPost',
'use_mcp' => 'Twórz posty w Claude, ChatGPT lub Cursor',
'plan_calendar' => 'Planuj posty w kalendarzu',
'stay_on_brand' => 'Utrzymuj każdy post spójny z marką',
'grow_audience' => 'Powiększaj grono odbiorców i zaangażowanie',
'drive_sales' => 'Zdobywaj więcej ruchu i sprzedaży',
'manage_clients' => 'Zarządzaj wieloma markami lub klientami',
'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',
],
];

View file

@ -71,6 +71,8 @@
'page_title' => 'Entrar', 'page_title' => 'Entrar',
'email' => 'Endereço de email', 'email' => 'Endereço de email',
'password' => 'Senha', 'password' => 'Senha',
'show_password' => 'Mostrar senha',
'hide_password' => 'Esconder senha',
'forgot_password' => 'Esqueceu a senha?', 'forgot_password' => 'Esqueceu a senha?',
'remember_me' => 'Lembrar de mim', 'remember_me' => 'Lembrar de mim',
'submit' => 'Entrar', 'submit' => 'Entrar',

View file

@ -21,15 +21,13 @@
'goals_description' => 'Marque tudo que faz sentido e a gente ajusta o TryPost pra você.', 'goals_description' => 'Marque tudo que faz sentido e a gente ajusta o TryPost pra você.',
'goals' => [ 'goals' => [
'save_time' => 'Economizar tempo postando em todas as redes de uma vez', 'save_time' => 'Economizar tempo postando em todas as redes de uma vez',
'ai_content' => 'Criar posts mais rápido com IA', 'ai_content' => 'Gerar posts com a IA do TryPost',
'use_mcp' => 'Criar posts pelo Claude, ChatGPT ou Cursor',
'plan_calendar' => 'Planejar meus posts num calendário', 'plan_calendar' => 'Planejar meus posts num calendário',
'stay_on_brand' => 'Manter a consistência da minha marca', 'stay_on_brand' => 'Manter a consistência da minha marca',
'grow_audience' => 'Crescer minha audiência e engajamento', 'grow_audience' => 'Crescer minha audiência e engajamento',
'drive_sales' => 'Conseguir mais tráfego e vendas', 'drive_sales' => 'Conseguir mais tráfego e vendas',
'manage_clients' => 'Gerenciar várias marcas ou clientes', 'manage_clients' => 'Gerenciar várias marcas ou clientes',
'team_collaboration' => 'Trabalhar com meu time',
'automate_api' => 'Automatizar publicações com a API, MCP ou código',
'track_performance' => 'Ver o desempenho dos meus posts',
'just_exploring' => 'Só dando uma olhada por enquanto', 'just_exploring' => 'Só dando uma olhada por enquanto',
'other' => 'Outra coisa', 'other' => 'Outra coisa',
], ],

View file

@ -60,6 +60,6 @@
'support' => [ 'support' => [
'docs' => 'Documentação', 'docs' => 'Documentação',
'referral' => 'Ganhe 30% de indicação', 'referral' => 'Ganhe 30% de indicação',
'stay_updated' => 'Fique por dentro', 'discord' => 'Comunidade Discord',
], ],
]; ];

57
lang/pt-BR/welcome.php Normal file
View file

@ -0,0 +1,57 @@
<?php
declare(strict_types=1);
return [
'title' => 'O que melhor descreve você?',
'description' => 'Escolha a opção mais próxima e a gente personaliza sua experiência.',
'continue' => 'Continuar',
'subscription_required_title' => 'Aguardando o dono da conta',
'subscription_required_description' => 'Esta conta ainda não tem uma assinatura ativa. Peça ao dono da conta para concluir o checkout — você terá acesso total assim que ela estiver ativa.',
'subscription_required_owner' => 'O dono da sua conta é :name.',
'subscription_required_auto' => 'Esta página atualiza automaticamente — não precisa recarregar.',
'progress' => 'Progresso das boas-vindas',
'go_to_step' => 'Ir para a etapa :step',
'step_current' => 'Etapa :step (atual)',
'personas' => [
'creator' => 'Criador de conteúdo',
'freelancer' => 'Freelancer',
'developer' => 'Desenvolvedor',
'startup' => 'Startup',
'agency' => 'Agência',
'small_business' => 'Pequena empresa',
'marketer' => 'Profissional de marketing',
'online_store' => 'Loja online',
'other' => 'Outro',
],
'goals_title' => 'Qual o seu objetivo?',
'goals_description' => 'Marque tudo que faz sentido e a gente ajusta o TryPost pra você.',
'goals' => [
'save_time' => 'Economizar tempo postando em todas as redes de uma vez',
'ai_content' => 'Gerar posts com a IA do TryPost',
'use_mcp' => 'Criar posts pelo Claude, ChatGPT ou Cursor',
'plan_calendar' => 'Planejar meus posts num calendário',
'stay_on_brand' => 'Manter a consistência da minha marca',
'grow_audience' => 'Crescer minha audiência e engajamento',
'drive_sales' => 'Conseguir mais tráfego e vendas',
'manage_clients' => 'Gerenciar várias marcas ou clientes',
'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',
],
];

View file

@ -71,6 +71,8 @@
'page_title' => 'Вход', 'page_title' => 'Вход',
'email' => 'Адрес email', 'email' => 'Адрес email',
'password' => 'Пароль', 'password' => 'Пароль',
'show_password' => 'Показать пароль',
'hide_password' => 'Скрыть пароль',
'forgot_password' => 'Забыли пароль?', 'forgot_password' => 'Забыли пароль?',
'remember_me' => 'Запомнить меня', 'remember_me' => 'Запомнить меня',
'submit' => 'Войти', 'submit' => 'Войти',

View file

@ -21,15 +21,13 @@
'goals_description' => 'Выберите всё, что подходит, и мы настроим TryPost для вас.', 'goals_description' => 'Выберите всё, что подходит, и мы настроим TryPost для вас.',
'goals' => [ 'goals' => [
'save_time' => 'Экономить время, публикуя всюду сразу', 'save_time' => 'Экономить время, публикуя всюду сразу',
'ai_content' => 'Создавать посты быстрее с помощью ИИ', 'ai_content' => 'Генерировать посты с ИИ TryPost',
'use_mcp' => 'Создавать посты через Claude, ChatGPT или Cursor',
'plan_calendar' => 'Планировать посты в календаре', 'plan_calendar' => 'Планировать посты в календаре',
'stay_on_brand' => 'Держать каждый пост в стиле бренда', 'stay_on_brand' => 'Держать каждый пост в стиле бренда',
'grow_audience' => 'Наращивать аудиторию и вовлечённость', 'grow_audience' => 'Наращивать аудиторию и вовлечённость',
'drive_sales' => 'Получать больше трафика и продаж', 'drive_sales' => 'Получать больше трафика и продаж',
'manage_clients' => 'Управлять несколькими брендами или клиентами', 'manage_clients' => 'Управлять несколькими брендами или клиентами',
'team_collaboration' => 'Работать с командой',
'automate_api' => 'Автоматизировать публикацию с помощью API, MCP или кода',
'track_performance' => 'Отслеживать эффективность постов',
'just_exploring' => 'Пока просто знакомлюсь', 'just_exploring' => 'Пока просто знакомлюсь',
'other' => 'Что-то ещё', 'other' => 'Что-то ещё',
], ],

View file

@ -60,6 +60,6 @@
'support' => [ 'support' => [
'docs' => 'Документация', 'docs' => 'Документация',
'referral' => 'Зарабатывайте 30% по реферальной программе', 'referral' => 'Зарабатывайте 30% по реферальной программе',
'stay_updated' => 'Следите за обновлениями', 'discord' => 'Сообщество Discord',
], ],
]; ];

57
lang/ru/welcome.php Normal file
View file

@ -0,0 +1,57 @@
<?php
declare(strict_types=1);
return [
'title' => 'Что лучше всего вас описывает?',
'description' => 'Выберите ближайший вариант — мы настроим опыт под вас.',
'continue' => 'Продолжить',
'subscription_required_title' => 'Ожидание владельца аккаунта',
'subscription_required_description' => 'У этого аккаунта пока нет активной подписки. Попросите владельца завершить оплату — вы получите полный доступ сразу после её активации.',
'subscription_required_owner' => 'Владелец вашего аккаунта — :name.',
'subscription_required_auto' => 'Эта страница обновляется автоматически — перезагружать не нужно.',
'progress' => 'Прогресс приветствия',
'go_to_step' => 'Перейти к шагу :step',
'step_current' => 'Шаг :step (текущий)',
'personas' => [
'creator' => 'Автор контента',
'freelancer' => 'Фрилансер',
'developer' => 'Разработчик',
'startup' => 'Стартап',
'agency' => 'Агентство',
'small_business' => 'Малый бизнес',
'marketer' => 'Маркетолог',
'online_store' => 'Интернет-магазин',
'other' => 'Другое',
],
'goals_title' => 'Какова ваша цель?',
'goals_description' => 'Выберите всё, что подходит, и мы настроим TryPost для вас.',
'goals' => [
'save_time' => 'Экономить время, публикуя всюду сразу',
'ai_content' => 'Генерировать посты с ИИ TryPost',
'use_mcp' => 'Создавать посты через Claude, ChatGPT или Cursor',
'plan_calendar' => 'Планировать посты в календаре',
'stay_on_brand' => 'Держать каждый пост в стиле бренда',
'grow_audience' => 'Наращивать аудиторию и вовлечённость',
'drive_sales' => 'Получать больше трафика и продаж',
'manage_clients' => 'Управлять несколькими брендами или клиентами',
'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' => 'Что-то другое',
],
];

View file

@ -73,6 +73,8 @@
'page_title' => 'Giriş yap', 'page_title' => 'Giriş yap',
'email' => 'E-posta adresi', 'email' => 'E-posta adresi',
'password' => 'Parola', 'password' => 'Parola',
'show_password' => 'Parolayı göster',
'hide_password' => 'Parolayı gizle',
'forgot_password' => 'Parolanızı mı unuttunuz?', 'forgot_password' => 'Parolanızı mı unuttunuz?',
'remember_me' => 'Beni hatırla', 'remember_me' => 'Beni hatırla',
'submit' => 'Giriş yap', 'submit' => 'Giriş yap',

View file

@ -21,15 +21,13 @@
'goals_description' => 'Size uyan her şeyi seçin, biz de TryPost\'u sizin için ayarlayalım.', 'goals_description' => 'Size uyan her şeyi seçin, biz de TryPost\'u sizin için ayarlayalım.',
'goals' => [ 'goals' => [
'save_time' => 'Her yere aynı anda paylaşarak zaman kazanmak', 'save_time' => 'Her yere aynı anda paylaşarak zaman kazanmak',
'ai_content' => 'AI ile daha hızlı gönderi oluşturmak', 'ai_content' => 'TryPost AI ile gönderi oluşturmak',
'use_mcp' => 'Claude, ChatGPT veya Cursor ile gönderi oluşturmak',
'plan_calendar' => 'Gönderilerimi bir takvimde planlamak', 'plan_calendar' => 'Gönderilerimi bir takvimde planlamak',
'stay_on_brand' => 'Her gönderiyi marka çizgisinde tutmak', 'stay_on_brand' => 'Her gönderiyi marka çizgisinde tutmak',
'grow_audience' => 'Kitlemi ve etkileşimimi büyütmek', 'grow_audience' => 'Kitlemi ve etkileşimimi büyütmek',
'drive_sales' => 'Daha fazla trafik ve satış elde etmek', 'drive_sales' => 'Daha fazla trafik ve satış elde etmek',
'manage_clients' => 'Birden fazla marka veya müşteri yönetmek', 'manage_clients' => 'Birden fazla marka veya müşteri yönetmek',
'team_collaboration' => 'Ekibimle çalışmak',
'automate_api' => 'API, MCP veya kodla paylaşımı otomatikleştirmek',
'track_performance' => 'Gönderilerimin performansını görmek',
'just_exploring' => 'Şimdilik sadece keşfetmek', 'just_exploring' => 'Şimdilik sadece keşfetmek',
'other' => 'Başka bir şey', 'other' => 'Başka bir şey',
], ],

View file

@ -60,6 +60,6 @@
'support' => [ 'support' => [
'docs' => 'Dokümantasyon', 'docs' => 'Dokümantasyon',
'referral' => '%30 referans kazanın', 'referral' => '%30 referans kazanın',
'stay_updated' => 'Güncel kalın', 'discord' => 'Discord topluluğu',
], ],
]; ];

57
lang/tr/welcome.php Normal file
View file

@ -0,0 +1,57 @@
<?php
declare(strict_types=1);
return [
'title' => 'Sizi en iyi ne tanımlar?',
'description' => 'En yakın seçeneği seçin, deneyiminizi kişiselleştirelim.',
'continue' => 'Devam et',
'subscription_required_title' => 'Hesap sahibi bekleniyor',
'subscription_required_description' => 'Bu hesabın henüz etkin bir aboneliği yok. Hesap sahibinden ödemeyi tamamlamasını isteyin — abonelik etkinleşir etkinleşmez tam erişiminiz olur.',
'subscription_required_owner' => 'Hesap sahibiniz :name.',
'subscription_required_auto' => 'Bu sayfa otomatik olarak güncellenir — yenilemenize gerek yok.',
'progress' => 'Karşılama ilerlemesi',
'go_to_step' => ':step. adıma git',
'step_current' => 'Adım :step (şu anki)',
'personas' => [
'creator' => 'İçerik üreticisi',
'freelancer' => 'Serbest çalışan',
'developer' => 'Geliştirici',
'startup' => 'Girişim',
'agency' => 'Ajans',
'small_business' => 'Küçük işletme',
'marketer' => 'Pazarlamacı',
'online_store' => 'Çevrimiçi mağaza',
'other' => 'Diğer',
],
'goals_title' => 'Hedefiniz nedir?',
'goals_description' => 'Size uyan her şeyi seçin, biz de TryPost\'u sizin için ayarlayalım.',
'goals' => [
'save_time' => 'Her yere aynı anda paylaşarak zaman kazanmak',
'ai_content' => 'TryPost AI ile gönderi oluşturmak',
'use_mcp' => 'Claude, ChatGPT veya Cursor ile gönderi oluşturmak',
'plan_calendar' => 'Gönderilerimi bir takvimde planlamak',
'stay_on_brand' => 'Her gönderiyi marka çizgisinde tutmak',
'grow_audience' => 'Kitlemi ve etkileşimimi büyütmek',
'drive_sales' => 'Daha fazla trafik ve satış elde etmek',
'manage_clients' => 'Birden fazla marka veya müşteri yönetmek',
'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',
],
];

View file

@ -71,6 +71,8 @@
'page_title' => 'Вхід', 'page_title' => 'Вхід',
'email' => 'Адреса email', 'email' => 'Адреса email',
'password' => 'Пароль', 'password' => 'Пароль',
'show_password' => 'Показати пароль',
'hide_password' => 'Приховати пароль',
'forgot_password' => 'Забули пароль?', 'forgot_password' => 'Забули пароль?',
'remember_me' => 'Запам’ятати мене', 'remember_me' => 'Запам’ятати мене',
'submit' => 'Увійти', 'submit' => 'Увійти',

View file

@ -21,15 +21,13 @@
'goals_description' => 'Виберіть усе, що підходить, і ми налаштуємо TryPost для вас.', 'goals_description' => 'Виберіть усе, що підходить, і ми налаштуємо TryPost для вас.',
'goals' => [ 'goals' => [
'save_time' => 'Економити час, публікуючи всюди одразу', 'save_time' => 'Економити час, публікуючи всюди одразу',
'ai_content' => 'Створювати пости швидше з AI', 'ai_content' => 'Генерувати пости з AI TryPost',
'use_mcp' => 'Створювати пости через Claude, ChatGPT або Cursor',
'plan_calendar' => 'Планувати пости в календарі', 'plan_calendar' => 'Планувати пости в календарі',
'stay_on_brand' => 'Тримати кожен пост у стилі бренду', 'stay_on_brand' => 'Тримати кожен пост у стилі бренду',
'grow_audience' => 'Збільшувати аудиторію та залучення', 'grow_audience' => 'Збільшувати аудиторію та залучення',
'drive_sales' => 'Отримувати більше трафіку та продажів', 'drive_sales' => 'Отримувати більше трафіку та продажів',
'manage_clients' => 'Керувати кількома брендами або клієнтами', 'manage_clients' => 'Керувати кількома брендами або клієнтами',
'team_collaboration' => 'Працювати з командою',
'automate_api' => 'Автоматизувати публікацію через API, MCP або код',
'track_performance' => 'Бачити, як працюють мої пости',
'just_exploring' => 'Поки що просто досліджую', 'just_exploring' => 'Поки що просто досліджую',
'other' => 'Щось інше', 'other' => 'Щось інше',
], ],

View file

@ -60,6 +60,6 @@
'support' => [ 'support' => [
'docs' => 'Документація', 'docs' => 'Документація',
'referral' => 'Отримуйте 30% за рефералами', 'referral' => 'Отримуйте 30% за рефералами',
'stay_updated' => 'Слідкуйте за оновленнями', 'discord' => 'Спільнота Discord',
], ],
]; ];

57
lang/uk/welcome.php Normal file
View file

@ -0,0 +1,57 @@
<?php
declare(strict_types=1);
return [
'title' => 'Що найкраще вас описує?',
'description' => 'Оберіть найближчий варіант — ми адаптуємо ваш досвід.',
'continue' => 'Продовжити',
'subscription_required_title' => 'Очікуємо власника акаунта',
'subscription_required_description' => 'У цього акаунта ще немає активної підписки. Попросіть власника завершити оплату — повний доступ з’явиться одразу після активації.',
'subscription_required_owner' => 'Власник вашого акаунта — :name.',
'subscription_required_auto' => 'Ця сторінка оновлюється автоматично — оновлювати вручну не потрібно.',
'progress' => 'Прогрес привітання',
'go_to_step' => 'Перейти до кроку :step',
'step_current' => 'Крок :step (поточний)',
'personas' => [
'creator' => 'Автор контенту',
'freelancer' => 'Фрілансер',
'developer' => 'Розробник',
'startup' => 'Стартап',
'agency' => 'Агенція',
'small_business' => 'Малий бізнес',
'marketer' => 'Маркетолог',
'online_store' => 'Інтернет-магазин',
'other' => 'Інше',
],
'goals_title' => 'Яка ваша мета?',
'goals_description' => 'Виберіть усе, що підходить, і ми налаштуємо TryPost для вас.',
'goals' => [
'save_time' => 'Економити час, публікуючи всюди одразу',
'ai_content' => 'Генерувати пости з AI TryPost',
'use_mcp' => 'Створювати пости через Claude, ChatGPT або Cursor',
'plan_calendar' => 'Планувати пости в календарі',
'stay_on_brand' => 'Тримати кожен пост у стилі бренду',
'grow_audience' => 'Збільшувати аудиторію та залучення',
'drive_sales' => 'Отримувати більше трафіку та продажів',
'manage_clients' => 'Керувати кількома брендами або клієнтами',
'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' => 'Щось інше',
],
];

View file

@ -71,6 +71,8 @@
'page_title' => '登录', 'page_title' => '登录',
'email' => '邮箱地址', 'email' => '邮箱地址',
'password' => '密码', 'password' => '密码',
'show_password' => '显示密码',
'hide_password' => '隐藏密码',
'forgot_password' => '忘记密码?', 'forgot_password' => '忘记密码?',
'remember_me' => '记住我', 'remember_me' => '记住我',
'submit' => '登录', 'submit' => '登录',

View file

@ -21,15 +21,13 @@
'goals_description' => '选择所有符合的选项,我们会为你配置好 TryPost。', 'goals_description' => '选择所有符合的选项,我们会为你配置好 TryPost。',
'goals' => [ 'goals' => [
'save_time' => '一次发布到所有平台,节省时间', 'save_time' => '一次发布到所有平台,节省时间',
'ai_content' => '借助 AI 更快地创建帖子', 'ai_content' => '用 TryPost AI 生成帖子',
'use_mcp' => '通过 Claude、ChatGPT 或 Cursor 创建帖子',
'plan_calendar' => '在日历上规划我的帖子', 'plan_calendar' => '在日历上规划我的帖子',
'stay_on_brand' => '让每一条帖子都符合品牌调性', 'stay_on_brand' => '让每一条帖子都符合品牌调性',
'grow_audience' => '增长我的受众和互动', 'grow_audience' => '增长我的受众和互动',
'drive_sales' => '获得更多流量和销量', 'drive_sales' => '获得更多流量和销量',
'manage_clients' => '管理多个品牌或客户', 'manage_clients' => '管理多个品牌或客户',
'team_collaboration' => '与我的团队协作',
'automate_api' => '通过 API、MCP 或代码自动发帖',
'track_performance' => '查看我的帖子表现',
'just_exploring' => '目前只是随便看看', 'just_exploring' => '目前只是随便看看',
'other' => '其他需求', 'other' => '其他需求',
], ],

View file

@ -60,6 +60,6 @@
'support' => [ 'support' => [
'docs' => '文档', 'docs' => '文档',
'referral' => '赚取 30% 推荐奖励', 'referral' => '赚取 30% 推荐奖励',
'stay_updated' => '获取最新动态', 'discord' => 'Discord 社区',
], ],
]; ];

57
lang/zh/welcome.php Normal file
View file

@ -0,0 +1,57 @@
<?php
declare(strict_types=1);
return [
'title' => '哪项最能描述你?',
'description' => '选择最接近的一项,我们会为你定制体验。',
'continue' => '继续',
'subscription_required_title' => '等待账户所有者',
'subscription_required_description' => '此账户还没有有效订阅。请让账户所有者完成结账 — 订阅生效后您即可获得完整访问权限。',
'subscription_required_owner' => '您的账户所有者是 :name。',
'subscription_required_auto' => '此页面会自动更新 — 无需刷新。',
'progress' => '欢迎进度',
'go_to_step' => '前往第 :step 步',
'step_current' => '第 :step 步(当前)',
'personas' => [
'creator' => '内容创作者',
'freelancer' => '自由职业者',
'developer' => '开发者',
'startup' => '初创公司',
'agency' => '代理机构',
'small_business' => '小型企业',
'marketer' => '营销人员',
'online_store' => '网店',
'other' => '其他',
],
'goals_title' => '你的目标是什么?',
'goals_description' => '选择所有符合的选项,我们会为你配置好 TryPost。',
'goals' => [
'save_time' => '一次发布到所有平台,节省时间',
'ai_content' => '用 TryPost AI 生成帖子',
'use_mcp' => '通过 Claude、ChatGPT 或 Cursor 创建帖子',
'plan_calendar' => '在日历上规划我的帖子',
'stay_on_brand' => '让每一条帖子都符合品牌调性',
'grow_audience' => '增长我的受众和互动',
'drive_sales' => '获得更多流量和销量',
'manage_clients' => '管理多个品牌或客户',
'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' => '其他',
],
];

View file

@ -4,7 +4,7 @@ import {
IconAffiliate, IconAffiliate,
IconAlertTriangle, IconAlertTriangle,
IconBolt, IconBolt,
IconBrandX, IconBrandDiscord,
IconCalendar, IconCalendar,
IconChartBar, IconChartBar,
IconChevronRight, IconChevronRight,
@ -178,9 +178,9 @@ const bottomNavItems = computed(() => [
icon: IconGift, icon: IconGift,
}, },
{ {
title: trans('sidebar.support.stay_updated'), title: trans('sidebar.support.discord'),
href: 'https://x.com/trypostit', href: 'https://trypost.it/discord',
icon: IconBrandX, icon: IconBrandDiscord,
}, },
{ {
title: trans('sidebar.support.docs'), title: trans('sidebar.support.docs'),

View file

@ -1,45 +0,0 @@
<script setup lang="ts">
import { Link } from '@inertiajs/vue3';
import { home } from '@/routes/app';
defineProps<{
title?: string;
description?: string;
step: number;
totalSteps?: number;
wide?: boolean;
}>();
</script>
<template>
<div class="flex min-h-svh flex-col items-center justify-center gap-6 bg-background p-6 md:p-10">
<div class="w-full" :class="wide ? 'max-w-4xl' : 'max-w-xl'">
<div class="flex flex-col gap-8">
<div class="flex flex-col items-center gap-4">
<Link :href="home()" class="flex flex-col items-center gap-2 font-medium">
<img src="/images/trypost/logo-light.png" alt="TryPost" class="dark:hidden h-8 w-auto" />
<img src="/images/trypost/logo-dark.png" alt="TryPost" class="hidden dark:block h-8 w-auto" />
</Link>
<div class="flex items-center gap-2">
<template v-for="i in (totalSteps || 2)" :key="i">
<div
class="h-2 w-8 rounded-full transition-colors"
:class="i <= step ? 'bg-primary' : 'bg-muted'"
/>
</template>
</div>
<div class="space-y-2 text-center">
<h1 class="text-2xl font-bold">{{ title }}</h1>
<p class="text-muted-foreground">
{{ description }}
</p>
</div>
</div>
<slot />
</div>
</div>
</div>
</template>

View file

@ -0,0 +1,118 @@
<script setup lang="ts">
import { Link } from '@inertiajs/vue3';
import { computed } from 'vue';
import {
goals as goalsRoute,
persona as personaRoute,
referralSource as referralSourceRoute,
} from '@/routes/app/welcome';
const props = withDefaults(
defineProps<{
title?: string;
description?: string;
step?: number;
totalSteps?: number;
wide?: boolean;
}>(),
{
title: undefined,
description: undefined,
step: undefined,
totalSteps: 3,
wide: false,
},
);
const stepRoutes = computed(() => [
personaRoute(),
goalsRoute(),
referralSourceRoute(),
]);
const canNavigateTo = (stepNumber: number): boolean =>
props.step !== undefined && stepNumber < props.step;
</script>
<template>
<div
class="flex min-h-svh flex-col items-center justify-center gap-6 bg-background p-6 md:p-10"
>
<div class="w-full" :class="wide ? 'max-w-4xl' : 'max-w-xl'">
<div class="flex flex-col gap-8">
<div class="flex flex-col items-center gap-4">
<Link
:href="personaRoute()"
class="flex flex-col items-center gap-2 font-medium"
>
<img
src="/images/trypost/logo-light.png"
alt="TryPost"
class="h-8 w-auto dark:hidden"
/>
<img
src="/images/trypost/logo-dark.png"
alt="TryPost"
class="hidden h-8 w-auto dark:block"
/>
</Link>
<nav
v-if="step !== undefined"
class="flex items-center gap-2"
:aria-label="$t('welcome.progress')"
>
<template
v-for="stepNumber in totalSteps"
:key="stepNumber"
>
<Link
v-if="canNavigateTo(stepNumber)"
:href="stepRoutes[stepNumber - 1]"
class="flex h-6 w-8 items-center"
:aria-label="
$t('welcome.go_to_step', {
step: String(stepNumber),
})
"
:data-testid="`welcome-step-${stepNumber}`"
>
<span
class="h-2 w-full rounded-full bg-primary transition-opacity hover:opacity-70 motion-reduce:transition-none"
/>
</Link>
<div
v-else
:class="[
'h-2 w-8 rounded-full transition-colors',
stepNumber <= step
? 'bg-primary'
: 'bg-muted',
]"
:aria-current="
stepNumber === step ? 'step' : undefined
"
:aria-label="
stepNumber === step
? $t('welcome.step_current', {
step: String(stepNumber),
})
: undefined
"
/>
</template>
</nav>
<div class="space-y-2 text-center">
<h1 class="text-2xl font-bold">{{ title }}</h1>
<p class="text-muted-foreground">
{{ description }}
</p>
</div>
</div>
<slot />
</div>
</div>
</div>
</template>

View file

@ -1,6 +1,7 @@
<script setup lang="ts"> <script setup lang="ts">
import { Form, Head, usePage } from '@inertiajs/vue3'; import { Form, Head, usePage } from '@inertiajs/vue3';
import { computed } from 'vue'; import { IconEye, IconEyeOff } from '@tabler/icons-vue';
import { computed, ref } from 'vue';
import SocialLogin from '@/components/auth/SocialLogin.vue'; import SocialLogin from '@/components/auth/SocialLogin.vue';
import InputError from '@/components/InputError.vue'; import InputError from '@/components/InputError.vue';
@ -10,6 +11,12 @@ import { Checkbox } from '@/components/ui/checkbox';
import { Input } from '@/components/ui/input'; import { Input } from '@/components/ui/input';
import { Label } from '@/components/ui/label'; import { Label } from '@/components/ui/label';
import { Spinner } from '@/components/ui/spinner'; import { Spinner } from '@/components/ui/spinner';
import {
Tooltip,
TooltipContent,
TooltipProvider,
TooltipTrigger,
} from '@/components/ui/tooltip';
import AuthBase from '@/layouts/AuthLayout.vue'; import AuthBase from '@/layouts/AuthLayout.vue';
import { register } from '@/routes'; import { register } from '@/routes';
import { store } from '@/routes/login'; import { store } from '@/routes/login';
@ -21,61 +28,157 @@ defineProps<{
redirect?: string | null; redirect?: string | null;
}>(); }>();
const showPassword = ref(false);
const page = usePage(); const page = usePage();
const isSelfHosted = computed(() => Boolean(page.props.selfHosted)); const isSelfHosted = computed(() => Boolean(page.props.selfHosted));
</script> </script>
<template> <template>
<AuthBase :title="$t('auth.login.title')" :description="$t('auth.login.description')"> <AuthBase
:title="$t('auth.login.title')"
:description="$t('auth.login.description')"
>
<Head :title="$t('auth.login.page_title')" /> <Head :title="$t('auth.login.page_title')" />
<div v-if="status" class="mb-4 text-center text-sm font-medium text-green-600"> <div
v-if="status"
class="mb-4 text-center text-sm font-medium text-green-600"
>
{{ status }} {{ status }}
</div> </div>
<div class="flex flex-col gap-6"> <div class="flex flex-col gap-6">
<SocialLogin mode="login" /> <SocialLogin mode="login" />
<Form v-bind="store.form()" :reset-on-success="['password']" v-slot="{ errors, processing }" <Form
class="flex flex-col gap-6"> v-bind="store.form()"
<input v-if="redirect" type="hidden" name="redirect" :value="redirect" /> :reset-on-success="['password']"
v-slot="{ errors, processing }"
class="flex flex-col gap-6"
>
<input
v-if="redirect"
type="hidden"
name="redirect"
:value="redirect"
/>
<div class="grid gap-6"> <div class="grid gap-6">
<div class="grid gap-2"> <div class="grid gap-2">
<Label for="email">{{ $t('auth.login.email') }}</Label> <Label for="email">{{ $t('auth.login.email') }}</Label>
<Input id="email" type="email" name="email" autofocus :tabindex="1" autocomplete="email" <Input
placeholder="email@example.com" :default-value="email ?? ''" /> id="email"
type="email"
name="email"
autofocus
:tabindex="1"
autocomplete="email"
placeholder="email@example.com"
:default-value="email ?? ''"
/>
<InputError :message="errors.email" /> <InputError :message="errors.email" />
</div> </div>
<div class="grid gap-2"> <div class="grid gap-2">
<div class="flex items-center justify-between"> <div class="flex items-center justify-between">
<Label for="password">{{ $t('auth.login.password') }}</Label> <Label for="password">{{
<TextLink :href="request()" class="text-sm" :tabindex="5"> $t('auth.login.password')
}}</Label>
<TextLink
:href="request()"
class="text-sm"
:tabindex="5"
>
{{ $t('auth.login.forgot_password') }} {{ $t('auth.login.forgot_password') }}
</TextLink> </TextLink>
</div> </div>
<Input id="password" type="password" name="password" :tabindex="2" <div class="relative">
autocomplete="current-password" :placeholder="$t('auth.login.password')" /> <Input
id="password"
:type="showPassword ? 'text' : 'password'"
name="password"
:tabindex="2"
autocomplete="current-password"
:placeholder="$t('auth.login.password')"
/>
<div
class="absolute inset-y-0 end-0 flex items-center pe-3"
>
<TooltipProvider>
<Tooltip>
<TooltipTrigger as-child>
<button
type="button"
:tabindex="-1"
class="cursor-pointer text-muted-foreground hover:text-foreground"
@click="
showPassword = !showPassword
"
>
<IconEyeOff
v-if="showPassword"
class="size-4"
/>
<IconEye
v-else
class="size-4"
/>
</button>
</TooltipTrigger>
<TooltipContent>
<p>
{{
showPassword
? $t(
'auth.login.hide_password',
)
: $t(
'auth.login.show_password',
)
}}
</p>
</TooltipContent>
</Tooltip>
</TooltipProvider>
</div>
</div>
<InputError :message="errors.password" /> <InputError :message="errors.password" />
</div> </div>
<div class="flex items-center justify-between"> <div class="flex items-center justify-between">
<Label for="remember" class="flex items-center space-x-3"> <Label
<Checkbox id="remember" name="remember" :tabindex="3" /> for="remember"
class="flex items-center space-x-3"
>
<Checkbox
id="remember"
name="remember"
:tabindex="3"
/>
<span>{{ $t('auth.login.remember_me') }}</span> <span>{{ $t('auth.login.remember_me') }}</span>
</Label> </Label>
</div> </div>
<Button type="submit" class="mt-4 w-full" :tabindex="4" :disabled="processing" data-test="login-button"> <Button
type="submit"
class="mt-4 w-full"
:tabindex="4"
:disabled="processing"
data-test="login-button"
>
<Spinner v-if="processing" /> <Spinner v-if="processing" />
{{ $t('auth.login.submit') }} {{ $t('auth.login.submit') }}
</Button> </Button>
</div> </div>
<div v-if="!isSelfHosted" class="text-center text-sm text-muted-foreground"> <div
v-if="!isSelfHosted"
class="text-center text-sm text-muted-foreground"
>
{{ $t('auth.login.no_account') }} {{ $t('auth.login.no_account') }}
<TextLink :href="register()" :tabindex="5">{{ $t('auth.login.sign_up') }}</TextLink> <TextLink :href="register()" :tabindex="5">{{
$t('auth.login.sign_up')
}}</TextLink>
</div> </div>
</Form> </Form>
</div> </div>

View file

@ -1,83 +0,0 @@
<script setup lang="ts">
import { Head, useForm } from '@inertiajs/vue3';
import { IconArrowRight } from '@tabler/icons-vue';
import { computed } from 'vue';
import { checkout } from '@/actions/App/Http/Controllers/App/OnboardingController';
import NetworkConnectGrid, {
type AvailablePlatform,
type ConnectedAccount,
} from '@/components/accounts/NetworkConnectGrid.vue';
import { Button } from '@/components/ui/button';
import { useTracking } from '@/composables/useTracking';
const props = defineProps<{
platforms: AvailablePlatform[];
accounts: ConnectedAccount[];
plan: { name: string; interval: string };
}>();
const form = useForm({});
const { trackBeginCheckout } = useTracking();
const hasConnected = computed((): boolean => props.accounts.length > 0);
const submit = (): void => {
if (!hasConnected.value || form.processing) {
return;
}
trackBeginCheckout({ name: props.plan.name, interval: props.plan.interval });
form.post(checkout.url());
};
</script>
<template>
<Head :title="$t('onboarding.connect.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-7xl 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.connect.title') }}
</h1>
<p class="text-balance text-base text-muted-foreground">
{{ $t('onboarding.connect.description') }}
</p>
</div>
<NetworkConnectGrid
:platforms="platforms"
:connected-accounts="accounts"
grid-class="grid-cols-3 sm:grid-cols-4 lg:grid-cols-7"
/>
<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="!hasConnected || form.processing"
@click="submit"
>
{{ $t('onboarding.continue') }}
<IconArrowRight class="size-4" />
</Button>
<p v-if="!hasConnected" class="text-center text-xs text-foreground/60">
{{ $t('onboarding.connect.must_connect') }}
</p>
</div>
</div>
</section>
</template>

View file

@ -1,147 +0,0 @@
<script setup lang="ts">
import { Head, useForm } from '@inertiajs/vue3';
import {
IconArrowRight,
IconCalendar,
IconChartBar,
IconCheck,
IconClock,
IconCoin,
IconCompass,
IconDots,
IconPalette,
IconRobot,
IconSparkles,
IconTrendingUp,
IconUsers,
IconUsersGroup,
} 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/goals';
const props = defineProps<{
goals: string[];
selected?: string[] | null;
}>();
const EXCLUSIVE_GOAL = 'just_exploring';
const form = useForm<{ goals: string[] }>({ goals: props.selected ?? [] });
const goalMeta: Record<string, { icon: FunctionalComponent; color: string }> = {
save_time: { icon: IconClock, color: 'text-amber-600' },
ai_content: { icon: IconSparkles, color: 'text-violet-700' },
plan_calendar: { icon: IconCalendar, color: 'text-blue-700' },
stay_on_brand: { icon: IconPalette, color: 'text-orange-600' },
grow_audience: { icon: IconTrendingUp, color: 'text-rose-600' },
drive_sales: { icon: IconCoin, color: 'text-emerald-600' },
manage_clients: { icon: IconUsersGroup, color: 'text-cyan-600' },
team_collaboration: { icon: IconUsers, color: 'text-fuchsia-600' },
automate_api: { icon: IconRobot, color: 'text-teal-600' },
track_performance: { icon: IconChartBar, color: 'text-indigo-600' },
just_exploring: { icon: IconCompass, color: 'text-sky-600' },
other: { icon: IconDots, color: 'text-foreground' },
};
const goalIcon = (value: string): FunctionalComponent => goalMeta[value]?.icon ?? IconDots;
const goalColor = (value: string): string => goalMeta[value]?.color ?? 'text-foreground';
const goalLabel = (value: string): string => trans(`onboarding.goals.${value}`);
const isSelected = (value: string): boolean => form.goals.includes(value);
const toggle = (value: string): void => {
if (value === EXCLUSIVE_GOAL) {
form.goals = isSelected(value) ? [] : [value];
return;
}
const withoutExclusive = form.goals.filter((goal) => goal !== EXCLUSIVE_GOAL);
form.goals = isSelected(value)
? withoutExclusive.filter((goal) => goal !== value)
: [...withoutExclusive, value];
};
const submit = (): void => {
if (form.goals.length === 0 || form.processing) {
return;
}
form.post(store.url());
};
</script>
<template>
<Head :title="$t('onboarding.goals_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.goals_title') }}
</h1>
<p class="text-balance text-base text-muted-foreground">
{{ $t('onboarding.goals_description') }}
</p>
</div>
<div class="grid grid-cols-1 gap-4 sm:grid-cols-2 lg:grid-cols-3">
<button
v-for="goal in goals"
:key="goal"
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(goal) ? 'bg-violet-100' : 'bg-card',
]"
@click="toggle(goal)"
>
<span class="inline-flex size-10 items-center justify-center rounded-2xl border-2 border-foreground bg-card shadow-2xs">
<component
:is="goalIcon(goal)"
:class="[goalColor(goal), 'size-5']"
stroke-width="2.25"
/>
</span>
<span class="text-base font-bold tracking-tight text-foreground">
{{ goalLabel(goal) }}
</span>
<span
v-if="isSelected(goal)"
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.goals.length === 0 || form.processing"
@click="submit"
>
{{ $t('onboarding.continue') }}
<IconArrowRight class="size-4" />
</Button>
</div>
</div>
</section>
</template>

View file

@ -1,127 +0,0 @@
<script setup lang="ts">
import { Head, useForm } from '@inertiajs/vue3';
import {
IconArrowRight,
IconBriefcase,
IconBuildingSkyscraper,
IconBuildingStore,
IconCheck,
IconCode,
IconDots,
IconRocket,
IconShoppingBag,
IconSpeakerphone,
IconUser,
} 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';
const props = defineProps<{
personas: string[];
selected?: string | null;
}>();
const form = useForm({ persona: props.selected ?? '' });
const personaMeta: Record<string, { icon: FunctionalComponent; color: string }> = {
creator: { icon: IconUser, color: 'text-rose-600' },
freelancer: { icon: IconBriefcase, color: 'text-amber-600' },
developer: { icon: IconCode, color: 'text-cyan-600' },
startup: { icon: IconRocket, color: 'text-violet-700' },
agency: { icon: IconBuildingSkyscraper, color: 'text-blue-700' },
small_business: { icon: IconBuildingStore, color: 'text-emerald-600' },
marketer: { icon: IconSpeakerphone, color: 'text-fuchsia-600' },
online_store: { icon: IconShoppingBag, color: 'text-teal-600' },
other: { icon: IconDots, color: 'text-sky-600' },
};
const personaIcon = (value: string): FunctionalComponent => personaMeta[value]?.icon ?? IconDots;
const personaColor = (value: string): string => personaMeta[value]?.color ?? 'text-foreground';
const personaLabel = (value: string): string => trans(`onboarding.personas.${value}`);
const select = (value: string): void => {
form.persona = value;
};
const submit = (): void => {
if (!form.persona || form.processing) {
return;
}
form.post(store.url());
};
</script>
<template>
<Head :title="$t('onboarding.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.title') }}
</h1>
<p class="text-balance text-base text-muted-foreground">
{{ $t('onboarding.description') }}
</p>
</div>
<div class="grid grid-cols-1 gap-4 sm:grid-cols-2 lg:grid-cols-3">
<button
v-for="persona in personas"
:key="persona"
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',
form.persona === persona ? 'bg-violet-100' : 'bg-card',
]"
@click="select(persona)"
>
<span class="inline-flex size-10 items-center justify-center rounded-2xl border-2 border-foreground bg-card shadow-2xs">
<component
:is="personaIcon(persona)"
:class="[personaColor(persona), 'size-5']"
stroke-width="2.25"
/>
</span>
<span class="text-base font-bold tracking-tight text-foreground">
{{ personaLabel(persona) }}
</span>
<span
v-if="form.persona === persona"
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.persona || form.processing"
@click="submit"
>
{{ $t('onboarding.continue') }}
<IconArrowRight class="size-4" />
</Button>
</div>
</div>
</section>
</template>

View file

@ -1,135 +0,0 @@
<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

@ -0,0 +1,194 @@
<script setup lang="ts">
import { Head, useForm } from '@inertiajs/vue3';
import {
IconCalendar,
IconCheck,
IconClock,
IconCoin,
IconCompass,
IconDots,
IconPalette,
IconPlug,
IconSparkles,
IconTrendingUp,
IconUsersGroup,
} from '@tabler/icons-vue';
import { trans } from 'laravel-vue-i18n';
import type { FunctionalComponent } from 'vue';
import InputError from '@/components/InputError.vue';
import { Button } from '@/components/ui/button';
import WelcomeLayout from '@/layouts/WelcomeLayout.vue';
import { store } from '@/routes/app/welcome/goals';
const props = defineProps<{
goals: string[];
selected?: string[] | null;
}>();
const EXCLUSIVE_GOAL = 'just_exploring';
// Drop removed/legacy goal values so mid-welcome users aren't soft-locked
// with selections that fail Rule::enum(Goal::class) on submit.
const form = useForm<{ goals: string[] }>({
goals: (props.selected ?? []).filter((goal) => props.goals.includes(goal)),
});
const goalMeta: Record<
string,
{ icon: FunctionalComponent; iconClass: string; badge: string }
> = {
save_time: {
icon: IconClock,
iconClass: 'text-amber-700',
badge: 'bg-amber-100',
},
ai_content: {
icon: IconSparkles,
iconClass: 'text-violet-700',
badge: 'bg-violet-100',
},
use_mcp: {
icon: IconPlug,
iconClass: 'text-teal-700',
badge: 'bg-teal-100',
},
plan_calendar: {
icon: IconCalendar,
iconClass: 'text-blue-700',
badge: 'bg-blue-100',
},
stay_on_brand: {
icon: IconPalette,
iconClass: 'text-orange-700',
badge: 'bg-orange-100',
},
grow_audience: {
icon: IconTrendingUp,
iconClass: 'text-rose-700',
badge: 'bg-rose-100',
},
drive_sales: {
icon: IconCoin,
iconClass: 'text-emerald-700',
badge: 'bg-emerald-100',
},
manage_clients: {
icon: IconUsersGroup,
iconClass: 'text-cyan-700',
badge: 'bg-cyan-100',
},
just_exploring: {
icon: IconCompass,
iconClass: 'text-sky-700',
badge: 'bg-sky-100',
},
other: {
icon: IconDots,
iconClass: 'text-foreground',
badge: 'bg-muted',
},
};
const metaFor = (value: string) =>
goalMeta[value] ?? {
icon: IconDots,
iconClass: 'text-foreground',
badge: 'bg-muted',
};
const goalLabel = (value: string): string => trans(`welcome.goals.${value}`);
const isSelected = (value: string): boolean => form.goals.includes(value);
const toggle = (value: string): void => {
if (value === EXCLUSIVE_GOAL) {
form.goals = isSelected(value) ? [] : [value];
return;
}
const withoutExclusive = form.goals.filter(
(goal) => goal !== EXCLUSIVE_GOAL,
);
form.goals = isSelected(value)
? withoutExclusive.filter((goal) => goal !== value)
: [...withoutExclusive, value];
};
const submit = (): void => {
if (form.goals.length === 0 || form.processing) {
return;
}
form.submit(store());
};
</script>
<template>
<Head :title="$t('welcome.goals_title')" />
<WelcomeLayout
:title="$t('welcome.goals_title')"
:description="$t('welcome.goals_description')"
:step="2"
wide
>
<div class="flex flex-wrap justify-center gap-2.5">
<button
v-for="goal in goals"
:key="goal"
type="button"
:aria-pressed="isSelected(goal)"
:data-testid="`welcome-goal-${goal}`"
:class="[
'inline-flex cursor-pointer items-center gap-3 rounded-full border-2 border-foreground py-2.5 ps-2.5 pe-5 text-start shadow-2xs transition-shadow hover:shadow-md',
isSelected(goal) ? 'bg-violet-100' : 'bg-card',
]"
@click="toggle(goal)"
>
<span
:class="[
'inline-flex size-11 shrink-0 items-center justify-center rounded-full border-2 border-foreground shadow-2xs',
metaFor(goal).badge,
]"
>
<component
:is="metaFor(goal).icon"
:class="[metaFor(goal).iconClass, 'size-6']"
stroke-width="2"
/>
</span>
<span
class="text-sm font-bold tracking-tight text-foreground sm:text-base"
>
{{ goalLabel(goal) }}
</span>
<span
v-if="isSelected(goal)"
class="inline-flex size-5 shrink-0 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 flex w-full max-w-sm flex-col items-center gap-3">
<InputError :message="form.errors.goals" />
<Button
type="button"
size="lg"
class="w-full rounded-full"
:disabled="form.goals.length === 0 || form.processing"
data-testid="welcome-goals-continue"
@click="submit"
>
{{ $t('welcome.continue') }}
</Button>
</div>
</WelcomeLayout>
</template>

View file

@ -0,0 +1,169 @@
<script setup lang="ts">
import { Head, useForm } from '@inertiajs/vue3';
import {
IconBriefcase,
IconBuildingSkyscraper,
IconBuildingStore,
IconCheck,
IconCode,
IconDots,
IconRocket,
IconShoppingBag,
IconSpeakerphone,
IconUser,
} from '@tabler/icons-vue';
import { trans } from 'laravel-vue-i18n';
import type { FunctionalComponent } from 'vue';
import InputError from '@/components/InputError.vue';
import { Button } from '@/components/ui/button';
import WelcomeLayout from '@/layouts/WelcomeLayout.vue';
import { store } from '@/routes/app/welcome/persona';
const props = defineProps<{
personas: string[];
selected?: string | null;
}>();
const form = useForm({ persona: props.selected ?? '' });
const personaMeta: Record<
string,
{ icon: FunctionalComponent; iconClass: string; badge: string }
> = {
creator: {
icon: IconUser,
iconClass: 'text-rose-700',
badge: 'bg-rose-100',
},
freelancer: {
icon: IconBriefcase,
iconClass: 'text-amber-700',
badge: 'bg-amber-100',
},
developer: {
icon: IconCode,
iconClass: 'text-cyan-700',
badge: 'bg-cyan-100',
},
startup: {
icon: IconRocket,
iconClass: 'text-violet-700',
badge: 'bg-violet-100',
},
agency: {
icon: IconBuildingSkyscraper,
iconClass: 'text-blue-700',
badge: 'bg-blue-100',
},
small_business: {
icon: IconBuildingStore,
iconClass: 'text-emerald-700',
badge: 'bg-emerald-100',
},
marketer: {
icon: IconSpeakerphone,
iconClass: 'text-fuchsia-700',
badge: 'bg-fuchsia-100',
},
online_store: {
icon: IconShoppingBag,
iconClass: 'text-teal-700',
badge: 'bg-teal-100',
},
other: {
icon: IconDots,
iconClass: 'text-foreground',
badge: 'bg-muted',
},
};
const metaFor = (value: string) =>
personaMeta[value] ?? {
icon: IconDots,
iconClass: 'text-foreground',
badge: 'bg-muted',
};
const personaLabel = (value: string): string =>
trans(`welcome.personas.${value}`);
const select = (value: string): void => {
form.persona = value;
};
const submit = (): void => {
if (!form.persona || form.processing) {
return;
}
form.submit(store());
};
</script>
<template>
<Head :title="$t('welcome.title')" />
<WelcomeLayout
:title="$t('welcome.title')"
:description="$t('welcome.description')"
:step="1"
wide
>
<div class="flex flex-wrap justify-center gap-2.5">
<button
v-for="persona in personas"
:key="persona"
type="button"
:aria-pressed="form.persona === persona"
:data-testid="`welcome-persona-${persona}`"
:class="[
'inline-flex cursor-pointer items-center gap-3 rounded-full border-2 border-foreground py-2.5 ps-2.5 pe-5 text-start shadow-2xs transition-shadow hover:shadow-md',
form.persona === persona ? 'bg-violet-100' : 'bg-card',
]"
@click="select(persona)"
>
<span
:class="[
'inline-flex size-11 shrink-0 items-center justify-center rounded-full border-2 border-foreground shadow-2xs',
metaFor(persona).badge,
]"
>
<component
:is="metaFor(persona).icon"
:class="[metaFor(persona).iconClass, 'size-6']"
stroke-width="2"
/>
</span>
<span
class="text-sm font-bold tracking-tight text-foreground sm:text-base"
>
{{ personaLabel(persona) }}
</span>
<span
v-if="form.persona === persona"
class="inline-flex size-5 shrink-0 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 flex w-full max-w-sm flex-col items-center gap-3">
<InputError :message="form.errors.persona" />
<Button
type="button"
size="lg"
class="w-full rounded-full"
:disabled="!form.persona || form.processing"
data-testid="welcome-persona-continue"
@click="submit"
>
{{ $t('welcome.continue') }}
</Button>
</div>
</WelcomeLayout>
</template>

View file

@ -0,0 +1,229 @@
<script setup lang="ts">
import { Head, useForm } from '@inertiajs/vue3';
import {
IconArticle,
IconBrandInstagram,
IconBrandLinkedin,
IconBrandProducthunt,
IconBrandReddit,
IconBrandTiktokFilled,
IconBrandXFilled,
IconBrandYoutubeFilled,
IconCheck,
IconDots,
IconSparkles,
IconUsers,
} from '@tabler/icons-vue';
import { trans } from 'laravel-vue-i18n';
import type { FunctionalComponent } from 'vue';
import InputError from '@/components/InputError.vue';
import { Button } from '@/components/ui/button';
import { useTracking } from '@/composables/useTracking';
import WelcomeLayout from '@/layouts/WelcomeLayout.vue';
import { store } from '@/routes/app/welcome/referral-source';
const props = defineProps<{
sources: string[];
selected?: string | null;
plan: { name: string; interval: string };
}>();
const form = useForm<{ referral_source: string }>({
referral_source: props.selected ?? '',
});
const { trackBeginCheckout } = useTracking();
type SourceMeta = {
icon?: FunctionalComponent;
logo?: string;
iconClass: string;
badge: string;
};
const sourceMeta: Record<string, SourceMeta> = {
google: {
logo: '/images/social/google.svg',
iconClass: '',
badge: 'bg-white',
},
x: {
icon: IconBrandXFilled,
iconClass: 'text-white',
badge: 'bg-black',
},
linkedin: {
icon: IconBrandLinkedin,
iconClass: 'text-white',
badge: 'bg-[#0A66C2]',
},
youtube: {
icon: IconBrandYoutubeFilled,
iconClass: 'text-white',
badge: 'bg-[#FF0000]',
},
tiktok: {
icon: IconBrandTiktokFilled,
iconClass: 'text-white',
badge: 'bg-black',
},
instagram: {
icon: IconBrandInstagram,
iconClass: 'text-white',
badge: 'bg-gradient-to-br from-[#f9ce34] via-[#ee2a7b] to-[#6228d7]',
},
reddit: {
icon: IconBrandReddit,
iconClass: 'text-white',
badge: 'bg-[#FF4500]',
},
product_hunt: {
icon: IconBrandProducthunt,
iconClass: 'text-[#FF6154]',
badge: 'bg-white',
},
ai_assistant: {
icon: IconSparkles,
iconClass: 'text-violet-700',
badge: 'bg-violet-100',
},
friend: {
icon: IconUsers,
iconClass: 'text-emerald-700',
badge: 'bg-emerald-100',
},
blog: {
icon: IconArticle,
iconClass: 'text-amber-800',
badge: 'bg-amber-100',
},
other: {
icon: IconDots,
iconClass: 'text-foreground',
badge: 'bg-muted',
},
};
const metaFor = (value: string): SourceMeta =>
sourceMeta[value] ?? {
icon: IconDots,
iconClass: 'text-foreground',
badge: 'bg-muted',
};
const sourceLabel = (value: string): string =>
trans(`welcome.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;
}
let shouldTrackCheckout = false;
form.submit(store(), {
onStart: () => {
shouldTrackCheckout = true;
},
onError: () => {
shouldTrackCheckout = false;
},
onHttpException: () => {
shouldTrackCheckout = false;
},
onFinish: () => {
if (!shouldTrackCheckout) {
return;
}
// Inertia::location navigates away before onSuccess; onFinish still
// runs and dataLayer can accept the event before unload.
trackBeginCheckout({
name: props.plan.name,
interval: props.plan.interval,
});
},
});
};
</script>
<template>
<Head :title="$t('welcome.referral_source_title')" />
<WelcomeLayout
:title="$t('welcome.referral_source_title')"
:description="$t('welcome.referral_source_description')"
:step="3"
wide
>
<div class="flex flex-wrap justify-center gap-2.5">
<button
v-for="source in sources"
:key="source"
type="button"
:aria-pressed="isSelected(source)"
:data-testid="`welcome-source-${source}`"
:class="[
'inline-flex cursor-pointer items-center gap-3 rounded-full border-2 border-foreground py-2.5 ps-2.5 pe-5 text-start shadow-2xs transition-shadow hover:shadow-md',
isSelected(source) ? 'bg-violet-100' : 'bg-card',
]"
@click="select(source)"
>
<span
:class="[
'inline-flex size-11 shrink-0 items-center justify-center rounded-full border-2 border-foreground shadow-2xs',
metaFor(source).badge,
]"
>
<img
v-if="metaFor(source).logo"
:src="metaFor(source).logo"
:alt="sourceLabel(source)"
class="size-6"
/>
<component
:is="metaFor(source).icon"
v-else
:class="[metaFor(source).iconClass, 'size-6']"
stroke-width="2"
/>
</span>
<span
class="text-sm font-bold tracking-tight text-foreground sm:text-base"
>
{{ sourceLabel(source) }}
</span>
<span
v-if="isSelected(source)"
class="inline-flex size-5 shrink-0 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 flex w-full max-w-sm flex-col items-center gap-3">
<InputError :message="form.errors.referral_source" />
<Button
type="button"
size="lg"
class="w-full rounded-full"
:disabled="form.referral_source === '' || form.processing"
data-testid="welcome-start-checkout"
@click="submit"
>
{{ $t('welcome.continue') }}
</Button>
</div>
</WelcomeLayout>
</template>

View file

@ -0,0 +1,49 @@
<script setup lang="ts">
import { Head, usePoll } from '@inertiajs/vue3';
import { IconCreditCard } from '@tabler/icons-vue';
import WelcomeLayout from '@/layouts/WelcomeLayout.vue';
defineProps<{
ownerName: string | null;
}>();
// The owner may be checking out in another tab once the subscription is
// active, the next reload redirects into the app. Only `auth` is reloaded:
// the redirect decision is server-side and the rest of the props are dead
// weight on a holding screen.
usePoll(10000, { only: ['auth'] });
</script>
<template>
<Head :title="$t('welcome.subscription_required_title')" />
<WelcomeLayout
:title="$t('welcome.subscription_required_title')"
:description="$t('welcome.subscription_required_description')"
>
<div
class="flex flex-col items-center gap-4 text-center"
data-testid="welcome-subscription-required"
>
<span
class="inline-flex size-14 -rotate-2 items-center justify-center rounded-2xl border-2 border-foreground bg-violet-100 shadow-2xs"
>
<IconCreditCard class="size-7 text-foreground" />
</span>
<p
v-if="ownerName"
class="text-sm font-semibold text-muted-foreground"
>
{{
$t('welcome.subscription_required_owner', {
name: ownerName,
})
}}
</p>
<p class="text-xs text-muted-foreground/80">
{{ $t('welcome.subscription_required_auto') }}
</p>
</div>
</WelcomeLayout>
</template>

View file

@ -12,7 +12,6 @@
use App\Http\Controllers\App\LinkPreviewController; use App\Http\Controllers\App\LinkPreviewController;
use App\Http\Controllers\App\McpSettingsController; use App\Http\Controllers\App\McpSettingsController;
use App\Http\Controllers\App\NotificationController; use App\Http\Controllers\App\NotificationController;
use App\Http\Controllers\App\OnboardingController;
use App\Http\Controllers\App\PostAiCreateController; use App\Http\Controllers\App\PostAiCreateController;
use App\Http\Controllers\App\PostAiGenerateController; use App\Http\Controllers\App\PostAiGenerateController;
use App\Http\Controllers\App\PostAiRegenerateMediaController; use App\Http\Controllers\App\PostAiRegenerateMediaController;
@ -28,6 +27,7 @@
use App\Http\Controllers\App\Settings\SettingsController; use App\Http\Controllers\App\Settings\SettingsController;
use App\Http\Controllers\App\Settings\UsageController; use App\Http\Controllers\App\Settings\UsageController;
use App\Http\Controllers\App\UnsplashController; use App\Http\Controllers\App\UnsplashController;
use App\Http\Controllers\App\WelcomeController;
use App\Http\Controllers\App\WorkspaceController; use App\Http\Controllers\App\WorkspaceController;
use App\Http\Controllers\App\WorkspaceInviteController; use App\Http\Controllers\App\WorkspaceInviteController;
use App\Http\Controllers\App\WorkspaceLabelController; use App\Http\Controllers\App\WorkspaceLabelController;
@ -58,14 +58,16 @@
})->name('app.home'); })->name('app.home');
Route::get('subscribe', [BillingController::class, 'subscribe'])->name('app.subscribe'); Route::get('subscribe', [BillingController::class, 'subscribe'])->name('app.subscribe');
Route::get('onboarding', [OnboardingController::class, 'index'])->name('app.onboarding'); Route::get('welcome', fn () => redirect()->route('app.welcome.persona'))->name('app.welcome');
Route::post('onboarding', [OnboardingController::class, 'store'])->name('app.onboarding.store'); Route::get('welcome/persona', [WelcomeController::class, 'persona'])->name('app.welcome.persona');
Route::get('onboarding/goals', [OnboardingController::class, 'goals'])->name('app.onboarding.goals'); Route::post('welcome/persona', [WelcomeController::class, 'storePersona'])->name('app.welcome.persona.store');
Route::post('onboarding/goals', [OnboardingController::class, 'storeGoals'])->name('app.onboarding.goals.store'); Route::get('welcome/goals', [WelcomeController::class, 'goals'])->name('app.welcome.goals');
Route::get('onboarding/referral-source', [OnboardingController::class, 'referralSource'])->name('app.onboarding.referral-source'); Route::post('welcome/goals', [WelcomeController::class, 'storeGoals'])->name('app.welcome.goals.store');
Route::post('onboarding/referral-source', [OnboardingController::class, 'storeReferralSource'])->name('app.onboarding.referral-source.store'); Route::get('welcome/referral-source', [WelcomeController::class, 'referralSource'])->name('app.welcome.referral-source');
Route::get('onboarding/connect', [OnboardingController::class, 'connect'])->name('app.onboarding.connect'); Route::get('welcome/subscription-required', [WelcomeController::class, 'subscriptionRequired'])->name('app.welcome.subscription-required');
Route::post('onboarding/connect', [OnboardingController::class, 'checkout'])->name('app.onboarding.checkout'); Route::post('welcome/referral-source', [WelcomeController::class, 'storeReferralSource'])
->middleware('throttle:6,1')
->name('app.welcome.referral-source.store');
Route::get('billing/processing', [BillingController::class, 'processing'])->name('app.billing.processing'); Route::get('billing/processing', [BillingController::class, 'processing'])->name('app.billing.processing');
Route::get('workspaces/create', [WorkspaceController::class, 'create'])->name('app.workspaces.create'); Route::get('workspaces/create', [WorkspaceController::class, 'create'])->name('app.workspaces.create');

View file

@ -31,12 +31,12 @@
$response->assertRedirect(route('login')); $response->assertRedirect(route('login'));
}); });
test('subscribe redirects to onboarding', function () { test('subscribe redirects to welcome', function () {
config(['trypost.self_hosted' => false]); config(['trypost.self_hosted' => false]);
$response = $this->actingAs($this->user)->get(route('app.subscribe')); $response = $this->actingAs($this->user)->get(route('app.subscribe'));
$response->assertRedirect(route('app.onboarding')); $response->assertRedirect(route('app.welcome.persona'));
}); });
test('swapToYearly redirects to calendar in self hosted mode', function () { test('swapToYearly redirects to calendar in self hosted mode', function () {

View file

@ -280,10 +280,10 @@
->assertForbidden(); ->assertForbidden();
}); });
it('redirects to onboarding when the account has no app access', function (): void { it('redirects to welcome when the account has no app access', function (): void {
$this->user->account->subscriptions()->delete(); $this->user->account->subscriptions()->delete();
$this->actingAs($this->user->fresh()) $this->actingAs($this->user->fresh())
->get(route('app.mcp.index')) ->get(route('app.mcp.index'))
->assertRedirect(route('app.onboarding')); ->assertRedirect(route('app.welcome.persona'));
}); });

View file

@ -11,13 +11,28 @@
config()->set('trypost.self_hosted', false); config()->set('trypost.self_hosted', false);
}); });
test('redirects to onboarding when account has no active subscription', function () { test('redirects owners to welcome when account has no active subscription', function () {
$account = Account::factory()->create(); $account = Account::factory()->create();
$user = User::factory()->create(['account_id' => $account->id]); $user = User::factory()->create(['account_id' => $account->id]);
$this->actingAs($user) $this->actingAs($user)
->get(route('app.calendar')) ->get(route('app.calendar'))
->assertRedirect(route('app.onboarding')); ->assertRedirect(route('app.welcome.persona'));
});
test('redirects members without app access straight to subscription required', function () {
$owner = User::factory()->create();
$member = User::factory()->create(['account_id' => $owner->account_id]);
$workspace = Workspace::factory()->create([
'account_id' => $owner->account_id,
'user_id' => $owner->id,
]);
$workspace->members()->attach($member->id, ['role' => Role::Member->value]);
$member->update(['current_workspace_id' => $workspace->id]);
$this->actingAs($member->fresh())
->get(route('app.calendar'))
->assertRedirect(route('app.welcome.subscription-required'));
}); });
test('redirects to workspace create when subscribed but no workspace', function () { test('redirects to workspace create when subscribed but no workspace', function () {

View file

@ -32,7 +32,7 @@
$response = $this->actingAs($user->fresh())->get(route('app.accounts')); $response = $this->actingAs($user->fresh())->get(route('app.accounts'));
$response->assertRedirect(route('app.onboarding')); $response->assertRedirect(route('app.welcome.persona'));
}); });
test('user with active subscription can access the app', function () { test('user with active subscription can access the app', function () {

View file

@ -1,588 +0,0 @@
<?php
declare(strict_types=1);
use App\Actions\Billing\StartSubscriptionCheckout;
use App\Enums\Plan\Slug;
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;
use App\Models\SocialAccount;
use App\Models\User;
use App\Models\Workspace;
use Illuminate\Support\Facades\Bus;
beforeEach(function () {
config(['trypost.self_hosted' => false]);
$this->user = User::factory()->create();
});
/**
* Give the acting user a current workspace under their account.
*/
function onboardingWorkspace(User $user): Workspace
{
$workspace = Workspace::factory()->create([
'user_id' => $user->id,
'account_id' => $user->account_id,
]);
$user->update(['current_workspace_id' => $workspace->id]);
return $workspace;
}
function subscribeOnboardingAccount(Account $account): void
{
$account->subscriptions()->create([
'type' => Account::SUBSCRIPTION_NAME,
'stripe_id' => 'sub_'.fake()->uuid(),
'stripe_status' => 'active',
'stripe_price' => 'price_123',
]);
}
test('onboarding renders the persona selection for an unsubscribed account', function () {
$response = $this->actingAs($this->user)->get(route('app.onboarding'));
$response->assertOk();
$response->assertInertia(fn ($page) => $page
->component('onboarding/Index')
->has('personas', count(Persona::cases()))
);
});
test('onboarding redirects to calendar in self-hosted mode', function () {
config(['trypost.self_hosted' => true]);
$response = $this->actingAs($this->user)->get(route('app.onboarding'));
$response->assertRedirect(route('app.calendar'));
});
test('onboarding redirects to calendar when already subscribed', function () {
subscribeOnboardingAccount($this->user->account);
$response = $this->actingAs($this->user->fresh())->get(route('app.onboarding'));
$response->assertRedirect(route('app.calendar'));
});
test('onboarding store rejects an invalid persona', function () {
$response = $this->actingAs($this->user)->post(route('app.onboarding.store'), [
'persona' => 'not-a-persona',
]);
$response->assertSessionHasErrors('persona');
expect($this->user->fresh()->persona)->toBeNull();
});
test('onboarding store requires a persona', function () {
$response = $this->actingAs($this->user)->post(route('app.onboarding.store'), []);
$response->assertSessionHasErrors('persona');
});
test('onboarding store does nothing in self-hosted mode', function () {
config(['trypost.self_hosted' => true]);
$response = $this->actingAs($this->user)->post(route('app.onboarding.store'), [
'persona' => Persona::Agency->value,
]);
$response->assertRedirect(route('app.calendar'));
expect($this->user->fresh()->persona)->toBeNull();
});
test('onboarding store saves the persona, mirrors to PostHog and advances to the goals step', function () {
config(['services.posthog.enabled' => true, 'services.posthog.api_key' => 'phc_test']);
Bus::fake();
$response = $this->actingAs($this->user)->post(route('app.onboarding.store'), [
'persona' => Persona::Agency->value,
]);
$response->assertRedirect(route('app.onboarding.goals'));
expect($this->user->fresh()->persona)->toBe(Persona::Agency);
Bus::assertDispatched(SendEvent::class);
});
test('onboarding store redirects an already-subscribed account to the calendar', function () {
subscribeOnboardingAccount($this->user->account);
$response = $this->actingAs($this->user->fresh())->post(route('app.onboarding.store'), [
'persona' => Persona::Agency->value,
]);
$response->assertRedirect(route('app.calendar'));
expect($this->user->fresh()->persona)->toBeNull();
});
test('goals renders the goal selection for an account that picked a persona', function () {
$this->user->update(['persona' => Persona::Agency->value]);
$response = $this->actingAs($this->user->fresh())->get(route('app.onboarding.goals'));
$response->assertOk();
$response->assertInertia(fn ($page) => $page
->component('onboarding/Goals')
->has('goals', count(Goal::cases()))
);
});
test('goals redirects to the persona step when no persona was chosen', function () {
$response = $this->actingAs($this->user)->get(route('app.onboarding.goals'));
$response->assertRedirect(route('app.onboarding'));
});
test('goals redirects to calendar in self-hosted mode', function () {
config(['trypost.self_hosted' => true]);
$response = $this->actingAs($this->user)->get(route('app.onboarding.goals'));
$response->assertRedirect(route('app.calendar'));
});
test('goals redirects to calendar when already subscribed', function () {
subscribeOnboardingAccount($this->user->account);
$response = $this->actingAs($this->user->fresh())->get(route('app.onboarding.goals'));
$response->assertRedirect(route('app.calendar'));
});
test('goals store requires at least one goal', function () {
$this->user->update(['persona' => Persona::Agency->value]);
$response = $this->actingAs($this->user->fresh())->post(route('app.onboarding.goals.store'), ['goals' => []]);
$response->assertSessionHasErrors('goals');
expect($this->user->fresh()->goals)->toBeNull();
});
test('goals store rejects an invalid goal', function () {
$this->user->update(['persona' => Persona::Agency->value]);
$response = $this->actingAs($this->user->fresh())->post(route('app.onboarding.goals.store'), [
'goals' => ['not-a-goal'],
]);
$response->assertSessionHasErrors('goals.0');
});
test('goals store accepts any combination of valid goals', function () {
$this->user->update(['persona' => Persona::Agency->value]);
$response = $this->actingAs($this->user->fresh())->post(route('app.onboarding.goals.store'), [
'goals' => [Goal::JustExploring->value, Goal::SaveTime->value],
]);
$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 referral-source step', function () {
config(['services.posthog.enabled' => true, 'services.posthog.api_key' => 'phc_test']);
Bus::fake();
$this->user->update(['persona' => Persona::Agency->value]);
$response = $this->actingAs($this->user->fresh())->post(route('app.onboarding.goals.store'), [
'goals' => [Goal::SaveTime->value, Goal::AiContent->value],
]);
$response->assertRedirect(route('app.onboarding.referral-source'));
expect($this->user->fresh()->goals)->toBe([Goal::SaveTime->value, Goal::AiContent->value]);
Bus::assertDispatched(SendEvent::class);
});
test('goals store saves just exploring on its own as a real signal', function () {
config(['services.posthog.enabled' => true, 'services.posthog.api_key' => 'phc_test']);
Bus::fake();
$this->user->update(['persona' => Persona::Agency->value]);
$response = $this->actingAs($this->user->fresh())->post(route('app.onboarding.goals.store'), [
'goals' => [Goal::JustExploring->value],
]);
$response->assertRedirect(route('app.onboarding.referral-source'));
expect($this->user->fresh()->goals)->toBe([Goal::JustExploring->value]);
Bus::assertDispatched(SendEvent::class);
});
test('goals store redirects to the persona step when no persona was chosen', function () {
$response = $this->actingAs($this->user)->post(route('app.onboarding.goals.store'), [
'goals' => [Goal::SaveTime->value],
]);
$response->assertRedirect(route('app.onboarding'));
expect($this->user->fresh()->goals)->toBeNull();
});
test('goals store does nothing in self-hosted mode', function () {
config(['trypost.self_hosted' => true]);
$this->user->update(['persona' => Persona::Agency->value]);
$response = $this->actingAs($this->user->fresh())->post(route('app.onboarding.goals.store'), [
'goals' => [Goal::SaveTime->value],
]);
$response->assertRedirect(route('app.calendar'));
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);
$response = $this->actingAs($this->user->fresh())->get(route('app.onboarding.connect'));
$response->assertRedirect(route('app.onboarding.goals'));
});
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], 'referral_source' => ReferralSource::Google->value]);
onboardingWorkspace($this->user);
$response = $this->actingAs($this->user->fresh())->get(route('app.onboarding.connect'));
$response->assertOk();
$response->assertInertia(fn ($page) => $page
->component('onboarding/Connect')
->has('platforms')
->has('platforms.0.network')
->has('accounts')
);
});
test('connect passes the workspace plan so the client can fire begin_checkout', function () {
$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();
$response = $this->actingAs($this->user->fresh())->get(route('app.onboarding.connect'));
$response->assertOk();
$response->assertInertia(fn ($page) => $page
->component('onboarding/Connect')
->where('plan.name', $plan->name)
->where('plan.interval', 'monthly')
);
});
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], 'referral_source' => ReferralSource::Google->value]);
onboardingWorkspace($this->user);
$response = $this->actingAs($this->user->fresh())->get(route('app.onboarding.connect'));
$response->assertOk();
$response->assertInertia(fn ($page) => $page
->component('onboarding/Connect')
->where('platforms', fn ($platforms) => collect($platforms)->contains('value', Platform::LinkedIn->value)
&& ! collect($platforms)->contains('value', Platform::LinkedInPage->value)
)
);
});
test('connect lists the workspace social accounts already connected', function () {
$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]);
$response = $this->actingAs($this->user->fresh())->get(route('app.onboarding.connect'));
$response->assertOk();
$response->assertInertia(fn ($page) => $page
->component('onboarding/Connect')
->has('accounts', 1)
);
});
test('connect redirects back to the persona step when no persona was chosen', function () {
onboardingWorkspace($this->user);
$response = $this->actingAs($this->user->fresh())->get(route('app.onboarding.connect'));
$response->assertRedirect(route('app.onboarding'));
});
test('connect redirects to calendar in self-hosted mode', function () {
config(['trypost.self_hosted' => true]);
$response = $this->actingAs($this->user)->get(route('app.onboarding.connect'));
$response->assertRedirect(route('app.calendar'));
});
test('connect redirects to calendar when already subscribed', function () {
subscribeOnboardingAccount($this->user->account);
$response = $this->actingAs($this->user->fresh())->get(route('app.onboarding.connect'));
$response->assertRedirect(route('app.calendar'));
});
test('checkout blocks and redirects back when no network is connected', function () {
$this->user->update(['persona' => Persona::Agency->value, 'goals' => [Goal::SaveTime->value]]);
onboardingWorkspace($this->user);
$this->mock(StartSubscriptionCheckout::class)
->shouldReceive('redirect')
->never();
$response = $this->actingAs($this->user->fresh())->post(route('app.onboarding.checkout'));
$response->assertRedirect(route('app.onboarding.connect'));
});
test('checkout starts monthly checkout once at least one network is connected', function () {
$this->user->update(['persona' => Persona::Agency->value, 'goals' => [Goal::SaveTime->value]]);
$workspace = onboardingWorkspace($this->user);
SocialAccount::factory()->create(['workspace_id' => $workspace->id]);
Plan::where('slug', Slug::Workspace)->firstOrFail()->update([
'stripe_monthly_price_id' => 'price_monthly_test',
'stripe_yearly_price_id' => 'price_yearly_test',
]);
$this->mock(StartSubscriptionCheckout::class)
->shouldReceive('redirect')
->once()
->withArgs(fn (Account $account, string $priceId, string $cancelUrl): bool => $priceId === 'price_monthly_test')
->andReturn(redirect()->route('app.calendar'));
$response = $this->actingAs($this->user->fresh())->post(route('app.onboarding.checkout'));
$response->assertRedirect(route('app.calendar'));
});
test('checkout redirects an already-subscribed account to the calendar', function () {
subscribeOnboardingAccount($this->user->account);
$this->mock(StartSubscriptionCheckout::class)
->shouldReceive('redirect')
->never();
$response = $this->actingAs($this->user->fresh())->post(route('app.onboarding.checkout'));
$response->assertRedirect(route('app.calendar'));
});
test('checkout does nothing in self-hosted mode', function () {
config(['trypost.self_hosted' => true]);
$this->mock(StartSubscriptionCheckout::class)
->shouldReceive('redirect')
->never();
$response = $this->actingAs($this->user)->post(route('app.onboarding.checkout'));
$response->assertRedirect(route('app.calendar'));
});
test('connect redirects to workspace creation when no workspace exists', function () {
$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'));
$response->assertRedirect(route('app.workspaces.create'));
});
test('a user walks the full onboarding flow from the account gate to stripe checkout', function () {
Plan::where('slug', Slug::Workspace)->firstOrFail()->update(['stripe_monthly_price_id' => 'price_monthly_test']);
$workspace = onboardingWorkspace($this->user);
// The account gate sends an unsubscribed user into onboarding.
$this->actingAs($this->user->fresh())->get(route('app.calendar'))
->assertRedirect(route('app.onboarding'));
// Persona advances to goals.
$this->actingAs($this->user->fresh())->post(route('app.onboarding.store'), ['persona' => Persona::Creator->value])
->assertRedirect(route('app.onboarding.goals'));
// 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, 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.
$this->actingAs($this->user->fresh())->post(route('app.onboarding.checkout'))
->assertRedirect(route('app.onboarding.connect'));
// Once a network is connected, checkout hands off to Stripe.
SocialAccount::factory()->create(['workspace_id' => $workspace->id]);
$this->mock(StartSubscriptionCheckout::class)
->shouldReceive('redirect')
->once()
->andReturn(redirect('https://checkout.stripe.test/session'));
$this->actingAs($this->user->fresh())->post(route('app.onboarding.checkout'))
->assertRedirect('https://checkout.stripe.test/session');
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 () {
config(['trypost.self_hosted' => true]);
onboardingWorkspace($this->user);
// The app is reachable directly, with no subscription.
$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.referral-source', 'app.onboarding.connect'] as $routeName) {
$this->actingAs($this->user->fresh())->get(route($routeName))->assertRedirect(route('app.calendar'));
}
});

View file

@ -0,0 +1,403 @@
<?php
declare(strict_types=1);
use App\Actions\Billing\StartSubscriptionCheckout;
use App\Enums\Plan\Slug;
use App\Enums\PostHog\WelcomeEvent;
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;
use App\Models\User;
use Illuminate\Support\Facades\Bus;
use Illuminate\Support\Facades\Route;
beforeEach(function () {
config(['trypost.self_hosted' => false]);
$this->user = User::factory()->create();
});
test('welcome redirects to the persona step', function () {
$this->actingAs($this->user)
->get(route('app.welcome'))
->assertRedirect(route('app.welcome.persona'));
});
test('persona renders for an unsubscribed account', function () {
$this->actingAs($this->user)
->get(route('app.welcome.persona'))
->assertOk()
->assertInertia(fn ($page) => $page
->component('welcome/Persona', false)
->has('personas', count(Persona::cases()))
);
});
test('persona requires a valid selection', function (array $payload) {
$this->actingAs($this->user)
->post(route('app.welcome.persona.store'), $payload)
->assertSessionHasErrors('persona');
expect($this->user->fresh()->persona)->toBeNull();
})->with([
'missing' => [[]],
'invalid' => [['persona' => 'not-a-persona']],
]);
test('persona store saves the selection mirrors it to PostHog and advances to goals', function () {
config(['services.posthog.enabled' => true, 'services.posthog.api_key' => 'phc_test']);
Bus::fake();
$this->actingAs($this->user)
->post(route('app.welcome.persona.store'), ['persona' => Persona::Agency->value])
->assertRedirect(route('app.welcome.goals'));
expect($this->user->fresh()->persona)->toBe(Persona::Agency);
Bus::assertDispatched(SendEvent::class, fn (SendEvent $event): bool => $event->method === 'capture'
&& data_get($event->payload, 'distinctId') === $this->user->id
&& data_get($event->payload, 'event') === WelcomeEvent::Persona->value
&& data_get($event->payload, 'properties.persona') === Persona::Agency->value);
});
test('goals redirects to persona until a persona is selected', function () {
$this->actingAs($this->user)
->get(route('app.welcome.goals'))
->assertRedirect(route('app.welcome.persona'));
});
test('goals renders after a persona is selected', function () {
$this->user->update(['persona' => Persona::Agency->value]);
$this->actingAs($this->user->fresh())
->get(route('app.welcome.goals'))
->assertOk()
->assertInertia(fn ($page) => $page
->component('welcome/Goals', false)
->has('goals', count(Goal::cases()))
);
});
test('goals requires at least one valid goal', function (array $goals, string $error) {
$this->user->update(['persona' => Persona::Agency->value]);
$this->actingAs($this->user->fresh())
->post(route('app.welcome.goals.store'), ['goals' => $goals])
->assertSessionHasErrors($error);
expect($this->user->fresh()->goals)->toBeNull();
})->with([
'empty' => [[], 'goals'],
'invalid' => [['not-a-goal'], 'goals.0'],
]);
test('goals store saves choices mirrors them to PostHog and advances to referral source', function () {
config(['services.posthog.enabled' => true, 'services.posthog.api_key' => 'phc_test']);
Bus::fake();
$this->user->update(['persona' => Persona::Creator->value]);
$goals = [Goal::AiContent->value, Goal::SaveTime->value];
$this->actingAs($this->user->fresh())
->post(route('app.welcome.goals.store'), ['goals' => $goals])
->assertRedirect(route('app.welcome.referral-source'));
expect($this->user->fresh()->goals)->toBe($goals);
Bus::assertDispatched(SendEvent::class, fn (SendEvent $event): bool => $event->method === 'capture'
&& data_get($event->payload, 'event') === WelcomeEvent::Goals->value
&& data_get($event->payload, 'properties.goals') === $goals);
});
test('completed welcome steps remain reachable when going back', function () {
$this->user->update([
'persona' => Persona::Agency->value,
'goals' => [Goal::SaveTime->value],
]);
$this->actingAs($this->user->fresh())
->get(route('app.welcome.persona'))
->assertOk()
->assertInertia(fn ($page) => $page->component('welcome/Persona', false));
$this->actingAs($this->user->fresh())
->get(route('app.welcome.goals'))
->assertOk()
->assertInertia(fn ($page) => $page->component('welcome/Goals', false));
});
test('referral source redirects through incomplete prior steps', function (array $attributes, string $routeName) {
$this->user->update($attributes);
$this->actingAs($this->user->fresh())
->get(route('app.welcome.referral-source'))
->assertRedirect(route($routeName));
})->with([
'missing persona' => [[], 'app.welcome.persona'],
'missing goals' => [['persona' => Persona::Agency->value], 'app.welcome.goals'],
'only removed goals' => [
[
'persona' => Persona::Agency->value,
'goals' => ['team_collaboration', 'automate_api', 'track_performance'],
],
'app.welcome.goals',
],
]);
test('referral source allows users who still have at least one current goal', function () {
$this->user->update([
'persona' => Persona::Agency->value,
'goals' => [Goal::SaveTime->value, 'team_collaboration'],
]);
$this->actingAs($this->user->fresh())
->get(route('app.welcome.referral-source'))
->assertOk()
->assertInertia(fn ($page) => $page->component('welcome/ReferralSource', false));
});
test('referral source renders after prior steps are complete', function () {
$this->user->update([
'persona' => Persona::Agency->value,
'goals' => [Goal::SaveTime->value],
]);
$plan = Plan::where('slug', Slug::Workspace)->firstOrFail();
$this->actingAs($this->user->fresh())
->get(route('app.welcome.referral-source'))
->assertOk()
->assertInertia(fn ($page) => $page
->component('welcome/ReferralSource', false)
->has('sources', count(ReferralSource::cases()))
->where('plan.name', $plan->name)
->where('plan.interval', 'monthly')
);
});
test('referral source requires a valid selection', function (array $payload) {
$this->user->update([
'persona' => Persona::Agency->value,
'goals' => [Goal::SaveTime->value],
]);
$this->actingAs($this->user->fresh())
->post(route('app.welcome.referral-source.store'), $payload)
->assertSessionHasErrors('referral_source');
expect($this->user->fresh()->referral_source)->toBeNull();
})->with([
'missing' => [[]],
'invalid' => [['referral_source' => 'not-a-source']],
]);
test('referral source store saves the source and starts Stripe checkout without a social account', 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],
]);
Plan::where('slug', Slug::Workspace)->firstOrFail()->update([
'stripe_monthly_price_id' => 'price_monthly_test',
]);
$this->mock(StartSubscriptionCheckout::class)
->shouldReceive('redirect')
->once()
->withArgs(fn (Account $account, string $priceId, string $cancelUrl): bool => $account->is($this->user->account)
&& $priceId === 'price_monthly_test'
&& $cancelUrl === route('app.welcome.referral-source'))
->andReturn(redirect('https://checkout.stripe.test/session'));
$this->actingAs($this->user->fresh())
->post(route('app.welcome.referral-source.store'), [
'referral_source' => ReferralSource::ProductHunt->value,
])
->assertRedirect('https://checkout.stripe.test/session');
expect($this->user->fresh()->referral_source)->toBe(ReferralSource::ProductHunt);
Bus::assertDispatched(SendEvent::class, fn (SendEvent $event): bool => $event->method === 'capture'
&& data_get($event->payload, 'event') === WelcomeEvent::Referral->value
&& data_get($event->payload, 'properties.referral_source') === ReferralSource::ProductHunt->value);
});
test('welcome steps redirect to calendar for subscribed accounts', function (string $routeName, string $method, array $payload = []) {
subscribeAccount($this->user->account);
$this->actingAs($this->user->fresh());
$response = $method === 'get'
? $this->get(route($routeName))
: $this->post(route($routeName), $payload);
$response->assertRedirect(route('app.calendar'));
})->with([
'persona' => ['app.welcome.persona', 'get'],
'persona store' => ['app.welcome.persona.store', 'post', ['persona' => Persona::Agency->value]],
'goals' => ['app.welcome.goals', 'get'],
'goals store' => ['app.welcome.goals.store', 'post', ['goals' => [Goal::SaveTime->value]]],
'referral source' => ['app.welcome.referral-source', 'get'],
'referral source store' => ['app.welcome.referral-source.store', 'post', ['referral_source' => ReferralSource::Google->value]],
]);
test('welcome redirects generic-trial accounts with app access to calendar', function () {
config(['trypost.billing.require_card_for_trial' => false]);
$this->user->account->forceFill([
'trial_ends_at' => now()->addDays(8),
])->save();
expect($this->user->account->fresh()->hasAppAccess())->toBeTrue()
->and($this->user->account->fresh()->subscribed(Account::SUBSCRIPTION_NAME))->toBeFalse();
$this->actingAs($this->user->fresh())
->get(route('app.welcome.persona'))
->assertRedirect(route('app.calendar'));
});
test('welcome steps redirect to calendar in self hosted mode', function (string $routeName, string $method, array $payload = []) {
config(['trypost.self_hosted' => true]);
$this->actingAs($this->user);
$response = $method === 'get'
? $this->get(route($routeName))
: $this->post(route($routeName), $payload);
$response->assertRedirect(route('app.calendar'));
})->with([
'persona' => ['app.welcome.persona', 'get'],
'persona store' => ['app.welcome.persona.store', 'post', ['persona' => Persona::Agency->value]],
'goals' => ['app.welcome.goals', 'get'],
'goals store' => ['app.welcome.goals.store', 'post', ['goals' => [Goal::SaveTime->value]]],
'referral source' => ['app.welcome.referral-source', 'get'],
'referral source store' => ['app.welcome.referral-source.store', 'post', ['referral_source' => ReferralSource::Google->value]],
]);
test('old onboarding icp routes are not registered', function (string $routeName) {
expect(Route::has($routeName))->toBeFalse();
})->with([
'root' => 'app.onboarding',
'store' => 'app.onboarding.store',
'goals' => 'app.onboarding.goals',
'goals store' => 'app.onboarding.goals.store',
'referral source' => 'app.onboarding.referral-source',
'referral source store' => 'app.onboarding.referral-source.store',
'connect' => 'app.onboarding.connect',
'checkout' => 'app.onboarding.checkout',
]);
test('members cannot start Stripe checkout from welcome', function () {
$member = User::factory()->create(['account_id' => $this->user->account_id]);
$member->update([
'persona' => Persona::Agency->value,
'goals' => [Goal::SaveTime->value],
]);
$this->mock(StartSubscriptionCheckout::class)->shouldNotReceive('redirect');
// Members never reach the referral step — they are held on the
// subscription-required screen before any checkout attempt.
$this->actingAs($member->fresh())
->get(route('app.welcome.referral-source'))
->assertRedirect(route('app.welcome.subscription-required'));
$this->actingAs($member->fresh())
->post(route('app.welcome.referral-source.store'), [
'referral_source' => ReferralSource::Google->value,
])
->assertRedirect(route('app.welcome.subscription-required'));
expect($member->fresh()->referral_source)->toBeNull();
});
test('members without app access are held on the subscription required screen', function (string $routeName, string $method, array $payload = []) {
$member = User::factory()->create(['account_id' => $this->user->account_id]);
$this->actingAs($member->fresh());
$response = $method === 'get'
? $this->get(route($routeName))
: $this->post(route($routeName), $payload);
$response->assertRedirect(route('app.welcome.subscription-required'));
})->with([
'persona' => ['app.welcome.persona', 'get'],
'persona store' => ['app.welcome.persona.store', 'post', ['persona' => Persona::Agency->value]],
'goals' => ['app.welcome.goals', 'get'],
'goals store' => ['app.welcome.goals.store', 'post', ['goals' => [Goal::SaveTime->value]]],
'referral source' => ['app.welcome.referral-source', 'get'],
'referral source store' => ['app.welcome.referral-source.store', 'post', ['referral_source' => ReferralSource::Google->value]],
]);
test('subscription required screen renders for members without app access', function () {
$member = User::factory()->create(['account_id' => $this->user->account_id]);
$this->actingAs($member->fresh())
->get(route('app.welcome.subscription-required'))
->assertOk()
->assertInertia(fn ($page) => $page
->component('welcome/SubscriptionRequired', false)
->where('ownerName', $this->user->name)
);
});
test('subscription required screen sends owners back to the welcome flow', function () {
$this->actingAs($this->user)
->get(route('app.welcome.subscription-required'))
->assertRedirect(route('app.welcome.persona'));
});
test('subscription required screen sends subscribed users to the calendar', function () {
subscribeAccount($this->user->account);
$this->actingAs($this->user->fresh())
->get(route('app.welcome.subscription-required'))
->assertRedirect(route('app.calendar'));
});
test('subscription required screen sends members with app access to the calendar', function () {
['owner' => $owner, 'member' => $member] = strandedMemberOnSharedAccount();
subscribeAccount($owner->account);
$this->actingAs($member)
->get(route('app.welcome.subscription-required'))
->assertRedirect(route('app.calendar'));
});
test('subscription required screen redirects to calendar in self hosted mode', function () {
config(['trypost.self_hosted' => true]);
$member = User::factory()->create(['account_id' => $this->user->account_id]);
$this->actingAs($member->fresh())
->get(route('app.welcome.subscription-required'))
->assertRedirect(route('app.calendar'));
});
test('welcome sends members with app access to the calendar', function () {
['owner' => $owner, 'member' => $member] = strandedMemberOnSharedAccount();
subscribeAccount($owner->account);
$this->actingAs($member)
->get(route('app.welcome.persona'))
->assertRedirect(route('app.calendar'));
});
test('referral source store fails loudly when the monthly price is not configured', function () {
$this->user->update([
'persona' => Persona::Agency->value,
'goals' => [Goal::SaveTime->value],
]);
Plan::where('slug', Slug::Workspace)->update(['stripe_monthly_price_id' => null]);
$this->mock(StartSubscriptionCheckout::class)->shouldNotReceive('redirect');
$this->actingAs($this->user->fresh())
->post(route('app.welcome.referral-source.store'), [
'referral_source' => ReferralSource::Google->value,
])
->assertServerError();
});

View file

@ -57,7 +57,7 @@
$response = $this->actingAs($this->user)->get(route('app.calendar')); $response = $this->actingAs($this->user)->get(route('app.calendar'));
$response->assertRedirect(route('app.onboarding')); $response->assertRedirect(route('app.welcome.persona'));
}); });
test('billing page is accessible by account owner', function () { test('billing page is accessible by account owner', function () {
@ -96,12 +96,12 @@
$response->assertForbidden(); $response->assertForbidden();
}); });
test('subscribe redirects to onboarding', function () { test('subscribe redirects to welcome', function () {
config(['trypost.self_hosted' => false]); config(['trypost.self_hosted' => false]);
$response = $this->actingAs($this->user)->get(route('app.subscribe')); $response = $this->actingAs($this->user)->get(route('app.subscribe'));
$response->assertRedirect(route('app.onboarding')); $response->assertRedirect(route('app.welcome.persona'));
}); });
test('stripe email returns account owner email', function () { test('stripe email returns account owner email', function () {