Activation checklist + MCP OAuth authorize UX (#239) (#250)

* Wire onboarding activation into Account, observers, and shared Inertia data

Add onboarding casts/hasFinishedOnboarding, AccessToken ObservedBy,
Platform::connectableOptions, Post/SocialAccount onboarding broadcast hooks,
and lazy onboardingResidual share + SharedData types.

* Register onboarding routes and post-checkout activation redirects.

Wire billing processing and the sidebar checklist so owners land on
activation after subscribe, with locale sidebar/uk onboarding strings.

* Align MCP grant usability with onboarding activation checks

Unbound MCP tokens fall back to the user's current workspace and require
createPost so viewer/unscoped grants neither unlock the checklist nor
broadcast onboarding status.

* Require bound MCP workspace for onboarding activation.

Drop current-workspace fallback from usable MCP grants so checklist
detection and broadcasts match Passport token scoping; viewers still
cannot unlock the MCP step.

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

* Harden onboarding review findings and tighten locale strings.

Fix Welcome/Persona/TrackPost suites broken by the activation route reuse
and PostObserver analytics side effects, restore Echo poll fallbacks,
reject unbound MCP grants in tests, and drop unused onboarding.mcp keys.

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

* Remove unused sidebar and MCP authorization locale keys.

Drop dead sidebar menu/theme strings (including the overwritten
workspace label and api_keys nav entry) and unused MCP authorize
app_title/approving copy across all locales.

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

* Fix SetLocale crashing on Passport Symfony OAuth responses.

OAuth errors return a raw Symfony Response without withCookie(); attach
the default locale cookie via headers so authorize no longer 500s.

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

* Prompt OAuth guests to log in before rejecting unknown clients.

MCP Inspector often reuses a stale client_id; validateAuthorizationRequest
was returning invalid_client JSON before the login redirect. Guests now
hit /login first, then client validation runs after authentication.

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

* Render Inertia OAuth authorize errors for browser logins.

After login, Inertia follows the intended authorize URL; raw invalid_client
JSON broke that visit. HTML/Inertia requests now get mcp/AuthorizeError
while API JSON clients still receive the OAuth error payload.

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

* Detect Inertia OAuth error pages via Request::inertia().

Use the framework helper so post-login authorize failures keep returning
an Inertia page instead of raw OAuth JSON.

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

* Simplify OAuth authorize error page detection to expectsJson.

Drop the X-Inertia header sniff; browser and Inertia visits already do
not expectsJson, while API clients still receive the OAuth JSON payload.

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

* Share MCP authorize layout and drop the error close button.

Keep authorize and authorize-error on the same centered card shell instead of the auth split layout.

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

* Simplify onboarding activation for reviewability and safety.

Use an exists-based MCP check, keep GETs read-only, move sync into
syncAndNotify, clear MCP skips on connect, restrict complete to owners,
and share Echo/poll via one composable.

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

* Move MCP OAuth authorize UX out of the onboarding PR.

Keep the activation checklist focused; OAuth guest/error-page work now
lives on fix/mcp-oauth-authorize-ux.

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

* Fix corrupted French MCP locale after OAuth key cleanup.

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

* Restore MCP OAuth authorize UX onto the onboarding branch.

Keep authorize error page, guest login-before-client validation, and
SetLocale Symfony cookie fix in #250.

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

* Fix OAuth prompt=none redirects and harden onboarding tests.

Keep login_required/consent_required as redirects instead of Inertia,
add regression coverage for owner-only activation, require invite email
confirmation, and align MCP connected apps with the sessions list UI.

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

* Simplify onboarding guards and dedupe viewed analytics.

Introduce isOnboardingOpen / belongsToAccount helpers, collapse
duplicated sync/dispatch paths, and capture onboarding.viewed once
per account.

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

* Simplify onboarding event, observers, and status helpers.

Tighten Account onboarding predicates, drop nullable broadcast/dispatch
APIs, and collapse repeated observer/controller guards.

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

* Treat in-app users as always having an account.

Add resolveAccount(), tighten belongsToAccount to string ids, and fold
guest residual handling into ResolveOnboardingStatus.

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

* Rename onboarding residual share test to progress.

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

* Simplify onboarding status and rename residual to progress.

Use accountOrFail, extract MCP onboarding scope, auto-leave the ready
screen, and send non-onboarding checkout back to accounts.

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

* Extract HasAccount and prefer data_get in onboarding flows.

Move account helpers off User, drop nullable sidebarProgress, and
read OAuth/onboarding payloads with data_get.

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

* Simplify onboarding checks and extract HasOnboarding.

Use Eloquent + policies for MCP/backfill paths, and move account
onboarding helpers into a dedicated trait.

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

* Add trait tests and tidy onboarding imports.

Cover HasAccount and HasOnboarding under Models/Traits, prefer filled() for checkout session ids, and import Throwable instead of FQCN.

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

* Simplify checkout session_id and OAuth error props.

Read session_id via request->string(), and take OAuth error details from the League exception instead of decoding the response body.

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

* Simplify PostObserver onboarding notify path.

Share one otherPosts check for first-create and last-delete instead of separate callbacks.

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

* Use post author as onboarding sync actor.

Drop Auth::user() preference in PostObserver; checklist sync attributes to $post->user.

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

* Simplify SocialAccountObserver and OAuth authorize flow.

Share create/delete onboarding notify, drop Auth actor fallback to owner, and inline Passport Inertia error handling.

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

* Use lazy Inertia props for onboarding partial reloads.

Drop partial-header branching; wrap page props in closures and always redirect completed/dismissed accounts to the calendar.

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

* Defer sidebar onboarding progress and stamp completion as owner-only.

Skip the MCP checklist work on full Inertia visits via deferred shared props,
early-exit token scans, and keep account completion stamps owner-gated.

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

* Simplify deferred onboarding progress share via canShowProgress.

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

* Add User firstName for shared auth and simplify onboarding page.

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

* Move User firstName coverage into UserTest.

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

* Use first_name directly without empty-name fallbacks.

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

* Resolve onboarding sample prompt on the frontend via i18n.

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

* Stamp onboarding completion via the account owner after teammate unlocks.

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

* Count only the account owner MCP grant toward onboarding activation.

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

* Fix OAuth consent auth-token mismatch for mid-activation owners.

Skip deferred onboardingProgress on Passport authorize so Inertia does not
rotate the session authToken, cover happy and stale-token paths in tests,
and polish MCP setup copy plus sidebar/onboarding layout.

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

* Keep users on onboarding after activation completes.

Stamp completion and re-render the finished checklist instead of
redirecting to the calendar so owners can review the done state.

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

* Clarify Passport consent-view opt-out and guard app-route deferral.

Rename the authorize-only route check and assert onboardingProgress still
defers on calendar, onboarding, and MCP settings.

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

* Harden onboarding completion and MCP consent workspace binding.

Reject OAuth approve without a workspace, retry auto-complete until
stamped, send dismissed complete straight to calendar, and cover the
device consent defer opt-out.

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

* Enable activation checklist for self-hosted installs.

Remove the self-hosted onboarding redirects, keep the SaaS-only dismiss backfill, and cover subscription-less owners plus skip/complete destinations.

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

* Add GitHub, Hacker News, and directories referral sources.

Expand the welcome referral step with open-source and directory discovery channels.

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

* Refine welcome referral sources and labels.

Split Instagram/Threads, add Founder, and shorten Google, GitHub, AI, and blog option labels.

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

* Sort accounts platforms alphabetically and drop connect hover plus.

Reuse connectableOptions for the accounts index and remove the unused plus badge on disconnected cards.

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

* Centralize PostHog once-capture so disabled installs don't burn dedupe keys.

Move isEnabled + Cache::add into PostHogService::captureOnce and route onboarding viewed/step events through it.

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

* Simplify onboarding backfill to complete every existing open account.

Drop self-hosted and subscription filters; down clears completed_at again.

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

* Drop PostHog captureOnce and use plain capture for onboarding.

Remove cache-based event dedupe; callers rely on PostHogService::capture gating.

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

---------

Co-authored-by: Cursor <cursoragent@cursor.com>
This commit is contained in:
Paulo Castellano 2026-08-07 19:34:43 -04:00 committed by GitHub
parent 2ca5948309
commit a1fa897106
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
126 changed files with 5742 additions and 1581 deletions

View file

@ -0,0 +1,462 @@
<?php
declare(strict_types=1);
namespace App\Actions\Onboarding;
use App\Enums\PostHog\OnboardingEvent;
use App\Enums\SocialAccount\Status;
use App\Events\OnboardingStatusUpdated;
use App\Models\Account;
use App\Models\User;
use App\Models\Workspace;
use App\Services\PostHogService;
use Illuminate\Database\Eloquent\Builder;
class ResolveOnboardingStatus
{
/**
* Checklist step identifiers mapped to their status keys. Steps are derived
* from real state; optional steps may additionally be skipped.
*/
public const STEPS = [
'mcp' => 'mcp_connected',
'social' => 'social_connected',
'first_post' => 'first_post_created',
];
public function __construct(
private readonly PostHogService $postHog,
) {}
public static function totalSteps(): int
{
return count(self::STEPS);
}
/**
* Pure read of activation checklist state. Safe for Inertia shared props.
*
* @return array{
* mcp_connected: bool,
* social_connected: bool,
* first_post_created: bool,
* skipped_steps: list<string>,
* all_complete: bool,
* show_progress: bool,
* completed_at: ?string,
* dismissed_at: ?string
* }
*/
public function handle(User $user): array
{
$account = $user->accountOrFail();
return $account->isOnboardingCompleted()
? $this->completedStatus($account)
: $this->activeStatus($user, $account);
}
/**
* Read checklist state, capture step analytics, and stamp completion when done.
* Call from observers / explicit mutations not from shared Inertia props or GETs.
*
* @return array{
* mcp_connected: bool,
* social_connected: bool,
* first_post_created: bool,
* skipped_steps: list<string>,
* all_complete: bool,
* show_progress: bool,
* completed_at: ?string,
* dismissed_at: ?string
* }
*/
public function syncProgress(User $user): array
{
$account = $user->accountOrFail();
// Completed is read-only. Dismissed still runs below so a late MCP
// connect can clear a leftover mcp skip.
if ($account->isOnboardingCompleted()) {
return $this->handle($user);
}
$this->clearMcpSkipIfConnected($account);
$status = $this->handle($user);
if ($account->isOnboardingDismissed()) {
return $status;
}
$this->captureCompletedSteps($user, $account, $status);
$owner = $account->owner;
if (! data_get($status, 'all_complete') || $owner === null) {
return $status;
}
$this->markCompleted($owner);
$account->refresh();
if (! $account->isOnboardingCompleted()) {
return $status;
}
return [
...$status,
'show_progress' => false,
'completed_at' => $account->onboarding_completed_at?->toIso8601String(),
];
}
/**
* Sync checklist progress for an account actor, then broadcast progress updates.
* Used by observers after a step unlocks (post-commit).
*/
public function syncAndNotify(Account $account, ?string $actorId = null): void
{
$actor = $actorId !== null
? User::query()->with('account')->find($actorId)
: null;
$syncTarget = $actor?->belongsToAccount($account->id)
? $actor
: $account->owner;
if ($syncTarget !== null) {
$this->syncProgress($syncTarget);
$account->refresh();
}
if ($account->isOnboardingOpen()) {
OnboardingStatusUpdated::broadcastForAccount($account);
}
}
/**
* Stamp account onboarding completion once and capture the funnel event.
* Owner-only teammates unlock steps but cannot finish the account funnel.
*/
public function markCompleted(User $user): bool
{
if (! $user->isAccountOwner()) {
return false;
}
$account = $user->accountOrFail();
if (! $account->isOnboardingOpen()) {
return false;
}
$completedAt = now();
$updated = Account::query()
->whereKey($account->id)
->onboardingOpen()
->update([
'onboarding_completed_at' => $completedAt,
'updated_at' => $completedAt,
]);
if ($updated === 0) {
return false;
}
$account->refresh();
$this->postHog->capture($user->id, OnboardingEvent::Completed->value, account: $account);
// Every stamp path must clear progress banners account-wide.
OnboardingStatusUpdated::broadcastForAccount($account);
return true;
}
/**
* Skip the optional MCP step, then stamp completion when it was the last open step.
*/
public function skipMcp(User $user): bool
{
$account = $user->accountOrFail();
if (! $account->isOnboardingOpen()) {
return false;
}
$status = $this->handle($user);
if ($this->stepIsSettled($status, 'mcp')) {
return false;
}
$skippedSteps = [...data_get($status, 'skipped_steps', []), 'mcp'];
$updated = Account::query()
->whereKey($account->id)
->onboardingOpen()
->where(function (Builder $query): void {
$query->whereNull('onboarding_skipped_steps')
->orWhereJsonDoesntContain('onboarding_skipped_steps', 'mcp');
})
->update([
'onboarding_skipped_steps' => $skippedSteps,
'updated_at' => now(),
]);
if ($updated === 0) {
return false;
}
$account->refresh();
$this->postHog->capture(
$user->id,
OnboardingEvent::StepSkipped->value,
['step' => 'mcp'],
$account,
);
$this->completeChecklistOrBroadcast($user, $account);
return true;
}
/**
* Sidebar checklist progress, or false when the banner should not show.
*
* Pure read safe for Inertia shared props and prefetch. Cross-workspace
* completion is stamped by syncProgress / observers, not here.
*
* @return array{completed: int, total: int}|false
*/
public function sidebarProgress(User $user): array|false
{
if (! $this->canShowProgress($user)) {
return false;
}
$status = $this->handle($user);
return data_get($status, 'show_progress')
? [
'completed' => $this->completedStepCount($status),
'total' => self::totalSteps(),
]
: false;
}
/**
* Cheap gates before handle()'s step queries.
* Non-owners never reach step state.
*/
public function canShowProgress(User $user, ?Account $account = null): bool
{
if (! $user->isAccountOwner()) {
return false;
}
$account ??= $user->account;
return $account !== null
&& $account->isOnboardingOpen()
&& $account->hasAppAccess();
}
/**
* After skipping a step: stamp completion when the checklist is done,
* otherwise broadcast so sidebar progress refreshes.
*/
private function completeChecklistOrBroadcast(User $user, Account $account): void
{
if (data_get($this->handle($user), 'all_complete') && $this->markCompleted($user)) {
return;
}
OnboardingStatusUpdated::broadcastForAccount($account);
}
/**
* @return array{
* mcp_connected: bool,
* social_connected: bool,
* first_post_created: bool,
* skipped_steps: list<string>,
* all_complete: bool,
* show_progress: bool,
* completed_at: string,
* dismissed_at: ?string
* }
*/
private function completedStatus(Account $account): array
{
$skippedSteps = $account->skippedOnboardingSteps();
// Preserve skip vs real MCP completion for the ready UI badge.
$mcpConnected = ! $account->hasSkippedOnboardingStep('mcp')
|| $account->hasOnboardingMcpConnection();
return [
'mcp_connected' => $mcpConnected,
'social_connected' => true,
'first_post_created' => true,
'skipped_steps' => $mcpConnected
? $this->withoutSkippedStep($skippedSteps, 'mcp')
: $skippedSteps,
'all_complete' => true,
'show_progress' => false,
'completed_at' => $account->onboarding_completed_at->toIso8601String(),
'dismissed_at' => $account->onboarding_dismissed_at?->toIso8601String(),
];
}
/**
* @return array{
* mcp_connected: bool,
* social_connected: bool,
* first_post_created: bool,
* skipped_steps: list<string>,
* all_complete: bool,
* show_progress: bool,
* completed_at: null,
* dismissed_at: ?string
* }
*/
private function activeStatus(User $user, Account $account): array
{
$skippedSteps = $account->skippedOnboardingSteps();
$steps = $this->stepStates($account);
$allComplete = collect($steps)->every(
fn (bool $done, string $step): bool => $this->stepIsComplete($done, $step, $skippedSteps),
);
return [
'mcp_connected' => data_get($steps, 'mcp'),
'social_connected' => data_get($steps, 'social'),
'first_post_created' => data_get($steps, 'first_post'),
'skipped_steps' => $skippedSteps,
'all_complete' => $allComplete,
'show_progress' => $this->canShowProgress($user, $account) && ! $allComplete,
'completed_at' => null,
'dismissed_at' => $account->onboarding_dismissed_at?->toIso8601String(),
];
}
/**
* @return array{mcp: bool, social: bool, first_post: bool}
*/
private function stepStates(Account $account): array
{
return [
'mcp' => $account->hasOnboardingMcpConnection(),
'social' => $this->accountHasSocialConnection($account),
'first_post' => $this->accountHasPost($account),
];
}
/**
* Whether a checklist step is already done or skipped in a status payload.
*
* @param array{mcp_connected?: bool, social_connected?: bool, first_post_created?: bool, skipped_steps?: list<string>} $status
*/
private function stepIsSettled(array $status, string $step): bool
{
return $this->stepIsComplete(
(bool) data_get($status, data_get(self::STEPS, $step)),
$step,
data_get($status, 'skipped_steps', []),
);
}
/**
* @param list<string> $skippedSteps
*/
private function stepIsComplete(bool $done, string $step, array $skippedSteps): bool
{
return $done || in_array($step, $skippedSteps, true);
}
/**
* @param list<string> $skippedSteps
* @return list<string>
*/
private function withoutSkippedStep(array $skippedSteps, string $step): array
{
return array_values(array_diff($skippedSteps, [$step]));
}
/**
* @param array{skipped_steps: list<string>, mcp_connected: bool, social_connected: bool, first_post_created: bool} $status
*/
private function completedStepCount(array $status): int
{
return collect(self::STEPS)
->filter(fn (string $statusKey, string $step): bool => $this->stepIsComplete(
(bool) data_get($status, $statusKey),
$step,
data_get($status, 'skipped_steps', []),
))
->count();
}
/**
* @param array{mcp_connected: bool, social_connected: bool, first_post_created: bool} $status
*/
private function captureCompletedSteps(User $user, Account $account, array $status): void
{
foreach (self::STEPS as $step => $statusKey) {
if (! data_get($status, $statusKey)) {
continue;
}
$this->postHog->capture(
$user->id,
OnboardingEvent::StepCompleted->value,
['step' => $step],
$account,
);
}
}
private function accountHasSocialConnection(Account $account): bool
{
return Workspace::query()
->where('account_id', $account->id)
->whereHas(
'socialAccounts',
fn (Builder $query): Builder => $query->where('status', Status::Connected),
)
->exists();
}
private function accountHasPost(Account $account): bool
{
return Workspace::query()
->where('account_id', $account->id)
->whereHas('posts')
->exists();
}
private function clearMcpSkipIfConnected(Account $account): void
{
if (! $account->hasSkippedOnboardingStep('mcp') || ! $account->hasOnboardingMcpConnection()) {
return;
}
Account::query()
->whereKey($account->id)
->whereNull('onboarding_completed_at')
->update([
'onboarding_skipped_steps' => $this->withoutSkippedStep(
$account->skippedOnboardingSteps(),
'mcp',
) ?: null,
'updated_at' => now(),
]);
$account->refresh();
}
}

View file

@ -0,0 +1,13 @@
<?php
declare(strict_types=1);
namespace App\Enums\PostHog;
enum OnboardingEvent: string
{
case Viewed = 'onboarding.viewed';
case StepCompleted = 'onboarding.step_completed';
case StepSkipped = 'onboarding.step_skipped';
case Completed = 'onboarding.completed';
}

View file

@ -390,6 +390,27 @@ public function isConnectable(): bool
};
}
/**
* Connectable platforms shaped for Inertia account/onboarding grids.
* Sorted alphabetically by label (ASC, case-insensitive).
*
* @return list<array{value: string, label: string, color: string, network: string}>
*/
public static function connectableOptions(): array
{
return collect(self::cases())
->filter(fn (self $platform): bool => $platform->isConnectable())
->sortBy(fn (self $platform): string => mb_strtolower($platform->label()))
->map(fn (self $platform): array => [
'value' => $platform->value,
'label' => $platform->label(),
'color' => $platform->color(),
'network' => $platform->network(),
])
->values()
->all();
}
/**
* Static, platform-specific data exposed to the frontend (e.g. TikTok privacy options,
* compliance URLs). Returns an empty array for platforms with no extra config.

View file

@ -12,10 +12,15 @@ enum ReferralSource: string
case YouTube = 'youtube';
case TikTok = 'tiktok';
case Instagram = 'instagram';
case Threads = 'threads';
case Reddit = 'reddit';
case ProductHunt = 'product_hunt';
case GitHub = 'github';
case HackerNews = 'hacker_news';
case Directories = 'directories';
case AiAssistant = 'ai_assistant';
case Friend = 'friend';
case Founder = 'founder';
case Blog = 'blog';
case Other = 'other';
}

View file

@ -0,0 +1,103 @@
<?php
declare(strict_types=1);
namespace App\Events;
use App\Actions\Onboarding\ResolveOnboardingStatus;
use App\Models\Account;
use App\Models\User;
use App\Models\Workspace;
use Illuminate\Broadcasting\InteractsWithSockets;
use Illuminate\Broadcasting\PrivateChannel;
use Illuminate\Contracts\Broadcasting\ShouldBroadcast;
use Illuminate\Contracts\Events\ShouldDispatchAfterCommit;
use Illuminate\Foundation\Events\Dispatchable;
use Illuminate\Queue\SerializesModels;
use Illuminate\Support\Facades\DB;
class OnboardingStatusUpdated implements ShouldBroadcast, ShouldDispatchAfterCommit
{
use Dispatchable, InteractsWithSockets, SerializesModels;
public function __construct(public string $workspaceId) {}
/**
* Notify every workspace channel that onboarding state changed no sync.
* Use after complete/skip so owner progress banners update immediately
* even when dispatchForAccount would early-return on stamped timestamps.
*/
public static function broadcastForAccount(Account $account): void
{
foreach ($account->workspaces()->pluck('id') as $workspaceId) {
static::dispatch((string) $workspaceId);
}
}
/**
* Sync progress then broadcast to every workspace on the account.
* Use when a step is account-scoped (e.g. MCP OAuth).
*/
public static function dispatchForAccount(Account $account, ?User $actor = null): void
{
if ($account->isOnboardingOpen()) {
static::syncAfterCommit($account->id, $actor?->id);
}
}
/**
* Sync progress for the workspace account, then broadcast.
* Actor-less flows (webhooks/jobs) fall back to the account owner.
*/
public static function dispatchForWorkspace(?string $workspaceId, ?User $actor = null): void
{
$account = filled($workspaceId)
? Workspace::query()->find($workspaceId)?->account
: null;
if ($account?->isOnboardingOpen()) {
static::syncAfterCommit($account->id, $actor?->id);
}
}
private static function syncAfterCommit(string $accountId, ?string $actorId): void
{
DB::afterCommit(function () use ($accountId, $actorId): void {
$account = Account::query()->find($accountId);
if ($account?->isOnboardingOpen()) {
app(ResolveOnboardingStatus::class)->syncAndNotify($account, $actorId);
}
});
}
public function broadcastAs(): string
{
return 'onboarding.status.updated';
}
/**
* @return array<int, PrivateChannel>
*/
public function broadcastOn(): array
{
return [
new PrivateChannel("workspace.{$this->workspaceId}"),
];
}
/**
* @return array{workspace_id: string}
*/
public function broadcastWith(): array
{
return [
'workspace_id' => $this->workspaceId,
];
}
public function broadcastQueue(): string
{
return 'broadcasts';
}
}

