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:
parent
90a32659c5
commit
b4f61be6ef
93 changed files with 2582 additions and 1514 deletions
12
app/Enums/PostHog/WelcomeEvent.php
Normal file
12
app/Enums/PostHog/WelcomeEvent.php
Normal 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';
|
||||
}
|
||||
|
|
@ -8,14 +8,12 @@ enum Goal: string
|
|||
{
|
||||
case SaveTime = 'save_time';
|
||||
case AiContent = 'ai_content';
|
||||
case UseMcp = 'use_mcp';
|
||||
case PlanCalendar = 'plan_calendar';
|
||||
case StayOnBrand = 'stay_on_brand';
|
||||
case GrowAudience = 'grow_audience';
|
||||
case DriveSales = 'drive_sales';
|
||||
case ManageClients = 'manage_clients';
|
||||
case TeamCollaboration = 'team_collaboration';
|
||||
case AutomateApi = 'automate_api';
|
||||
case TrackPerformance = 'track_performance';
|
||||
case JustExploring = 'just_exploring';
|
||||
case Other = 'other';
|
||||
}
|
||||
|
|
|
|||
|
|
@ -18,7 +18,7 @@ class BillingController extends Controller
|
|||
{
|
||||
public function subscribe(): RedirectResponse
|
||||
{
|
||||
return redirect()->route('app.onboarding');
|
||||
return redirect()->route('app.welcome.persona');
|
||||
}
|
||||
|
||||
public function processing(Request $request): Response|RedirectResponse
|
||||
|
|
|
|||
|
|
@ -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'),
|
||||
);
|
||||
}
|
||||
}
|
||||
240
app/Http/Controllers/App/WelcomeController.php
Normal file
240
app/Http/Controllers/App/WelcomeController.php
Normal 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;
|
||||
}
|
||||
}
|
||||
|
|
@ -25,7 +25,13 @@ public function handle(Request $request, Closure $next): Response
|
|||
$account = $user->account;
|
||||
|
||||
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');
|
||||
}
|
||||
}
|
||||
|
||||
|
|
|
|||
|
|
@ -2,13 +2,13 @@
|
|||
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace App\Http\Requests\App\Onboarding;
|
||||
namespace App\Http\Requests\App\Welcome;
|
||||
|
||||
use App\Enums\User\Goal;
|
||||
use Illuminate\Foundation\Http\FormRequest;
|
||||
use Illuminate\Validation\Rule;
|
||||
|
||||
class StoreOnboardingGoalsRequest extends FormRequest
|
||||
class StoreWelcomeGoalsRequest extends FormRequest
|
||||
{
|
||||
public function authorize(): bool
|
||||
{
|
||||
|
|
@ -2,13 +2,13 @@
|
|||
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace App\Http\Requests\App\Onboarding;
|
||||
namespace App\Http\Requests\App\Welcome;
|
||||
|
||||
use App\Enums\User\Persona;
|
||||
use Illuminate\Foundation\Http\FormRequest;
|
||||
use Illuminate\Validation\Rule;
|
||||
|
||||
class StoreOnboardingRequest extends FormRequest
|
||||
class StoreWelcomePersonaRequest extends FormRequest
|
||||
{
|
||||
public function authorize(): bool
|
||||
{
|
||||
|
|
@ -2,13 +2,13 @@
|
|||
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace App\Http\Requests\App\Onboarding;
|
||||
namespace App\Http\Requests\App\Welcome;
|
||||
|
||||
use App\Enums\User\ReferralSource;
|
||||
use Illuminate\Foundation\Http\FormRequest;
|
||||
use Illuminate\Validation\Rule;
|
||||
|
||||
class StoreOnboardingReferralSourceRequest extends FormRequest
|
||||
class StoreWelcomeReferralSourceRequest extends FormRequest
|
||||
{
|
||||
public function authorize(): bool
|
||||
{
|
||||
|
|
@ -71,6 +71,8 @@
|
|||
'page_title' => 'تسجيل الدخول',
|
||||
'email' => 'البريد الإلكتروني',
|
||||
'password' => 'كلمة المرور',
|
||||
'show_password' => 'إظهار كلمة المرور',
|
||||
'hide_password' => 'إخفاء كلمة المرور',
|
||||
'forgot_password' => 'نسيت كلمة المرور؟',
|
||||
'remember_me' => 'تذكّرني',
|
||||
'submit' => 'تسجيل الدخول',
|
||||
|
|
|
|||
|
|
@ -21,15 +21,13 @@
|
|||
'goals_description' => 'اختر كل ما يناسبك وسنقوم بإعداد TryPost من أجلك.',
|
||||
'goals' => [
|
||||
'save_time' => 'توفير الوقت بالنشر في كل مكان دفعة واحدة',
|
||||
'ai_content' => 'إنشاء منشورات أسرع بالذكاء الاصطناعي',
|
||||
'ai_content' => 'إنشاء منشورات بذكاء TryPost الاصطناعي',
|
||||
'use_mcp' => 'إنشاء منشورات عبر Claude أو ChatGPT أو Cursor',
|
||||
'plan_calendar' => 'التخطيط لمنشوراتي على التقويم',
|
||||
'stay_on_brand' => 'الحفاظ على اتساق كل منشور مع العلامة التجارية',
|
||||
'grow_audience' => 'تنمية جمهوري وزيادة التفاعل',
|
||||
'drive_sales' => 'الحصول على المزيد من الزيارات والمبيعات',
|
||||
'manage_clients' => 'إدارة عدة علامات تجارية أو عملاء',
|
||||
'team_collaboration' => 'العمل مع فريقي',
|
||||
'automate_api' => 'أتمتة النشر عبر الواجهة البرمجية أو MCP أو الكود',
|
||||
'track_performance' => 'معرفة أداء منشوراتي',
|
||||
'just_exploring' => 'مجرد استكشاف في الوقت الحالي',
|
||||
'other' => 'شيء آخر',
|
||||
],
|
||||
|
|
|
|||
|
|
@ -60,6 +60,6 @@
|
|||
'support' => [
|
||||
'docs' => 'التوثيق',
|
||||
'referral' => 'اربح عمولة إحالة 30%',
|
||||
'stay_updated' => 'ابقَ على اطلاع',
|
||||
'discord' => 'مجتمع Discord',
|
||||
],
|
||||
];
|
||||
|
|
|
|||
57
lang/ar/welcome.php
Normal file
57
lang/ar/welcome.php
Normal 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' => 'شيء آخر',
|
||||
],
|
||||
];
|
||||
|
|
@ -73,6 +73,8 @@
|
|||
'page_title' => 'Anmelden',
|
||||
'email' => 'E-Mail-Adresse',
|
||||
'password' => 'Passwort',
|
||||
'show_password' => 'Passwort anzeigen',
|
||||
'hide_password' => 'Passwort verbergen',
|
||||
'forgot_password' => 'Passwort vergessen?',
|
||||
'remember_me' => 'Angemeldet bleiben',
|
||||
'submit' => 'Anmelden',
|
||||
|
|
|
|||
|
|
@ -21,15 +21,13 @@
|
|||
'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 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',
|
||||
'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',
|
||||
'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',
|
||||
'other' => 'Etwas anderes',
|
||||
],
|
||||
|
|
|
|||
|
|
@ -60,6 +60,6 @@
|
|||
'support' => [
|
||||
'docs' => 'Dokumentation',
|
||||
'referral' => '30% Provision verdienen',
|
||||
'stay_updated' => 'Auf dem Laufenden bleiben',
|
||||
'discord' => 'Discord-Community',
|
||||
],
|
||||
];
|
||||
|
|
|
|||
57
lang/de/welcome.php
Normal file
57
lang/de/welcome.php
Normal 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',
|
||||
],
|
||||
];
|
||||
|
|
@ -71,6 +71,8 @@
|
|||
'page_title' => 'Σύνδεση',
|
||||
'email' => 'Διεύθυνση email',
|
||||
'password' => 'Κωδικός πρόσβασης',
|
||||
'show_password' => 'Εμφάνιση κωδικού',
|
||||
'hide_password' => 'Απόκρυψη κωδικού',
|
||||
'forgot_password' => 'Ξεχάσατε τον κωδικό;',
|
||||
'remember_me' => 'Να με θυμάσαι',
|
||||
'submit' => 'Σύνδεση',
|
||||
|
|
|
|||
|
|
@ -21,15 +21,13 @@
|
|||
'goals_description' => 'Επιλέξτε ό,τι σας ταιριάζει και θα ρυθμίσουμε το TryPost για εσάς.',
|
||||
'goals' => [
|
||||
'save_time' => 'Εξοικονόμηση χρόνου δημοσιεύοντας παντού ταυτόχρονα',
|
||||
'ai_content' => 'Δημιουργία δημοσιεύσεων ταχύτερα με AI',
|
||||
'ai_content' => 'Δημιουργία δημοσιεύσεων με το AI του TryPost',
|
||||
'use_mcp' => 'Δημιουργία δημοσιεύσεων από Claude, ChatGPT ή Cursor',
|
||||
'plan_calendar' => 'Προγραμματισμός των δημοσιεύσεών μου σε ημερολόγιο',
|
||||
'stay_on_brand' => 'Διατήρηση κάθε δημοσίευσης εναρμονισμένης με τη μάρκα',
|
||||
'grow_audience' => 'Ανάπτυξη του κοινού και της αλληλεπίδρασής μου',
|
||||
'drive_sales' => 'Περισσότερη επισκεψιμότητα και πωλήσεις',
|
||||
'manage_clients' => 'Διαχείριση πολλών μαρκών ή πελατών',
|
||||
'team_collaboration' => 'Συνεργασία με την ομάδα μου',
|
||||
'automate_api' => 'Αυτοματοποίηση δημοσιεύσεων με το API, το MCP ή κώδικα',
|
||||
'track_performance' => 'Παρακολούθηση της απόδοσης των δημοσιεύσεών μου',
|
||||
'just_exploring' => 'Απλώς εξερευνώ προς το παρόν',
|
||||
'other' => 'Κάτι άλλο',
|
||||
],
|
||||
|
|
|
|||
|
|
@ -60,6 +60,6 @@
|
|||
'support' => [
|
||||
'docs' => 'Τεκμηρίωση',
|
||||
'referral' => 'Κερδίστε 30% από συστάσεις',
|
||||
'stay_updated' => 'Μείνετε ενημερωμένοι',
|
||||
'discord' => 'Κοινότητα Discord',
|
||||
],
|
||||
];
|
||||
|
|
|
|||
57
lang/el/welcome.php
Normal file
57
lang/el/welcome.php
Normal 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' => 'Κάτι άλλο',
|
||||
],
|
||||
];
|
||||
|
|
@ -71,6 +71,8 @@
|
|||
'page_title' => 'Log in',
|
||||
'email' => 'Email address',
|
||||
'password' => 'Password',
|
||||
'show_password' => 'Show password',
|
||||
'hide_password' => 'Hide password',
|
||||
'forgot_password' => 'Forgot password?',
|
||||
'remember_me' => 'Remember me',
|
||||
'submit' => 'Log in',
|
||||
|
|
|
|||
|
|
@ -21,15 +21,13 @@
|
|||
'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' => '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',
|
||||
'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',
|
||||
'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',
|
||||
'other' => 'Something else',
|
||||
],
|
||||
|
|
|
|||
|
|
@ -60,6 +60,6 @@
|
|||
'support' => [
|
||||
'docs' => 'Documentation',
|
||||
'referral' => 'Earn 30% referral',
|
||||
'stay_updated' => 'Stay updated',
|
||||
'discord' => 'Discord community',
|
||||
],
|
||||
];
|
||||
|
|
|
|||
57
lang/en/welcome.php
Normal file
57
lang/en/welcome.php
Normal 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',
|
||||
],
|
||||
];
|
||||
|
|
@ -59,6 +59,8 @@
|
|||
'page_title' => 'Iniciar sesión',
|
||||
'email' => 'Correo electrónico',
|
||||
'password' => 'Contraseña',
|
||||
'show_password' => 'Mostrar contraseña',
|
||||
'hide_password' => 'Ocultar contraseña',
|
||||
'forgot_password' => '¿Olvidaste tu contraseña?',
|
||||
'remember_me' => 'Recuérdame',
|
||||
'submit' => 'Iniciar sesión',
|
||||
|
|
|
|||
|
|
@ -21,15 +21,13 @@
|
|||
'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' => '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',
|
||||
'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',
|
||||
'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',
|
||||
'other' => 'Otra cosa',
|
||||
],
|
||||
|
|
|
|||
|
|
@ -60,6 +60,6 @@
|
|||
'support' => [
|
||||
'docs' => 'Documentación',
|
||||
'referral' => 'Gana 30% de comisión',
|
||||
'stay_updated' => 'Mantente al día',
|
||||
'discord' => 'Comunidad de Discord',
|
||||
],
|
||||
];
|
||||
|
|
|
|||
57
lang/es/welcome.php
Normal file
57
lang/es/welcome.php
Normal 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',
|
||||
],
|
||||
];
|
||||
|
|
@ -71,6 +71,8 @@
|
|||
'page_title' => 'Connexion',
|
||||
'email' => 'Adresse e-mail',
|
||||
'password' => 'Mot de passe',
|
||||
'show_password' => 'Afficher le mot de passe',
|
||||
'hide_password' => 'Masquer le mot de passe',
|
||||
'forgot_password' => 'Mot de passe oublié ?',
|
||||
'remember_me' => 'Se souvenir de moi',
|
||||
'submit' => 'Se connecter',
|
||||
|
|
|
|||
|
|
@ -21,15 +21,13 @@
|
|||
'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' => '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',
|
||||
'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',
|
||||
'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',
|
||||
'other' => 'Autre chose',
|
||||
],
|
||||
|
|
|
|||
|
|
@ -60,6 +60,6 @@
|
|||
'support' => [
|
||||
'docs' => 'Documentation',
|
||||
'referral' => 'Gagnez 30 % de parrainage',
|
||||
'stay_updated' => 'Rester informé',
|
||||
'discord' => 'Communauté Discord',
|
||||
],
|
||||
];
|
||||
|
|
|
|||
57
lang/fr/welcome.php
Normal file
57
lang/fr/welcome.php
Normal 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 d’accueil',
|
||||
'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',
|
||||
],
|
||||
];
|
||||
|
|
@ -71,6 +71,8 @@
|
|||
'page_title' => 'Accedi',
|
||||
'email' => 'Indirizzo email',
|
||||
'password' => 'Password',
|
||||
'show_password' => 'Mostra password',
|
||||
'hide_password' => 'Nascondi password',
|
||||
'forgot_password' => 'Password dimenticata?',
|
||||
'remember_me' => 'Ricordami',
|
||||
'submit' => 'Accedi',
|
||||
|
|
|
|||
|
|
@ -21,15 +21,13 @@
|
|||
'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' => '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',
|
||||
'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',
|
||||
'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',
|
||||
'other' => 'Qualcos\'altro',
|
||||
],
|
||||
|
|
|
|||
|
|
@ -60,6 +60,6 @@
|
|||
'support' => [
|
||||
'docs' => 'Documentazione',
|
||||
'referral' => 'Guadagna il 30% di referral',
|
||||
'stay_updated' => 'Resta aggiornato',
|
||||
'discord' => 'Community Discord',
|
||||
],
|
||||
];
|
||||
|
|
|
|||
57
lang/it/welcome.php
Normal file
57
lang/it/welcome.php
Normal 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',
|
||||
],
|
||||
];
|
||||
|
|
@ -71,6 +71,8 @@
|
|||
'page_title' => 'ログイン',
|
||||
'email' => 'メールアドレス',
|
||||
'password' => 'パスワード',
|
||||
'show_password' => 'パスワードを表示',
|
||||
'hide_password' => 'パスワードを隠す',
|
||||
'forgot_password' => 'パスワードをお忘れですか?',
|
||||
'remember_me' => 'ログイン状態を保持',
|
||||
'submit' => 'ログイン',
|
||||
|
|
|
|||
|
|
@ -21,15 +21,13 @@
|
|||
'goals_description' => '当てはまるものをすべて選んでください。TryPost をあなた向けに設定します。',
|
||||
'goals' => [
|
||||
'save_time' => 'すべての場所へ一度に投稿して時間を節約する',
|
||||
'ai_content' => 'AI でより速く投稿を作成する',
|
||||
'ai_content' => 'TryPost AI で投稿を生成する',
|
||||
'use_mcp' => 'Claude・ChatGPT・Cursor から投稿を作成する',
|
||||
'plan_calendar' => 'カレンダーで投稿を計画する',
|
||||
'stay_on_brand' => 'すべての投稿をブランドに沿ったものにする',
|
||||
'grow_audience' => 'オーディエンスとエンゲージメントを増やす',
|
||||
'drive_sales' => 'トラフィックと売上を増やす',
|
||||
'manage_clients' => '複数のブランドやクライアントを管理する',
|
||||
'team_collaboration' => 'チームで作業する',
|
||||
'automate_api' => 'API、MCP、コードで投稿を自動化する',
|
||||
'track_performance' => '投稿のパフォーマンスを確認する',
|
||||
'just_exploring' => '今はまだ様子を見ている',
|
||||
'other' => 'その他',
|
||||
],
|
||||
|
|
|
|||
|
|
@ -60,6 +60,6 @@
|
|||
'support' => [
|
||||
'docs' => 'ドキュメント',
|
||||
'referral' => '30% の紹介報酬を獲得',
|
||||
'stay_updated' => '最新情報を受け取る',
|
||||
'discord' => 'Discord コミュニティ',
|
||||
],
|
||||
];
|
||||
|
|
|
|||
57
lang/ja/welcome.php
Normal file
57
lang/ja/welcome.php
Normal 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' => 'その他',
|
||||
],
|
||||
];
|
||||
|
|
@ -71,6 +71,8 @@
|
|||
'page_title' => '로그인',
|
||||
'email' => '이메일 주소',
|
||||
'password' => '비밀번호',
|
||||
'show_password' => '비밀번호 표시',
|
||||
'hide_password' => '비밀번호 숨기기',
|
||||
'forgot_password' => '비밀번호를 잊으셨나요?',
|
||||
'remember_me' => '로그인 상태 유지',
|
||||
'submit' => '로그인',
|
||||
|
|
|
|||
|
|
@ -21,15 +21,13 @@
|
|||
'goals_description' => '해당되는 항목을 모두 선택하면 TryPost를 맞춤 설정해 드립니다.',
|
||||
'goals' => [
|
||||
'save_time' => '한 번에 여러 곳에 게시하여 시간 절약',
|
||||
'ai_content' => 'AI로 더 빠르게 게시물 작성',
|
||||
'ai_content' => 'TryPost AI로 게시물 생성',
|
||||
'use_mcp' => 'Claude, ChatGPT 또는 Cursor에서 게시물 작성',
|
||||
'plan_calendar' => '캘린더에서 게시물 계획',
|
||||
'stay_on_brand' => '모든 게시물을 브랜드에 맞게 유지',
|
||||
'grow_audience' => '팔로워와 참여 늘리기',
|
||||
'drive_sales' => '더 많은 트래픽과 판매 유도',
|
||||
'manage_clients' => '여러 브랜드 또는 클라이언트 관리',
|
||||
'team_collaboration' => '팀과 협업',
|
||||
'automate_api' => 'API, MCP 또는 코드로 게시 자동화',
|
||||
'track_performance' => '게시물 성과 확인',
|
||||
'just_exploring' => '지금은 둘러보는 중',
|
||||
'other' => '다른 것',
|
||||
],
|
||||
|
|
|
|||
|
|
@ -60,6 +60,6 @@
|
|||
'support' => [
|
||||
'docs' => '문서',
|
||||
'referral' => '30% 추천 수익 받기',
|
||||
'stay_updated' => '최신 소식 받기',
|
||||
'discord' => 'Discord 커뮤니티',
|
||||
],
|
||||
];
|
||||
|
|
|
|||
57
lang/ko/welcome.php
Normal file
57
lang/ko/welcome.php
Normal 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' => '기타',
|
||||
],
|
||||
];
|
||||
|
|
@ -71,6 +71,8 @@
|
|||
'page_title' => 'Inloggen',
|
||||
'email' => 'E-mailadres',
|
||||
'password' => 'Wachtwoord',
|
||||
'show_password' => 'Wachtwoord tonen',
|
||||
'hide_password' => 'Wachtwoord verbergen',
|
||||
'forgot_password' => 'Wachtwoord vergeten?',
|
||||
'remember_me' => 'Ingelogd blijven',
|
||||
'submit' => 'Inloggen',
|
||||
|
|
|
|||
|
|
@ -21,15 +21,13 @@
|
|||
'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' => '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',
|
||||
'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',
|
||||
'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',
|
||||
'other' => 'Iets anders',
|
||||
],
|
||||
|
|
|
|||
|
|
@ -60,6 +60,6 @@
|
|||
'support' => [
|
||||
'docs' => 'Documentatie',
|
||||
'referral' => 'Verdien 30% referral',
|
||||
'stay_updated' => 'Blijf op de hoogte',
|
||||
'discord' => 'Discord-community',
|
||||
],
|
||||
];
|
||||
|
|
|
|||
57
lang/nl/welcome.php
Normal file
57
lang/nl/welcome.php
Normal 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',
|
||||
],
|
||||
];
|
||||
|
|
@ -71,6 +71,8 @@
|
|||
'page_title' => 'Zaloguj się',
|
||||
'email' => 'Adres e-mail',
|
||||
'password' => 'Hasło',
|
||||
'show_password' => 'Pokaż hasło',
|
||||
'hide_password' => 'Ukryj hasło',
|
||||
'forgot_password' => 'Nie pamiętasz hasła?',
|
||||
'remember_me' => 'Zapamiętaj mnie',
|
||||
'submit' => 'Zaloguj się',
|
||||
|
|
|
|||
|
|
@ -21,15 +21,13 @@
|
|||
'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' => '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',
|
||||
'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',
|
||||
'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',
|
||||
'other' => 'Coś innego',
|
||||
],
|
||||
|
|
|
|||
|
|
@ -60,6 +60,6 @@
|
|||
'support' => [
|
||||
'docs' => 'Dokumentacja',
|
||||
'referral' => 'Zarabiaj 30% z poleceń',
|
||||
'stay_updated' => 'Bądź na bieżąco',
|
||||
'discord' => 'Społeczność Discord',
|
||||
],
|
||||
];
|
||||
|
|
|
|||
57
lang/pl/welcome.php
Normal file
57
lang/pl/welcome.php
Normal 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',
|
||||
],
|
||||
];
|
||||
|
|
@ -71,6 +71,8 @@
|
|||
'page_title' => 'Entrar',
|
||||
'email' => 'Endereço de email',
|
||||
'password' => 'Senha',
|
||||
'show_password' => 'Mostrar senha',
|
||||
'hide_password' => 'Esconder senha',
|
||||
'forgot_password' => 'Esqueceu a senha?',
|
||||
'remember_me' => 'Lembrar de mim',
|
||||
'submit' => 'Entrar',
|
||||
|
|
|
|||
|
|
@ -21,15 +21,13 @@
|
|||
'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' => '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',
|
||||
'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',
|
||||
'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',
|
||||
'other' => 'Outra coisa',
|
||||
],
|
||||
|
|
|
|||
|
|
@ -60,6 +60,6 @@
|
|||
'support' => [
|
||||
'docs' => 'Documentação',
|
||||
'referral' => 'Ganhe 30% de indicação',
|
||||
'stay_updated' => 'Fique por dentro',
|
||||
'discord' => 'Comunidade Discord',
|
||||
],
|
||||
];
|
||||
|
|
|
|||
57
lang/pt-BR/welcome.php
Normal file
57
lang/pt-BR/welcome.php
Normal 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',
|
||||
],
|
||||
];
|
||||
|
|
@ -71,6 +71,8 @@
|
|||
'page_title' => 'Вход',
|
||||
'email' => 'Адрес email',
|
||||
'password' => 'Пароль',
|
||||
'show_password' => 'Показать пароль',
|
||||
'hide_password' => 'Скрыть пароль',
|
||||
'forgot_password' => 'Забыли пароль?',
|
||||
'remember_me' => 'Запомнить меня',
|
||||
'submit' => 'Войти',
|
||||
|
|
|
|||
|
|
@ -21,15 +21,13 @@
|
|||
'goals_description' => 'Выберите всё, что подходит, и мы настроим TryPost для вас.',
|
||||
'goals' => [
|
||||
'save_time' => 'Экономить время, публикуя всюду сразу',
|
||||
'ai_content' => 'Создавать посты быстрее с помощью ИИ',
|
||||
'ai_content' => 'Генерировать посты с ИИ TryPost',
|
||||
'use_mcp' => 'Создавать посты через Claude, ChatGPT или Cursor',
|
||||
'plan_calendar' => 'Планировать посты в календаре',
|
||||
'stay_on_brand' => 'Держать каждый пост в стиле бренда',
|
||||
'grow_audience' => 'Наращивать аудиторию и вовлечённость',
|
||||
'drive_sales' => 'Получать больше трафика и продаж',
|
||||
'manage_clients' => 'Управлять несколькими брендами или клиентами',
|
||||
'team_collaboration' => 'Работать с командой',
|
||||
'automate_api' => 'Автоматизировать публикацию с помощью API, MCP или кода',
|
||||
'track_performance' => 'Отслеживать эффективность постов',
|
||||
'just_exploring' => 'Пока просто знакомлюсь',
|
||||
'other' => 'Что-то ещё',
|
||||
],
|
||||
|
|
|
|||
|
|
@ -60,6 +60,6 @@
|
|||
'support' => [
|
||||
'docs' => 'Документация',
|
||||
'referral' => 'Зарабатывайте 30% по реферальной программе',
|
||||
'stay_updated' => 'Следите за обновлениями',
|
||||
'discord' => 'Сообщество Discord',
|
||||
],
|
||||
];
|
||||
|
|
|
|||
57
lang/ru/welcome.php
Normal file
57
lang/ru/welcome.php
Normal 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' => 'Что-то другое',
|
||||
],
|
||||
];
|
||||
|
|
@ -73,6 +73,8 @@
|
|||
'page_title' => 'Giriş yap',
|
||||
'email' => 'E-posta adresi',
|
||||
'password' => 'Parola',
|
||||
'show_password' => 'Parolayı göster',
|
||||
'hide_password' => 'Parolayı gizle',
|
||||
'forgot_password' => 'Parolanızı mı unuttunuz?',
|
||||
'remember_me' => 'Beni hatırla',
|
||||
'submit' => 'Giriş yap',
|
||||
|
|
|
|||
|
|
@ -21,15 +21,13 @@
|
|||
'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' => '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',
|
||||
'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',
|
||||
'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',
|
||||
'other' => 'Başka bir şey',
|
||||
],
|
||||
|
|
|
|||
|
|
@ -60,6 +60,6 @@
|
|||
'support' => [
|
||||
'docs' => 'Dokümantasyon',
|
||||
'referral' => '%30 referans kazanın',
|
||||
'stay_updated' => 'Güncel kalın',
|
||||
'discord' => 'Discord topluluğu',
|
||||
],
|
||||
];
|
||||
|
|
|
|||
57
lang/tr/welcome.php
Normal file
57
lang/tr/welcome.php
Normal 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',
|
||||
],
|
||||
];
|
||||
|
|
@ -71,6 +71,8 @@
|
|||
'page_title' => 'Вхід',
|
||||
'email' => 'Адреса email',
|
||||
'password' => 'Пароль',
|
||||
'show_password' => 'Показати пароль',
|
||||
'hide_password' => 'Приховати пароль',
|
||||
'forgot_password' => 'Забули пароль?',
|
||||
'remember_me' => 'Запам’ятати мене',
|
||||
'submit' => 'Увійти',
|
||||
|
|
|
|||
|
|
@ -21,15 +21,13 @@
|
|||
'goals_description' => 'Виберіть усе, що підходить, і ми налаштуємо TryPost для вас.',
|
||||
'goals' => [
|
||||
'save_time' => 'Економити час, публікуючи всюди одразу',
|
||||
'ai_content' => 'Створювати пости швидше з AI',
|
||||
'ai_content' => 'Генерувати пости з AI TryPost',
|
||||
'use_mcp' => 'Створювати пости через Claude, ChatGPT або Cursor',
|
||||
'plan_calendar' => 'Планувати пости в календарі',
|
||||
'stay_on_brand' => 'Тримати кожен пост у стилі бренду',
|
||||
'grow_audience' => 'Збільшувати аудиторію та залучення',
|
||||
'drive_sales' => 'Отримувати більше трафіку та продажів',
|
||||
'manage_clients' => 'Керувати кількома брендами або клієнтами',
|
||||
'team_collaboration' => 'Працювати з командою',
|
||||
'automate_api' => 'Автоматизувати публікацію через API, MCP або код',
|
||||
'track_performance' => 'Бачити, як працюють мої пости',
|
||||
'just_exploring' => 'Поки що просто досліджую',
|
||||
'other' => 'Щось інше',
|
||||
],
|
||||
|
|
|
|||
|
|
@ -60,6 +60,6 @@
|
|||
'support' => [
|
||||
'docs' => 'Документація',
|
||||
'referral' => 'Отримуйте 30% за рефералами',
|
||||
'stay_updated' => 'Слідкуйте за оновленнями',
|
||||
'discord' => 'Спільнота Discord',
|
||||
],
|
||||
];
|
||||
|
|
|
|||
57
lang/uk/welcome.php
Normal file
57
lang/uk/welcome.php
Normal 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' => 'Щось інше',
|
||||
],
|
||||
];
|
||||
|
|
@ -71,6 +71,8 @@
|
|||
'page_title' => '登录',
|
||||
'email' => '邮箱地址',
|
||||
'password' => '密码',
|
||||
'show_password' => '显示密码',
|
||||
'hide_password' => '隐藏密码',
|
||||
'forgot_password' => '忘记密码?',
|
||||
'remember_me' => '记住我',
|
||||
'submit' => '登录',
|
||||
|
|
|
|||
|
|
@ -21,15 +21,13 @@
|
|||
'goals_description' => '选择所有符合的选项,我们会为你配置好 TryPost。',
|
||||
'goals' => [
|
||||
'save_time' => '一次发布到所有平台,节省时间',
|
||||
'ai_content' => '借助 AI 更快地创建帖子',
|
||||
'ai_content' => '用 TryPost AI 生成帖子',
|
||||
'use_mcp' => '通过 Claude、ChatGPT 或 Cursor 创建帖子',
|
||||
'plan_calendar' => '在日历上规划我的帖子',
|
||||
'stay_on_brand' => '让每一条帖子都符合品牌调性',
|
||||
'grow_audience' => '增长我的受众和互动',
|
||||
'drive_sales' => '获得更多流量和销量',
|
||||
'manage_clients' => '管理多个品牌或客户',
|
||||
'team_collaboration' => '与我的团队协作',
|
||||
'automate_api' => '通过 API、MCP 或代码自动发帖',
|
||||
'track_performance' => '查看我的帖子表现',
|
||||
'just_exploring' => '目前只是随便看看',
|
||||
'other' => '其他需求',
|
||||
],
|
||||
|
|
|
|||
|
|
@ -60,6 +60,6 @@
|
|||
'support' => [
|
||||
'docs' => '文档',
|
||||
'referral' => '赚取 30% 推荐奖励',
|
||||
'stay_updated' => '获取最新动态',
|
||||
'discord' => 'Discord 社区',
|
||||
],
|
||||
];
|
||||
|
|
|
|||
57
lang/zh/welcome.php
Normal file
57
lang/zh/welcome.php
Normal 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' => '其他',
|
||||
],
|
||||
];
|
||||
|
|
@ -4,7 +4,7 @@ import {
|
|||
IconAffiliate,
|
||||
IconAlertTriangle,
|
||||
IconBolt,
|
||||
IconBrandX,
|
||||
IconBrandDiscord,
|
||||
IconCalendar,
|
||||
IconChartBar,
|
||||
IconChevronRight,
|
||||
|
|
@ -178,9 +178,9 @@ const bottomNavItems = computed(() => [
|
|||
icon: IconGift,
|
||||
},
|
||||
{
|
||||
title: trans('sidebar.support.stay_updated'),
|
||||
href: 'https://x.com/trypostit',
|
||||
icon: IconBrandX,
|
||||
title: trans('sidebar.support.discord'),
|
||||
href: 'https://trypost.it/discord',
|
||||
icon: IconBrandDiscord,
|
||||
},
|
||||
{
|
||||
title: trans('sidebar.support.docs'),
|
||||
|
|
|
|||
|
|
@ -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>
|
||||
118
resources/js/layouts/WelcomeLayout.vue
Normal file
118
resources/js/layouts/WelcomeLayout.vue
Normal 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>
|
||||
|
|
@ -1,6 +1,7 @@
|
|||
<script setup lang="ts">
|
||||
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 InputError from '@/components/InputError.vue';
|
||||
|
|
@ -10,6 +11,12 @@ import { Checkbox } from '@/components/ui/checkbox';
|
|||
import { Input } from '@/components/ui/input';
|
||||
import { Label } from '@/components/ui/label';
|
||||
import { Spinner } from '@/components/ui/spinner';
|
||||
import {
|
||||
Tooltip,
|
||||
TooltipContent,
|
||||
TooltipProvider,
|
||||
TooltipTrigger,
|
||||
} from '@/components/ui/tooltip';
|
||||
import AuthBase from '@/layouts/AuthLayout.vue';
|
||||
import { register } from '@/routes';
|
||||
import { store } from '@/routes/login';
|
||||
|
|
@ -21,61 +28,157 @@ defineProps<{
|
|||
redirect?: string | null;
|
||||
}>();
|
||||
|
||||
const showPassword = ref(false);
|
||||
|
||||
const page = usePage();
|
||||
const isSelfHosted = computed(() => Boolean(page.props.selfHosted));
|
||||
</script>
|
||||
|
||||
<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')" />
|
||||
|
||||
<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 }}
|
||||
</div>
|
||||
|
||||
<div class="flex flex-col gap-6">
|
||||
<SocialLogin mode="login" />
|
||||
|
||||
<Form v-bind="store.form()" :reset-on-success="['password']" v-slot="{ errors, processing }"
|
||||
class="flex flex-col gap-6">
|
||||
<input v-if="redirect" type="hidden" name="redirect" :value="redirect" />
|
||||
<Form
|
||||
v-bind="store.form()"
|
||||
: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-2">
|
||||
<Label for="email">{{ $t('auth.login.email') }}</Label>
|
||||
<Input id="email" type="email" name="email" autofocus :tabindex="1" autocomplete="email"
|
||||
placeholder="email@example.com" :default-value="email ?? ''" />
|
||||
<Input
|
||||
id="email"
|
||||
type="email"
|
||||
name="email"
|
||||
autofocus
|
||||
:tabindex="1"
|
||||
autocomplete="email"
|
||||
placeholder="email@example.com"
|
||||
:default-value="email ?? ''"
|
||||
/>
|
||||
<InputError :message="errors.email" />
|
||||
</div>
|
||||
|
||||
<div class="grid gap-2">
|
||||
<div class="flex items-center justify-between">
|
||||
<Label for="password">{{ $t('auth.login.password') }}</Label>
|
||||
<TextLink :href="request()" class="text-sm" :tabindex="5">
|
||||
<Label for="password">{{
|
||||
$t('auth.login.password')
|
||||
}}</Label>
|
||||
<TextLink
|
||||
:href="request()"
|
||||
class="text-sm"
|
||||
:tabindex="5"
|
||||
>
|
||||
{{ $t('auth.login.forgot_password') }}
|
||||
</TextLink>
|
||||
</div>
|
||||
<Input id="password" type="password" name="password" :tabindex="2"
|
||||
autocomplete="current-password" :placeholder="$t('auth.login.password')" />
|
||||
<div class="relative">
|
||||
<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" />
|
||||
</div>
|
||||
|
||||
<div class="flex items-center justify-between">
|
||||
<Label for="remember" class="flex items-center space-x-3">
|
||||
<Checkbox id="remember" name="remember" :tabindex="3" />
|
||||
<Label
|
||||
for="remember"
|
||||
class="flex items-center space-x-3"
|
||||
>
|
||||
<Checkbox
|
||||
id="remember"
|
||||
name="remember"
|
||||
:tabindex="3"
|
||||
/>
|
||||
<span>{{ $t('auth.login.remember_me') }}</span>
|
||||
</Label>
|
||||
</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" />
|
||||
{{ $t('auth.login.submit') }}
|
||||
</Button>
|
||||
</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') }}
|
||||
<TextLink :href="register()" :tabindex="5">{{ $t('auth.login.sign_up') }}</TextLink>
|
||||
<TextLink :href="register()" :tabindex="5">{{
|
||||
$t('auth.login.sign_up')
|
||||
}}</TextLink>
|
||||
</div>
|
||||
</Form>
|
||||
</div>
|
||||
|
|
|
|||
|
|
@ -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>
|
||||
|
|
@ -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>
|
||||
|
|
@ -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>
|
||||
|
|
@ -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>
|
||||
194
resources/js/pages/welcome/Goals.vue
Normal file
194
resources/js/pages/welcome/Goals.vue
Normal 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>
|
||||
169
resources/js/pages/welcome/Persona.vue
Normal file
169
resources/js/pages/welcome/Persona.vue
Normal 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>
|
||||
229
resources/js/pages/welcome/ReferralSource.vue
Normal file
229
resources/js/pages/welcome/ReferralSource.vue
Normal 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>
|
||||
49
resources/js/pages/welcome/SubscriptionRequired.vue
Normal file
49
resources/js/pages/welcome/SubscriptionRequired.vue
Normal 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>
|
||||
|
|
@ -12,7 +12,6 @@
|
|||
use App\Http\Controllers\App\LinkPreviewController;
|
||||
use App\Http\Controllers\App\McpSettingsController;
|
||||
use App\Http\Controllers\App\NotificationController;
|
||||
use App\Http\Controllers\App\OnboardingController;
|
||||
use App\Http\Controllers\App\PostAiCreateController;
|
||||
use App\Http\Controllers\App\PostAiGenerateController;
|
||||
use App\Http\Controllers\App\PostAiRegenerateMediaController;
|
||||
|
|
@ -28,6 +27,7 @@
|
|||
use App\Http\Controllers\App\Settings\SettingsController;
|
||||
use App\Http\Controllers\App\Settings\UsageController;
|
||||
use App\Http\Controllers\App\UnsplashController;
|
||||
use App\Http\Controllers\App\WelcomeController;
|
||||
use App\Http\Controllers\App\WorkspaceController;
|
||||
use App\Http\Controllers\App\WorkspaceInviteController;
|
||||
use App\Http\Controllers\App\WorkspaceLabelController;
|
||||
|
|
@ -58,14 +58,16 @@
|
|||
})->name('app.home');
|
||||
|
||||
Route::get('subscribe', [BillingController::class, 'subscribe'])->name('app.subscribe');
|
||||
Route::get('onboarding', [OnboardingController::class, 'index'])->name('app.onboarding');
|
||||
Route::post('onboarding', [OnboardingController::class, 'store'])->name('app.onboarding.store');
|
||||
Route::get('onboarding/goals', [OnboardingController::class, 'goals'])->name('app.onboarding.goals');
|
||||
Route::post('onboarding/goals', [OnboardingController::class, 'storeGoals'])->name('app.onboarding.goals.store');
|
||||
Route::get('onboarding/referral-source', [OnboardingController::class, 'referralSource'])->name('app.onboarding.referral-source');
|
||||
Route::post('onboarding/referral-source', [OnboardingController::class, 'storeReferralSource'])->name('app.onboarding.referral-source.store');
|
||||
Route::get('onboarding/connect', [OnboardingController::class, 'connect'])->name('app.onboarding.connect');
|
||||
Route::post('onboarding/connect', [OnboardingController::class, 'checkout'])->name('app.onboarding.checkout');
|
||||
Route::get('welcome', fn () => redirect()->route('app.welcome.persona'))->name('app.welcome');
|
||||
Route::get('welcome/persona', [WelcomeController::class, 'persona'])->name('app.welcome.persona');
|
||||
Route::post('welcome/persona', [WelcomeController::class, 'storePersona'])->name('app.welcome.persona.store');
|
||||
Route::get('welcome/goals', [WelcomeController::class, 'goals'])->name('app.welcome.goals');
|
||||
Route::post('welcome/goals', [WelcomeController::class, 'storeGoals'])->name('app.welcome.goals.store');
|
||||
Route::get('welcome/referral-source', [WelcomeController::class, 'referralSource'])->name('app.welcome.referral-source');
|
||||
Route::get('welcome/subscription-required', [WelcomeController::class, 'subscriptionRequired'])->name('app.welcome.subscription-required');
|
||||
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('workspaces/create', [WorkspaceController::class, 'create'])->name('app.workspaces.create');
|
||||
|
|
|
|||
|
|
@ -31,12 +31,12 @@
|
|||
$response->assertRedirect(route('login'));
|
||||
});
|
||||
|
||||
test('subscribe redirects to onboarding', function () {
|
||||
test('subscribe redirects to welcome', function () {
|
||||
config(['trypost.self_hosted' => false]);
|
||||
|
||||
$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 () {
|
||||
|
|
|
|||
|
|
@ -280,10 +280,10 @@
|
|||
->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->actingAs($this->user->fresh())
|
||||
->get(route('app.mcp.index'))
|
||||
->assertRedirect(route('app.onboarding'));
|
||||
->assertRedirect(route('app.welcome.persona'));
|
||||
});
|
||||
|
|
|
|||
|
|
@ -11,13 +11,28 @@
|
|||
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();
|
||||
$user = User::factory()->create(['account_id' => $account->id]);
|
||||
|
||||
$this->actingAs($user)
|
||||
->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 () {
|
||||
|
|
|
|||
|
|
@ -32,7 +32,7 @@
|
|||
|
||||
$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 () {
|
||||
|
|
|
|||
|
|
@ -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'));
|
||||
}
|
||||
});
|
||||
403
tests/Feature/Welcome/WelcomeControllerTest.php
Normal file
403
tests/Feature/Welcome/WelcomeControllerTest.php
Normal 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();
|
||||
});
|
||||
|
|
@ -57,7 +57,7 @@
|
|||
|
||||
$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 () {
|
||||
|
|
@ -96,12 +96,12 @@
|
|||
$response->assertForbidden();
|
||||
});
|
||||
|
||||
test('subscribe redirects to onboarding', function () {
|
||||
test('subscribe redirects to welcome', function () {
|
||||
config(['trypost.self_hosted' => false]);
|
||||
|
||||
$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 () {
|
||||
|
|
|
|||
Loading…
Reference in a new issue