View file

@ -27,21 +27,27 @@ public function processing(Request $request): Response|RedirectResponse
return redirect()->route('app.calendar');
}
$account = $request->user()->account;
$sessionId = $request->query('session_id');
$user = $request->user();
$account = $user->accountOrFail();
$sessionId = $request->string('session_id')->toString();
// Consume the checkout session once: `fromCheckout` is true only the first
// time this session_id is seen, so a back-button/refresh to the success URL
// can't re-fire `checkout.completed`. `Cache::add` is atomic — it returns
// true only when the key didn't exist yet.
$fromCheckout = is_string($sessionId) && $sessionId !== ''
$fromCheckout = filled($sessionId)
&& Cache::add("checkout_tracked:{$sessionId}", true, now()->addDay());
$subscriptionActive = $account->subscribed(Account::SUBSCRIPTION_NAME);
$redirectToOnboarding = $user->isAccountOwner()
&& $account->isOnboardingOpen();
return Inertia::render('billing/Processing', [
'subscriptionActive' => $account && $account->subscribed(Account::SUBSCRIPTION_NAME),
'subscriptionActive' => $subscriptionActive,
'fromCheckout' => $fromCheckout,
'persona' => $request->user()->persona?->value,
'conversion' => $fromCheckout && $account?->stripe_id
'redirectToOnboarding' => $redirectToOnboarding,
'persona' => $user->persona?->value,
'conversion' => $fromCheckout && $account->stripe_id
? fn () => $this->buildConversionData($account, $sessionId)
: null,
]);

View file

@ -0,0 +1,95 @@
<?php
declare(strict_types=1);
namespace App\Http\Controllers\App;
use App\Actions\Onboarding\ResolveOnboardingStatus;
use App\Enums\PostHog\OnboardingEvent;
use App\Enums\SocialAccount\Platform as SocialPlatform;
use App\Http\Resources\App\SocialAccountResource;
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 OnboardingController extends Controller
{
public function __construct(
private readonly ResolveOnboardingStatus $resolveOnboardingStatus,
private readonly PostHogService $postHog,
) {}
public function index(Request $request): InertiaResponse|RedirectResponse
{
$user = $request->user();
$workspace = $user->currentWorkspace;
$account = $user->accountOrFail();
// Pure read on GET — observers + POST complete/skip stamp progress.
$status = $this->resolveOnboardingStatus->handle($user);
if (data_get($status, 'dismissed_at') !== null) {
return redirect()->route('app.calendar');
}
if (
$account->isOnboardingOpen()
&& $user->isAccountOwner()
&& ! data_get($status, 'all_complete')
) {
$this->postHog->capture(
$user->id,
OnboardingEvent::Viewed->value,
account: $account,
);
}
return Inertia::render('onboarding/Index', [
'status' => fn (): array => $status,
'canSkipSteps' => fn (): bool => $user->isAccountOwner(),
'canManageAccounts' => fn (): bool => $user->can('manageAccounts', $workspace),
'canCreatePost' => fn (): bool => $user->can('createPost', $workspace),
'mcpUrl' => fn (): string => route('mcp.trypost'),
'platforms' => fn (): array => SocialPlatform::connectableOptions(),
'accounts' => fn (): array => SocialAccountResource::collection(
$workspace->socialAccounts()->orderBy('id')->get(),
)->resolve(),
]);
}
public function skipMcp(Request $request): RedirectResponse
{
abort_unless($request->user()->isAccountOwner(), Response::HTTP_FORBIDDEN);
$this->resolveOnboardingStatus->skipMcp($request->user());
return back();
}
public function complete(Request $request): RedirectResponse
{
$user = $request->user();
abort_unless($user->isAccountOwner(), Response::HTTP_FORBIDDEN);
$account = $user->accountOrFail();
if ($account->isOnboardingDismissed()) {
return redirect()->route('app.calendar');
}
if ($account->isOnboardingCompleted()) {
return redirect()->route('app.onboarding');
}
if (! data_get($this->resolveOnboardingStatus->handle($user), 'all_complete')) {
return redirect()->route('app.onboarding');
}
$this->resolveOnboardingStatus->markCompleted($user);
return redirect()->route('app.onboarding');
}
}

View file

@ -38,18 +38,9 @@ public function index(Request $request): Response
$this->authorize('manageAccounts', $workspace);
$platforms = collect(SocialPlatform::cases())
->filter(fn ($platform) => $platform->isConnectable())
->map(fn ($platform) => [
'value' => $platform->value,
'label' => $platform->label(),
'color' => $platform->color(),
'network' => $platform->network(),
])->values();
return Inertia::render('accounts/Index', [
'workspace' => $workspace,
'platforms' => $platforms,
'platforms' => SocialPlatform::connectableOptions(),
'connectedAccounts' => SocialAccountResource::collection(
$workspace->socialAccounts()->orderBy('id')->get(),
)->resolve(),

View file

@ -4,12 +4,16 @@
namespace App\Http\Middleware\App;
use App\Actions\Onboarding\ResolveOnboardingStatus;
use App\Enums\PostPlatform\ContentType;
use App\Http\Resources\App\HandleInertiaRequests\AuthAccountResource;
use App\Http\Resources\App\HandleInertiaRequests\AuthPlanResource;
use App\Http\Resources\App\HandleInertiaRequests\AuthUserResource;
use App\Http\Resources\App\HandleInertiaRequests\AuthWorkspaceResource;
use App\Models\User;
use Illuminate\Http\Request;
use Inertia\DeferProp;
use Inertia\Inertia;
use Inertia\Middleware;
class HandleInertiaRequests extends Middleware
@ -48,6 +52,7 @@ public function share(Request $request): array
],
'usage' => $account && ! $isSelfHosted ? $account->usage() : null,
'features' => $account && ! $isSelfHosted ? $account->featureLimits() : null,
'onboardingProgress' => $this->onboardingProgress($request, $user),
'sidebarOpen' => ! $request->hasCookie('sidebar_state') || $request->cookie('sidebar_state') === 'true',
'flash' => $request->session()->get('flash', []),
'applicationUrl' => config('app.url'),
@ -74,4 +79,37 @@ public function shareOnce(Request $request): array
'contentTypeMediaRules' => fn (): array => ContentType::mediaRulesForFrontend(),
];
}
/**
* Defer step queries for mid-activation owners; everyone else gets false inline.
*
* Never defer on Passport consent *views*: Inertia deferred props re-request the
* same URL, Passport rotates `authToken` on every authorize hit, and approve then
* fails with InvalidAuthTokenException against the stale token still on the page.
*/
private function onboardingProgress(Request $request, ?User $user): DeferProp|false
{
if ($this->isPassportConsentViewRequest($request)) {
return false;
}
$onboarding = app(ResolveOnboardingStatus::class);
return $user && $onboarding->canShowProgress($user)
? Inertia::defer(fn (): array|false => $onboarding->sidebarProgress($user))
: false;
}
/**
* Exact GET consent-view route names only not approve/deny, and not wildcards
* like passport.authorizations.* (those would suppress defer on POST approve too,
* which is unnecessary and easy to misread as "all OAuth").
*/
private function isPassportConsentViewRequest(Request $request): bool
{
return $request->routeIs(
'passport.authorizations.authorize',
'passport.device.authorizations.authorize',
);
}
}

View file

@ -29,8 +29,10 @@ public function handle(Request $request, Closure $next): Response
$response = $next($request);
// Passport OAuth errors return a raw Symfony Response (no withCookie()).
// Attach via headers so both Illuminate and Symfony responses work.
if (! $isValid) {
$response->withCookie(
$response->headers->setCookie(
cookie()->forever('locale', config('languages.default'), '/', config('session.domain')),
);
}

View file

@ -16,6 +16,7 @@ public static function make(User $user): array
return [
'id' => $user->id,
'name' => $user->name,
'first_name' => $user->firstName(),
'email' => $user->email,
'has_photo' => $user->has_photo,
'photo_url' => $user->photo_url,

View file

@ -4,10 +4,13 @@
namespace App\Models;
use App\Observers\AccessTokenObserver;
use Illuminate\Database\Eloquent\Attributes\ObservedBy;
use Illuminate\Database\Eloquent\Builder;
use Illuminate\Database\Eloquent\Relations\BelongsTo;
use Laravel\Passport\Token;
#[ObservedBy(AccessTokenObserver::class)]
class AccessToken extends Token
{
/**
@ -176,6 +179,22 @@ public function isActiveMcpGrant(): bool
return $this->expires_at === null || ! $this->expires_at->isPast();
}
/**
* Bound MCP grant that unlocks the account onboarding checklist
* (account-owner grant + usable + createPost on the bound workspace).
*/
public function unlocksOnboardingChecklist(?User $user = null): bool
{
$user ??= $this->user;
$workspace = $this->workspace;
return $user instanceof User
&& $workspace instanceof Workspace
&& $user->isAccountOwner()
&& $this->isUsableMcpGrant($user, $workspace)
&& $user->can('createPost', $workspace);
}
/**
* Whether a refresh token can still mint a new access token for this grant.
*/

View file

@ -4,6 +4,7 @@
namespace App\Models;
use App\Models\Traits\HasOnboarding;
use App\Models\Traits\HasUsage;
use Carbon\CarbonInterface;
use Database\Factories\AccountFactory;
@ -19,7 +20,7 @@
class Account extends Model
{
/** @use HasFactory<AccountFactory> */
use Billable, HasFactory, HasUsage, HasUuids;
use Billable, HasFactory, HasOnboarding, HasUsage, HasUuids;
public const SUBSCRIPTION_NAME = 'default';
@ -42,6 +43,9 @@ public static function postsCountCacheKey(string $accountId): string
protected $casts = [
'trial_ends_at' => 'datetime',
'onboarding_completed_at' => 'datetime',
'onboarding_dismissed_at' => 'datetime',
'onboarding_skipped_steps' => 'array',
];
public function owner(): BelongsTo

View file

@ -0,0 +1,46 @@
<?php
declare(strict_types=1);
namespace App\Models\Traits;
use App\Models\Account;
use Illuminate\Database\Eloquent\Relations\BelongsTo;
trait HasAccount
{
public function account(): BelongsTo
{
return $this->belongsTo(Account::class);
}
/**
* Account for authenticated product flows (onboarding, billing, settings).
* In-app users always have one; null only during account teardown.
*/
public function accountOrFail(): Account
{
return $this->account ?? $this->account()->firstOrFail();
}
public function isAccountOwner(): bool
{
if (! $this->account_id) {
return false;
}
if ($this->relationLoaded('account')) {
return $this->id === $this->account?->owner_id;
}
return Account::query()
->whereKey($this->account_id)
->where('owner_id', $this->id)
->exists();
}
public function belongsToAccount(string $accountId): bool
{
return $this->account_id === $accountId;
}
}

View file

@ -0,0 +1,92 @@
<?php
declare(strict_types=1);
namespace App\Models\Traits;
use App\Models\AccessToken;
use App\Models\Account;
use Illuminate\Database\Eloquent\Builder;
trait HasOnboarding
{
public function hasFinishedOnboarding(): bool
{
return $this->isOnboardingCompleted() || $this->isOnboardingDismissed();
}
public function isOnboardingCompleted(): bool
{
return $this->onboarding_completed_at !== null;
}
public function isOnboardingDismissed(): bool
{
return $this->onboarding_dismissed_at !== null;
}
/**
* Whether activation progress should still be tracked for this account.
* Prefer `$account?->isOnboardingOpen()` at call sites so null accounts bail out cleanly.
*/
public function isOnboardingOpen(): bool
{
return ! $this->hasFinishedOnboarding();
}
/**
* @return list<string>
*/
public function skippedOnboardingSteps(): array
{
return $this->onboarding_skipped_steps ?? [];
}
public function hasSkippedOnboardingStep(string $step): bool
{
return in_array($step, $this->skippedOnboardingSteps(), true);
}
/**
* Whether the account owner has a bound MCP OAuth grant that unlocks
* the checklist. Teammate grants do not count activation is owner-owned.
* Early-exits on the first matching grant so accounts with many MCP
* tokens do not pay for a full collection + Gate pass every time.
*/
public function hasOnboardingMcpConnection(): bool
{
if ($this->owner_id === null) {
return false;
}
$tokens = AccessToken::query()
->activeMcpOAuth()
->where('user_id', $this->owner_id)
->whereNotNull('workspace_id')
->whereHas(
'workspace',
fn (Builder $workspace): Builder => $workspace->where('account_id', $this->id),
)
->with(['user', 'workspace', 'client'])
->lazyById();
foreach ($tokens as $token) {
if ($token->unlocksOnboardingChecklist()) {
return true;
}
}
return false;
}
/**
* @param Builder<Account> $query
* @return Builder<Account>
*/
public function scopeOnboardingOpen(Builder $query): Builder
{
return $query
->whereNull('onboarding_completed_at')
->whereNull('onboarding_dismissed_at');
}
}

View file

@ -7,24 +7,25 @@
use App\Enums\Notification\Type as NotificationType;
use App\Enums\User\Persona;
use App\Enums\User\ReferralSource;
use App\Models\Traits\HasAccount;
use App\Models\Traits\HasMedia;
use App\Models\Traits\HasWorkspace;
use Database\Factories\UserFactory;
use Illuminate\Contracts\Auth\MustVerifyEmail;
use Illuminate\Database\Eloquent\Concerns\HasUuids;
use Illuminate\Database\Eloquent\Factories\HasFactory;
use Illuminate\Database\Eloquent\Relations\BelongsTo;
use Illuminate\Database\Eloquent\Relations\HasMany;
use Illuminate\Database\Eloquent\Relations\HasOne;
use Illuminate\Foundation\Auth\User as Authenticatable;
use Illuminate\Notifications\Notifiable;
use Illuminate\Support\Str;
use Laravel\Passport\Contracts\OAuthenticatable;
use Laravel\Passport\HasApiTokens;
class User extends Authenticatable implements MustVerifyEmail, OAuthenticatable
{
/** @use HasFactory<UserFactory> */
use HasApiTokens, HasFactory, HasMedia, HasUuids, HasWorkspace, Notifiable;
use HasAccount, HasApiTokens, HasFactory, HasMedia, HasUuids, HasWorkspace, Notifiable;
/**
* @var list<string>
@ -74,6 +75,14 @@ public function getPhotoUrlAttribute(): ?string
return $this->getFirstMediaUrl('avatar');
}
/**
* First whitespace-delimited token of the display name (empty when unset).
*/
public function firstName(): string
{
return (string) Str::of($this->name ?? '')->trim()->before(' ');
}
protected function casts(): array
{
return [
@ -96,27 +105,6 @@ public function notificationPreference(): HasOne
return $this->hasOne(NotificationPreference::class);
}
public function account(): BelongsTo
{
return $this->belongsTo(Account::class);
}
public function isAccountOwner(): bool
{
if (! $this->account_id) {
return false;
}
if ($this->relationLoaded('account')) {
return $this->id === $this->account?->owner_id;
}
return Account::query()
->whereKey($this->account_id)
->where('owner_id', $this->id)
->exists();
}
public function wantsEmailFor(NotificationType $type): bool
{
$preference = $this->notificationPreference;

View file

@ -0,0 +1,56 @@
<?php
declare(strict_types=1);
namespace App\Observers;
use App\Events\OnboardingStatusUpdated;
use App\Models\AccessToken;
use App\Models\User;
class AccessTokenObserver
{
/**
* OAuth MCP grants unlock the MCP onboarding step for the whole account.
*/
public function created(AccessToken $accessToken): void
{
$this->broadcastIfMcpOAuth($accessToken);
}
public function updated(AccessToken $accessToken): void
{
if ($accessToken->wasChanged('revoked')) {
$this->broadcastIfMcpOAuth($accessToken);
}
}
private function broadcastIfMcpOAuth(AccessToken $accessToken): void
{
if (! $this->looksLikeMcpOAuth($accessToken)) {
return;
}
$user = User::query()
->with(['account', 'currentWorkspace'])
->find($accessToken->user_id);
if ($user === null) {
return;
}
// Revocation always notifies so progress can clear. Live grants only
// unlock when bound + createPost (viewers must not complete the checklist).
if ($accessToken->revoked || $accessToken->unlocksOnboardingChecklist($user)) {
OnboardingStatusUpdated::dispatchForAccount($user->accountOrFail(), $user);
}
}
private function looksLikeMcpOAuth(AccessToken $accessToken): bool
{
// Ignore personal-access tokens; treat mid-revoke rows as MCP so
// disconnect still clears progress.
return in_array('mcp:use', $accessToken->scopes ?? [], true)
&& ! $accessToken->isPersonalAccessToken();
}
}

View file

@ -6,9 +6,12 @@
use App\Enums\Automation\Trigger\Type as TriggerType;
use App\Enums\Post\Status as PostStatus;
use App\Events\OnboardingStatusUpdated;
use App\Events\PostCreated;
use App\Jobs\Automation\DispatchPostTriggerAutomationsJob;
use App\Models\Account;
use App\Models\Post;
use Illuminate\Database\Eloquent\Builder;
use Illuminate\Support\Facades\DB;
class PostObserver
@ -16,6 +19,13 @@ class PostObserver
public function created(Post $post): void
{
DB::afterCommit(fn () => PostCreated::dispatch($post));
$this->notifyOnboarding($post);
}
public function deleted(Post $post): void
{
$this->notifyOnboarding($post);
}
public function saved(Post $post): void
@ -30,10 +40,31 @@ public function saved(Post $post): void
default => null,
};
if ($triggerType === null) {
return;
if ($triggerType !== null) {
DispatchPostTriggerAutomationsJob::dispatch($post, $triggerType)->afterCommit();
}
}
DispatchPostTriggerAutomationsJob::dispatch($post, $triggerType)->afterCommit();
/**
* Notify only on the account's first post create or last post delete
* later creates / partial deletes would spam Echo while activation is open.
*/
private function notifyOnboarding(Post $post): void
{
$account = $post->workspace?->account;
if ($account?->isOnboardingOpen() && $this->otherPosts($account, $post)->doesntExist()) {
OnboardingStatusUpdated::dispatchForWorkspace($post->workspace_id, $post->user);
}
}
/**
* @return Builder<Post>
*/
private function otherPosts(Account $account, Post $post): Builder
{
return Post::query()
->whereIn('workspace_id', $account->workspaces()->select('id'))
->whereKeyNot($post->id);
}
}

View file

@ -5,6 +5,8 @@
namespace App\Observers;
use App\Enums\SocialAccount\Platform;
use App\Enums\SocialAccount\Status;
use App\Events\OnboardingStatusUpdated;
use App\Exceptions\SocialAccount\NetworkAlreadyConnectedException;
use App\Jobs\PostHog\SyncAccountUsage;
use App\Models\SocialAccount;
@ -21,45 +23,83 @@ class SocialAccountObserver
*/
public function creating(SocialAccount $socialAccount): void
{
if (config('trypost.self_hosted')) {
return;
}
$platform = $socialAccount->platform;
if (! $platform instanceof Platform) {
if (config('trypost.self_hosted') || ! $socialAccount->platform instanceof Platform) {
return;
}
$conflict = SocialAccount::query()
->where('workspace_id', $socialAccount->workspace_id)
->whereIn('platform', $platform->networkPlatformValues())
->whereIn('platform', $socialAccount->platform->networkPlatformValues())
->exists();
if ($conflict) {
throw new NetworkAlreadyConnectedException($platform);
throw new NetworkAlreadyConnectedException($socialAccount->platform);
}
}
public function created(SocialAccount $socialAccount): void
{
$this->syncUsage($socialAccount);
$this->syncUsageAndOnboarding($socialAccount);
}
public function deleted(SocialAccount $socialAccount): void
{
$this->syncUsageAndOnboarding($socialAccount);
}
public function updated(SocialAccount $socialAccount): void
{
if (! $socialAccount->wasChanged('status')) {
return;
}
$wasConnected = $socialAccount->getRawOriginal('status') === Status::Connected->value;
$isConnected = $socialAccount->status === Status::Connected;
if ($wasConnected !== $isConnected) {
$this->notifyOnboarding($socialAccount);
}
}
private function syncUsageAndOnboarding(SocialAccount $socialAccount): void
{
$this->syncUsage($socialAccount);
if ($socialAccount->status === Status::Connected) {
$this->notifyOnboarding($socialAccount);
}
}
/**
* First usable connect / last disconnect for the account.
* Actor-less syncAndNotify falls back to the account owner.
*/
private function notifyOnboarding(SocialAccount $socialAccount): void
{
$account = $socialAccount->workspace?->account;
if (! $account?->isOnboardingOpen()) {
return;
}
$hasOtherConnections = SocialAccount::query()
->whereIn('workspace_id', $account->workspaces()->select('id'))
->whereKeyNot($socialAccount->id)
->where('status', Status::Connected)
->exists();
if (! $hasOtherConnections) {
OnboardingStatusUpdated::dispatchForWorkspace($socialAccount->workspace_id);
}
}
private function syncUsage(SocialAccount $socialAccount): void
{
if (! PostHogService::isEnabled()) {
return;
if (PostHogService::isEnabled()) {
SyncAccountUsage::dispatch(
(string) $socialAccount->workspace->account_id,
(string) $socialAccount->workspace_id,
);
}
SyncAccountUsage::dispatch(
(string) $socialAccount->workspace->account_id,
(string) $socialAccount->workspace_id,
);
}
}

View file

@ -10,6 +10,7 @@
use Laravel\Passport\Bridge\AuthCodeRepository as PassportAuthCodeRepository;
use Laravel\Passport\Passport;
use League\OAuth2\Server\Entities\AuthCodeEntityInterface;
use League\OAuth2\Server\Exception\OAuthServerException;
/**
* Persists the chosen workspace on the auth code so the subsequent token
@ -17,17 +18,27 @@
*
* Requires an explicit `workspace_id` from the consent form (membership-
* checked). No current-workspace fallback silent re-consent is disabled
* so the picker always runs.
* so the picker always runs. Missing/invalid workspace fails here (during
* approve) instead of issuing a code that dies at token exchange.
*/
class AuthCodeRepository extends PassportAuthCodeRepository
{
public function persistNewAuthCode(AuthCodeEntityInterface $authCodeEntity): void
{
$workspaceId = $this->resolveWorkspaceId($authCodeEntity->getUserIdentifier());
if ($workspaceId === null) {
throw OAuthServerException::invalidRequest(
'workspace_id',
'Unable to bind this connection to a workspace. Reconnect from a workspace you belong to.',
);
}
Passport::authCode()->forceFill([
'id' => $authCodeEntity->getIdentifier(),
'user_id' => $authCodeEntity->getUserIdentifier(),
'client_id' => $authCodeEntity->getClient()->getIdentifier(),
'workspace_id' => $this->resolveWorkspaceId($authCodeEntity->getUserIdentifier()),
'workspace_id' => $workspaceId,
'scopes' => json_encode($authCodeEntity->getScopes()),
'revoked' => false,
'expires_at' => $authCodeEntity->getExpiryDateTime(),

View file

@ -5,9 +5,17 @@
namespace App\Passport;
use Illuminate\Contracts\Auth\Authenticatable;
use Illuminate\Http\Request;
use Inertia\Inertia;
use Laravel\Passport\Client;
use Laravel\Passport\Contracts\AuthorizationViewResponse;
use Laravel\Passport\Exceptions\OAuthServerException;
use Laravel\Passport\Http\Controllers\AuthorizationController as PassportAuthorizationController;
use Laravel\Passport\Scope;
use League\OAuth2\Server\Exception\OAuthServerException as LeagueOAuthServerException;
use Psr\Http\Message\ResponseInterface;
use Psr\Http\Message\ServerRequestInterface;
use Symfony\Component\HttpFoundation\Response;
/**
* Always show the MCP consent screen so the user can pick a workspace.
@ -15,9 +23,50 @@
* Passport otherwise skips consent when the user already granted the same
* scopes (silent re-consent), which would bind the auth code via
* current_workspace without an explicit pick.
*
* Guests are sent to login before client validation so a stale MCP Inspector
* client_id does not return invalid_client JSON instead of the login page.
* Browser non-redirect OAuth failures get an Inertia error page; JSON clients
* and redirect errors (prompt=none) keep Passport's response.
*/
class AuthorizationController extends PassportAuthorizationController
{
public function authorize(
ServerRequestInterface $psrRequest,
Request $request,
ResponseInterface $psrResponse,
AuthorizationViewResponse $viewResponse
): Response|AuthorizationViewResponse {
if ($this->guard->guest()) {
$prompt = $request->string('prompt')->explode(' ')->map(trim(...))->filter()->values();
// prompt=none must not show a login UI — fall through to Passport.
if ($prompt->doesntContain('none')) {
$this->promptForLogin($request);
}
}
try {
return parent::authorize($psrRequest, $request, $psrResponse, $viewResponse);
} catch (OAuthServerException $exception) {
if ($request->expectsJson() || $exception->getResponse()->isRedirection()) {
throw $exception;
}
$oauth = $exception->getPrevious();
return Inertia::render('mcp/AuthorizeError', $oauth instanceof LeagueOAuthServerException
? [
'error' => $oauth->getErrorType(),
'errorDescription' => $oauth->getMessage(),
]
: [
'error' => 'server_error',
'errorDescription' => __('mcp.authorize.error_body'),
])->toResponse($request);
}
}
/**
* @param Scope[] $scopes
*/

View file

@ -7,6 +7,7 @@
use App\Jobs\PostHog\SendEvent;
use App\Models\Account;
use Illuminate\Support\Facades\Log;
use Throwable;
class PostHogService
{
@ -78,7 +79,7 @@ private function dispatch(string $method, array $payload): void
{
try {
SendEvent::dispatch($method, $payload);
} catch (\Throwable $e) {
} catch (Throwable $e) {
Log::warning('PostHogService: failed to dispatch event', ['method' => $method, 'error' => $e->getMessage()]);
}
}

View file

@ -0,0 +1,32 @@
<?php
declare(strict_types=1);
use Illuminate\Database\Migrations\Migration;
use Illuminate\Database\Schema\Blueprint;
use Illuminate\Support\Facades\Schema;
return new class extends Migration
{
/**
* Run the migrations.
*/
public function up(): void
{
Schema::table('accounts', function (Blueprint $table) {
$table->timestamp('onboarding_completed_at')->nullable()->after('trial_ends_at');
$table->timestamp('onboarding_dismissed_at')->nullable()->after('onboarding_completed_at');
$table->json('onboarding_skipped_steps')->nullable()->after('onboarding_dismissed_at');
});
}
/**
* Reverse the migrations.
*/
public function down(): void
{
Schema::table('accounts', function (Blueprint $table) {
$table->dropColumn(['onboarding_completed_at', 'onboarding_dismissed_at', 'onboarding_skipped_steps']);
});
}
};

View file

@ -0,0 +1,39 @@
<?php
declare(strict_types=1);
use App\Models\Account;
use Illuminate\Database\Migrations\Migration;
return new class extends Migration
{
/**
* Stamp onboarding_completed_at for every account that already existed when
* the activation checklist shipped, so upgrades (SaaS or self-hosted) do
* not suddenly surface the new funnel. Fresh installs run this before any
* account exists the first owner created afterward still sees it.
*/
public function up(): void
{
$now = now();
Account::query()
->whereNull('onboarding_dismissed_at')
->whereNull('onboarding_completed_at')
->update([
'onboarding_completed_at' => $now,
'updated_at' => $now,
]);
}
public function down(): void
{
Account::query()
->whereNull('onboarding_dismissed_at')
->whereNotNull('onboarding_completed_at')
->update([
'onboarding_completed_at' => null,
'updated_at' => now(),
]);
}
};

View file

@ -8,7 +8,7 @@
'copy_step' => 'انسخ عنوان خادم TryPost',
'open_step' => 'افتح مساعد الذكاء الاصطناعي',
'copy' => 'نسخ الرابط',
'connect' => 'الاتصال عبر :client',
'connect' => 'اتصال :client',
'step_add' => 'الصق الاسم أو الرابط أو الإعداد أدناه في تطبيقك. يفتح تسجيل الدخول في المتصفح عند أول اتصال.',
'name_label' => 'الاسم',
'url_label' => 'رابط الخادم',
@ -45,6 +45,10 @@
'search_workspace' => 'Search workspaces...',
'no_workspace_found' => 'No workspace found',
'scope_mcp_use' => 'Use MCP server',
'error_page_title' => 'فشل التفويض - TryPost',
'error_title' => 'تعذر الاتصال',
'error_body' => 'طلب التفويض هذا غير صالح أو منتهٍ. أغلق هذه النافذة وحاول الاتصال مرة أخرى من عميل MCP.',
'error_code' => 'خطأ: :error',
],
'other_clients_title' => 'تطبيقات أخرى',

View file

@ -3,53 +3,38 @@
declare(strict_types=1);
return [
'title' => 'مرحبًا بك في TryPost',
'description' => 'أخبرنا بما يصفك أنت أو نشاطك التجاري على أفضل وجه حتى نتمكن من تخصيص تجربتك.',
'continue' => 'متابعة',
'personas' => [
'creator' => 'صانع محتوى',
'freelancer' => 'مستقل',
'developer' => 'مطوّر',
'startup' => 'شركة ناشئة',
'agency' => 'وكالة',
'small_business' => 'نشاط تجاري صغير',
'marketer' => 'مسوّق',
'online_store' => 'متجر إلكتروني',
'other' => 'أخرى',
'title' => 'البدء',
'welcome' => 'مرحبًا بك في TryPost، :name',
'description' => 'اتبع الخطوات أدناه لمعرفة كيف يعمل TryPost ونشر منشورك الأول.',
'skip_step' => 'تخطي هذه الخطوة',
'continue' => 'المتابعة إلى TryPost',
'status' => [
'complete' => 'مكتمل',
'todo' => 'مطلوب',
'skipped' => 'تم تخطيه',
],
'goals_title' => 'ما هدفك من استخدام TryPost؟',
'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' => 'شيء آخر',
'mcp' => [
'title' => 'اربط مساعد الذكاء الاصطناعي',
'description' => 'أضِف TryPost كخادم MCP ليتمكّن مساعدك من إنشاء منشورات التواصل وإدارتها نيابةً عنك.',
'copied' => 'تم نسخ رابط MCP.',
],
'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' => 'شيء آخر',
'social' => [
'title' => 'اربط حسابًا اجتماعيًا',
'description' => 'اختر شبكة واحدة واحدة على الأقل يمكن لـ TryPost النشر عليها.',
'connected_elsewhere' => 'لقد ربطت حسابًا في مساحة عمل أخرى بالفعل، لذا اكتملت هذه الخطوة.',
],
'connect' => [
'title' => 'اربط شبكتك الأولى',
'description' => 'اربط حسابًا اجتماعيًا واحدًا على الأقل لبدء الجدولة. يمكنك إضافة المزيد في أي وقت.',
'must_connect' => 'اربط شبكة واحدة على الأقل للمتابعة.',
'first_post' => [
'title' => 'أنشئ منشورك الأول',
'description' => 'جرّب هذا الموجّه مع مساعدك المتصل، أو أنشئ المنشور مباشرة في TryPost.',
'prompt_label' => 'موجّه نموذجي',
'sample_prompt' => 'أنشئ منشورًا اجتماعيًا ودّيًا يعرّف بعلامتي التجارية وكيّفه لكل شبكة متصلة.',
'copy_prompt' => 'نسخ الموجّه',
'copied' => 'تم نسخ الموجّه النموذجي.',
'create_button' => 'إنشاء منشورك الأول',
'or' => 'أو',
],
'ready' => [
'title' => 'أنت جاهز للنشر',
'description' => 'كل شيء جاهز. تابع إلى TryPost وابدأ بتخطيط محتواك.',
],
];

View file

@ -7,33 +7,27 @@
'select_workspace' => 'اختر مساحة العمل',
'create_workspace' => 'إنشاء مساحة عمل',
'create_post' => 'إنشاء منشور',
'profile' => 'الملف الشخصي',
'my_account' => 'حسابي',
'account_settings' => 'الحساب والفوترة',
'workspace_settings' => 'إعدادات مساحة العمل',
'log_out' => 'تسجيل الخروج',
'workspace' => 'مساحة العمل: :name',
'workspace_select' => 'مساحة العمل: اختر',
'theme' => 'المظهر: :name',
'theme_light' => 'فاتح',
'theme_dark' => 'داكن',
'theme_system' => 'النظام',
'workspace' => [
'connections' => 'الاتصالات',
'signatures' => 'التوقيعات',
'labels' => 'التسميات',
'assets' => 'الوسائط',
'mcp' => 'MCP',
],
'language' => 'اللغة: :name',
'language_select' => 'اللغة: اختر',
'groups' => [
'posts' => 'المنشورات',
'workspace' => 'مساحة العمل',
'others' => 'أخرى',
],
'analytics' => 'التحليلات',
'automations' => 'الأتمتة',
'settings' => 'الإعدادات',
'onboarding' => 'البدء',
'onboarding_hint' => 'أكمل الإعداد',
'posts' => [
'calendar' => 'التقويم',
'all' => 'الكل',
@ -41,22 +35,11 @@
'posted' => 'المنشورة',
'drafts' => 'المسودات',
],
'workspace' => [
'connections' => 'الاتصالات',
'signatures' => 'التوقيعات',
'labels' => 'التسميات',
'assets' => 'الوسائط',
'mcp' => 'MCP',
'api_keys' => 'مفاتيح API',
],
'notifications' => 'الإشعارات',
'mark_all_read' => 'تعليم الكل كمقروء',
'mark_as_read' => 'تعليم كمقروء',
'archive_all' => 'أرشفة الكل',
'no_notifications' => 'لا توجد إشعارات',
'support' => [
'docs' => 'التوثيق',
'referral' => 'اربح عمولة إحالة 30%',

View file

@ -41,17 +41,22 @@
'referral_source_title' => 'كيف وجدتنا؟',
'referral_source_description' => 'يساعدنا هذا على فهم كيفية اكتشاف الأشخاص لـ TryPost.',
'referral_source' => [
'google' => 'Google أو البحث',
'google' => 'Google',
'x' => 'X (Twitter)',
'linkedin' => 'LinkedIn',
'youtube' => 'YouTube',
'tiktok' => 'TikTok',
'instagram' => 'Instagram أو Threads',
'instagram' => 'Instagram',
'threads' => 'Threads',
'reddit' => 'Reddit',
'product_hunt' => 'Product Hunt',
'ai_assistant' => 'مساعد ذكاء اصطناعي (ChatGPT، Claude…)',
'github' => 'GitHub',
'hacker_news' => 'Hacker News',
'directories' => 'دليل (G2، Capterra…)',
'ai_assistant' => 'ChatGPT / Claude',
'friend' => 'صديق أو زميل',
'blog' => 'مدونة أو نشرة إخبارية أو مقال',
'founder' => 'المؤسس',
'blog' => 'Blog / newsletter',
'other' => 'شيء آخر',
],
];

View file

@ -8,7 +8,7 @@
'copy_step' => 'Kopiere deine TryPost-Server-URL',
'open_step' => 'Öffne deinen KI-Assistenten',
'copy' => 'URL kopieren',
'connect' => 'Mit :client verbinden',
'connect' => ':client verbinden',
'step_add' => 'Füge Name, URL oder Config unten in deine App ein. Die Anmeldung öffnet sich beim ersten Verbinden im Browser.',
'name_label' => 'Name',
'url_label' => 'Server-URL',
@ -45,6 +45,10 @@
'search_workspace' => 'Search workspaces...',
'no_workspace_found' => 'No workspace found',
'scope_mcp_use' => 'Use MCP server',
'error_page_title' => 'Autorisierung fehlgeschlagen - TryPost',
'error_title' => 'Verbindung fehlgeschlagen',
'error_body' => 'Diese Autorisierungsanfrage ist ungültig oder abgelaufen. Schließen Sie dieses Fenster und verbinden Sie sich erneut über Ihren MCP-Client.',
'error_code' => 'Fehler: :error',
],
'other_clients_title' => 'Andere Apps',

View file

@ -3,53 +3,38 @@
declare(strict_types=1);
return [
'title' => 'Willkommen bei TryPost',
'description' => 'Sag uns, was dich oder dein Unternehmen am besten beschreibt, damit wir dein Erlebnis anpassen können.',
'continue' => 'Weiter',
'personas' => [
'creator' => 'Content Creator',
'freelancer' => 'Freelancer',
'developer' => 'Entwickler',
'startup' => 'Startup',
'agency' => 'Agentur',
'small_business' => 'Kleinunternehmen',
'marketer' => 'Marketer',
'online_store' => 'Onlineshop',
'other' => 'Sonstiges',
'title' => 'Erste Schritte',
'welcome' => 'Willkommen bei TryPost, :name',
'description' => 'Folge den Schritten unten, um zu sehen, wie TryPost funktioniert, und deinen ersten Beitrag zu veröffentlichen.',
'skip_step' => 'Diesen Schritt überspringen',
'continue' => 'Weiter zu TryPost',
'status' => [
'complete' => 'Erledigt',
'todo' => 'Offen',
'skipped' => 'Übersprungen',
],
'goals_title' => 'Was ist dein Ziel mit TryPost?',
'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',
'mcp' => [
'title' => 'Verbinde deinen KI-Assistenten',
'description' => 'Füge TryPost als MCP-Server hinzu, damit dein Assistent Social-Beiträge für dich erstellen und verwalten kann.',
'copied' => 'MCP-URL kopiert.',
],
'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',
'social' => [
'title' => 'Verbinde ein Social-Konto',
'description' => 'Wähle mindestens ein Netzwerk, in dem TryPost deine Inhalte veröffentlichen kann.',
'connected_elsewhere' => 'Du hast bereits ein Konto in einem anderen Workspace verbunden — dieser Schritt ist erledigt.',
],
'connect' => [
'title' => 'Verbinde dein erstes Netzwerk',
'description' => 'Verknüpfe mindestens ein Social-Media-Konto, um mit der Planung zu beginnen. Du kannst jederzeit weitere hinzufügen.',
'must_connect' => 'Verbinde mindestens ein Netzwerk, um fortzufahren.',
'first_post' => [
'title' => 'Erstelle deinen ersten Beitrag',
'description' => 'Probiere diesen Starter-Prompt mit deinem verbundenen Assistenten aus, oder erstelle den Beitrag direkt in TryPost.',
'prompt_label' => 'Beispiel-Prompt',
'sample_prompt' => 'Erstelle einen freundlichen Social-Beitrag, der meine Marke vorstellt, und passe ihn für jedes verbundene Netzwerk an.',
'copy_prompt' => 'Prompt kopieren',
'copied' => 'Beispiel-Prompt kopiert.',
'create_button' => 'Ersten Beitrag erstellen',
'or' => 'oder',
],
'ready' => [
'title' => 'Du bist bereit zum Veröffentlichen',
'description' => 'Alles klar. Weiter zu TryPost und plane deine Inhalte.',
],
];

View file

@ -7,33 +7,27 @@
'select_workspace' => 'Workspace auswählen',
'create_workspace' => 'Workspace erstellen',
'create_post' => 'Beitrag erstellen',
'profile' => 'Profil',
'my_account' => 'Mein Konto',
'account_settings' => 'Konto & Abrechnung',
'workspace_settings' => 'Workspace-Einstellungen',
'log_out' => 'Abmelden',
'workspace' => 'Workspace: :name',
'workspace_select' => 'Workspace: Auswählen',
'theme' => 'Design: :name',
'theme_light' => 'Hell',
'theme_dark' => 'Dunkel',
'theme_system' => 'System',
'workspace' => [
'connections' => 'Verbindungen',
'signatures' => 'Signaturen',
'labels' => 'Labels',
'assets' => 'Assets',
'mcp' => 'MCP',
],
'language' => 'Sprache: :name',
'language_select' => 'Sprache: Auswählen',
'groups' => [
'posts' => 'Beiträge',
'workspace' => 'Workspace',
'others' => 'Sonstiges',
],
'analytics' => 'Analytics',
'automations' => 'Automatisierungen',
'settings' => 'Einstellungen',
'onboarding' => 'Erste Schritte',
'onboarding_hint' => 'Einrichtung abschließen',
'posts' => [
'calendar' => 'Kalender',
'all' => 'Alle',
@ -41,22 +35,11 @@
'posted' => 'Veröffentlicht',
'drafts' => 'Entwürfe',
],
'workspace' => [
'connections' => 'Verbindungen',
'signatures' => 'Signaturen',
'labels' => 'Labels',
'assets' => 'Assets',
'mcp' => 'MCP',
'api_keys' => 'API-Keys',
],
'notifications' => 'Benachrichtigungen',
'mark_all_read' => 'Alle als gelesen markieren',
'mark_as_read' => 'Als gelesen markieren',
'archive_all' => 'Alle archivieren',
'no_notifications' => 'Keine Benachrichtigungen',
'support' => [
'docs' => 'Dokumentation',
'referral' => '30% Provision verdienen',

View file

@ -41,17 +41,22 @@
'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',
'google' => 'Google',
'x' => 'X (Twitter)',
'linkedin' => 'LinkedIn',
'youtube' => 'YouTube',
'tiktok' => 'TikTok',
'instagram' => 'Instagram oder Threads',
'instagram' => 'Instagram',
'threads' => 'Threads',
'reddit' => 'Reddit',
'product_hunt' => 'Product Hunt',
'ai_assistant' => 'KI-Assistent (ChatGPT, Claude…)',
'github' => 'GitHub',
'hacker_news' => 'Hacker News',
'directories' => 'Verzeichnis (G2, Capterra…)',
'ai_assistant' => 'ChatGPT / Claude',
'friend' => 'Freund oder Kollege',
'blog' => 'Blog, Newsletter oder Artikel',
'founder' => 'Gründer',
'blog' => 'Blog / newsletter',
'other' => 'Etwas anderes',
],
];

View file

@ -8,7 +8,7 @@
'copy_step' => 'Αντίγραψε το URL του διακομιστή TryPost',
'open_step' => 'Άνοιξε τον βοηθό AI σου',
'copy' => 'Αντιγραφή URL',
'connect' => 'Σύνδεση με :client',
'connect' => 'Σύνδεση :client',
'step_add' => 'Επικολλήστε το όνομα, το URL ή το config παρακάτω στην εφαρμογή σας. Η σύνδεση ανοίγει στο πρόγραμμα περιήγησης την πρώτη φορά.',
'name_label' => 'Όνομα',
'url_label' => 'URL διακομιστή',
@ -45,6 +45,10 @@
'search_workspace' => 'Search workspaces...',
'no_workspace_found' => 'No workspace found',
'scope_mcp_use' => 'Use MCP server',
'error_page_title' => 'Αποτυχία εξουσιοδότησης - TryPost',
'error_title' => 'Αδυναμία σύνδεσης',
'error_body' => 'Αυτό το αίτημα εξουσιοδότησης είναι μη έγκυρο ή έχει λήξει. Κλείστε αυτό το παράθυρο και δοκιμάστε ξανά από τον πελάτη MCP.',
'error_code' => 'Σφάλμα: :error',
],
'other_clients_title' => 'Άλλες εφαρμογές',

View file

@ -3,53 +3,38 @@
declare(strict_types=1);
return [
'title' => 'Καλώς ήρθατε στο TryPost',
'description' => 'Πείτε μας τι σας περιγράφει καλύτερα, εσάς ή την επιχείρησή σας, ώστε να προσαρμόσουμε την εμπειρία σας.',
'continue' => 'Συνέχεια',
'personas' => [
'creator' => 'Δημιουργός περιεχομένου',
'freelancer' => 'Ελεύθερος επαγγελματίας',
'developer' => 'Προγραμματιστής',
'startup' => 'Startup',
'agency' => 'Πρακτορείο',
'small_business' => 'Μικρή επιχείρηση',
'marketer' => 'Marketer',
'online_store' => 'Ηλεκτρονικό κατάστημα',
'other' => 'Άλλο',
'title' => 'Ξεκινώντας',
'welcome' => 'Καλώς ήρθες στο TryPost, :name',
'description' => 'Ακολούθησε τα παρακάτω βήματα για να δεις πώς λειτουργεί το TryPost και να δημοσιεύσεις την πρώτη σου ανάρτηση.',
'skip_step' => 'Παράλειψη αυτού του βήματος',
'continue' => 'Συνέχεια στο TryPost',
'status' => [
'complete' => 'Ολοκληρώθηκε',
'todo' => 'Εκκρεμεί',
'skipped' => 'Παραλείφθηκε',
],
'goals_title' => 'Ποιος είναι ο στόχος σας με το TryPost;',
'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' => 'Κάτι άλλο',
'mcp' => [
'title' => 'Σύνδεσε τον βοηθό AI σου',
'description' => 'Πρόσθεσε το TryPost ως διακομιστή MCP ώστε ο βοηθός σου να δημιουργεί και να διαχειρίζεται social posts για εσένα.',
'copied' => 'Το URL MCP αντιγράφηκε.',
],
'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' => 'Κάτι άλλο',
'social' => [
'title' => 'Σύνδεσε έναν λογαριασμό social',
'description' => 'Διάλεξε τουλάχιστον ένα δίκτυο όπου το TryPost μπορεί να δημοσιεύει το περιεχόμενό σου.',
'connected_elsewhere' => 'Έχεις ήδη συνδέσει λογαριασμό σε άλλο workspace, οπότε αυτό το βήμα ολοκληρώθηκε.',
],
'connect' => [
'title' => 'Συνδέστε το πρώτο σας δίκτυο',
'description' => 'Συνδέστε τουλάχιστον έναν λογαριασμό κοινωνικού δικτύου για να ξεκινήσετε τον προγραμματισμό. Μπορείτε να προσθέσετε κι άλλους ανά πάσα στιγμή.',
'must_connect' => 'Συνδέστε τουλάχιστον ένα δίκτυο για να συνεχίσετε.',
'first_post' => [
'title' => 'Δημιούργησε την πρώτη σου ανάρτηση',
'description' => 'Δοκίμασε αυτό το αρχικό prompt με τον συνδεδεμένο βοηθό σου ή δημιούργησε την ανάρτηση απευθείας στο TryPost.',
'prompt_label' => 'Δείγμα prompt',
'sample_prompt' => 'Δημιούργησε μια φιλική social ανάρτηση που παρουσιάζει το brand μου και προσαρμοσέ την για κάθε συνδεδεμένο δίκτυο.',
'copy_prompt' => 'Αντιγραφή prompt',
'copied' => 'Το δείγμα prompt αντιγράφηκε.',
'create_button' => 'Δημιούργησε την πρώτη σου ανάρτηση',
'or' => 'ή',
],
'ready' => [
'title' => 'Είσαι έτοιμος να δημοσιεύσεις',
'description' => 'Όλα είναι έτοιμα. Συνέχισε στο TryPost και ξεκίνα να σχεδιάζεις το περιεχόμενό σου.',
],
];

View file

@ -7,33 +7,27 @@
'select_workspace' => 'Επιλογή workspace',
'create_workspace' => 'Δημιουργία workspace',
'create_post' => 'Δημιουργία δημοσίευσης',
'profile' => 'Προφίλ',
'my_account' => 'Ο λογαριασμός μου',
'account_settings' => 'Λογαριασμός και χρέωση',
'workspace_settings' => 'Ρυθμίσεις workspace',
'log_out' => 'Αποσύνδεση',
'workspace' => 'Workspace: :name',
'workspace_select' => 'Workspace: Επιλογή',
'theme' => 'Θέμα: :name',
'theme_light' => 'Φωτεινό',
'theme_dark' => 'Σκοτεινό',
'theme_system' => 'Συστήματος',
'workspace' => [
'connections' => 'Συνδέσεις',
'signatures' => 'Υπογραφές',
'labels' => 'Ετικέτες',
'assets' => 'Στοιχεία',
'mcp' => 'MCP',
],
'language' => 'Γλώσσα: :name',
'language_select' => 'Γλώσσα: Επιλογή',
'groups' => [
'posts' => 'Δημοσιεύσεις',
'workspace' => 'Workspace',
'others' => 'Άλλα',
],
'analytics' => 'Στατιστικά',
'automations' => 'Αυτοματισμοί',
'settings' => 'Ρυθμίσεις',
'onboarding' => 'Ξεκινώντας',
'onboarding_hint' => 'Ολοκλήρωση ρύθμισης',
'posts' => [
'calendar' => 'Ημερολόγιο',
'all' => 'Όλες',
@ -41,22 +35,11 @@
'posted' => 'Δημοσιευμένες',
'drafts' => 'Πρόχειρα',
],
'workspace' => [
'connections' => 'Συνδέσεις',
'signatures' => 'Υπογραφές',
'labels' => 'Ετικέτες',
'assets' => 'Στοιχεία',
'mcp' => 'MCP',
'api_keys' => 'Κλειδιά API',
],
'notifications' => 'Ειδοποιήσεις',
'mark_all_read' => 'Επισήμανση όλων ως αναγνωσμένων',
'mark_as_read' => 'Επισήμανση ως αναγνωσμένου',
'archive_all' => 'Αρχειοθέτηση όλων',
'no_notifications' => 'Δεν υπάρχουν ειδοποιήσεις',
'support' => [
'docs' => 'Τεκμηρίωση',
'referral' => 'Κερδίστε 30% από συστάσεις',

View file

@ -41,17 +41,22 @@
'referral_source_title' => 'Πώς μας βρήκατε;',
'referral_source_description' => 'Αυτό μας βοηθά να καταλάβουμε πώς οι άνθρωποι ανακαλύπτουν το TryPost.',
'referral_source' => [
'google' => 'Google ή αναζήτηση',
'google' => 'Google',
'x' => 'X (Twitter)',
'linkedin' => 'LinkedIn',
'youtube' => 'YouTube',
'tiktok' => 'TikTok',
'instagram' => 'Instagram ή Threads',
'instagram' => 'Instagram',
'threads' => 'Threads',
'reddit' => 'Reddit',
'product_hunt' => 'Product Hunt',
'ai_assistant' => 'Βοηθός AI (ChatGPT, Claude…)',
'github' => 'GitHub',
'hacker_news' => 'Hacker News',
'directories' => 'Κατάλογος (G2, Capterra…)',
'ai_assistant' => 'ChatGPT / Claude',
'friend' => 'Φίλος ή συνάδελφος',
'blog' => 'Blog, newsletter ή άρθρο',
'founder' => 'Ιδρυτής',
'blog' => 'Blog / newsletter',
'other' => 'Κάτι άλλο',
],
];

View file

@ -8,7 +8,7 @@
'copy_step' => 'Copy your TryPost server URL',
'open_step' => 'Open your AI assistant',
'copy' => 'Copy URL',
'connect' => 'Connect with :client',
'connect' => 'Connect :client',
'step_add' => 'Paste the name, URL, or config below into your app. Sign-in opens in the browser the first time it connects.',
'name_label' => 'Name',
'url_label' => 'Server URL',
@ -45,6 +45,10 @@
'search_workspace' => 'Search workspaces...',
'no_workspace_found' => 'No workspace found',
'scope_mcp_use' => 'Use MCP server',
'error_page_title' => 'Authorization failed - TryPost',
'error_title' => 'Could not connect',
'error_body' => 'This authorization request is invalid or expired. Close this window and try connecting again from your MCP client.',
'error_code' => 'Error: :error',
],
'other_clients_title' => 'Other apps',

View file

@ -3,53 +3,38 @@
declare(strict_types=1);
return [
'title' => 'Welcome to TryPost',
'description' => 'Tell us what best describes you or your business so we can tailor your experience.',
'continue' => 'Continue',
'personas' => [
'creator' => 'Content creator',
'freelancer' => 'Freelancer',
'developer' => 'Developer',
'startup' => 'Startup',
'agency' => 'Agency',
'small_business' => 'Small business',
'marketer' => 'Marketer',
'online_store' => 'Online store',
'other' => 'Other',
'title' => 'Getting started',
'welcome' => 'Welcome to TryPost, :name',
'description' => 'Follow the steps below to see how TryPost works and publish your first post.',
'skip_step' => 'Skip this step',
'continue' => 'Continue to TryPost',
'status' => [
'complete' => 'Complete',
'todo' => 'To do',
'skipped' => 'Skipped',
],
'goals_title' => 'What\'s your goal with TryPost?',
'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',
'mcp' => [
'title' => 'Connect your AI assistant',
'description' => 'Add TryPost as an MCP server so your assistant can create and manage social posts for you.',
'copied' => 'MCP URL copied.',
],
'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',
'social' => [
'title' => 'Connect a social account',
'description' => 'Choose at least one network where TryPost can publish your content.',
'connected_elsewhere' => 'You already connected an account in another workspace, so this step is done.',
],
'connect' => [
'title' => 'Connect your first network',
'description' => 'Link at least one social account to start scheduling. You can add more anytime.',
'must_connect' => 'Connect at least one network to continue.',
'first_post' => [
'title' => 'Create your first post',
'description' => 'Try this starter prompt with your connected assistant, or create the post directly in TryPost.',
'prompt_label' => 'Sample prompt',
'sample_prompt' => 'Create a friendly social post introducing my brand and adapt it for each connected network.',
'copy_prompt' => 'Copy prompt',
'copied' => 'Sample prompt copied.',
'create_button' => 'Create your first post',
'or' => 'or',
],
'ready' => [
'title' => 'You are ready to publish',
'description' => 'You are set. Continue to TryPost and start planning your content.',
],
];

View file

@ -7,33 +7,27 @@
'select_workspace' => 'Select workspace',
'create_workspace' => 'Create workspace',
'create_post' => 'Create post',
'profile' => 'Profile',
'my_account' => 'My account',
'account_settings' => 'Account & billing',
'workspace_settings' => 'Workspace settings',
'log_out' => 'Log out',
'workspace' => 'Workspace: :name',
'workspace_select' => 'Workspace: Select',
'theme' => 'Theme: :name',
'theme_light' => 'Light',
'theme_dark' => 'Dark',
'theme_system' => 'System',
'workspace' => [
'connections' => 'Connections',
'signatures' => 'Signatures',
'labels' => 'Labels',
'assets' => 'Assets',
'mcp' => 'MCP',
],
'language' => 'Language: :name',
'language_select' => 'Language: Select',
'groups' => [
'posts' => 'Posts',
'workspace' => 'Workspace',
'others' => 'Others',
],
'analytics' => 'Analytics',
'automations' => 'Automations',
'settings' => 'Settings',
'onboarding' => 'Getting started',
'onboarding_hint' => 'Finish setup',
'posts' => [
'calendar' => 'Calendar',
'all' => 'All',
@ -41,22 +35,11 @@
'posted' => 'Posted',
'drafts' => 'Drafts',
],
'workspace' => [
'connections' => 'Connections',
'signatures' => 'Signatures',
'labels' => 'Labels',
'assets' => 'Assets',
'mcp' => 'MCP',
'api_keys' => 'API Keys',
],
'notifications' => 'Notifications',
'mark_all_read' => 'Mark all as read',
'mark_as_read' => 'Mark as read',
'archive_all' => 'Archive all',
'no_notifications' => 'No notifications',
'support' => [
'docs' => 'Documentation',
'referral' => 'Earn 30% referral',

View file

@ -41,17 +41,22 @@
'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',
'google' => 'Google',
'x' => 'X (Twitter)',
'linkedin' => 'LinkedIn',
'youtube' => 'YouTube',
'tiktok' => 'TikTok',
'instagram' => 'Instagram or Threads',
'instagram' => 'Instagram',
'threads' => 'Threads',
'reddit' => 'Reddit',
'product_hunt' => 'Product Hunt',
'ai_assistant' => 'AI assistant (ChatGPT, Claude…)',
'github' => 'GitHub',
'hacker_news' => 'Hacker News',
'directories' => 'Directory (G2, Capterra…)',
'ai_assistant' => 'ChatGPT / Claude',
'friend' => 'Friend or colleague',
'blog' => 'Blog, newsletter or article',
'founder' => 'Founder',
'blog' => 'Blog / newsletter',
'other' => 'Something else',
],
];

View file

@ -8,7 +8,7 @@
'copy_step' => 'Copia la URL del servidor TryPost',
'open_step' => 'Abre tu asistente de IA',
'copy' => 'Copiar URL',
'connect' => 'Conectar con :client',
'connect' => 'Conectar :client',
'step_add' => 'Pega el nombre, la URL o la config abajo en tu app. El inicio de sesión se abre en el navegador la primera vez.',
'name_label' => 'Nombre',
'url_label' => 'URL del servidor',
@ -45,6 +45,10 @@
'search_workspace' => 'Search workspaces...',
'no_workspace_found' => 'No workspace found',
'scope_mcp_use' => 'Use MCP server',
'error_page_title' => 'Error de autorización - TryPost',
'error_title' => 'No se pudo conectar',
'error_body' => 'Esta solicitud de autorización no es válida o ha caducado. Cierra esta ventana e intenta conectar de nuevo desde tu cliente MCP.',
'error_code' => 'Error: :error',
],
'other_clients_title' => 'Otras apps',

View file

@ -3,53 +3,38 @@
declare(strict_types=1);
return [
'title' => 'Bienvenido a TryPost',
'description' => 'Cuéntanos qué te describe mejor a ti o a tu negocio para personalizar tu experiencia.',
'continue' => 'Continuar',
'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',
'title' => 'Primeros pasos',
'welcome' => 'Bienvenido a TryPost, :name',
'description' => 'Sigue los pasos a continuación para ver cómo funciona TryPost y publicar tu primer post.',
'skip_step' => 'Omitir este paso',
'continue' => 'Continuar a TryPost',
'status' => [
'complete' => 'Completado',
'todo' => 'Pendiente',
'skipped' => 'Omitido',
],
'goals_title' => '¿Cuál es tu objetivo con TryPost?',
'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',
'mcp' => [
'title' => 'Conecta tu asistente de IA',
'description' => 'Añade TryPost como servidor MCP para que tu asistente pueda crear y gestionar posts por ti.',
'copied' => 'URL de MCP copiada.',
],
'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',
'social' => [
'title' => 'Conecta una cuenta social',
'description' => 'Elige al menos una red donde TryPost pueda publicar tu contenido.',
'connected_elsewhere' => 'Ya conectaste una cuenta en otro workspace, así que este paso está listo.',
],
'connect' => [
'title' => 'Conecta tu primera red',
'description' => 'Vincula al menos una cuenta social para empezar a programar. Puedes añadir más cuando quieras.',
'must_connect' => 'Conecta al menos una red para continuar.',
'first_post' => [
'title' => 'Crea tu primer post',
'description' => 'Prueba este prompt inicial con tu asistente conectado, o crea el post directamente en TryPost.',
'prompt_label' => 'Prompt de ejemplo',
'sample_prompt' => 'Crea un post social amable presentando mi marca y adáptalo para cada red conectada.',
'copy_prompt' => 'Copiar prompt',
'copied' => 'Prompt de ejemplo copiado.',
'create_button' => 'Crear tu primer post',
'or' => 'o',
],
'ready' => [
'title' => 'Listo para publicar',
'description' => 'Todo listo. Continúa a TryPost y empieza a planificar tu contenido.',
],
];

View file

@ -7,33 +7,27 @@
'select_workspace' => 'Seleccionar workspace',
'create_workspace' => 'Crear workspace',
'create_post' => 'Crear post',
'profile' => 'Perfil',
'my_account' => 'Mi cuenta',
'account_settings' => 'Cuenta y facturación',
'workspace_settings' => 'Configuración del workspace',
'log_out' => 'Cerrar sesión',
'workspace' => 'Workspace: :name',
'workspace_select' => 'Workspace: Seleccionar',
'theme' => 'Tema: :name',
'theme_light' => 'Claro',
'theme_dark' => 'Oscuro',
'theme_system' => 'Sistema',
'workspace' => [
'connections' => 'Conexiones',
'signatures' => 'Firmas',
'labels' => 'Etiquetas',
'assets' => 'Medios',
'mcp' => 'MCP',
],
'language' => 'Idioma: :name',
'language_select' => 'Idioma: Seleccionar',
'groups' => [
'posts' => 'Posts',
'workspace' => 'Workspace',
'others' => 'Otros',
],
'analytics' => 'Analytics',
'automations' => 'Automatizaciones',
'settings' => 'Configuración',
'onboarding' => 'Primeros pasos',
'onboarding_hint' => 'Termina la configuración',
'posts' => [
'calendar' => 'Calendario',
'all' => 'Todos',
@ -41,22 +35,11 @@
'posted' => 'Publicados',
'drafts' => 'Borradores',
],
'workspace' => [
'connections' => 'Conexiones',
'signatures' => 'Firmas',
'labels' => 'Etiquetas',
'assets' => 'Medios',
'mcp' => 'MCP',
'api_keys' => 'API Keys',
],
'notifications' => 'Notificaciones',
'mark_all_read' => 'Marcar todo como leído',
'mark_as_read' => 'Marcar como leído',
'archive_all' => 'Archivar todo',
'no_notifications' => 'Sin notificaciones',
'support' => [
'docs' => 'Documentación',
'referral' => 'Gana 30% de comisión',

View file

@ -41,17 +41,22 @@
'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',
'google' => 'Google',
'x' => 'X (Twitter)',
'linkedin' => 'LinkedIn',
'youtube' => 'YouTube',
'tiktok' => 'TikTok',
'instagram' => 'Instagram o Threads',
'instagram' => 'Instagram',
'threads' => 'Threads',
'reddit' => 'Reddit',
'product_hunt' => 'Product Hunt',
'ai_assistant' => 'Asistente de IA (ChatGPT, Claude…)',
'github' => 'GitHub',
'hacker_news' => 'Hacker News',
'directories' => 'Directorio (G2, Capterra…)',
'ai_assistant' => 'ChatGPT / Claude',
'friend' => 'Amigo o colega',
'blog' => 'Blog, newsletter o artículo',
'founder' => 'Fundador',
'blog' => 'Blog / newsletter',
'other' => 'Otra cosa',
],
];

View file

@ -8,7 +8,7 @@
'copy_step' => 'Copiez lURL du serveur TryPost',
'open_step' => 'Ouvrez votre assistant IA',
'copy' => 'Copier lURL',
'connect' => 'Connecter avec :client',
'connect' => 'Connecter :client',
'step_add' => 'Collez le nom, lURL ou la config ci-dessous dans votre app. La connexion souvre dans le navigateur la première fois.',
'name_label' => 'Nom',
'url_label' => 'URL du serveur',
@ -45,6 +45,10 @@
'search_workspace' => 'Search workspaces...',
'no_workspace_found' => 'No workspace found',
'scope_mcp_use' => 'Use MCP server',
'error_page_title' => 'Échec de l\'autorisation - TryPost',
'error_title' => 'Impossible de se connecter',
'error_body' => 'Cette demande d\'autorisation est invalide ou a expiré. Fermez cette fenêtre et réessayez depuis votre client MCP.',
'error_code' => 'Erreur : :error',
],
'other_clients_title' => 'Autres apps',

View file

@ -3,53 +3,38 @@
declare(strict_types=1);
return [
'title' => 'Bienvenue sur TryPost',
'description' => 'Dites-nous ce qui vous décrit le mieux, vous ou votre entreprise, afin que nous puissions personnaliser votre expérience.',
'continue' => 'Continuer',
'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',
'title' => 'Premiers pas',
'welcome' => 'Bienvenue sur TryPost, :name',
'description' => 'Suivez les étapes ci-dessous pour découvrir comment TryPost fonctionne et publier votre premier post.',
'skip_step' => 'Ignorer cette étape',
'continue' => 'Continuer vers TryPost',
'status' => [
'complete' => 'Terminé',
'todo' => 'À faire',
'skipped' => 'Ignorée',
],
'goals_title' => 'Quel est votre objectif avec TryPost ?',
'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',
'mcp' => [
'title' => 'Connectez votre assistant IA',
'description' => 'Ajoutez TryPost comme serveur MCP pour que votre assistant puisse créer et gérer vos posts sociaux.',
'copied' => 'URL MCP copiée.',
],
'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',
'social' => [
'title' => 'Connectez un compte social',
'description' => 'Choisissez au moins un réseau où TryPost pourra publier votre contenu.',
'connected_elsewhere' => 'Vous avez déjà connecté un compte dans un autre workspace, cette étape est donc terminée.',
],
'connect' => [
'title' => 'Connectez votre premier réseau',
'description' => 'Associez au moins un compte social pour commencer à programmer. Vous pourrez en ajouter d\'autres à tout moment.',
'must_connect' => 'Connectez au moins un réseau pour continuer.',
'first_post' => [
'title' => 'Créez votre premier post',
'description' => 'Essayez ce prompt de démarrage avec votre assistant connecté, ou créez le post directement dans TryPost.',
'prompt_label' => 'Prompt dexemple',
'sample_prompt' => 'Crée un post social amical présentant ma marque et adapte-le pour chaque réseau connecté.',
'copy_prompt' => 'Copier le prompt',
'copied' => 'Prompt dexemple copié.',
'create_button' => 'Créer votre premier post',
'or' => 'ou',
],
'ready' => [
'title' => 'Vous êtes prêt à publier',
'description' => 'Tout est bon. Continuez vers TryPost et commencez à planifier votre contenu.',
],
];

View file

@ -7,33 +7,27 @@
'select_workspace' => 'Sélectionner un espace de travail',
'create_workspace' => 'Créer un espace de travail',
'create_post' => 'Créer une publication',
'profile' => 'Profil',
'my_account' => 'Mon compte',
'account_settings' => 'Compte et facturation',
'workspace_settings' => 'Paramètres de l\'espace de travail',
'log_out' => 'Se déconnecter',
'workspace' => 'Espace de travail : :name',
'workspace_select' => 'Espace de travail : Sélectionner',
'theme' => 'Thème : :name',
'theme_light' => 'Clair',
'theme_dark' => 'Sombre',
'theme_system' => 'Système',
'workspace' => [
'connections' => 'Connexions',
'signatures' => 'Signatures',
'labels' => 'Étiquettes',
'assets' => 'Médias',
'mcp' => 'MCP',
],
'language' => 'Langue : :name',
'language_select' => 'Langue : Sélectionner',
'groups' => [
'posts' => 'Publications',
'workspace' => 'Espace de travail',
'others' => 'Autres',
],
'analytics' => 'Statistiques',
'automations' => 'Automatisations',
'settings' => 'Paramètres',
'onboarding' => 'Premiers pas',
'onboarding_hint' => 'Terminer la configuration',
'posts' => [
'calendar' => 'Calendrier',
'all' => 'Toutes',
@ -41,22 +35,11 @@
'posted' => 'Publiées',
'drafts' => 'Brouillons',
],
'workspace' => [
'connections' => 'Connexions',
'signatures' => 'Signatures',
'labels' => 'Étiquettes',
'assets' => 'Médias',
'mcp' => 'MCP',
'api_keys' => 'Clés API',
],
'notifications' => 'Notifications',
'mark_all_read' => 'Tout marquer comme lu',
'mark_as_read' => 'Marquer comme lu',
'archive_all' => 'Tout archiver',
'no_notifications' => 'Aucune notification',
'support' => [
'docs' => 'Documentation',
'referral' => 'Gagnez 30 % de parrainage',

View file

@ -41,17 +41,22 @@
'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',
'google' => 'Google',
'x' => 'X (Twitter)',
'linkedin' => 'LinkedIn',
'youtube' => 'YouTube',
'tiktok' => 'TikTok',
'instagram' => 'Instagram ou Threads',
'instagram' => 'Instagram',
'threads' => 'Threads',
'reddit' => 'Reddit',
'product_hunt' => 'Product Hunt',
'ai_assistant' => 'Assistant IA (ChatGPT, Claude…)',
'github' => 'GitHub',
'hacker_news' => 'Hacker News',
'directories' => 'Annuaire (G2, Capterra…)',
'ai_assistant' => 'ChatGPT / Claude',
'friend' => 'Ami ou collègue',
'blog' => 'Blog, newsletter ou article',
'founder' => 'Fondateur',
'blog' => 'Blog / newsletter',
'other' => 'Autre chose',
],
];

View file

@ -8,7 +8,7 @@
'copy_step' => 'Copia lURL del server TryPost',
'open_step' => 'Apri il tuo assistente IA',
'copy' => 'Copia URL',
'connect' => 'Collega con :client',
'connect' => 'Collega :client',
'step_add' => 'Incolla nome, URL o config qui sotto nella tua app. Il login si apre nel browser al primo collegamento.',
'name_label' => 'Nome',
'url_label' => 'URL del server',
@ -45,6 +45,10 @@
'search_workspace' => 'Search workspaces...',
'no_workspace_found' => 'No workspace found',
'scope_mcp_use' => 'Use MCP server',
'error_page_title' => 'Autorizzazione non riuscita - TryPost',
'error_title' => 'Impossibile connettersi',
'error_body' => 'Questa richiesta di autorizzazione non è valida o è scaduta. Chiudi questa finestra e riprova dal tuo client MCP.',
'error_code' => 'Errore: :error',
],
'other_clients_title' => 'Altre app',

View file

@ -3,53 +3,38 @@
declare(strict_types=1);
return [
'title' => 'Benvenuto su TryPost',
'description' => 'Dicci cosa descrive meglio te o la tua attività così possiamo personalizzare la tua esperienza.',
'continue' => 'Continua',
'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',
'title' => 'Primi passi',
'welcome' => 'Benvenuto su TryPost, :name',
'description' => 'Segui i passaggi qui sotto per vedere come funziona TryPost e pubblicare il tuo primo post.',
'skip_step' => 'Salta questo passaggio',
'continue' => 'Continua su TryPost',
'status' => [
'complete' => 'Completato',
'todo' => 'Da fare',
'skipped' => 'Saltato',
],
'goals_title' => 'Qual è il tuo obiettivo con TryPost?',
'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',
'mcp' => [
'title' => 'Collega il tuo assistente IA',
'description' => 'Aggiungi TryPost come server MCP così il tuo assistente può creare e gestire i post social per te.',
'copied' => 'URL MCP copiato.',
],
'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',
'social' => [
'title' => 'Collega un account social',
'description' => 'Scegli almeno una rete dove TryPost possa pubblicare i tuoi contenuti.',
'connected_elsewhere' => 'Hai già collegato un account in un altro workspace, quindi questo passaggio è completato.',
],
'connect' => [
'title' => 'Collega la tua prima rete',
'description' => 'Collega almeno un account social per iniziare a programmare. Puoi aggiungerne altri in qualsiasi momento.',
'must_connect' => 'Collega almeno una rete per continuare.',
'first_post' => [
'title' => 'Crea il tuo primo post',
'description' => 'Prova questo prompt iniziale con il tuo assistente collegato, oppure crea il post direttamente in TryPost.',
'prompt_label' => 'Prompt di esempio',
'sample_prompt' => 'Crea un post social amichevole per presentare il mio brand e adattalo a ogni rete collegata.',
'copy_prompt' => 'Copia prompt',
'copied' => 'Prompt di esempio copiato.',
'create_button' => 'Crea il tuo primo post',
'or' => 'oppure',
],
'ready' => [
'title' => 'Sei pronto a pubblicare',
'description' => 'Tutto a posto. Continua su TryPost e inizia a pianificare i tuoi contenuti.',
],
];

View file

@ -7,33 +7,27 @@
'select_workspace' => 'Seleziona workspace',
'create_workspace' => 'Crea workspace',
'create_post' => 'Crea post',
'profile' => 'Profilo',
'my_account' => 'Il mio account',
'account_settings' => 'Account e fatturazione',
'workspace_settings' => 'Impostazioni workspace',
'log_out' => 'Esci',
'workspace' => 'Workspace: :name',
'workspace_select' => 'Workspace: Seleziona',
'theme' => 'Tema: :name',
'theme_light' => 'Chiaro',
'theme_dark' => 'Scuro',
'theme_system' => 'Sistema',
'workspace' => [
'connections' => 'Connessioni',
'signatures' => 'Firme',
'labels' => 'Etichette',
'assets' => 'Risorse',
'mcp' => 'MCP',
],
'language' => 'Lingua: :name',
'language_select' => 'Lingua: Seleziona',
'groups' => [
'posts' => 'Post',
'workspace' => 'Workspace',
'others' => 'Altro',
],
'analytics' => 'Statistiche',
'automations' => 'Automazioni',
'settings' => 'Impostazioni',
'onboarding' => 'Primi passi',
'onboarding_hint' => 'Completa la configurazione',
'posts' => [
'calendar' => 'Calendario',
'all' => 'Tutti',
@ -41,22 +35,11 @@
'posted' => 'Pubblicati',
'drafts' => 'Bozze',
],
'workspace' => [
'connections' => 'Connessioni',
'signatures' => 'Firme',
'labels' => 'Etichette',
'assets' => 'Risorse',
'mcp' => 'MCP',
'api_keys' => 'Chiavi API',
],
'notifications' => 'Notifiche',
'mark_all_read' => 'Segna tutte come lette',
'mark_as_read' => 'Segna come letta',
'archive_all' => 'Archivia tutte',
'no_notifications' => 'Nessuna notifica',
'support' => [
'docs' => 'Documentazione',
'referral' => 'Guadagna il 30% di referral',

View file

@ -41,17 +41,22 @@
'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',
'google' => 'Google',
'x' => 'X (Twitter)',
'linkedin' => 'LinkedIn',
'youtube' => 'YouTube',
'tiktok' => 'TikTok',
'instagram' => 'Instagram o Threads',
'instagram' => 'Instagram',
'threads' => 'Threads',
'reddit' => 'Reddit',
'product_hunt' => 'Product Hunt',
'ai_assistant' => 'Assistente IA (ChatGPT, Claude…)',
'github' => 'GitHub',
'hacker_news' => 'Hacker News',
'directories' => 'Directory (G2, Capterra…)',
'ai_assistant' => 'ChatGPT / Claude',
'friend' => 'Amico o collega',
'blog' => 'Blog, newsletter o articolo',
'founder' => 'Fondatore',
'blog' => 'Blog / newsletter',
'other' => 'Qualcos\'altro',
],
];

View file

@ -8,7 +8,7 @@
'copy_step' => 'TryPostサーバーURLをコピー',
'open_step' => 'AIアシスタントを開く',
'copy' => 'URLをコピー',
'connect' => ':client接続',
'connect' => ':client接続',
'step_add' => '下の名前・URL・設定をアプリに貼り付けてください。初回接続時はブラウザでログインが開きます。',
'name_label' => '名前',
'url_label' => 'サーバーURL',
@ -45,6 +45,10 @@
'search_workspace' => 'Search workspaces...',
'no_workspace_found' => 'No workspace found',
'scope_mcp_use' => 'Use MCP server',
'error_page_title' => '認可に失敗しました - TryPost',
'error_title' => '接続できませんでした',
'error_body' => 'この認可リクエストは無効か期限切れです。このウィンドウを閉じ、MCPクライアントからもう一度接続してください。',
'error_code' => 'エラー: :error',
],
'other_clients_title' => 'その他のアプリ',

View file

@ -3,53 +3,38 @@
declare(strict_types=1);
return [
'title' => 'TryPost へようこそ',
'description' => 'あなたやあなたのビジネスに最も当てはまるものを教えてください。体験を最適化します。',
'continue' => '続ける',
'personas' => [
'creator' => 'コンテンツクリエイター',
'freelancer' => 'フリーランス',
'developer' => '開発者',
'startup' => 'スタートアップ',
'agency' => '代理店',
'small_business' => '中小企業',
'marketer' => 'マーケター',
'online_store' => 'オンラインストア',
'other' => 'その他',
'title' => 'はじめに',
'welcome' => 'TryPostへようこそ、:nameさん',
'description' => '以下のステップでTryPostの使い方を確認し、最初の投稿を公開しましょう。',
'skip_step' => 'このステップをスキップ',
'continue' => 'TryPostへ進む',
'status' => [
'complete' => '完了',
'todo' => '未完了',
'skipped' => 'スキップ済み',
],
'goals_title' => 'TryPost での目標は何ですか?',
'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' => 'その他',
'mcp' => [
'title' => 'AIアシスタントを接続',
'description' => 'TryPostをMCPサーバーとして追加すると、アシスタントがSNS投稿の作成・管理を行えます。',
'copied' => 'MCP URLをコピーしました。',
],
'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' => 'その他',
'social' => [
'title' => 'SNSアカウントを接続',
'description' => 'TryPostが投稿できるネットワークを少なくとも1つ選びます。',
'connected_elsewhere' => '別のワークスペースでアカウントを接続済みなので、このステップは完了しています。',
],
'connect' => [
'title' => '最初のネットワークを接続',
'description' => 'スケジュールを始めるには、少なくとも 1 つのソーシャルアカウントを連携してください。後からいつでも追加できます。',
'must_connect' => '続けるには、少なくとも 1 つのネットワークを接続してください。',
'first_post' => [
'title' => '最初の投稿を作成',
'description' => '接続したアシスタントでこのスタータープロンプトを試すか、TryPostで直接投稿を作成します。',
'prompt_label' => 'サンプルプロンプト',
'sample_prompt' => 'ブランドを紹介する親しみやすいSNS投稿を作成し、接続済みの各ネットワーク向けに最適化してください。',
'copy_prompt' => 'プロンプトをコピー',
'copied' => 'サンプルプロンプトをコピーしました。',
'create_button' => '最初の投稿を作成',
'or' => 'または',
],
'ready' => [
'title' => '公開の準備ができました',
'description' => '設定完了です。TryPostへ進み、コンテンツの計画を始めましょう。',
],
];

View file

@ -7,33 +7,27 @@
'select_workspace' => 'ワークスペースを選択',
'create_workspace' => 'ワークスペースを作成',
'create_post' => '投稿を作成',
'profile' => 'プロフィール',
'my_account' => 'マイアカウント',
'account_settings' => 'アカウントと請求',
'workspace_settings' => 'ワークスペース設定',
'log_out' => 'ログアウト',
'workspace' => 'ワークスペース: :name',
'workspace_select' => 'ワークスペース: 選択',
'theme' => 'テーマ: :name',
'theme_light' => 'ライト',
'theme_dark' => 'ダーク',
'theme_system' => 'システム',
'workspace' => [
'connections' => '接続',
'signatures' => '署名',
'labels' => 'ラベル',
'assets' => 'アセット',
'mcp' => 'MCP',
],
'language' => '言語: :name',
'language_select' => '言語: 選択',
'groups' => [
'posts' => '投稿',
'workspace' => 'ワークスペース',
'others' => 'その他',
],
'analytics' => 'アナリティクス',
'automations' => 'オートメーション',
'settings' => '設定',
'onboarding' => 'はじめに',
'onboarding_hint' => 'セットアップを完了',
'posts' => [
'calendar' => 'カレンダー',
'all' => 'すべて',
@ -41,22 +35,11 @@
'posted' => '公開済み',
'drafts' => '下書き',
],
'workspace' => [
'connections' => '接続',
'signatures' => '署名',
'labels' => 'ラベル',
'assets' => 'アセット',
'mcp' => 'MCP',
'api_keys' => 'API キー',
],
'notifications' => '通知',
'mark_all_read' => 'すべて既読にする',
'mark_as_read' => '既読にする',
'archive_all' => 'すべてアーカイブ',
'no_notifications' => '通知はありません',
'support' => [
'docs' => 'ドキュメント',
'referral' => '30% の紹介報酬を獲得',

View file

@ -41,17 +41,22 @@
'referral_source_title' => 'どこで私たちを知りましたか?',
'referral_source_description' => 'これは、人々がどのように TryPost を見つけるかを理解するのに役立ちます。',
'referral_source' => [
'google' => 'Google または検索',
'google' => 'Google',
'x' => 'X (Twitter)',
'linkedin' => 'LinkedIn',
'youtube' => 'YouTube',
'tiktok' => 'TikTok',
'instagram' => 'Instagram または Threads',
'instagram' => 'Instagram',
'threads' => 'Threads',
'reddit' => 'Reddit',
'product_hunt' => 'Product Hunt',
'ai_assistant' => 'AI アシスタントChatGPT、Claude など)',
'github' => 'GitHub',
'hacker_news' => 'Hacker News',
'directories' => 'ディレクトリG2、Capterra…',
'ai_assistant' => 'ChatGPT / Claude',
'friend' => '友人または同僚',
'blog' => 'ブログ、ニュースレター、記事',
'founder' => '創業者',
'blog' => 'Blog / newsletter',
'other' => 'その他',
],
];

View file

@ -8,7 +8,7 @@
'copy_step' => 'TryPost 서버 URL 복사',
'open_step' => 'AI 어시스턴트 열기',
'copy' => 'URL 복사',
'connect' => ':client 연결',
'connect' => ':client 연결',
'step_add' => '아래 이름, URL 또는 설정을 앱에 붙여넣으세요. 처음 연결할 때 브라우저에서 로그인이 열립니다.',
'name_label' => '이름',
'url_label' => '서버 URL',
@ -45,6 +45,10 @@
'search_workspace' => 'Search workspaces...',
'no_workspace_found' => 'No workspace found',
'scope_mcp_use' => 'Use MCP server',
'error_page_title' => '승인 실패 - TryPost',
'error_title' => '연결할 수 없음',
'error_body' => '이 승인 요청이 잘못되었거나 만료되었습니다. 이 창을 닫고 MCP 클라이언트에서 다시 연결하세요.',
'error_code' => '오류: :error',
],
'other_clients_title' => '다른 앱',

View file

@ -3,53 +3,38 @@
declare(strict_types=1);
return [
'title' => 'TryPost에 오신 것을 환영합니다',
'description' => '회원님이나 비즈니스를 가장 잘 설명하는 항목을 알려주시면 맞춤형 경험을 제공해 드립니다.',
'continue' => '계속',
'personas' => [
'creator' => '콘텐츠 크리에이터',
'freelancer' => '프리랜서',
'developer' => '개발자',
'startup' => '스타트업',
'agency' => '에이전시',
'small_business' => '소상공인',
'marketer' => '마케터',
'online_store' => '온라인 스토어',
'other' => '기타',
'title' => '시작하기',
'welcome' => 'TryPost에 오신 걸 환영해요, :name',
'description' => '아래 단계를 따라 TryPost 사용법을 확인하고 첫 게시물을 발행하세요.',
'skip_step' => '이 단계 건너뛰기',
'continue' => 'TryPost로 계속',
'status' => [
'complete' => '완료',
'todo' => '할 일',
'skipped' => '건너뜀',
],
'goals_title' => 'TryPost로 이루려는 목표는 무엇인가요?',
'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' => '다른 것',
'mcp' => [
'title' => 'AI 어시스턴트 연결',
'description' => 'TryPost를 MCP 서버로 추가하면 어시스턴트가 소셜 게시물을 만들고 관리할 수 있어요.',
'copied' => 'MCP URL이 복사되었어요.',
],
'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' => '기타',
'social' => [
'title' => '소셜 계정 연결',
'description' => 'TryPost가 콘텐츠를 발행할 네트워크를 하나 이상 선택하세요.',
'connected_elsewhere' => '다른 워크스페이스에서 이미 계정을 연결했으므로 이 단계는 완료되었습니다.',
],
'connect' => [
'title' => '첫 네트워크 연결',
'description' => '예약을 시작하려면 소셜 계정을 하나 이상 연결하세요. 언제든지 추가할 수 있습니다.',
'must_connect' => '계속하려면 네트워크를 하나 이상 연결하세요.',
'first_post' => [
'title' => '첫 게시물 만들기',
'description' => '연결된 어시스턴트에서 이 시작 프롬프트를 써 보거나, TryPost에서 바로 게시물을 만드세요.',
'prompt_label' => '샘플 프롬프트',
'sample_prompt' => '내 브랜드를 소개하는 친근한 소셜 게시물을 만들고 연결된 각 네트워크에 맞게 조정해 주세요.',
'copy_prompt' => '프롬프트 복사',
'copied' => '샘플 프롬프트가 복사되었어요.',
'create_button' => '첫 게시물 만들기',
'or' => '또는',
],
'ready' => [
'title' => '발행할 준비가 됐어요',
'description' => '설정이 끝났어요. TryPost로 가서 콘텐츠 계획을 시작하세요.',
],
];

View file

@ -7,33 +7,27 @@
'select_workspace' => '워크스페이스 선택',
'create_workspace' => '워크스페이스 만들기',
'create_post' => '게시물 만들기',
'profile' => '프로필',
'my_account' => '내 계정',
'account_settings' => '계정 및 결제',
'workspace_settings' => '워크스페이스 설정',
'log_out' => '로그아웃',
'workspace' => '워크스페이스: :name',
'workspace_select' => '워크스페이스: 선택',
'theme' => '테마: :name',
'theme_light' => '라이트',
'theme_dark' => '다크',
'theme_system' => '시스템',
'workspace' => [
'connections' => '연결',
'signatures' => '서명',
'labels' => '라벨',
'assets' => '에셋',
'mcp' => 'MCP',
],
'language' => '언어: :name',
'language_select' => '언어: 선택',
'groups' => [
'posts' => '게시물',
'workspace' => '워크스페이스',
'others' => '기타',
],
'analytics' => '분석',
'automations' => '자동화',
'settings' => '설정',
'onboarding' => '시작하기',
'onboarding_hint' => '설정 마치기',
'posts' => [
'calendar' => '캘린더',
'all' => '전체',
@ -41,22 +35,11 @@
'posted' => '게시됨',
'drafts' => '초안',
],
'workspace' => [
'connections' => '연결',
'signatures' => '서명',
'labels' => '라벨',
'assets' => '에셋',
'mcp' => 'MCP',
'api_keys' => 'API 키',
],
'notifications' => '알림',
'mark_all_read' => '모두 읽음으로 표시',
'mark_as_read' => '읽음으로 표시',
'archive_all' => '모두 보관',
'no_notifications' => '알림 없음',
'support' => [
'docs' => '문서',
'referral' => '30% 추천 수익 받기',

View file

@ -41,17 +41,22 @@
'referral_source_title' => '저희를 어떻게 알게 되셨나요?',
'referral_source_description' => '사람들이 TryPost를 어떻게 발견하는지 파악하는 데 도움이 됩니다.',
'referral_source' => [
'google' => 'Google 또는 검색',
'google' => 'Google',
'x' => 'X (Twitter)',
'linkedin' => 'LinkedIn',
'youtube' => 'YouTube',
'tiktok' => 'TikTok',
'instagram' => 'Instagram 또는 Threads',
'instagram' => 'Instagram',
'threads' => 'Threads',
'reddit' => 'Reddit',
'product_hunt' => 'Product Hunt',
'ai_assistant' => 'AI 어시스턴트 (ChatGPT, Claude 등)',
'github' => 'GitHub',
'hacker_news' => 'Hacker News',
'directories' => '디렉터리 (G2, Capterra…)',
'ai_assistant' => 'ChatGPT / Claude',
'friend' => '친구 또는 동료',
'blog' => '블로그, 뉴스레터 또는 기사',
'founder' => '창업자',
'blog' => 'Blog / newsletter',
'other' => '기타',
],
];

View file

@ -8,7 +8,7 @@
'copy_step' => 'Kopieer je TryPost-server-URL',
'open_step' => 'Open je AI-assistent',
'copy' => 'URL kopiëren',
'connect' => 'Verbinden met :client',
'connect' => 'Verbind :client',
'step_add' => 'Plak de naam, URL of config hieronder in je app. Inloggen opent in de browser bij de eerste verbinding.',
'name_label' => 'Naam',
'url_label' => 'Server-URL',
@ -45,6 +45,10 @@
'search_workspace' => 'Search workspaces...',
'no_workspace_found' => 'No workspace found',
'scope_mcp_use' => 'Use MCP server',
'error_page_title' => 'Autorisatie mislukt - TryPost',
'error_title' => 'Verbinden mislukt',
'error_body' => 'Dit autorisatieverzoek is ongeldig of verlopen. Sluit dit venster en probeer opnieuw te verbinden via je MCP-client.',
'error_code' => 'Fout: :error',
],
'other_clients_title' => 'Andere apps',

View file

@ -3,53 +3,38 @@
declare(strict_types=1);
return [
'title' => 'Welkom bij TryPost',
'description' => 'Vertel ons wat jou of je bedrijf het beste omschrijft, zodat we je ervaring kunnen afstemmen.',
'continue' => 'Doorgaan',
'personas' => [
'creator' => 'Contentmaker',
'freelancer' => 'Freelancer',
'developer' => 'Ontwikkelaar',
'startup' => 'Startup',
'agency' => 'Bureau',
'small_business' => 'Klein bedrijf',
'marketer' => 'Marketeer',
'online_store' => 'Webshop',
'other' => 'Anders',
'title' => 'Aan de slag',
'welcome' => 'Welkom bij TryPost, :name',
'description' => 'Volg de stappen hieronder om te zien hoe TryPost werkt en je eerste post te publiceren.',
'skip_step' => 'Deze stap overslaan',
'continue' => 'Doorgaan naar TryPost',
'status' => [
'complete' => 'Voltooid',
'todo' => 'Te doen',
'skipped' => 'Overgeslagen',
],
'goals_title' => 'Wat is je doel met TryPost?',
'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',
'mcp' => [
'title' => 'Koppel je AI-assistent',
'description' => 'Voeg TryPost toe als MCP-server zodat je assistent social posts voor je kan maken en beheren.',
'copied' => 'MCP-URL gekopieerd.',
],
'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',
'social' => [
'title' => 'Koppel een social account',
'description' => 'Kies minstens één netwerk waar TryPost je content kan publiceren.',
'connected_elsewhere' => 'Je hebt al een account gekoppeld in een andere workspace, dus deze stap is klaar.',
],
'connect' => [
'title' => 'Koppel je eerste netwerk',
'description' => 'Koppel ten minste één social account om te beginnen met plannen. Je kunt er altijd meer toevoegen.',
'must_connect' => 'Koppel ten minste één netwerk om door te gaan.',
'first_post' => [
'title' => 'Maak je eerste post',
'description' => 'Probeer deze startprompt met je gekoppelde assistent, of maak de post direct in TryPost.',
'prompt_label' => 'Voorbeeldprompt',
'sample_prompt' => 'Maak een vriendelijke social post die mijn merk introduceert en pas die aan voor elk gekoppeld netwerk.',
'copy_prompt' => 'Prompt kopiëren',
'copied' => 'Voorbeeldprompt gekopieerd.',
'create_button' => 'Je eerste post maken',
'or' => 'of',
],
'ready' => [
'title' => 'Je bent klaar om te publiceren',
'description' => 'Alles staat. Ga door naar TryPost en begin je content te plannen.',
],
];

View file

@ -7,33 +7,27 @@
'select_workspace' => 'Workspace selecteren',
'create_workspace' => 'Workspace aanmaken',
'create_post' => 'Post aanmaken',
'profile' => 'Profiel',
'my_account' => 'Mijn account',
'account_settings' => 'Account en facturatie',
'workspace_settings' => 'Workspace-instellingen',
'log_out' => 'Uitloggen',
'workspace' => 'Workspace: :name',
'workspace_select' => 'Workspace: Selecteren',
'theme' => 'Thema: :name',
'theme_light' => 'Licht',
'theme_dark' => 'Donker',
'theme_system' => 'Systeem',
'workspace' => [
'connections' => 'Koppelingen',
'signatures' => 'Handtekeningen',
'labels' => 'Labels',
'assets' => 'Assets',
'mcp' => 'MCP',
],
'language' => 'Taal: :name',
'language_select' => 'Taal: Selecteren',
'groups' => [
'posts' => 'Posts',
'workspace' => 'Workspace',
'others' => 'Overige',
],
'analytics' => 'Statistieken',
'automations' => 'Automatiseringen',
'settings' => 'Instellingen',
'onboarding' => 'Aan de slag',
'onboarding_hint' => 'Setup afronden',
'posts' => [
'calendar' => 'Kalender',
'all' => 'Alle',
@ -41,22 +35,11 @@
'posted' => 'Geplaatst',
'drafts' => 'Concepten',
],
'workspace' => [
'connections' => 'Koppelingen',
'signatures' => 'Handtekeningen',
'labels' => 'Labels',
'assets' => 'Assets',
'mcp' => 'MCP',
'api_keys' => 'API-sleutels',
],
'notifications' => 'Meldingen',
'mark_all_read' => 'Alles als gelezen markeren',
'mark_as_read' => 'Als gelezen markeren',
'archive_all' => 'Alles archiveren',
'no_notifications' => 'Geen meldingen',
'support' => [
'docs' => 'Documentatie',
'referral' => 'Verdien 30% referral',

View file

@ -41,17 +41,22 @@
'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',
'google' => 'Google',
'x' => 'X (Twitter)',
'linkedin' => 'LinkedIn',
'youtube' => 'YouTube',
'tiktok' => 'TikTok',
'instagram' => 'Instagram of Threads',
'instagram' => 'Instagram',
'threads' => 'Threads',
'reddit' => 'Reddit',
'product_hunt' => 'Product Hunt',
'ai_assistant' => 'AI-assistent (ChatGPT, Claude…)',
'github' => 'GitHub',
'hacker_news' => 'Hacker News',
'directories' => 'Directory (G2, Capterra…)',
'ai_assistant' => 'ChatGPT / Claude',
'friend' => 'Vriend of collega',
'blog' => 'Blog, nieuwsbrief of artikel',
'founder' => 'Oprichter',
'blog' => 'Blog / newsletter',
'other' => 'Iets anders',
],
];

View file

@ -8,7 +8,7 @@
'copy_step' => 'Skopiuj URL serwera TryPost',
'open_step' => 'Otwórz asystenta AI',
'copy' => 'Kopiuj URL',
'connect' => 'Połącz z :client',
'connect' => 'Połącz :client',
'step_add' => 'Wklej nazwę, URL lub config poniżej do swojej aplikacji. Logowanie otworzy się w przeglądarce przy pierwszym połączeniu.',
'name_label' => 'Nazwa',
'url_label' => 'URL serwera',
@ -45,6 +45,10 @@
'search_workspace' => 'Search workspaces...',
'no_workspace_found' => 'No workspace found',
'scope_mcp_use' => 'Use MCP server',
'error_page_title' => 'Autoryzacja nie powiodła się - TryPost',
'error_title' => 'Nie udało się połączyć',
'error_body' => 'To żądanie autoryzacji jest nieprawidłowe lub wygasło. Zamknij to okno i spróbuj ponownie połączyć się z poziomu klienta MCP.',
'error_code' => 'Błąd: :error',
],
'other_clients_title' => 'Inne aplikacje',

View file

@ -3,53 +3,38 @@
declare(strict_types=1);
return [
'title' => 'Witamy w TryPost',
'description' => 'Powiedz nam, co najlepiej opisuje Ciebie lub Twoją firmę, abyśmy mogli dopasować Twoje doświadczenie.',
'continue' => 'Kontynuuj',
'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',
'title' => 'Pierwsze kroki',
'welcome' => 'Witaj w TryPost, :name',
'description' => 'Wykonaj poniższe kroki, aby zobaczyć, jak działa TryPost, i opublikować pierwszy post.',
'skip_step' => 'Pomiń ten krok',
'continue' => 'Przejdź do TryPost',
'status' => [
'complete' => 'Ukończone',
'todo' => 'Do zrobienia',
'skipped' => 'Pominięty',
],
'goals_title' => 'Jaki jest Twój cel z TryPost?',
'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',
'mcp' => [
'title' => 'Połącz asystenta AI',
'description' => 'Dodaj TryPost jako serwer MCP, aby asystent mógł tworzyć i zarządzać postami społecznościowymi za Ciebie.',
'copied' => 'URL MCP skopiowany.',
],
'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',
'social' => [
'title' => 'Połącz konto społecznościowe',
'description' => 'Wybierz co najmniej jedną sieć, na której TryPost może publikować Twoje treści.',
'connected_elsewhere' => 'Masz już połączone konto w innym workspace, więc ten krok jest ukończony.',
],
'connect' => [
'title' => 'Połącz swoją pierwszą sieć',
'description' => 'Połącz co najmniej jedno konto społecznościowe, aby zacząć planować. Więcej możesz dodać w dowolnym momencie.',
'must_connect' => 'Połącz co najmniej jedną sieć, aby kontynuować.',
'first_post' => [
'title' => 'Utwórz pierwszy post',
'description' => 'Wypróbuj ten prompt startowy z podłączonym asystentem albo utwórz post bezpośrednio w TryPost.',
'prompt_label' => 'Przykładowy prompt',
'sample_prompt' => 'Utwórz przyjazny post społecznościowy przedstawiający moją markę i dostosuj go do każdej podłączonej sieci.',
'copy_prompt' => 'Kopiuj prompt',
'copied' => 'Przykładowy prompt skopiowany.',
'create_button' => 'Utwórz pierwszy post',
'or' => 'lub',
],
'ready' => [
'title' => 'Możesz już publikować',
'description' => 'Wszystko gotowe. Przejdź do TryPost i zacznij planować treści.',
],
];

View file

@ -7,33 +7,27 @@
'select_workspace' => 'Wybierz przestrzeń roboczą',
'create_workspace' => 'Utwórz przestrzeń roboczą',
'create_post' => 'Utwórz post',
'profile' => 'Profil',
'my_account' => 'Moje konto',
'account_settings' => 'Konto i płatności',
'workspace_settings' => 'Ustawienia workspace',
'log_out' => 'Wyloguj się',
'workspace' => 'Przestrzeń robocza: :name',
'workspace_select' => 'Przestrzeń robocza: Wybierz',
'theme' => 'Motyw: :name',
'theme_light' => 'Jasny',
'theme_dark' => 'Ciemny',
'theme_system' => 'Systemowy',
'workspace' => [
'connections' => 'Połączenia',
'signatures' => 'Sygnatury',
'labels' => 'Etykiety',
'assets' => 'Zasoby',
'mcp' => 'MCP',
],
'language' => 'Język: :name',
'language_select' => 'Język: Wybierz',
'groups' => [
'posts' => 'Posty',
'workspace' => 'Przestrzeń robocza',
'others' => 'Inne',
],
'analytics' => 'Analityka',
'automations' => 'Automatyzacje',
'settings' => 'Ustawienia',
'onboarding' => 'Pierwsze kroki',
'onboarding_hint' => 'Dokończ konfigurację',
'posts' => [
'calendar' => 'Kalendarz',
'all' => 'Wszystkie',
@ -41,22 +35,11 @@
'posted' => 'Opublikowane',
'drafts' => 'Szkice',
],
'workspace' => [
'connections' => 'Połączenia',
'signatures' => 'Sygnatury',
'labels' => 'Etykiety',
'assets' => 'Zasoby',
'mcp' => 'MCP',
'api_keys' => 'Klucze API',
],
'notifications' => 'Powiadomienia',
'mark_all_read' => 'Oznacz wszystkie jako przeczytane',
'mark_as_read' => 'Oznacz jako przeczytane',
'archive_all' => 'Zarchiwizuj wszystkie',
'no_notifications' => 'Brak powiadomień',
'support' => [
'docs' => 'Dokumentacja',
'referral' => 'Zarabiaj 30% z poleceń',

View file

@ -41,17 +41,22 @@
'referral_source_title' => 'Jak nas znalazłeś?',
'referral_source_description' => 'To pomaga nam zrozumieć, jak ludzie odkrywają TryPost.',
'referral_source' => [
'google' => 'Google lub wyszukiwarka',
'google' => 'Google',
'x' => 'X (Twitter)',
'linkedin' => 'LinkedIn',
'youtube' => 'YouTube',
'tiktok' => 'TikTok',
'instagram' => 'Instagram lub Threads',
'instagram' => 'Instagram',
'threads' => 'Threads',
'reddit' => 'Reddit',
'product_hunt' => 'Product Hunt',
'ai_assistant' => 'Asystent AI (ChatGPT, Claude…)',
'github' => 'GitHub',
'hacker_news' => 'Hacker News',
'directories' => 'Katalog (G2, Capterra…)',
'ai_assistant' => 'ChatGPT / Claude',
'friend' => 'Znajomy lub współpracownik',
'blog' => 'Blog, newsletter lub artykuł',
'founder' => 'Założyciel',
'blog' => 'Blog / newsletter',
'other' => 'Coś innego',
],
];

View file

@ -8,7 +8,7 @@
'copy_step' => 'Copie a URL do servidor TryPost',
'open_step' => 'Abra seu assistente de IA',
'copy' => 'Copiar URL',
'connect' => 'Conectar com :client',
'connect' => 'Conectar :client',
'step_add' => 'Cole o nome, a URL ou o config abaixo no seu app. O login abre no navegador na primeira conexão.',
'name_label' => 'Nome',
'url_label' => 'URL do servidor',
@ -45,6 +45,10 @@
'search_workspace' => 'Buscar workspaces...',
'no_workspace_found' => 'Nenhum workspace encontrado',
'scope_mcp_use' => 'Usar o servidor MCP',
'error_page_title' => 'Falha na autorização - TryPost',
'error_title' => 'Não foi possível conectar',
'error_body' => 'Este pedido de autorização é inválido ou expirou. Feche esta janela e tente conectar de novo pelo seu cliente MCP.',
'error_code' => 'Erro: :error',
],
'other_clients_title' => 'Outros apps',

View file

@ -3,53 +3,38 @@
declare(strict_types=1);
return [
'title' => 'Bem-vindo ao TryPost',
'description' => 'Conte o que melhor descreve você ou seu negócio para personalizarmos sua experiência.',
'continue' => 'Continuar',
'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',
'title' => 'Primeiros passos',
'welcome' => 'Boas-vindas ao TryPost, :name',
'description' => 'Siga os passos abaixo pra ver como o TryPost funciona e publicar seu primeiro post.',
'skip_step' => 'Pular esta etapa',
'continue' => 'Continuar no TryPost',
'status' => [
'complete' => 'Concluído',
'todo' => 'Pendente',
'skipped' => 'Pulada',
],
'goals_title' => 'Qual o seu objetivo com o TryPost?',
'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',
'mcp' => [
'title' => 'Conecte seu assistente de IA',
'description' => 'Adicione o TryPost como servidor MCP para o assistente criar e gerenciar posts por você.',
'copied' => 'URL do MCP copiada.',
],
'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',
'social' => [
'title' => 'Conecte uma rede social',
'description' => 'Escolha pelo menos uma rede onde o TryPost possa publicar seu conteúdo.',
'connected_elsewhere' => 'Você já conectou uma conta em outro workspace, então este passo está pronto.',
],
'connect' => [
'title' => 'Conecte sua primeira rede',
'description' => 'Vincule pelo menos uma conta social para começar a agendar. Você pode adicionar mais quando quiser.',
'must_connect' => 'Conecte pelo menos uma rede para continuar.',
'first_post' => [
'title' => 'Crie seu primeiro post',
'description' => 'Use este prompt no seu assistente, ou crie o post direto no TryPost.',
'prompt_label' => 'Prompt de exemplo',
'sample_prompt' => 'Crie um post social amigável apresentando minha marca e adapte para cada rede conectada.',
'copy_prompt' => 'Copiar prompt',
'copied' => 'Prompt de exemplo copiado.',
'create_button' => 'Criar seu primeiro post',
'or' => 'ou',
],
'ready' => [
'title' => 'Tudo pronto pra publicar',
'description' => 'Você já pode seguir. Continue no TryPost e comece a planejar seu conteúdo.',
],
];

View file

@ -7,33 +7,27 @@
'select_workspace' => 'Selecionar workspace',
'create_workspace' => 'Criar workspace',
'create_post' => 'Novo post',
'profile' => 'Perfil',
'my_account' => 'Minha conta',
'account_settings' => 'Conta e cobrança',
'workspace_settings' => 'Configurações do workspace',
'log_out' => 'Sair',
'workspace' => 'Workspace: :name',
'workspace_select' => 'Workspace: Selecionar',
'theme' => 'Tema: :name',
'theme_light' => 'Claro',
'theme_dark' => 'Escuro',
'theme_system' => 'Sistema',
'workspace' => [
'connections' => 'Conexões',
'signatures' => 'Assinaturas',
'labels' => 'Etiquetas',
'assets' => 'Mídias',
'mcp' => 'MCP',
],
'language' => 'Idioma: :name',
'language_select' => 'Idioma: Selecionar',
'groups' => [
'posts' => 'Posts',
'workspace' => 'Workspace',
'others' => 'Outros',
],
'analytics' => 'Analytics',
'automations' => 'Automações',
'settings' => 'Configurações',
'onboarding' => 'Primeiros passos',
'onboarding_hint' => 'Complete a configuração',
'posts' => [
'calendar' => 'Calendário',
'all' => 'Todos',
@ -41,22 +35,11 @@
'posted' => 'Publicados',
'drafts' => 'Rascunhos',
],
'workspace' => [
'connections' => 'Conexões',
'signatures' => 'Assinaturas',
'labels' => 'Etiquetas',
'assets' => 'Mídias',
'mcp' => 'MCP',
'api_keys' => 'API Keys',
],
'notifications' => 'Notificações',
'mark_all_read' => 'Marcar tudo como lido',
'mark_as_read' => 'Marcar como lido',
'archive_all' => 'Arquivar tudo',
'no_notifications' => 'Sem notificações',
'support' => [
'docs' => 'Documentação',
'referral' => 'Ganhe 30% de indicação',

View file

@ -41,17 +41,22 @@
'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',
'google' => 'Google',
'x' => 'X (Twitter)',
'linkedin' => 'LinkedIn',
'youtube' => 'YouTube',
'tiktok' => 'TikTok',
'instagram' => 'Instagram ou Threads',
'instagram' => 'Instagram',
'threads' => 'Threads',
'reddit' => 'Reddit',
'product_hunt' => 'Product Hunt',
'ai_assistant' => 'Assistente de IA (ChatGPT, Claude…)',
'github' => 'GitHub',
'hacker_news' => 'Hacker News',
'directories' => 'Diretório (G2, Capterra…)',
'ai_assistant' => 'ChatGPT / Claude',
'friend' => 'Amigo ou colega',
'blog' => 'Blog, newsletter ou artigo',
'founder' => 'Fundador',
'blog' => 'Blog / newsletter',
'other' => 'Outra coisa',
],
];

View file

@ -8,7 +8,7 @@
'copy_step' => 'Скопируйте URL сервера TryPost',
'open_step' => 'Откройте ИИ-ассистента',
'copy' => 'Копировать URL',
'connect' => 'Подключить через :client',
'connect' => 'Подключить :client',
'step_add' => 'Вставьте имя, URL или config ниже в своё приложение. Вход откроется в браузере при первом подключении.',
'name_label' => 'Имя',
'url_label' => 'URL сервера',
@ -45,6 +45,10 @@
'search_workspace' => 'Search workspaces...',
'no_workspace_found' => 'No workspace found',
'scope_mcp_use' => 'Use MCP server',
'error_page_title' => 'Ошибка авторизации - TryPost',
'error_title' => 'Не удалось подключиться',
'error_body' => 'Этот запрос авторизации недействителен или устарел. Закройте это окно и попробуйте подключиться снова из MCP-клиента.',
'error_code' => 'Ошибка: :error',
],
'other_clients_title' => 'Другие приложения',

View file

@ -3,53 +3,38 @@
declare(strict_types=1);
return [
'title' => 'Добро пожаловать в TryPost',
'description' => 'Расскажите, что лучше всего описывает вас или ваш бизнес, чтобы мы могли настроить работу под вас.',
'continue' => 'Продолжить',
'personas' => [
'creator' => 'Автор контента',
'freelancer' => 'Фрилансер',
'developer' => 'Разработчик',
'startup' => 'Стартап',
'agency' => 'Агентство',
'small_business' => 'Малый бизнес',
'marketer' => 'Маркетолог',
'online_store' => 'Интернет-магазин',
'other' => 'Другое',
'title' => 'Начало работы',
'welcome' => 'Добро пожаловать в TryPost, :name',
'description' => 'Выполните шаги ниже, чтобы увидеть, как работает TryPost, и опубликовать первый пост.',
'skip_step' => 'Пропустить этот шаг',
'continue' => 'Перейти в TryPost',
'status' => [
'complete' => 'Готово',
'todo' => 'Сделать',
'skipped' => 'Пропущено',
],
'goals_title' => 'Какова ваша цель с TryPost?',
'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' => 'Что-то ещё',
'mcp' => [
'title' => 'Подключите ИИ-ассистента',
'description' => 'Добавьте TryPost как MCP-сервер, чтобы ассистент мог создавать и вести соцпосты за вас.',
'copied' => 'URL MCP скопирован.',
],
'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' => 'Что-то другое',
'social' => [
'title' => 'Подключите соцсеть',
'description' => 'Выберите хотя бы одну сеть, где TryPost сможет публиковать ваш контент.',
'connected_elsewhere' => 'Вы уже подключили аккаунт в другом пространстве, так что этот шаг выполнен.',
],
'connect' => [
'title' => 'Подключите первую сеть',
'description' => 'Привяжите хотя бы один социальный аккаунт, чтобы начать планировать. Вы можете добавить другие в любой момент.',
'must_connect' => 'Подключите хотя бы одну сеть, чтобы продолжить.',
'first_post' => [
'title' => 'Создайте первый пост',
'description' => 'Попробуйте этот стартовый промпт с подключённым ассистентом или создайте пост прямо в TryPost.',
'prompt_label' => 'Пример промпта',
'sample_prompt' => 'Создай дружелюбный соцпост с представлением моего бренда и адаптируй его для каждой подключённой сети.',
'copy_prompt' => 'Копировать промпт',
'copied' => 'Пример промпта скопирован.',
'create_button' => 'Создать первый пост',
'or' => 'или',
],
'ready' => [
'title' => 'Вы готовы публиковать',
'description' => 'Всё настроено. Перейдите в TryPost и начните планировать контент.',
],
];

View file

@ -7,33 +7,27 @@
'select_workspace' => 'Выберите рабочее пространство',
'create_workspace' => 'Создать рабочее пространство',
'create_post' => 'Создать пост',
'profile' => 'Профиль',
'my_account' => 'Мой аккаунт',
'account_settings' => 'Аккаунт и оплата',
'workspace_settings' => 'Настройки workspace',
'log_out' => 'Выйти',
'workspace' => 'Рабочее пространство: :name',
'workspace_select' => 'Рабочее пространство: выбрать',
'theme' => 'Тема: :name',
'theme_light' => 'Светлая',
'theme_dark' => 'Тёмная',
'theme_system' => 'Системная',
'workspace' => [
'connections' => 'Подключения',
'signatures' => 'Подписи',
'labels' => 'Метки',
'assets' => 'Медиафайлы',
'mcp' => 'MCP',
],
'language' => 'Язык: :name',
'language_select' => 'Язык: выбрать',
'groups' => [
'posts' => 'Посты',
'workspace' => 'Рабочее пространство',
'others' => 'Прочее',
],
'analytics' => 'Аналитика',
'automations' => 'Автоматизации',
'settings' => 'Настройки',
'onboarding' => 'Начало работы',
'onboarding_hint' => 'Завершите настройку',
'posts' => [
'calendar' => 'Календарь',
'all' => 'Все',
@ -41,22 +35,11 @@
'posted' => 'Опубликованные',
'drafts' => 'Черновики',
],
'workspace' => [
'connections' => 'Подключения',
'signatures' => 'Подписи',
'labels' => 'Метки',
'assets' => 'Медиафайлы',
'mcp' => 'MCP',
'api_keys' => 'API-ключи',
],
'notifications' => 'Уведомления',
'mark_all_read' => 'Отметить все как прочитанные',
'mark_as_read' => 'Отметить как прочитанное',
'archive_all' => 'Архивировать все',
'no_notifications' => 'Нет уведомлений',
'support' => [
'docs' => 'Документация',
'referral' => 'Зарабатывайте 30% по реферальной программе',

View file

@ -41,17 +41,22 @@
'referral_source_title' => 'Как вы нас нашли?',
'referral_source_description' => 'Это помогает нам понять, как люди узнают о TryPost.',
'referral_source' => [
'google' => 'Google или поиск',
'google' => 'Google',
'x' => 'X (Twitter)',
'linkedin' => 'LinkedIn',
'youtube' => 'YouTube',
'tiktok' => 'TikTok',
'instagram' => 'Instagram или Threads',
'instagram' => 'Instagram',
'threads' => 'Threads',
'reddit' => 'Reddit',
'product_hunt' => 'Product Hunt',
'ai_assistant' => 'ИИ-ассистент (ChatGPT, Claude…)',
'github' => 'GitHub',
'hacker_news' => 'Hacker News',
'directories' => 'Каталог (G2, Capterra…)',
'ai_assistant' => 'ChatGPT / Claude',
'friend' => 'Друг или коллега',
'blog' => 'Блог, рассылка или статья',
'founder' => 'Основатель',
'blog' => 'Blog / newsletter',
'other' => 'Что-то другое',
],
];

View file

@ -8,7 +8,7 @@
'copy_step' => 'TryPost sunucu URLini kopyala',
'open_step' => 'AI asistanını aç',
'copy' => 'URLyi kopyala',
'connect' => ':client ile bağlan',
'connect' => ':client bağla',
'step_add' => 'Adı, URLyi veya configi aşağıdaki gibi uygulamanıza yapıştırın. İlk bağlantıda oturum açma tarayıcıda açılır.',
'name_label' => 'Ad',
'url_label' => 'Sunucu URLsi',
@ -45,6 +45,10 @@
'search_workspace' => 'Search workspaces...',
'no_workspace_found' => 'No workspace found',
'scope_mcp_use' => 'Use MCP server',
'error_page_title' => 'Yetkilendirme başarısız - TryPost',
'error_title' => 'Bağlantı kurulamadı',
'error_body' => 'Bu yetkilendirme isteği geçersiz veya süresi dolmuş. Bu pencereyi kapatın ve MCP istemcinizden yeniden bağlanmayı deneyin.',
'error_code' => 'Hata: :error',
],
'other_clients_title' => 'Diğer uygulamalar',

View file

@ -3,53 +3,38 @@
declare(strict_types=1);
return [
'title' => 'TryPost\'a hoş geldiniz',
'description' => 'Deneyiminizi kişiselleştirebilmemiz için sizi veya işinizi en iyi tanımlayan seçeneği belirtin.',
'continue' => 'Devam et',
'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',
'title' => 'Başlarken',
'welcome' => 'TryPosta hoş geldin, :name',
'description' => 'TryPostun nasıl çalıştığını görmek ve ilk gönderini yayınlamak için aşağıdaki adımları izle.',
'skip_step' => 'Bu adımı atla',
'continue' => 'TryPosta devam et',
'status' => [
'complete' => 'Tamamlandı',
'todo' => 'Yapılacak',
'skipped' => 'Atlandı',
],
'goals_title' => 'TryPost ile 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',
'mcp' => [
'title' => 'AI asistanını bağla',
'description' => 'Asistanının senin için sosyal gönderiler oluşturup yönetebilmesi için TryPostu MCP sunucusu olarak ekle.',
'copied' => 'MCP URLsi kopyalandı.',
],
'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',
'social' => [
'title' => 'Bir sosyal hesap bağla',
'description' => 'TryPostun içeriğini yayınlayabileceği en az bir ağ seç.',
'connected_elsewhere' => 'Başka bir çalışma alanında zaten bir hesap bağladın, bu adım tamam.',
],
'connect' => [
'title' => 'İlk ağınızı bağlayın',
'description' => 'Zamanlamaya başlamak için en az bir sosyal hesap bağlayın. İstediğiniz zaman daha fazlasını ekleyebilirsiniz.',
'must_connect' => 'Devam etmek için en az bir ağ bağlayın.',
'first_post' => [
'title' => 'İlk gönderini oluştur',
'description' => 'Bu başlangıç promptunu bağlı asistanınla dene veya gönderiyi doğrudan TryPostta oluştur.',
'prompt_label' => 'Örnek prompt',
'sample_prompt' => 'Markamı tanıtan samimi bir sosyal gönderi oluştur ve bağlı her ağ için uyarla.',
'copy_prompt' => 'Promptu kopyala',
'copied' => 'Örnek prompt kopyalandı.',
'create_button' => 'İlk gönderini oluştur',
'or' => 'veya',
],
'ready' => [
'title' => 'Yayınlamaya hazırsın',
'description' => 'Her şey hazır. TryPosta devam et ve içeriğini planlamaya başla.',
],
];

View file

@ -7,33 +7,27 @@
'select_workspace' => 'Çalışma alanı seç',
'create_workspace' => 'Çalışma alanı oluştur',
'create_post' => 'Gönderi oluştur',
'profile' => 'Profil',
'my_account' => 'Hesabım',
'account_settings' => 'Hesap ve faturalandırma',
'workspace_settings' => 'Çalışma alanı ayarları',
'log_out' => ıkış yap',
'workspace' => 'Çalışma alanı: :name',
'workspace_select' => 'Çalışma alanı: Seç',
'theme' => 'Tema: :name',
'theme_light' => 'Açık',
'theme_dark' => 'Koyu',
'theme_system' => 'Sistem',
'workspace' => [
'connections' => 'Bağlantılar',
'signatures' => 'İmzalar',
'labels' => 'Etiketler',
'assets' => 'Varlıklar',
'mcp' => 'MCP',
],
'language' => 'Dil: :name',
'language_select' => 'Dil: Seç',
'groups' => [
'posts' => 'Gönderiler',
'workspace' => 'Çalışma Alanı',
'others' => 'Diğerleri',
],
'analytics' => 'Analitik',
'automations' => 'Otomasyonlar',
'settings' => 'Ayarlar',
'onboarding' => 'Başlarken',
'onboarding_hint' => 'Kurulumu bitir',
'posts' => [
'calendar' => 'Takvim',
'all' => 'Tümü',
@ -41,22 +35,11 @@
'posted' => 'Paylaşılan',
'drafts' => 'Taslaklar',
],
'workspace' => [
'connections' => 'Bağlantılar',
'signatures' => 'İmzalar',
'labels' => 'Etiketler',
'assets' => 'Varlıklar',
'mcp' => 'MCP',
'api_keys' => 'API Anahtarları',
],
'notifications' => 'Bildirimler',
'mark_all_read' => 'Tümünü okundu olarak işaretle',
'mark_as_read' => 'Okundu olarak işaretle',
'archive_all' => 'Tümünü arşivle',
'no_notifications' => 'Bildirim yok',
'support' => [
'docs' => 'Dokümantasyon',
'referral' => '%30 referans kazanın',

View file

@ -41,17 +41,22 @@
'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',
'google' => 'Google',
'x' => 'X (Twitter)',
'linkedin' => 'LinkedIn',
'youtube' => 'YouTube',
'tiktok' => 'TikTok',
'instagram' => 'Instagram veya Threads',
'instagram' => 'Instagram',
'threads' => 'Threads',
'reddit' => 'Reddit',
'product_hunt' => 'Product Hunt',
'ai_assistant' => 'Yapay zeka asistanı (ChatGPT, Claude…)',
'github' => 'GitHub',
'hacker_news' => 'Hacker News',
'directories' => 'Dizin (G2, Capterra…)',
'ai_assistant' => 'ChatGPT / Claude',
'friend' => 'Arkadaş veya meslektaş',
'blog' => 'Blog, bülten veya makale',
'founder' => 'Kurucu',
'blog' => 'Blog / newsletter',
'other' => 'Başka bir şey',
],
];

View file

@ -8,7 +8,7 @@
'copy_step' => 'Скопіюйте URL сервера TryPost',
'open_step' => 'Відкрийте свого ІІ-асистента',
'copy' => 'Скопіювати URL',
'connect' => 'Підключити через :client',
'connect' => 'Підключити :client',
'step_add' => 'Вставте назву, URL або config нижче у свій застосунок. Вхід відкриється в браузері під час першого підключення.',
'name_label' => 'Назва',
'url_label' => 'URL сервера',
@ -45,6 +45,10 @@
'search_workspace' => 'Search workspaces...',
'no_workspace_found' => 'No workspace found',
'scope_mcp_use' => 'Use MCP server',
'error_page_title' => 'Помилка авторизації - TryPost',
'error_title' => 'Не вдалося підключитися',
'error_body' => 'Цей запит на авторизацію недійсний або застарів. Закрийте це вікно й спробуйте підключитися знову з MCP-клієнта.',
'error_code' => 'Помилка: :error',
],
'other_clients_title' => 'Інші застосунки',

View file

@ -3,53 +3,38 @@
declare(strict_types=1);
return [
'title' => 'Ласкаво просимо до TryPost',
'description' => 'Розкажіть, що найкраще описує вас або ваш бізнес, щоб ми могли адаптувати ваш досвід.',
'continue' => 'Продовжити',
'personas' => [
'creator' => 'Автор контенту',
'freelancer' => 'Фрілансер',
'developer' => 'Розробник',
'startup' => 'Стартап',
'agency' => 'Агенція',
'small_business' => 'Малий бізнес',
'marketer' => 'Маркетолог',
'online_store' => 'Інтернет-магазин',
'other' => 'Інше',
'title' => 'Початок роботи',
'welcome' => 'Ласкаво просимо до TryPost, :name',
'description' => 'Виконайте кроки нижче, щоб зрозуміти, як працює TryPost, і опублікувати свій перший пост.',
'skip_step' => 'Пропустити цей крок',
'continue' => 'Продовжити до TryPost',
'status' => [
'complete' => 'Готово',
'todo' => 'До виконання',
'skipped' => 'Пропущено',
],
'goals_title' => 'Яка ваша мета з TryPost?',
'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' => 'Щось інше',
'mcp' => [
'title' => 'Підключіть свого AI-асистента',
'description' => 'Додайте TryPost як MCP-сервер, щоб асистент міг створювати й керувати соцмережевими постами за вас.',
'copied' => 'MCP URL скопійовано.',
],
'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' => 'Щось інше',
'social' => [
'title' => 'Підключіть соціальний акаунт',
'description' => 'Оберіть принаймні одну мережу, у якій TryPost зможе публікувати ваш контент.',
'connected_elsewhere' => 'Ви вже підключили акаунт в іншому робочому просторі, тож цей крок виконано.',
],
'connect' => [
'title' => 'Підключіть першу мережу',
'description' => 'Під’єднайте хоча б один соціальний акаунт, щоб почати планування. Ви зможете додати більше будь-коли.',
'must_connect' => 'Підключіть хоча б одну мережу, щоб продовжити.',
'first_post' => [
'title' => 'Створіть свій перший пост',
'description' => 'Спробуйте цей стартовий промпт із підключеним асистентом або створіть пост безпосередньо в TryPost.',
'prompt_label' => 'Приклад промпту',
'sample_prompt' => 'Створи дружній пост, що представляє мій бренд, і адаптуй його для кожної підключеної мережі.',
'copy_prompt' => 'Скопіювати промпт',
'copied' => 'Приклад промпту скопійовано.',
'create_button' => 'Створити перший пост',
'or' => 'або',
],
'ready' => [
'title' => 'Ви готові публікувати',
'description' => 'Усе налаштовано. Продовжуйте в TryPost і починайте планувати контент.',
],
];

View file

@ -7,33 +7,27 @@
'select_workspace' => 'Виберіть робочий простір',
'create_workspace' => 'Створити робочий простір',
'create_post' => 'Створити пост',
'profile' => 'Профіль',
'my_account' => 'Мій акаунт',
'account_settings' => 'Акаунт і оплата',
'workspace_settings' => 'Налаштування робочого простору',
'log_out' => 'Вийти',
'workspace' => 'Робочий простір: :name',
'workspace_select' => 'Робочий простір: вибрати',
'theme' => 'Тема: :name',
'theme_light' => 'Світла',
'theme_dark' => 'Темна',
'theme_system' => 'Системна',
'workspace' => [
'connections' => 'Підключення',
'signatures' => 'Підписи',
'labels' => 'Мітки',
'assets' => 'Медіафайли',
'mcp' => 'MCP',
],
'language' => 'Мова: :name',
'language_select' => 'Мова: вибрати',
'groups' => [
'posts' => 'Пости',
'workspace' => 'Робочий простір',
'others' => 'Інше',
],
'analytics' => 'Аналітика',
'automations' => 'Автоматизації',
'settings' => 'Налаштування',
'onboarding' => 'Початок роботи',
'onboarding_hint' => 'Завершіть налаштування',
'posts' => [
'calendar' => 'Календар',
'all' => 'Усі',
@ -41,22 +35,11 @@
'posted' => 'Опубліковані',
'drafts' => 'Чернетки',
],
'workspace' => [
'connections' => 'Підключення',
'signatures' => 'Підписи',
'labels' => 'Мітки',
'assets' => 'Медіафайли',
'mcp' => 'MCP',
'api_keys' => 'API-ключі',
],
'notifications' => 'Сповіщення',
'mark_all_read' => 'Позначити все прочитаним',
'mark_as_read' => 'Позначити прочитаним',
'archive_all' => 'Архівувати все',
'no_notifications' => 'Немає сповіщень',
'support' => [
'docs' => 'Документація',
'referral' => 'Отримуйте 30% за рефералами',

View file

@ -41,17 +41,22 @@
'referral_source_title' => 'Як ви нас знайшли?',
'referral_source_description' => 'Це допомагає нам зрозуміти, як люди дізнаються про TryPost.',
'referral_source' => [
'google' => 'Google або пошук',
'google' => 'Google',
'x' => 'X (Twitter)',
'linkedin' => 'LinkedIn',
'youtube' => 'YouTube',
'tiktok' => 'TikTok',
'instagram' => 'Instagram або Threads',
'instagram' => 'Instagram',
'threads' => 'Threads',
'reddit' => 'Reddit',
'product_hunt' => 'Product Hunt',
'ai_assistant' => 'AI-асистент (ChatGPT, Claude…)',
'github' => 'GitHub',
'hacker_news' => 'Hacker News',
'directories' => 'Каталог (G2, Capterra…)',
'ai_assistant' => 'ChatGPT / Claude',
'friend' => 'Друг або колега',
'blog' => 'Блог, розсилка або стаття',
'founder' => 'Засновник',
'blog' => 'Blog / newsletter',
'other' => 'Щось інше',
],
];

View file

@ -8,7 +8,7 @@
'copy_step' => '复制你的 TryPost 服务器 URL',
'open_step' => '打开你的 AI 助手',
'copy' => '复制 URL',
'connect' => '使用 :client 连接',
'connect' => '连接 :client',
'step_add' => '将下方的名称、URL 或配置粘贴到你的应用中。首次连接时会在浏览器中打开登录。',
'name_label' => '名称',
'url_label' => '服务器 URL',
@ -45,6 +45,10 @@
'search_workspace' => 'Search workspaces...',
'no_workspace_found' => 'No workspace found',
'scope_mcp_use' => 'Use MCP server',
'error_page_title' => '授权失败 - TryPost',
'error_title' => '无法连接',
'error_body' => '此授权请求无效或已过期。请关闭此窗口,然后从 MCP 客户端重新连接。',
'error_code' => '错误::error',
],
'other_clients_title' => '其他应用',

View file

@ -3,53 +3,38 @@
declare(strict_types=1);
return [
'title' => '欢迎使用 TryPost',
'description' => '告诉我们最能描述你或你业务的选项,以便我们为你定制体验。',
'continue' => '继续',
'personas' => [
'creator' => '内容创作者',
'freelancer' => '自由职业者',
'developer' => '开发者',
'startup' => '初创公司',
'agency' => '代理机构',
'small_business' => '小型企业',
'marketer' => '营销人员',
'online_store' => '网店',
'other' => '其他',
'title' => '开始使用',
'welcome' => '欢迎使用 TryPost:name',
'description' => '按下面的步骤了解 TryPost 的用法,并发布你的第一条内容。',
'skip_step' => '跳过此步骤',
'continue' => '继续前往 TryPost',
'status' => [
'complete' => '已完成',
'todo' => '待完成',
'skipped' => '已跳过',
],
'goals_title' => '你使用 TryPost 的目标是什么?',
'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' => '其他需求',
'mcp' => [
'title' => '连接你的 AI 助手',
'description' => '将 TryPost 添加为 MCP 服务器,让助手帮你创建和管理社交内容。',
'copied' => '已复制 MCP URL。',
],
'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' => '其他',
'social' => [
'title' => '连接社交账号',
'description' => '至少选择一个网络,让 TryPost 可以发布你的内容。',
'connected_elsewhere' => '你已在其他工作空间连接了账户,因此这一步已完成。',
],
'connect' => [
'title' => '连接你的第一个平台',
'description' => '至少关联一个社交账号即可开始排期。你可以随时添加更多。',
'must_connect' => '请至少连接一个平台以继续。',
'first_post' => [
'title' => '创建第一条内容',
'description' => '用已连接的助手试试这个入门提示词,或直接在 TryPost 中创建内容。',
'prompt_label' => '示例提示词',
'sample_prompt' => '写一条友好的社交内容介绍我的品牌,并为每个已连接的网络做适配。',
'copy_prompt' => '复制提示词',
'copied' => '已复制示例提示词。',
'create_button' => '创建第一条内容',
'or' => '或',
],
'ready' => [
'title' => '可以开始发布了',
'description' => '一切就绪。继续前往 TryPost开始规划你的内容。',
],
];

View file

@ -7,33 +7,27 @@
'select_workspace' => '选择工作区',
'create_workspace' => '创建工作区',
'create_post' => '创建帖子',
'profile' => '个人资料',
'my_account' => '我的账户',
'account_settings' => '账户与账单',
'workspace_settings' => '工作区设置',
'log_out' => '退出登录',
'workspace' => '工作区::name',
'workspace_select' => '工作区:选择',
'theme' => '主题::name',
'theme_light' => '浅色',
'theme_dark' => '深色',
'theme_system' => '跟随系统',
'workspace' => [
'connections' => '连接',
'signatures' => '签名',
'labels' => '标签',
'assets' => '素材库',
'mcp' => 'MCP',
],
'language' => '语言::name',
'language_select' => '语言:选择',
'groups' => [
'posts' => '帖子',
'workspace' => '工作区',
'others' => '其他',
],
'analytics' => '分析',
'automations' => '自动化',
'settings' => '设置',
'onboarding' => '开始使用',
'onboarding_hint' => '完成设置',
'posts' => [
'calendar' => '日历',
'all' => '全部',
@ -41,22 +35,11 @@
'posted' => '已发布',
'drafts' => '草稿',
],
'workspace' => [
'connections' => '连接',
'signatures' => '签名',
'labels' => '标签',
'assets' => '素材库',
'mcp' => 'MCP',
'api_keys' => 'API 密钥',
],
'notifications' => '通知',
'mark_all_read' => '全部标为已读',
'mark_as_read' => '标为已读',
'archive_all' => '全部归档',
'no_notifications' => '暂无通知',
'support' => [
'docs' => '文档',
'referral' => '赚取 30% 推荐奖励',

View file

@ -41,17 +41,22 @@
'referral_source_title' => '您是如何找到我们的?',
'referral_source_description' => '这有助于我们了解人们是如何发现 TryPost 的。',
'referral_source' => [
'google' => 'Google 或搜索',
'google' => 'Google',
'x' => 'X (Twitter)',
'linkedin' => 'LinkedIn',
'youtube' => 'YouTube',
'tiktok' => 'TikTok',
'instagram' => 'Instagram 或 Threads',
'instagram' => 'Instagram',
'threads' => 'Threads',
'reddit' => 'Reddit',
'product_hunt' => 'Product Hunt',
'ai_assistant' => 'AI 助手ChatGPT、Claude 等)',
'github' => 'GitHub',
'hacker_news' => 'Hacker News',
'directories' => '软件目录G2、Capterra…',
'ai_assistant' => 'ChatGPT / Claude',
'friend' => '朋友或同事',
'blog' => '博客、新闻通讯或文章',
'founder' => '创始人',
'blog' => 'Blog / newsletter',
'other' => '其他',
],
];

View file

@ -30,6 +30,7 @@ import {
import NavMain from '@/components/NavMain.vue';
import NavSupport from '@/components/NavSupport.vue';
import NotificationBell from '@/components/NotificationBell.vue';
import SidebarOnboarding from '@/components/onboarding/SidebarOnboarding.vue';
import { Avatar } from '@/components/ui/avatar';
import { Button } from '@/components/ui/button';
import {
@ -282,6 +283,8 @@ const bottomNavItems = computed(() => [
</div>
</SidebarContent>
<SidebarFooter>
<SidebarOnboarding v-if="currentWorkspace" />
<div
v-if="subscriptionPastDue"
dusk="past-due-notice"

View file

@ -104,8 +104,9 @@ const handleLogout = (): void => {
<template>
<DropdownMenuLabel
class="px-2 py-1.5 text-xs font-medium tracking-normal text-muted-foreground normal-case"
data-testid="sidebar-menu-greeting"
>
{{ user.name }}
{{ user.first_name }}
</DropdownMenuLabel>
<DropdownMenuGroup>

View file

@ -1,6 +1,6 @@
<script setup lang="ts">
import { router } from '@inertiajs/vue3';
import { IconAlertTriangle, IconCheck, IconPlus } from '@tabler/icons-vue';
import { IconAlertTriangle, IconCheck } from '@tabler/icons-vue';
import { trans } from 'laravel-vue-i18n';
import { computed, ref } from 'vue';
import { toast } from 'vue-sonner';
@ -251,13 +251,6 @@ const cardState = computed((): Record<string, CardStateValue> => {
>
<IconAlertTriangle class="size-3.5" stroke-width="2.5" />
</span>
<span
v-else
class="pointer-events-none absolute -top-2 -right-2 inline-flex size-6 items-center justify-center rounded-full border-2 border-foreground bg-violet-200 text-foreground opacity-0 shadow-2xs transition-all group-hover:scale-110 group-hover:rotate-90 group-hover:opacity-100"
aria-hidden="true"
>
<IconPlus class="size-3.5" stroke-width="3" />
</span>
<div
:class="[

View file

@ -16,18 +16,11 @@ const copyMcpUrl = (): void => {
</script>
<template>
<ol class="space-y-6">
<li>
<div class="mb-3 flex items-center gap-3">
<span
class="inline-flex size-6 shrink-0 items-center justify-center rounded-full bg-foreground text-xs font-bold text-background"
>
1
</span>
<p class="text-sm font-bold">
{{ $t('mcp.copy_step') }}
</p>
</div>
<div class="space-y-6">
<div>
<p class="mb-3 text-sm font-bold">
{{ $t('mcp.copy_step') }}
</p>
<div
class="flex flex-col gap-2 rounded-xl border-2 border-foreground bg-background p-2 shadow-2xs sm:flex-row sm:items-center"
>
@ -52,19 +45,12 @@ const copyMcpUrl = (): void => {
{{ $t('mcp.copy') }}
</Button>
</div>
</li>
</div>
<li>
<div class="mb-3 flex items-center gap-3">
<span
class="inline-flex size-6 shrink-0 items-center justify-center rounded-full bg-foreground text-xs font-bold text-background"
>
2
</span>
<p class="text-sm font-bold">
{{ $t('mcp.open_step') }}
</p>
</div>
<div>
<p class="mb-3 text-sm font-bold">
{{ $t('mcp.open_step') }}
</p>
<div class="grid gap-4 md:grid-cols-2">
<article
@ -114,6 +100,6 @@ const copyMcpUrl = (): void => {
</Button>
</article>
</div>
</li>
</ol>
</div>
</div>
</template>

View file

@ -0,0 +1,67 @@
<script setup lang="ts">
import { IconCheck } from '@tabler/icons-vue';
import { Badge } from '@/components/ui/badge';
defineProps<{
done: boolean;
step: number;
title: string;
description: string;
accentClass: string;
skipped?: boolean;
}>();
</script>
<template>
<section
class="overflow-hidden rounded-2xl border-2 border-foreground bg-card shadow-2xs"
>
<header
:class="[
'flex items-center justify-between gap-4 border-b-2 border-foreground px-5 py-4 sm:px-6',
skipped ? 'bg-muted' : done ? 'bg-emerald-100' : accentClass,
]"
>
<div class="flex min-w-0 items-center gap-3">
<span
:class="[
'inline-flex size-8 shrink-0 items-center justify-center rounded-full border-2 border-foreground text-sm font-bold shadow-2xs',
done ? 'bg-emerald-300' : 'bg-card',
]"
>
<IconCheck
v-if="done && !skipped"
class="size-4"
stroke-width="3"
/>
<template v-else>{{ step }}</template>
</span>
<div class="min-w-0">
<h2 class="text-base font-bold">
{{ title }}
</h2>
<p class="text-sm text-foreground/70">
{{ description }}
</p>
</div>
</div>
<Badge
class="shrink-0"
:variant="done && !skipped ? 'success' : 'outline'"
>
{{
skipped
? $t('onboarding.status.skipped')
: done
? $t('onboarding.status.complete')
: $t('onboarding.status.todo')
}}
</Badge>
</header>
<div class="p-5 sm:p-6">
<slot />
</div>
</section>
</template>

View file

@ -0,0 +1,99 @@
<script setup lang="ts">
import { Link, usePage } from '@inertiajs/vue3';
import { IconListCheck } from '@tabler/icons-vue';
import { computed } from 'vue';
import { useOnboardingLiveReload } from '@/composables/useOnboardingLiveReload';
import { useActiveUrl } from '@/composables/useActiveUrl';
import { onboarding } from '@/routes/app';
import type { OnboardingProgress } from '@/types';
const page = usePage();
const { urlIsActive } = useActiveUrl();
const onboardingProgress = computed<OnboardingProgress | false | undefined>(
() => page.props.onboardingProgress,
);
const progressPercent = computed(() => {
if (! onboardingProgress.value) {
return 0;
}
const { completed, total } = onboardingProgress.value;
return total > 0 ? Math.round((completed / total) * 100) : 0;
});
const liveEnabled = computed(
() =>
Boolean(onboardingProgress.value) &&
page.component !== 'onboarding/Index',
);
useOnboardingLiveReload({
only: ['onboardingProgress'],
enabled: liveEnabled,
});
</script>
<template>
<div
v-if="onboardingProgress"
class="px-1 pb-1 group-data-[collapsible=icon]:hidden"
>
<Link
:href="onboarding.url()"
data-testid="sidebar-onboarding"
:class="[
'block rounded-lg border-2 border-foreground p-3 shadow-2xs transition-colors',
urlIsActive(onboarding.url())
? 'bg-amber-200'
: 'bg-amber-100 hover:bg-amber-200',
]"
>
<div class="flex items-start gap-2">
<span
class="inline-flex size-7 shrink-0 items-center justify-center rounded-md border-2 border-foreground bg-card shadow-2xs"
>
<IconListCheck
class="size-4 text-amber-800"
stroke-width="2.5"
/>
</span>
<div class="flex min-w-0 flex-1 flex-col gap-0.5">
<div class="flex items-baseline justify-between gap-2">
<p
class="min-w-0 truncate text-sm leading-tight font-bold text-foreground"
>
{{ $t('sidebar.onboarding') }}
</p>
<span
class="shrink-0 rounded-full border-2 border-foreground bg-card px-1.5 py-px text-[10px] leading-none font-bold tabular-nums"
>
{{ onboardingProgress.completed }}/{{
onboardingProgress.total
}}
</span>
</div>
<p class="text-xs leading-snug text-foreground/70">
{{ $t('sidebar.onboarding_hint') }}
</p>
</div>
</div>
<div
class="mt-2.5 h-1.5 overflow-hidden rounded-full border border-foreground bg-card"
role="progressbar"
:aria-label="$t('sidebar.onboarding')"
:aria-valuenow="onboardingProgress.completed"
:aria-valuemin="0"
:aria-valuemax="onboardingProgress.total"
>
<div
class="h-full bg-foreground transition-[width]"
:style="{ width: `${progressPercent}%` }"
/>
</div>
</Link>
</div>
</template>

View file

@ -161,7 +161,7 @@ const changeRole = (member: Member, role: string) => {
size="icon"
class="size-8 bg-rose-100 hover:bg-rose-200"
:aria-label="$t('settings.members.cancel_invite_modal.action')"
@click="cancelInvitationModal?.open({ url: destroyInvite.url(invitation.id) })"
@click="cancelInvitationModal?.open({ url: destroyInvite.url(invitation.id), confirmText: invitation.email })"
>
<IconTrash class="size-4 text-rose-700" />
</Button>

View file

@ -0,0 +1,53 @@
import { router, usePoll } from '@inertiajs/vue3';
import { watch, type WatchSource } from 'vue';
import { useWorkspaceEcho } from '@/composables/echo/useWorkspaceEcho';
/**
* Echo for fast onboarding updates; sparse poll as Reverb fallback.
*/
export const useOnboardingLiveReload = (options: {
only: string[];
pollMs?: number;
enabled: WatchSource<boolean>;
onEcho?: () => void;
}): void => {
const pollMs = options.pollMs ?? 30_000;
useWorkspaceEcho('.onboarding.status.updated', () => {
if (! toEnabled(options.enabled)) {
return;
}
options.onEcho?.();
router.reload({ only: options.only });
});
const { start, stop } = usePoll(
pollMs,
{ only: options.only },
{ autoStart: false },
);
watch(
options.enabled,
(enabled) => {
if (enabled) {
start();
return;
}
stop();
},
{ immediate: true },
);
};
const toEnabled = (source: WatchSource<boolean>): boolean => {
if (typeof source === 'function') {
return source();
}
return source.value;
};

View file

@ -0,0 +1,46 @@
<script setup lang="ts">
import { Head } from '@inertiajs/vue3';
defineProps<{
title: string;
description?: string;
pageTitle?: string;
}>();
</script>
<template>
<div
class="flex min-h-svh flex-col items-center justify-center bg-background p-6 md:p-10"
>
<Head v-if="pageTitle" :title="pageTitle" />
<div class="w-full max-w-md space-y-8">
<div class="flex flex-col items-center gap-4 text-center">
<img
src="/images/trypost/logo-light.png"
alt="TryPost"
class="h-10 w-auto"
/>
<div class="space-y-2">
<h1
class="text-xl font-semibold tracking-tight text-foreground"
>
{{ title }}
</h1>
<p
v-if="description"
class="text-sm text-muted-foreground"
>
{{ description }}
</p>
</div>
</div>
<div
class="space-y-6 rounded-xl border-2 border-foreground bg-card p-6 shadow-sm"
>
<slot />
</div>
</div>
</div>
</template>

View file

@ -4,12 +4,13 @@ import { IconLoader2 } from '@tabler/icons-vue';
import { onMounted, onUnmounted, ref, watch } from 'vue';
import { useTracking } from '@/composables/useTracking';
import { accounts } from '@/routes/app';
import { accounts, onboarding } from '@/routes/app';
import type { Auth } from '@/types';
const props = defineProps<{
subscriptionActive: boolean;
fromCheckout: boolean;
redirectToOnboarding: boolean;
persona?: string | null;
conversion?: { value: number; currency: string; transaction_id: string } | null;
}>();
@ -26,7 +27,7 @@ const page = usePage();
// initial render that row doesn't exist yet, so the interval would default
// to 'monthly' even for a yearly purchase.
const { stop } = usePoll(2000, {
only: ['subscriptionActive', 'auth'],
only: ['subscriptionActive', 'redirectToOnboarding', 'auth'],
});
const { trackPurchase } = useTracking();
@ -34,7 +35,11 @@ const { trackPurchase } = useTracking();
const finishing = ref(false);
let redirectTimer: ReturnType<typeof setTimeout> | null = null;
const goToAccounts = () => router.visit(accounts.url());
const goNext = (): void => {
router.visit(
props.redirectToOnboarding ? onboarding.url() : accounts.url(),
);
};
// Fires `checkout.completed` exactly once for a real checkout. A trial-with-card
// subscription is already `subscribed()` (status `trialing`) by the time the
@ -42,7 +47,7 @@ const goToAccounts = () => router.visit(accounts.url());
// false true poll transition never happens. We therefore complete the purchase
// from whichever path runs first (immediate active state or poll transition),
// gated on `fromCheckout` so back-button/refresh visits don't over-count.
const completePurchase = () => {
const completePurchase = (): void => {
if (finishing.value) {
return;
}
@ -61,7 +66,7 @@ const completePurchase = () => {
// Always hold for the same window before navigating, so PostHog and the ad
// pixels (Google/Meta via dataLayer GTM) reliably flush.
redirectTimer = setTimeout(goToAccounts, REDIRECT_DELAY_MS);
redirectTimer = setTimeout(goNext, REDIRECT_DELAY_MS);
};
watch(

View file

@ -1,5 +1,4 @@
<script setup lang="ts">
import { Head } from '@inertiajs/vue3';
import { IconChevronDown } from '@tabler/icons-vue';
import { trans } from 'laravel-vue-i18n';
import { computed, ref } from 'vue';
@ -16,6 +15,7 @@ import {
ComboboxTrigger,
} from '@/components/ui/combobox';
import { Label } from '@/components/ui/label';
import AuthorizeLayout from '@/layouts/mcp/AuthorizeLayout.vue';
import { approve, deny } from '@/routes/passport/authorizations';
type Scope = {
@ -53,17 +53,38 @@ const workspaceIdForSubmit = computed(
() => selectedWorkspace.value?.id ?? '',
);
const canApprove = computed(() => workspaceIdForSubmit.value !== '');
const approving = ref(false);
const csrfToken =
document.querySelector<HTMLMetaElement>('meta[name="csrf-token"]')
?.content ?? '';
const pageTitle = computed(() => trans('mcp.authorize.page_title'));
const heading = computed(() =>
trans('mcp.authorize.heading', {
client: props.client.name,
}),
);
const description = computed(
() =>
`${trans('mcp.authorize.intro')} ${trans('mcp.authorize.intro_capability')}`,
);
const scopeLabel = (scope: Scope): string =>
scope.id === 'mcp:use'
? trans('mcp.authorize.scope_mcp_use')
: scope.description;
const onApproveSubmit = (): void => {
const onApproveSubmit = (event: Event): void => {
if (! canApprove.value) {
event.preventDefault();
return;
}
approving.value = true;
// Popup MCP clients expect the window to close after the redirect.
@ -94,209 +115,153 @@ const onDenySubmit = (): void => {
</script>
<template>
<div
class="flex min-h-svh flex-col items-center justify-center bg-background p-6 md:p-10"
<AuthorizeLayout
:page-title="pageTitle"
:title="heading"
:description="description"
>
<Head :title="trans('mcp.authorize.page_title')" />
<div class="w-full max-w-md space-y-8">
<div class="flex flex-col items-center gap-4 text-center">
<img
src="/images/trypost/logo-light.png"
alt="TryPost"
class="h-10 w-auto"
/>
<div class="space-y-2">
<h1 class="text-xl font-semibold tracking-tight text-foreground">
{{
$t('mcp.authorize.heading', {
client: client.name,
})
}}
</h1>
<p class="text-sm text-muted-foreground">
{{ $t('mcp.authorize.intro') }}
{{ $t('mcp.authorize.intro_capability') }}
</p>
</div>
<div class="space-y-4">
<div class="space-y-1.5">
<p class="text-sm text-muted-foreground">
{{ $t('mcp.authorize.logged_in_as') }}
</p>
<p class="font-medium" dusk="mcp-authorize-email">
{{ user.email }}
</p>
</div>
<div
class="space-y-6 rounded-xl border-2 border-foreground bg-card p-6 shadow-sm"
>
<div class="space-y-4">
<div class="space-y-1.5">
<p class="text-sm text-muted-foreground">
{{ $t('mcp.authorize.logged_in_as') }}
</p>
<p class="font-medium" dusk="mcp-authorize-email">
{{ user.email }}
</p>
</div>
<div class="space-y-1.5">
<Label>{{ $t('mcp.authorize.workspace') }}</Label>
<Combobox
v-model="selectedWorkspace"
by="id"
:display-value="
(workspace: WorkspaceOption | undefined) =>
workspace?.name ?? ''
"
>
<ComboboxAnchor class="w-full">
<ComboboxTrigger as-child>
<button
type="button"
class="flex h-10 w-full items-center justify-between rounded-md border-2 border-foreground bg-card px-3 py-2 text-sm font-medium text-foreground shadow-2xs transition-colors hover:bg-muted/40"
dusk="mcp-authorize-workspace"
>
<span
:class="
selectedWorkspace
? 'text-foreground'
: 'text-foreground/50'
"
>
{{
selectedWorkspace?.name ??
$t(
'mcp.authorize.select_workspace',
)
}}
</span>
<IconChevronDown
class="size-4 shrink-0 text-foreground/60"
/>
</button>
</ComboboxTrigger>
</ComboboxAnchor>
<ComboboxList>
<ComboboxInput
:placeholder="
$t('mcp.authorize.search_workspace')
"
/>
<ComboboxEmpty>
{{
$t('mcp.authorize.no_workspace_found')
}}
</ComboboxEmpty>
<ComboboxGroup>
<ComboboxItem
v-for="workspace in workspaces"
:key="workspace.id"
:value="workspace"
>
{{ workspace.name }}
</ComboboxItem>
</ComboboxGroup>
</ComboboxList>
</Combobox>
<p class="text-xs text-muted-foreground">
{{ $t('mcp.authorize.workspace_scope') }}
</p>
</div>
</div>
<div v-if="scopes.length > 0" class="space-y-2">
<p class="text-sm font-medium">
{{ $t('mcp.authorize.permissions') }}
</p>
<ul class="space-y-2">
<li
v-for="scope in scopes"
:key="scope.id"
class="flex items-start gap-2"
>
<div
class="mt-0.5 rounded-full bg-primary/10 p-1"
<div class="space-y-1.5">
<Label>{{ $t('mcp.authorize.workspace') }}</Label>
<Combobox
v-model="selectedWorkspace"
by="id"
:display-value="
(workspace: WorkspaceOption | undefined) =>
workspace?.name ?? ''
"
>
<ComboboxAnchor class="w-full">
<ComboboxTrigger as-child>
<button
type="button"
class="flex h-10 w-full items-center justify-between rounded-md border-2 border-foreground bg-card px-3 py-2 text-sm font-medium text-foreground shadow-2xs transition-colors hover:bg-muted/40"
dusk="mcp-authorize-workspace"
>
<div
class="size-1.5 rounded-full bg-primary"
<span
:class="
selectedWorkspace
? 'text-foreground'
: 'text-foreground/50'
"
>
{{
selectedWorkspace?.name ??
$t('mcp.authorize.select_workspace')
}}
</span>
<IconChevronDown
class="size-4 shrink-0 text-foreground/60"
/>
</div>
<span class="text-sm text-muted-foreground">
{{ scopeLabel(scope) }}
</span>
</li>
</ul>
</div>
<div class="flex flex-col gap-3 sm:flex-row sm:justify-start">
<!-- Native forms: Passport redirects off-site; Inertia visits would break the OAuth popup. -->
<form
id="authorizeForm"
method="POST"
:action="approve.url()"
class="flex-1"
@submit="onApproveSubmit"
>
<input
type="hidden"
name="_token"
:value="csrfToken"
</button>
</ComboboxTrigger>
</ComboboxAnchor>
<ComboboxList>
<ComboboxInput
:placeholder="
$t('mcp.authorize.search_workspace')
"
/>
<input type="hidden" name="state" :value="state" />
<input
type="hidden"
name="client_id"
:value="client.id"
/>
<input
type="hidden"
name="auth_token"
:value="authToken"
/>
<input
type="hidden"
name="workspace_id"
:value="workspaceIdForSubmit"
/>
<Button
type="submit"
class="w-full"
:loading="approving"
dusk="mcp-authorize-approve"
>
{{ $t('mcp.authorize.approve') }}
</Button>
</form>
<form
method="POST"
:action="deny.url()"
class="flex-1"
@submit="onDenySubmit"
>
<input
type="hidden"
name="_token"
:value="csrfToken"
/>
<input type="hidden" name="_method" value="DELETE" />
<input type="hidden" name="state" :value="state" />
<input
type="hidden"
name="client_id"
:value="client.id"
/>
<input
type="hidden"
name="auth_token"
:value="authToken"
/>
<Button
type="submit"
variant="outline"
class="w-full"
dusk="mcp-authorize-cancel"
>
{{ $t('mcp.authorize.cancel') }}
</Button>
</form>
</div>
<ComboboxEmpty>
{{ $t('mcp.authorize.no_workspace_found') }}
</ComboboxEmpty>
<ComboboxGroup>
<ComboboxItem
v-for="workspace in workspaces"
:key="workspace.id"
:value="workspace"
>
{{ workspace.name }}
</ComboboxItem>
</ComboboxGroup>
</ComboboxList>
</Combobox>
<p class="text-xs text-muted-foreground">
{{ $t('mcp.authorize.workspace_scope') }}
</p>
</div>
</div>
</div>
<div v-if="scopes.length > 0" class="space-y-2">
<p class="text-sm font-medium">
{{ $t('mcp.authorize.permissions') }}
</p>
<ul class="space-y-2">
<li
v-for="scope in scopes"
:key="scope.id"
class="flex items-start gap-2"
>
<div class="mt-0.5 rounded-full bg-primary/10 p-1">
<div class="size-1.5 rounded-full bg-primary" />
</div>
<span class="text-sm text-muted-foreground">
{{ scopeLabel(scope) }}
</span>
</li>
</ul>
</div>
<div class="flex flex-col gap-3 sm:flex-row sm:justify-start">
<!-- Native forms: Passport redirects off-site; Inertia visits would break the OAuth popup. -->
<form
id="authorizeForm"
method="POST"
:action="approve.url()"
class="flex-1"
@submit="onApproveSubmit"
>
<input type="hidden" name="_token" :value="csrfToken" />
<input type="hidden" name="state" :value="state" />
<input type="hidden" name="client_id" :value="client.id" />
<input type="hidden" name="auth_token" :value="authToken" />
<input
type="hidden"
name="workspace_id"
:value="workspaceIdForSubmit"
/>
<Button
type="submit"
class="w-full"
:class="{
'pointer-events-none opacity-25': !canApprove,
}"
:loading="approving"
dusk="mcp-authorize-approve"
>
{{ $t('mcp.authorize.approve') }}
</Button>
</form>
<form
method="POST"
:action="deny.url()"
class="flex-1"
@submit="onDenySubmit"
>
<input type="hidden" name="_token" :value="csrfToken" />
<input type="hidden" name="_method" value="DELETE" />
<input type="hidden" name="state" :value="state" />
<input type="hidden" name="client_id" :value="client.id" />
<input type="hidden" name="auth_token" :value="authToken" />
<Button
type="submit"
variant="outline"
class="w-full"
dusk="mcp-authorize-cancel"
>
{{ $t('mcp.authorize.cancel') }}
</Button>
</form>
</div>
</AuthorizeLayout>
</template>

View file

@ -0,0 +1,30 @@
<script setup lang="ts">
import AuthorizeLayout from '@/layouts/mcp/AuthorizeLayout.vue';
defineProps<{
error: string;
errorDescription: string;
}>();
</script>
<template>
<AuthorizeLayout
:page-title="$t('mcp.authorize.error_page_title')"
:title="$t('mcp.authorize.error_title')"
:description="$t('mcp.authorize.error_body')"
>
<p
class="text-sm text-muted-foreground"
dusk="mcp-authorize-error-description"
>
{{ errorDescription }}
</p>
<p
class="rounded-lg border-2 border-foreground bg-muted px-3 py-2 font-mono text-xs text-foreground"
dusk="mcp-authorize-error-code"
>
{{ $t('mcp.authorize.error_code', { error }) }}
</p>
</AuthorizeLayout>
</template>

Some files were not shown because too many files have changed in this diff Show more