diff --git a/.claude/release-assets/render-thumbnail.mjs b/.claude/release-assets/render-thumbnail.mjs index 8bab47c09..8322d4bd9 100644 --- a/.claude/release-assets/render-thumbnail.mjs +++ b/.claude/release-assets/render-thumbnail.mjs @@ -16,11 +16,11 @@ // Playwright is resolved from the repo's node_modules, so the script works // regardless of where it is invoked. -import { createRequire } from 'module'; import { readFileSync, writeFileSync, unlinkSync } from 'fs'; -import { fileURLToPath } from 'url'; -import { dirname, join } from 'path'; +import { createRequire } from 'module'; import { tmpdir } from 'os'; +import { dirname, join } from 'path'; +import { fileURLToPath } from 'url'; const here = dirname(fileURLToPath(import.meta.url)); const repoRoot = join(here, '..', '..'); diff --git a/.cursor/rules/tests-dusk.mdc b/.cursor/rules/tests-dusk.mdc index 26984c76d..e88104732 100644 --- a/.cursor/rules/tests-dusk.mdc +++ b/.cursor/rules/tests-dusk.mdc @@ -1,10 +1,10 @@ --- -description: Laravel Dusk browser tests — named routes, dusk selectors, no CSS/text-based assertions +description: Browser tests (Pest + Playwright) — named routes, data-testid selectors, no CSS/text-based assertions globs: tests/Browser/**/*.php alwaysApply: false --- -# Dusk Browser Tests +# Browser Tests ## Named routes (IMPORTANT) @@ -12,32 +12,40 @@ ALWAYS use named routes via the `route()` helper. NEVER hardcode URLs like `'htt ```php // BAD -$browser->visit('https://trypost.test/login'); +visit('https://trypost.test/login'); // GOOD -$browser->visit(route('login')); +visit(route('login')); ``` -## Dusk selectors +## Selectors (IMPORTANT) -ALWAYS use `dusk` selectors (`@selector-name`) for interactions and assertions. NEVER use CSS classes (`.text-red-600`), tag names, or text strings. +ALWAYS use `data-testid` attributes and target them with `@selector-name`. NEVER use CSS classes (`.text-red-600`), tag names, or text strings. -Add `dusk="my-element"` attributes to Vue components and target them with `$browser->click('@my-element')`, `$browser->waitFor('@my-element')`, etc. +The Pest browser plugin resolves `@selector` against `[data-testid]` / `[data-test]` only — `dusk="..."` attributes are NOT matched (legacy from Laravel Dusk). + +Add `data-testid="my-element"` attributes to Vue components and target them with `$page->click('@my-element')`, `$page->assertVisible('@my-element')`, etc. ```php // BAD -$browser->waitFor('.text-red-600'); -$browser->click('button.primary'); -$browser->assertSee('Welcome'); +$page->assertVisible('.text-red-600'); +$page->click('button.primary'); // GOOD -$browser->waitFor('@input-error'); -$browser->click('@submit-button'); -$browser->assertVisible('@welcome-message'); +$page->assertVisible('@input-error'); +$page->click('@submit-button'); ``` ```vue - -{{ error }} + +{{ error }} ``` + +## No auto-wait + +Browser assertions do not auto-wait. Poll browser-side (see `waitForTestId` / `waitForDusk` helpers in existing tests) before asserting on async UI. + +## Strict models + +Use `->fresh()` when acting as a factory-created user — the in-process server keeps the guard user across requests, and factory instances lack nullable columns (strict mode throws `MissingAttributeException`). diff --git a/.env.example b/.env.example index 4a631e7ee..98f0f6d28 100644 --- a/.env.example +++ b/.env.example @@ -11,6 +11,13 @@ WEBHOOK_URL= # Self-hosted mode (skips payment requirements) SELF_HOSTED=true +# Passport OAuth keys (API tokens / MCP). Prefer env vars over key files so +# every node behind a load balancer shares the same key pair. Use literal \n +# for newlines in the PEM. When unset, Passport falls back to storage/oauth-*.key +# (generate with: php artisan passport:keys). +# PASSPORT_PRIVATE_KEY="-----BEGIN PRIVATE KEY-----\n...\n-----END PRIVATE KEY-----" +# PASSPORT_PUBLIC_KEY="-----BEGIN PUBLIC KEY-----\n...\n-----END PUBLIC KEY-----" + TELESCOPE_ENABLED=false APP_LOCALE=en @@ -191,11 +198,17 @@ STRIPE_SECRET= STRIPE_WEBHOOK_SECRET= # Set to false to allow generic signup trial without requiring a card. REQUIRE_CARD_FOR_TRIAL=true -# Free trial length in days, used only for the no-card generic trial above. +# Checkout trial length in days (card collected up front when REQUIRE_CARD_FOR_TRIAL=true). +# - >0: Stripe Checkout trial for N days for first-time signups only (no prior +# real subscription). Values below 2 are clamped to 2 (Stripe's 48h min). +# - 0: no Checkout trial; charge starts immediately. +# - Returning / canceled accounts never get another Checkout trial. +# - When REQUIRE_CARD_FOR_TRIAL=false, Checkout never applies a trial (generic +# signup trial uses this value instead; must be >= 1 in that mode). +# Empty = default 8. CASHIER_TRIAL_DAYS=8 -# Stripe Coupon ID (amount_off, duration=once) so the first invoice at -# checkout comes out to $1 instead of the full monthly price. -STRIPE_FIRST_MONTH_COUPON_ID= +# Show Stripe Checkout promotion-code field. +CASHIER_ALLOW_PROMOTION_CODES=true # Stripe Plan Price IDs (one per plan × interval). Used by PlanSeeder. STRIPE_WORKSPACE_MONTHLY= diff --git a/.github/workflows/lint.yml b/.github/workflows/lint.yml index c55a1127a..e30c1cf90 100644 --- a/.github/workflows/lint.yml +++ b/.github/workflows/lint.yml @@ -1,4 +1,4 @@ -name: linter +name: lints on: push: @@ -10,14 +10,11 @@ on: - develop - main -permissions: - contents: write - jobs: quality: runs-on: ubuntu-latest steps: - - uses: actions/checkout@v7 + - uses: actions/checkout@v6 - name: Setup PHP uses: shivammathur/setup-php@v2 @@ -29,17 +26,11 @@ jobs: composer install -q --no-ansi --no-interaction --no-scripts --no-progress --prefer-dist npm install - - name: Run Pint - run: composer lint + - name: Check PHP Formatting + run: composer test:lint - - name: Format Frontend - run: npm run format + - name: Check Frontend Formatting + run: npm run format:check - name: Lint Frontend - run: npm run lint - - # - name: Commit Changes - # uses: stefanzweifel/git-auto-commit-action@v5 - # with: - # commit_message: fix code style - # commit_options: '--no-verify' + run: npm run lint:check diff --git a/.github/workflows/tests.yml b/.github/workflows/tests.yml index 497efe6d7..af3ff2b36 100644 --- a/.github/workflows/tests.yml +++ b/.github/workflows/tests.yml @@ -108,6 +108,9 @@ jobs: - name: Build assets run: npm run build + - name: Type-check frontend + run: npm run types + - name: Install Playwright browsers run: npx playwright install --with-deps chromium diff --git a/.gitignore b/.gitignore index e61ad860e..d9daa1326 100644 --- a/.gitignore +++ b/.gitignore @@ -7,6 +7,7 @@ /public/hot /public/storage /storage/*.key +/storage/passport /storage/pail /resources/js/actions /resources/js/routes diff --git a/CLAUDE.md b/CLAUDE.md index 709aa853f..2981ed280 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -306,13 +306,16 @@ Vue components must have a single root element. - Example: `$this->postJson(route('app.posts.store'))` instead of `$this->postJson('/posts')`. - With params: `route('app.posts.ai.create.finalize', $creationId)`. -## Dusk (Browser Tests) - -- In Dusk tests, ALWAYS use named routes via `route()` helper. NEVER hardcode URLs like `'https://trypost.test/login'`. - - Example: `$browser->visit(route('login'))` instead of `$browser->visit('https://trypost.test/login')`. -- ALWAYS use `dusk` selectors (`@selector-name`) for interacting with and asserting elements. NEVER use CSS classes (`.text-red-600`), tag names, or text strings. - - Add `dusk="my-element"` attributes to Vue components and use `$browser->click('@my-element')`, `$browser->waitFor('@my-element')`, etc. - - Example: `$browser->waitFor('@input-error')` instead of `$browser->waitFor('.text-red-600')`. +## Browser Tests + +- In browser tests, ALWAYS use named routes via `route()` helper. NEVER hardcode URLs like `'https://trypost.test/login'`. + - Example: `visit(route('login'))` instead of `visit('https://trypost.test/login')`. +- ALWAYS use `data-testid` selectors (`@selector-name`) for interacting with and asserting elements. NEVER use CSS classes (`.text-red-600`), tag names, or text strings. + - The Pest browser plugin resolves `@selector` against `[data-testid]` / `[data-test]` only — `dusk="..."` attributes are NOT matched. + - Add `data-testid="my-element"` attributes to Vue components and use `$page->click('@my-element')`, `$page->assertVisible('@my-element')`, etc. + - Example: `$page->assertVisible('@input-error')` instead of `$page->assertVisible('.text-red-600')`. +- Assertions do not auto-wait — poll browser-side (see `waitForTestId`/`waitForDusk` helpers in `tests/Browser`) before asserting on async UI. +- Use `->fresh()` when acting as a factory-created user: the in-process server keeps the guard user across requests, and factory instances lack nullable columns (strict mode throws `MissingAttributeException`). ## Array Data Access diff --git a/app/Actions/AccessToken/ListConnectedMcpClients.php b/app/Actions/AccessToken/ListConnectedMcpClients.php new file mode 100644 index 000000000..d0e01bfdc --- /dev/null +++ b/app/Actions/AccessToken/ListConnectedMcpClients.php @@ -0,0 +1,44 @@ + + */ + public static function forAccount(string $accountId, User $viewer): array + { + $tokens = AccessToken::query() + ->whereIn('user_id', User::query()->select('id')->where('account_id', $accountId)) + ->activeMcpOAuth() + ->with(['client', 'user']) + ->get(); + + return $tokens + ->groupBy(fn (AccessToken $token): string => "{$token->client_id}:{$token->user_id}") + ->map(function (Collection $group) use ($viewer): array { + /** @var AccessToken $token */ + $token = $group->first(); + + return [ + 'client_id' => $token->client_id, + 'name' => $token->client->name, + 'user_id' => (string) $token->user_id, + 'user_name' => (string) ($token->user?->name ?? ''), + 'can_disconnect' => $token->user_id === $viewer->id, + 'last_used_at' => $group->max('last_used_at'), + ]; + }) + ->values() + ->all(); + } +} diff --git a/app/Actions/AccessToken/RevokeMcpOAuthGrants.php b/app/Actions/AccessToken/RevokeMcpOAuthGrants.php new file mode 100644 index 000000000..ed01956b1 --- /dev/null +++ b/app/Actions/AccessToken/RevokeMcpOAuthGrants.php @@ -0,0 +1,99 @@ +where('user_id', $user->id) + ->mcpOAuth() + ->where('revoked', false) + ->get(), + $user, + ); + } + + /** + * Revoke active MCP OAuth grants for one OAuth client owned by the user. + * + * @return bool True when at least one grant was revoked. + */ + public static function forUserClient(User $user, string $clientId): bool + { + return self::revoke( + AccessToken::query() + ->where('user_id', $user->id) + ->where('client_id', $clientId) + ->mcpOAuth() + ->where('revoked', false) + ->get(), + $user, + ); + } + + public static function canCreatePostSomewhere(User $user): bool + { + return $user->workspaces() + ->get() + ->contains(fn (Workspace $workspace): bool => $user->can('createPost', $workspace)); + } + + /** + * @param Collection $tokens + */ + private static function revoke(Collection $tokens, User $user): bool + { + if ($tokens->isEmpty()) { + return false; + } + + DB::transaction(function () use ($tokens): void { + $tokenIds = $tokens->pluck('id'); + + DB::table('oauth_refresh_tokens') + ->whereIn('access_token_id', $tokenIds) + ->update(['revoked' => true]); + + $tokens->each(function (AccessToken $token): void { + $token->forceFill(['revoked' => true])->saveQuietly(); + }); + }); + + OnboardingStatusUpdated::dispatchForAccount($user->account, $user); + + return true; + } +} diff --git a/app/Actions/AccessToken/RevokeWorkspaceApiKeys.php b/app/Actions/AccessToken/RevokeWorkspaceApiKeys.php new file mode 100644 index 000000000..61f0171ee --- /dev/null +++ b/app/Actions/AccessToken/RevokeWorkspaceApiKeys.php @@ -0,0 +1,45 @@ +where('user_id', $userId) + ->where('workspace_id', $workspace->id) + ->where('revoked', false) + ->update(['revoked' => true]); + } + + /** + * Admins (and account owners acting as admin) may keep workspace API keys. + * Any other role loses them. + * + * @return int Number of tokens revoked. + */ + public static function forUserUnlessAdmin( + string $userId, + Workspace $workspace, + WorkspaceRole $role, + ): int { + if ($role === WorkspaceRole::Admin) { + return 0; + } + + return self::forUserOnWorkspace($userId, $workspace); + } +} diff --git a/app/Actions/ApiKey/CreateApiKey.php b/app/Actions/ApiKey/CreateApiKey.php new file mode 100644 index 000000000..a9b7d1b2f --- /dev/null +++ b/app/Actions/ApiKey/CreateApiKey.php @@ -0,0 +1,44 @@ + + */ + public static function expiresAtRules(): array + { + return ['nullable', 'date', 'after_or_equal:today']; + } + + /** + * @param array{name: string, expires_at?: string|null} $data + * @return array{token: AccessToken, plain_token: string} + */ + public static function execute(User $user, Workspace $workspace, array $data): array + { + $result = $user->createToken((string) data_get($data, 'name')); + $token = AccessToken::query()->findOrFail($result->token->id); + $expiresAt = data_get($data, 'expires_at'); + + $token->forceFill([ + 'workspace_id' => $workspace->id, + // Empty = never expire (overrides Passport's JWT default lifetime in the DB). + 'expires_at' => filled($expiresAt) + ? now()->parse((string) $expiresAt)->endOfDay() + : null, + ])->saveQuietly(); + + return [ + 'token' => $token->refresh(), + 'plain_token' => $result->accessToken, + ]; + } +} diff --git a/app/Actions/Billing/StartSubscriptionCheckout.php b/app/Actions/Billing/StartSubscriptionCheckout.php index 949defc1b..5b00e24a7 100644 --- a/app/Actions/Billing/StartSubscriptionCheckout.php +++ b/app/Actions/Billing/StartSubscriptionCheckout.php @@ -5,20 +5,52 @@ namespace App\Actions\Billing; use App\Models\Account; -use App\Support\Billing\FirstMonthCheckoutDiscount; +use App\Support\Billing\CheckoutConversionData; +use App\Support\Billing\ConfigureSubscriptionCheckout; +use Illuminate\Support\Facades\Cache; +use Illuminate\Support\Facades\Log; use Inertia\Inertia; +use Laravel\Cashier\Cashier; use Symfony\Component\HttpFoundation\Response; +use Throwable; class StartSubscriptionCheckout { + private const PENDING_SESSION_TTL_HOURS = 24; + /** * Create a Stripe Checkout session for the given price and return an Inertia - * redirect to it. Quantity tracks the account's workspace count; the first - * month's coupon is applied when the instance requires a card up front, so - * the first invoice charges $1 instead of running a $0 trial authorization. + * redirect to it. Quantity tracks the account's workspace count. Trial days + * and promotion codes come from Cashier env config. */ public function redirect(Account $account, string $priceId, string $cancelUrl): Response { + $cacheKey = self::pendingCacheKey($account, $priceId); + + $account->refresh(); + + if ($account->hasAppAccess()) { + self::forgetPending($cacheKey); + + return $this->location(route('app.billing.processing')); + } + + $pending = $this->resolvePendingCheckout($account, $cacheKey); + + if (data_get($pending, 'kind') === 'reuse') { + return $this->location((string) data_get($pending, 'url')); + } + + // Paid/complete session whose webhook has not landed yet — + // never mint a second Checkout (double-subscription risk). + if (data_get($pending, 'kind') === 'processing') { + $sessionId = (string) data_get($pending, 'session_id'); + + return $this->location( + route('app.billing.processing', ['session_id' => $sessionId]), + ); + } + $account->createOrGetStripeCustomer([ 'email' => $account->stripeEmail(), 'name' => $account->stripeName(), @@ -26,14 +58,128 @@ public function redirect(Account $account, string $priceId, string $cancelUrl): $subscription = $account->newSubscription(Account::SUBSCRIPTION_NAME, $priceId) ->quantity(max(1, $account->workspaces()->count())); + $trialDays = ConfigureSubscriptionCheckout::checkoutTrialDays($account); - FirstMonthCheckoutDiscount::apply($subscription, $account); + ConfigureSubscriptionCheckout::apply($subscription, $account); $session = $subscription->checkout([ 'success_url' => route('app.billing.processing').'?session_id={CHECKOUT_SESSION_ID}', 'cancel_url' => $cancelUrl, + 'client_reference_id' => (string) $account->id, + 'metadata' => [ + 'trypost_purpose' => CheckoutConversionData::PURPOSE, + 'trypost_account_id' => (string) $account->id, + 'trypost_price_id' => $priceId, + 'trypost_trial_days' => (string) $trialDays, + ], ]); - return Inertia::location($session->url); + $this->rememberPending($cacheKey, (string) $session->url, (string) $session->id); + + return $this->location($session->url); + } + + public static function pendingCacheKey(Account $account, string $priceId): string + { + return "billing:checkout:{$account->id}:{$priceId}"; + } + + /** + * @return array{kind: 'reuse', url: string}|array{kind: 'processing', session_id: string}|array{kind: 'none'} + */ + private function resolvePendingCheckout(Account $account, string $cacheKey): array + { + $pending = Cache::get($cacheKey); + + if (! is_array($pending)) { + if ($pending !== null) { + self::forgetPending($cacheKey); + } + + return ['kind' => 'none']; + } + + $url = data_get($pending, 'url'); + $sessionId = data_get($pending, 'session_id'); + + if (! is_string($url) || $url === '' || ! is_string($sessionId) || $sessionId === '') { + self::forgetPending($cacheKey); + + return ['kind' => 'none']; + } + + try { + $session = Cashier::stripe()->checkout->sessions->retrieve($sessionId); + } catch (Throwable $exception) { + Log::warning('Stripe Checkout pending session could not be retrieved.', [ + 'account_id' => $account->id, + 'session_id' => $sessionId, + 'exception' => $exception, + ]); + self::forgetPending($cacheKey); + + return ['kind' => 'none']; + } + + $status = $session->status ?? null; + + if ($status === 'open') { + $liveUrl = is_string($session->url ?? null) && $session->url !== '' + ? $session->url + : $url; + + return ['kind' => 'reuse', 'url' => $liveUrl]; + } + + if ($status === 'complete') { + // Keep the pending entry until hasAppAccess() clears it. Forgetting + // here lets a second checkout attempt mint another Stripe session + // while the webhook is still catching up (double-subscription risk). + $this->rememberPending($cacheKey, $url, $sessionId); + + return ['kind' => 'processing', 'session_id' => $sessionId]; + } + + // expired / canceled / unknown — mint a fresh session. + self::forgetPending($cacheKey); + + return ['kind' => 'none']; + } + + private function rememberPending(string $cacheKey, string $url, string $sessionId): void + { + try { + Cache::put( + $cacheKey, + [ + 'url' => $url, + 'session_id' => $sessionId, + ], + now()->addHours(self::PENDING_SESSION_TTL_HOURS), + ); + } catch (Throwable $exception) { + Log::warning('Stripe Checkout pending session could not be re-cached.', [ + 'cache_key' => $cacheKey, + 'session_id' => $sessionId, + 'exception' => $exception, + ]); + } + } + + private static function forgetPending(string $cacheKey): void + { + try { + Cache::forget($cacheKey); + } catch (Throwable $exception) { + Log::warning('Stripe Checkout pending cache could not be cleared.', [ + 'cache_key' => $cacheKey, + 'exception' => $exception, + ]); + } + } + + private function location(string $url): Response + { + return Inertia::location($url); } } diff --git a/app/Actions/Invite/RemoveMember.php b/app/Actions/Invite/RemoveMember.php index 37f6f62bb..0ae33766b 100644 --- a/app/Actions/Invite/RemoveMember.php +++ b/app/Actions/Invite/RemoveMember.php @@ -4,6 +4,8 @@ namespace App\Actions\Invite; +use App\Actions\AccessToken\RevokeMcpOAuthGrants; +use App\Actions\AccessToken\RevokeWorkspaceApiKeys; use App\Actions\User\ReassignCurrentWorkspace; use App\Actions\User\SettleStrandedMember; use App\Actions\User\StrandedSettlement; @@ -30,6 +32,7 @@ public static function execute(Workspace $workspace, string $userId): void $user = User::query()->find($userId); $workspace->members()->detach($userId); + RevokeWorkspaceApiKeys::forUserOnWorkspace($userId, $workspace); if (! $user) { return; @@ -50,6 +53,14 @@ public static function execute(Workspace $workspace, string $userId): void ) { $settlement = SettleStrandedMember::execute($user, $account); } + + // If the member still exists but can no longer create posts anywhere, + // drop their MCP OAuth grants (refresh tokens included). + $remaining = User::query()->find($userId); + + if ($remaining instanceof User) { + RevokeMcpOAuthGrants::forUserIfLacksCreatePost($remaining); + } }); $settlement->flush(); diff --git a/app/Actions/Onboarding/ResolveOnboardingStatus.php b/app/Actions/Onboarding/ResolveOnboardingStatus.php new file mode 100644 index 000000000..5d290e0e1 --- /dev/null +++ b/app/Actions/Onboarding/ResolveOnboardingStatus.php @@ -0,0 +1,362 @@ + 'mcp_connected', + 'social' => 'social_connected', + 'first_post' => 'first_post_created', + ]; + + public function __construct( + private readonly PostHogService $postHog, + ) {} + + /** + * 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, + * all_complete: bool, + * show_residual: bool, + * completed_at: ?string, + * dismissed_at: ?string + * } + */ + public function handle(User $user): array + { + $account = $user->account; + $skippedSteps = $account?->onboarding_skipped_steps ?? []; + + if ($account?->onboarding_completed_at !== null) { + // Preserve skip vs real MCP completion for the ready UI badge. + $mcpConnected = ! in_array('mcp', $skippedSteps, true) + || $this->accountHasMcpConnection($account); + $effectiveSkippedSteps = $mcpConnected + ? array_values(array_diff($skippedSteps, ['mcp'])) + : $skippedSteps; + + return [ + 'mcp_connected' => $mcpConnected, + 'social_connected' => true, + 'first_post_created' => true, + 'skipped_steps' => $effectiveSkippedSteps, + 'all_complete' => true, + 'show_residual' => false, + 'completed_at' => $account->onboarding_completed_at->toIso8601String(), + 'dismissed_at' => $account->onboarding_dismissed_at?->toIso8601String(), + ]; + } + + // All three steps are account-scoped so checklist checkmarks, residual + // progress, and cross-workspace completion stay aligned. + $stepStates = [ + 'mcp' => $this->accountHasMcpConnection($account), + 'social' => $this->accountHasSocialConnection($account), + 'first_post' => $this->accountHasPost($account), + ]; + + // A skipped optional step counts as done for completion purposes. + $allComplete = collect($stepStates) + ->every(fn (bool $done, string $step): bool => $done || in_array($step, $skippedSteps, true)); + + $showResidual = ! config('trypost.self_hosted') + && $user->isAccountOwner() + && ($account?->hasAppAccess() ?? false) + && $account->onboarding_dismissed_at === null + && ! $allComplete; + + return [ + 'mcp_connected' => $stepStates['mcp'], + 'social_connected' => $stepStates['social'], + 'first_post_created' => $stepStates['first_post'], + 'skipped_steps' => $skippedSteps, + 'all_complete' => $allComplete, + 'show_residual' => $showResidual, + 'completed_at' => null, + 'dismissed_at' => $account?->onboarding_dismissed_at?->toIso8601String(), + ]; + } + + /** + * Read checklist state, capture step analytics, and stamp completion when done. + * Call from intentional onboarding surfaces — not from shared Inertia props. + * + * @return array{ + * mcp_connected: bool, + * social_connected: bool, + * first_post_created: bool, + * skipped_steps: list, + * all_complete: bool, + * show_residual: bool, + * completed_at: ?string, + * dismissed_at: ?string + * } + */ + public function syncProgress(User $user): array + { + $status = $this->handle($user); + $account = $user->account; + + if ($account === null || $account->onboarding_completed_at !== null) { + return $status; + } + + if ($account->onboarding_dismissed_at === null) { + foreach (self::STEPS as $step => $statusKey) { + $this->captureCompletedStep($user, $account, $step, $status[$statusKey]); + } + } + + if ($account->onboarding_dismissed_at !== null) { + return $status; + } + + if ($status['all_complete']) { + $this->markCompleted($user); + $account->refresh(); + + return [ + ...$status, + 'show_residual' => false, + 'completed_at' => $account->onboarding_completed_at?->toIso8601String(), + ]; + } + + return $status; + } + + /** + * Stamp account onboarding completion once and capture the funnel event. + */ + public function markCompleted(User $user): bool + { + $account = $user->account; + + if ( + $account === null + || $account->hasFinishedOnboarding() + ) { + return false; + } + + $completedAt = now(); + + $updated = Account::query() + ->whereKey($account->id) + ->whereNull('onboarding_completed_at') + ->whereNull('onboarding_dismissed_at') + ->update([ + 'onboarding_completed_at' => $completedAt, + 'updated_at' => $completedAt, + ]); + + if ($updated === 0) { + return false; + } + + $account->refresh(); + + if (PostHogService::isEnabled()) { + $this->postHog->capture( + $user->id, + OnboardingEvent::Completed->value, + account: $account, + ); + } + + // Every stamp path (endpoint, syncProgress, observers) must clear residual + // banners account-wide — not only the explicit complete() action. + 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->account; + + if ($account === null || $account->hasFinishedOnboarding()) { + return false; + } + + $status = $this->handle($user); + + if ($status['mcp_connected'] || in_array('mcp', $status['skipped_steps'], true)) { + return false; + } + + $skippedSteps = [...$status['skipped_steps'], 'mcp']; + + $updated = Account::query() + ->whereKey($account->id) + ->whereNull('onboarding_completed_at') + ->whereNull('onboarding_dismissed_at') + ->update([ + 'onboarding_skipped_steps' => $skippedSteps, + 'updated_at' => now(), + ]); + + if ($updated === 0) { + return false; + } + + $account->refresh(); + + if (PostHogService::isEnabled()) { + $this->postHog->capture( + $user->id, + OnboardingEvent::StepSkipped->value, + ['step' => 'mcp'], + $account, + ); + } + + $completed = $this->handle($user)['all_complete'] && $this->markCompleted($user); + + if (! $completed) { + OnboardingStatusUpdated::broadcastForAccount($account); + } + + return true; + } + + /** + * Sidebar residual banner payload, 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 residual(User $user): array|false + { + $account = $user->account; + + // Cheap gates first: this runs on every full Inertia load, so dismissed / + // completed accounts, members, and self-hosted must never pay for the + // step EXISTS queries in handle() — or even for a subscription lookup. + if (config('trypost.self_hosted') + || $account === null + || $account->hasFinishedOnboarding() + || ! $user->isAccountOwner() + || ! $account->hasAppAccess() + ) { + return false; + } + + $status = $this->handle($user); + + if (! $status['show_residual']) { + return false; + } + + return [ + 'completed' => collect(self::STEPS) + ->filter(fn (string $statusKey, string $step): bool => $status[$statusKey] + || in_array($step, $status['skipped_steps'], true)) + ->count(), + 'total' => self::TOTAL_STEPS, + ]; + } + + private function accountHasMcpConnection(?Account $account): bool + { + if ($account === null) { + return false; + } + + $tokens = AccessToken::query() + ->whereIn( + 'user_id', + User::query()->select('id')->where('account_id', $account->id), + ) + ->activeMcpOAuth() + ->with('workspace') + ->get(); + $users = User::query() + ->with('currentWorkspace') + ->whereIn('id', $tokens->pluck('user_id')->filter()->unique()) + ->get() + ->keyBy('id'); + + return $tokens->contains( + fn (AccessToken $token): bool => $token->isUsableMcpGrant( + $users->get($token->user_id), + ), + ); + } + + private function accountHasSocialConnection(?Account $account): bool + { + if ($account === null) { + return false; + } + + 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 + { + if ($account === null) { + return false; + } + + return Workspace::query() + ->where('account_id', $account->id) + ->whereHas('posts') + ->exists(); + } + + private function captureCompletedStep(User $user, Account $account, string $step, bool $completed): void + { + if (! $completed) { + return; + } + + // One capture per account/step — replaces the former PostHog job dedupe. + if (! Cache::add("onboarding:step:{$account->id}:{$step}", true)) { + return; + } + + $this->postHog->capture( + $user->id, + OnboardingEvent::StepCompleted->value, + ['step' => $step], + $account, + ); + } +} diff --git a/app/Actions/User/CreateUser.php b/app/Actions/User/CreateUser.php index ead5eca04..c03276e0d 100644 --- a/app/Actions/User/CreateUser.php +++ b/app/Actions/User/CreateUser.php @@ -12,6 +12,7 @@ use App\Models\User; use App\Services\PostHogService; use Illuminate\Support\Facades\DB; +use RuntimeException; class CreateUser { @@ -30,8 +31,17 @@ public static function execute(array $data, array $utmParameters = []): User ]; if (! $requiresCardForTrial) { + $trialDays = (int) config('cashier.trial_days'); + + if ($trialDays < 1) { + throw new RuntimeException( + 'CASHIER_TRIAL_DAYS must be at least 1 when REQUIRE_CARD_FOR_TRIAL=false, ' + .'otherwise new accounts would have no trial and no subscription access.' + ); + } + $accountAttributes['plan_id'] = Plan::where('slug', Slug::Workspace)->value('id'); - $accountAttributes['trial_ends_at'] = now()->addDays(config('cashier.trial_days')); + $accountAttributes['trial_ends_at'] = now()->addDays($trialDays); } $account = Account::create($accountAttributes); diff --git a/app/Enums/PostHog/OnboardingEvent.php b/app/Enums/PostHog/OnboardingEvent.php new file mode 100644 index 000000000..d09f96910 --- /dev/null +++ b/app/Enums/PostHog/OnboardingEvent.php @@ -0,0 +1,13 @@ + + */ + public static function connectableOptions(): array + { + return collect(self::cases()) + ->filter(fn (self $platform): bool => $platform->isConnectable()) + ->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. diff --git a/app/Enums/User/Goal.php b/app/Enums/User/Goal.php index ced8c382e..836e4bd13 100644 --- a/app/Enums/User/Goal.php +++ b/app/Enums/User/Goal.php @@ -13,9 +13,6 @@ enum Goal: string case GrowAudience = 'grow_audience'; case DriveSales = 'drive_sales'; case ManageClients = 'manage_clients'; - case TeamCollaboration = 'team_collaboration'; - case AutomateApi = 'automate_api'; - case TrackPerformance = 'track_performance'; case JustExploring = 'just_exploring'; case Other = 'other'; } diff --git a/app/Events/OnboardingStatusUpdated.php b/app/Events/OnboardingStatusUpdated.php new file mode 100644 index 000000000..3cc67fb51 --- /dev/null +++ b/app/Events/OnboardingStatusUpdated.php @@ -0,0 +1,157 @@ +workspaces()->pluck('id') as $workspaceId) { + static::dispatch((string) $workspaceId); + } + } + + /** + * Broadcast to every workspace on the account and sync progress for the actor. + * Use when a step is account-scoped (e.g. MCP OAuth). + * + * Sync/analytics run afterCommit so Cache/PostHog never outlive a rolled-back + * CreatePost (or similar) transaction. + */ + public static function dispatchForAccount(?Account $account, ?User $actor = null): void + { + if ($account === null || $account->hasFinishedOnboarding()) { + return; + } + + $accountId = $account->id; + $actorId = $actor?->id; + + DB::afterCommit(function () use ($accountId, $actorId): void { + $account = Account::query()->find($accountId); + + if ($account === null || $account->hasFinishedOnboarding()) { + return; + } + + static::syncAndBroadcast($account, $actorId); + }); + } + + /** + * Broadcast only while the workspace account still has active onboarding. + * Steps are account-scoped, so syncing the actor (or the account owner when + * there is no actor, e.g. webhook-driven connects) is enough to stamp. + * + * Sync/analytics run afterCommit so Cache/PostHog never outlive a rolled-back + * CreatePost (or similar) transaction. + */ + public static function dispatchForWorkspace(?string $workspaceId, ?User $actor = null): void + { + if (blank($workspaceId)) { + return; + } + + $account = Workspace::query()->find($workspaceId)?->account; + + if ($account === null || $account->hasFinishedOnboarding()) { + return; + } + + $accountId = $account->id; + $actorId = $actor?->id; + + DB::afterCommit(function () use ($accountId, $actorId): void { + $account = Account::query()->find($accountId); + + if ($account === null || $account->hasFinishedOnboarding()) { + return; + } + + static::syncAndBroadcast($account, $actorId); + }); + } + + /** + * Stamp completion for the actor — falling back to the account owner so + * actor-less flows (Telegram webhook, jobs) still complete — then notify + * every workspace channel exactly once. markCompleted already broadcasts + * when it stamps, so an un-stamped sync is the only case that fans out here. + */ + private static function syncAndBroadcast(Account $account, ?string $actorId): void + { + $actor = $actorId !== null + ? User::query()->with('account')->find($actorId) + : null; + + $syncTarget = $actor !== null && (string) $actor->account_id === (string) $account->id + ? $actor + : $account->owner; + + if ($syncTarget !== null) { + app(ResolveOnboardingStatus::class)->syncProgress($syncTarget); + $account->refresh(); + } + + if (! $account->hasFinishedOnboarding()) { + static::broadcastForAccount($account); + } + } + + public function broadcastAs(): string + { + return 'onboarding.status.updated'; + } + + /** + * @return array + */ + 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'; + } +} diff --git a/app/Exceptions/SocialAccount/NetworkAlreadyConnectedException.php b/app/Exceptions/SocialAccount/NetworkAlreadyConnectedException.php index af66de5ac..0a3b5c299 100644 --- a/app/Exceptions/SocialAccount/NetworkAlreadyConnectedException.php +++ b/app/Exceptions/SocialAccount/NetworkAlreadyConnectedException.php @@ -6,6 +6,7 @@ use App\Enums\SocialAccount\Platform; use RuntimeException; +use Throwable; class NetworkAlreadyConnectedException extends RuntimeException { @@ -13,4 +14,9 @@ public function __construct(public readonly Platform $platform) { parent::__construct("This workspace already has a {$platform->network()} account connected."); } + + public static function matches(Throwable $exception): bool + { + return $exception instanceof self; + } } diff --git a/app/Http/Controllers/Api/ApiKeyController.php b/app/Http/Controllers/Api/ApiKeyController.php index 3c164c3a7..48fe36c2f 100644 --- a/app/Http/Controllers/Api/ApiKeyController.php +++ b/app/Http/Controllers/Api/ApiKeyController.php @@ -4,6 +4,7 @@ namespace App\Http\Controllers\Api; +use App\Actions\ApiKey\CreateApiKey; use App\Http\Requests\Api\ApiKey\StoreApiKeyRequest; use App\Http\Resources\Api\ApiKeyResource; use App\Models\AccessToken; @@ -16,6 +17,8 @@ class ApiKeyController extends Controller { public function index(Request $request): AnonymousResourceCollection { + $this->authorize('manageTeam', $request->user()->currentWorkspace); + $tokens = AccessToken::where('user_id', $request->user()->id) ->where('workspace_id', $request->user()->currentWorkspace->id) ->where('revoked', false) @@ -28,24 +31,24 @@ public function index(Request $request): AnonymousResourceCollection public function store(StoreApiKeyRequest $request): JsonResponse { $workspace = $request->user()->currentWorkspace; - $validated = $request->validated(); - - $result = $request->user()->createToken($validated['name']); + $this->authorize('manageTeam', $workspace); - $token = AccessToken::find($result->token->id); - $token->forceFill([ - 'workspace_id' => $workspace->id, - 'expires_at' => $validated['expires_at'] ?? null, - ])->saveQuietly(); + $created = CreateApiKey::execute( + $request->user(), + $workspace, + $request->validated(), + ); return response()->json([ - 'token' => new ApiKeyResource($token->refresh()), - 'plain_token' => $result->accessToken, + 'token' => new ApiKeyResource($created['token']), + 'plain_token' => $created['plain_token'], ], Response::HTTP_CREATED); } public function destroy(Request $request, string $tokenId): JsonResponse { + $this->authorize('manageTeam', $request->user()->currentWorkspace); + $token = AccessToken::where('id', $tokenId) ->where('user_id', $request->user()->id) ->where('workspace_id', $request->user()->currentWorkspace->id) diff --git a/app/Http/Controllers/App/AnalyticsController.php b/app/Http/Controllers/App/AnalyticsController.php index 919486bf8..9cdca33dc 100644 --- a/app/Http/Controllers/App/AnalyticsController.php +++ b/app/Http/Controllers/App/AnalyticsController.php @@ -20,8 +20,8 @@ use Illuminate\Http\Request; use Illuminate\Support\Carbon; use Inertia\Inertia; -use Inertia\Response; -use Symfony\Component\HttpFoundation\Response as HttpResponse; +use Inertia\Response as InertiaResponse; +use Symfony\Component\HttpFoundation\Response; class AnalyticsController extends Controller { @@ -38,7 +38,7 @@ class AnalyticsController extends Controller Platform::Telegram, ]; - public function index(Request $request): Response + public function index(Request $request): InertiaResponse { $workspace = $request->user()->currentWorkspace; @@ -66,7 +66,7 @@ public function show(Request $request, SocialAccount $account): JsonResponse $workspace = $request->user()->currentWorkspace; if ($account->workspace_id !== $workspace->id) { - abort(HttpResponse::HTTP_FORBIDDEN); + abort(Response::HTTP_FORBIDDEN); } $since = $request->has('since') ? Carbon::parse($request->input('since')) : null; diff --git a/app/Http/Controllers/App/ApiKeyController.php b/app/Http/Controllers/App/ApiKeyController.php index 8d0369db1..01f4f8077 100644 --- a/app/Http/Controllers/App/ApiKeyController.php +++ b/app/Http/Controllers/App/ApiKeyController.php @@ -4,15 +4,18 @@ namespace App\Http\Controllers\App; +use App\Actions\ApiKey\CreateApiKey; +use App\Http\Requests\App\ApiKey\StoreApiKeyRequest; use App\Models\AccessToken; use Illuminate\Http\RedirectResponse; use Illuminate\Http\Request; use Inertia\Inertia; -use Inertia\Response; +use Inertia\Response as InertiaResponse; +use Symfony\Component\HttpFoundation\Response; class ApiKeyController extends Controller { - public function index(Request $request): Response|RedirectResponse + public function index(Request $request): InertiaResponse|RedirectResponse { $workspace = $request->user()->currentWorkspace; @@ -41,7 +44,7 @@ public function index(Request $request): Response|RedirectResponse ]); } - public function store(Request $request): RedirectResponse + public function store(StoreApiKeyRequest $request): RedirectResponse { $workspace = $request->user()->currentWorkspace; @@ -51,21 +54,15 @@ public function store(Request $request): RedirectResponse $this->authorize('manageTeam', $workspace); - $validated = $request->validate([ - 'name' => ['required', 'string', 'max:255'], - 'expires_at' => ['nullable', 'date', 'after:today'], - ]); - - $result = $request->user()->createToken($validated['name']); - $accessToken = AccessToken::find($result->token->id); - $accessToken->forceFill([ - 'workspace_id' => $workspace->id, - 'expires_at' => $validated['expires_at'] ?? null, - ])->saveQuietly(); + $created = CreateApiKey::execute( + $request->user(), + $workspace, + $request->validated(), + ); return back() ->with('flash.success', __('settings.api_keys.flash.created')) - ->with('flash.plainToken', $result->accessToken); + ->with('flash.plainToken', $created['plain_token']); } public function destroy(Request $request, string $tokenId): RedirectResponse @@ -84,7 +81,7 @@ public function destroy(Request $request, string $tokenId): RedirectResponse ->first(); if (! $token) { - abort(404); + abort(Response::HTTP_NOT_FOUND); } $token->forceFill(['revoked' => true])->saveQuietly(); diff --git a/app/Http/Controllers/App/AssetController.php b/app/Http/Controllers/App/AssetController.php index 2d8bf40ef..ba6ee85b2 100644 --- a/app/Http/Controllers/App/AssetController.php +++ b/app/Http/Controllers/App/AssetController.php @@ -19,13 +19,13 @@ use Illuminate\Support\Facades\Storage; use Illuminate\Support\Str; use Inertia\Inertia; -use Inertia\Response; +use Inertia\Response as InertiaResponse; use RuntimeException; -use Symfony\Component\HttpFoundation\Response as SymfonyResponse; +use Symfony\Component\HttpFoundation\Response; class AssetController extends Controller { - public function index(Request $request): Response|RedirectResponse + public function index(Request $request): InertiaResponse|RedirectResponse { $workspace = $request->user()->currentWorkspace; @@ -104,11 +104,11 @@ public function storeFromUrl(StoreAssetFromUrlRequest $request, UnsplashService try { $response = $safeHttp->guardedRequest($url)->timeout(30)->get($url); } catch (RuntimeException) { - abort(SymfonyResponse::HTTP_BAD_REQUEST, 'Failed to download image from URL'); + abort(Response::HTTP_BAD_REQUEST, 'Failed to download image from URL'); } if ($response->failed()) { - abort(SymfonyResponse::HTTP_BAD_REQUEST, 'Failed to download image from URL'); + abort(Response::HTTP_BAD_REQUEST, 'Failed to download image from URL'); } $mimeType = $response->header('Content-Type', 'image/jpeg'); @@ -156,7 +156,7 @@ public function destroy(Request $request, Media $media): RedirectResponse $this->authorize('createPost', $workspace); if ($media->mediable_type !== $workspace->getMorphClass() || $media->mediable_id !== $workspace->id) { - abort(SymfonyResponse::HTTP_FORBIDDEN); + abort(Response::HTTP_FORBIDDEN); } $media->delete(); diff --git a/app/Http/Controllers/App/AutomationController.php b/app/Http/Controllers/App/AutomationController.php index ee1a9e608..b8e831259 100644 --- a/app/Http/Controllers/App/AutomationController.php +++ b/app/Http/Controllers/App/AutomationController.php @@ -39,15 +39,14 @@ use App\Services\Brand\SafeHttpFetcher; use Illuminate\Http\JsonResponse; use Illuminate\Http\RedirectResponse; -use Illuminate\Http\Response as HttpResponse; use Inertia\Inertia; -use Inertia\Response; +use Inertia\Response as InertiaResponse; use RuntimeException; -use Symfony\Component\HttpFoundation\Response as SymfonyResponse; +use Symfony\Component\HttpFoundation\Response; class AutomationController extends Controller { - public function index(ListAutomations $list): Response + public function index(ListAutomations $list): InertiaResponse { $this->authorize('viewAny', Automation::class); @@ -85,7 +84,7 @@ public function show(Automation $automation): RedirectResponse return redirect()->route("app.automations.{$tab}", $automation->id); } - public function workflow(Automation $automation, GetAutomationEditorData $editorData): Response + public function workflow(Automation $automation, GetAutomationEditorData $editorData): InertiaResponse { $this->authorize('update', $automation); @@ -104,7 +103,7 @@ public function workflow(Automation $automation, GetAutomationEditorData $editor ]); } - public function invocations(Automation $automation, GetAutomationInvocations $invocations): Response + public function invocations(Automation $automation, GetAutomationInvocations $invocations): InertiaResponse { $this->authorize('view', $automation); @@ -123,7 +122,7 @@ public function invocations(Automation $automation, GetAutomationInvocations $in ]); } - public function settings(Automation $automation): Response + public function settings(Automation $automation): InertiaResponse { $this->authorize('view', $automation); @@ -132,7 +131,7 @@ public function settings(Automation $automation): Response ]); } - public function metrics(Automation $automation, GetAutomationMetrics $metrics): Response + public function metrics(Automation $automation, GetAutomationMetrics $metrics): InertiaResponse { $this->authorize('view', $automation); @@ -202,9 +201,9 @@ public function retryRun( RetryRunFromNode $retry, Automation $automation, AutomationRun $run, - ): HttpResponse { + ): Response { $this->authorize('update', $automation); - abort_unless($run->automation_id === $automation->id, 404); + abort_unless($run->automation_id === $automation->id, Response::HTTP_NOT_FOUND); $nodeId = $request->validated('node_id') ?? $run->current_node_id; $retry($run, $nodeId); @@ -241,13 +240,13 @@ public function inspectFeed( try { $response = $safeHttp->get($feedUrl); } catch (RuntimeException) { - return response()->json(['message' => __('automations.errors.fetch_rss_request_failed')], SymfonyResponse::HTTP_UNPROCESSABLE_ENTITY); + return response()->json(['message' => __('automations.errors.fetch_rss_request_failed')], Response::HTTP_UNPROCESSABLE_ENTITY); } $items = $parser->parse($response->body()); if ($items === null) { - return response()->json(['message' => __('automations.errors.fetch_rss_malformed')], SymfonyResponse::HTTP_UNPROCESSABLE_ENTITY); + return response()->json(['message' => __('automations.errors.fetch_rss_malformed')], Response::HTTP_UNPROCESSABLE_ENTITY); } return new FeedInspectionResource($items[0] ?? []); @@ -256,7 +255,7 @@ public function inspectFeed( public function showRun(Automation $automation, AutomationRun $run): JsonResponse { $this->authorize('view', $automation); - abort_unless($run->automation_id === $automation->id, 404); + abort_unless($run->automation_id === $automation->id, Response::HTTP_NOT_FOUND); // Aggregate the node runs of every branch forked by a fan-out so the test // panel shows the whole execution, not just the branch the root walked. diff --git a/app/Http/Controllers/App/BillingController.php b/app/Http/Controllers/App/BillingController.php index e85d36c5d..2c2667f66 100644 --- a/app/Http/Controllers/App/BillingController.php +++ b/app/Http/Controllers/App/BillingController.php @@ -4,83 +4,75 @@ namespace App\Http\Controllers\App; +use App\Actions\Onboarding\ResolveOnboardingStatus; use App\Models\Account; +use App\Support\Billing\CheckoutPurchaseTracker; use Illuminate\Http\RedirectResponse; use Illuminate\Http\Request; -use Illuminate\Support\Facades\Cache; use Illuminate\Support\Facades\Gate; use Inertia\Inertia; -use Inertia\Response; -use Symfony\Component\HttpFoundation\Response as SymfonyResponse; -use Throwable; +use Inertia\Response as InertiaResponse; +use Symfony\Component\HttpFoundation\Response; class BillingController extends Controller { + public function __construct( + private readonly CheckoutPurchaseTracker $checkoutPurchaseTracker, + private readonly ResolveOnboardingStatus $resolveOnboardingStatus, + ) {} + public function subscribe(): RedirectResponse { - return redirect()->route('app.onboarding'); + return redirect()->route('app.welcome.persona'); } - public function processing(Request $request): Response|RedirectResponse + public function processing(Request $request): InertiaResponse|RedirectResponse { if (config('trypost.self_hosted')) { return redirect()->route('app.calendar'); } - $account = $request->user()->account; + $user = $request->user(); + $account = $user->account; $sessionId = $request->query('session_id'); - // 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 !== '' - && Cache::add("checkout_tracked:{$sessionId}", true, now()->addDay()); - - return Inertia::render('billing/Processing', [ - 'subscriptionActive' => $account && $account->subscribed(Account::SUBSCRIPTION_NAME), - 'fromCheckout' => $fromCheckout, - 'persona' => $request->user()->persona?->value, - 'conversion' => $fromCheckout && $account?->stripe_id - ? fn () => $this->buildConversionData($account, $sessionId) - : null, - ]); - } - - /** - * @return array{value: float, currency: string, transaction_id: string}|null - */ - private function buildConversionData(Account $account, string $sessionId): ?array - { - try { - $session = $account->stripe()->checkout->sessions->retrieve( - $sessionId, - ['expand' => ['line_items.data.price']], - ); - } catch (Throwable) { - return null; - } - - if (data_get($session, 'customer') !== $account->stripe_id) { - return null; + // Verified purchase conversion for ad/analytics (PostHog + GTM). + // Consumed on first resolve — the client keeps it across polls. + $conversion = null; + $conversionResolved = true; + + if ( + $account !== null + && $user->isAccountOwner() + && is_string($sessionId) + && $sessionId !== '' + ) { + $resolved = $this->checkoutPurchaseTracker->resolve($account, $sessionId); + $conversion = data_get($resolved, 'conversion'); + $conversionResolved = (bool) data_get($resolved, 'conversionResolved', true); } - $unitAmount = data_get($session, 'line_items.data.0.price.unit_amount'); - $currency = data_get($session, 'line_items.data.0.price.currency'); - $transactionId = data_get($session, 'id'); + $subscriptionActive = $account !== null + && $account->subscribed(Account::SUBSCRIPTION_NAME); + $redirectToOnboarding = $account !== null + && $user->isAccountOwner() + && ! $account->hasFinishedOnboarding(); - if (! is_int($unitAmount) || ! is_string($currency) || ! is_string($transactionId)) { - return null; + if ($subscriptionActive && $redirectToOnboarding) { + $status = $this->resolveOnboardingStatus->syncProgress($user); + $redirectToOnboarding = (bool) data_get($status, 'show_residual', true); } - return [ - 'value' => $unitAmount / 100, - 'currency' => strtoupper($currency), - 'transaction_id' => $transactionId, - ]; + return Inertia::render('billing/Processing', [ + 'subscriptionActive' => $subscriptionActive, + 'redirectToOnboarding' => $redirectToOnboarding, + 'persona' => $user->persona?->value, + 'conversion' => $conversion, + 'conversionResolved' => $conversionResolved, + ]); } - public function index(Request $request): Response|RedirectResponse + public function index(Request $request): InertiaResponse|RedirectResponse { if (config('trypost.self_hosted')) { return redirect()->route('app.calendar'); @@ -88,7 +80,7 @@ public function index(Request $request): Response|RedirectResponse $account = $request->user()->account; - abort_unless($request->user()->isAccountOwner(), SymfonyResponse::HTTP_FORBIDDEN); + abort_unless($request->user()->isAccountOwner(), Response::HTTP_FORBIDDEN); $subscription = $account->subscription(Account::SUBSCRIPTION_NAME); @@ -121,17 +113,17 @@ public function swapToYearly(Request $request): RedirectResponse $account = $request->user()->account; - abort_unless($request->user()->isAccountOwner(), SymfonyResponse::HTTP_FORBIDDEN); - abort_unless($account->subscribed(Account::SUBSCRIPTION_NAME), SymfonyResponse::HTTP_UNPROCESSABLE_ENTITY, 'No active subscription'); + abort_unless($request->user()->isAccountOwner(), Response::HTTP_FORBIDDEN); + abort_unless($account->subscribed(Account::SUBSCRIPTION_NAME), Response::HTTP_UNPROCESSABLE_ENTITY, 'No active subscription'); $plan = $account->plan; $yearlyPriceId = $plan?->stripe_yearly_price_id; - abort_if($yearlyPriceId === null, SymfonyResponse::HTTP_UNPROCESSABLE_ENTITY, 'No annual price configured'); + abort_if($yearlyPriceId === null, Response::HTTP_UNPROCESSABLE_ENTITY, 'No annual price configured'); $subscription = $account->subscription(Account::SUBSCRIPTION_NAME); - abort_if($subscription === null, SymfonyResponse::HTTP_UNPROCESSABLE_ENTITY, 'No active subscription'); + abort_if($subscription === null, Response::HTTP_UNPROCESSABLE_ENTITY, 'No active subscription'); if ($subscription->stripe_price === $yearlyPriceId) { return redirect()->route('app.billing.index'); @@ -157,7 +149,7 @@ public function portal(Request $request): RedirectResponse $account = $request->user()->account; - abort_unless($request->user()->isAccountOwner(), SymfonyResponse::HTTP_FORBIDDEN); + abort_unless($request->user()->isAccountOwner(), Response::HTTP_FORBIDDEN); return $account->redirectToBillingPortal( route('app.billing.index') diff --git a/app/Http/Controllers/App/McpSettingsController.php b/app/Http/Controllers/App/McpSettingsController.php new file mode 100644 index 000000000..2c1afe5fd --- /dev/null +++ b/app/Http/Controllers/App/McpSettingsController.php @@ -0,0 +1,41 @@ +user(); + $workspace = $user->currentWorkspace; + + $this->authorize('createPost', $workspace); + + return Inertia::render('settings/workspace/Mcp', [ + 'mcpUrl' => route('mcp.trypost'), + 'connectedClients' => ListConnectedMcpClients::forAccount($workspace->account_id, $user), + ]); + } + + public function disconnect(Request $request, string $client): RedirectResponse + { + $user = $request->user(); + + $this->authorize('createPost', $user->currentWorkspace); + + if (! RevokeMcpOAuthGrants::forUserClient($user, $client)) { + return back(); + } + + return back()->with('flash.success', __('mcp.disconnected')); + } +} diff --git a/app/Http/Controllers/App/OnboardingController.php b/app/Http/Controllers/App/OnboardingController.php index a9628a224..ca56c0675 100644 --- a/app/Http/Controllers/App/OnboardingController.php +++ b/app/Http/Controllers/App/OnboardingController.php @@ -4,254 +4,128 @@ namespace App\Http\Controllers\App; -use App\Actions\Billing\StartSubscriptionCheckout; -use App\Enums\Plan\Slug; +use App\Actions\Onboarding\ResolveOnboardingStatus; +use App\Enums\PostHog\OnboardingEvent; use App\Enums\SocialAccount\Platform as SocialPlatform; -use App\Enums\User\Goal; -use App\Enums\User\Persona; -use App\Enums\User\ReferralSource; -use App\Http\Requests\App\Onboarding\StoreOnboardingGoalsRequest; -use App\Http\Requests\App\Onboarding\StoreOnboardingReferralSourceRequest; -use App\Http\Requests\App\Onboarding\StoreOnboardingRequest; use App\Http\Resources\App\SocialAccountResource; -use App\Models\Account; -use App\Models\Plan; use App\Services\PostHogService; use Illuminate\Http\RedirectResponse; use Illuminate\Http\Request; use Inertia\Inertia; -use Inertia\Response; -use Symfony\Component\HttpFoundation\Response as SymfonyResponse; +use Inertia\Response as InertiaResponse; +use Symfony\Component\HttpFoundation\Response; class OnboardingController extends Controller { - public function index(Request $request): Response|RedirectResponse - { - if (config('trypost.self_hosted')) { - return redirect()->route('app.calendar'); - } - - $user = $request->user(); - - if ($user->account?->subscribed(Account::SUBSCRIPTION_NAME)) { - return redirect()->route('app.calendar'); - } - - return Inertia::render('onboarding/Index', [ - 'personas' => array_map(fn (Persona $persona): string => $persona->value, Persona::cases()), - 'selected' => $user->persona?->value, - ]); - } + public function __construct( + private readonly ResolveOnboardingStatus $resolveOnboardingStatus, + private readonly PostHogService $postHog, + ) {} - public function store(StoreOnboardingRequest $request, PostHogService $postHog): RedirectResponse + public function index(Request $request): InertiaResponse|RedirectResponse { - if (config('trypost.self_hosted')) { - return redirect()->route('app.calendar'); + if ($redirect = $this->redirectIfSelfHosted()) { + return $redirect; } $user = $request->user(); + $workspace = $user->currentWorkspace; + // Capture before syncProgress — same-request auto-stamp still shows the ready state. + $wasAlreadyComplete = $user->account?->onboarding_completed_at !== null; + $status = $this->resolveOnboardingStatus->syncProgress($user); - if ($user->account?->subscribed(Account::SUBSCRIPTION_NAME)) { + // Legacy dismiss (deploy backfill) is terminal, including Echo partial reloads. + if ($status['dismissed_at'] !== null) { return redirect()->route('app.calendar'); } - $persona = (string) $request->validated('persona'); - - $user->update(['persona' => $persona]); - - $postHog->identify($user->id, [ - 'persona' => $persona, - ]); - - return redirect()->route('app.onboarding.goals'); - } + $isPartial = $request->hasHeader('X-Inertia-Partial-Component'); - public function goals(Request $request): Response|RedirectResponse - { - if (config('trypost.self_hosted')) { + // Full revisit after completion → calendar. Partials (Echo) and the same + // request that just stamped still render the ready state. + if ($wasAlreadyComplete && ! $isPartial) { return redirect()->route('app.calendar'); } - $user = $request->user(); - - if ($user->account?->subscribed(Account::SUBSCRIPTION_NAME)) { - return redirect()->route('app.calendar'); + if ( + ! $isPartial + && $status['completed_at'] === null + && $status['dismissed_at'] === null + && $user->isAccountOwner() + && $user->account !== null + ) { + $this->postHog->capture( + $user->id, + OnboardingEvent::Viewed->value, + account: $user->account, + ); } - if (! $user->persona) { - return redirect()->route('app.onboarding'); - } + $accounts = SocialAccountResource::collection( + $workspace->socialAccounts()->orderBy('id')->get(), + )->resolve(); - return Inertia::render('onboarding/Goals', [ - 'goals' => array_map(fn (Goal $goal): string => $goal->value, Goal::cases()), - 'selected' => $user->goals ?? [], + return Inertia::render('onboarding/Index', [ + 'status' => $status, + 'canSkipSteps' => $user->isAccountOwner(), + 'canManageAccounts' => $user->can('manageAccounts', $workspace), + 'canCreatePost' => $user->can('createPost', $workspace), + 'mcpUrl' => route('mcp.trypost'), + 'samplePrompt' => __('onboarding.first_post.sample_prompt'), + 'platforms' => SocialPlatform::connectableOptions(), + 'accounts' => $accounts, ]); } - public function storeGoals(StoreOnboardingGoalsRequest $request, PostHogService $postHog): RedirectResponse + public function skipMcp(Request $request): RedirectResponse { - if (config('trypost.self_hosted')) { - return redirect()->route('app.calendar'); - } - - $user = $request->user(); - - if ($user->account?->subscribed(Account::SUBSCRIPTION_NAME)) { - return redirect()->route('app.calendar'); - } - - if (! $user->persona) { - return redirect()->route('app.onboarding'); + if ($redirect = $this->redirectIfSelfHosted()) { + return $redirect; } - $goals = array_values($request->validated('goals')); - - $user->update(['goals' => $goals]); + abort_unless($request->user()->isAccountOwner(), Response::HTTP_FORBIDDEN); - $postHog->identify($user->id, [ - 'goals' => $goals, - ]); + $this->resolveOnboardingStatus->skipMcp($request->user()); - return redirect()->route('app.onboarding.referral-source'); + return back(); } - public function referralSource(Request $request): Response|RedirectResponse + public function complete(Request $request): RedirectResponse { - if (config('trypost.self_hosted')) { - return redirect()->route('app.calendar'); - } - - $user = $request->user(); - - if ($user->account?->subscribed(Account::SUBSCRIPTION_NAME)) { - return redirect()->route('app.calendar'); - } - - if (! $user->persona) { - return redirect()->route('app.onboarding'); - } - - if (! $user->goals) { - return redirect()->route('app.onboarding.goals'); - } - - return Inertia::render('onboarding/ReferralSource', [ - 'sources' => array_map(fn (ReferralSource $source): string => $source->value, ReferralSource::cases()), - 'selected' => $user->referral_source?->value, - ]); - } - - public function storeReferralSource(StoreOnboardingReferralSourceRequest $request, PostHogService $postHog): RedirectResponse - { - if (config('trypost.self_hosted')) { - return redirect()->route('app.calendar'); + if ($redirect = $this->redirectIfSelfHosted()) { + return $redirect; } $user = $request->user(); + $account = $user->account; - if ($user->account?->subscribed(Account::SUBSCRIPTION_NAME)) { + // Already stamped (e.g. observer / syncProgress auto-complete) — just leave. + if ($account?->onboarding_completed_at !== null) { return redirect()->route('app.calendar'); } - if (! $user->persona) { - return redirect()->route('app.onboarding'); - } - - if (! $user->goals) { - return redirect()->route('app.onboarding.goals'); - } - - $referralSource = (string) $request->validated('referral_source'); - - $user->update(['referral_source' => $referralSource]); - - $postHog->identify($user->id, [ - 'referral_source' => $referralSource, - ]); - - return redirect()->route('app.onboarding.connect'); - } - - public function connect(Request $request): Response|RedirectResponse - { - if (config('trypost.self_hosted')) { + // Legacy dismiss stays terminal: never let Continue stamp after backfill. + if ($account?->onboarding_dismissed_at !== null) { return redirect()->route('app.calendar'); } - $user = $request->user(); - - if ($user->account?->subscribed(Account::SUBSCRIPTION_NAME)) { - return redirect()->route('app.calendar'); - } + $status = $this->resolveOnboardingStatus->handle($user); - if (! $user->persona) { + if (! $status['all_complete']) { return redirect()->route('app.onboarding'); } - if (! $user->goals) { - return redirect()->route('app.onboarding.goals'); - } - - if (! $user->referral_source) { - return redirect()->route('app.onboarding.referral-source'); - } - - $workspace = $user->currentWorkspace; - - if (! $workspace) { - return redirect()->route('app.workspaces.create'); - } - - $accounts = $workspace->socialAccounts()->orderBy('id')->get(); - - $platforms = collect(SocialPlatform::cases()) - ->filter(fn (SocialPlatform $platform): bool => $platform->isConnectable()) - ->map(fn (SocialPlatform $platform): array => [ - 'value' => $platform->value, - 'label' => $platform->label(), - 'color' => $platform->color(), - 'network' => $platform->network(), - ])->values(); + $this->resolveOnboardingStatus->markCompleted($user); - $plan = Plan::where('slug', Slug::Workspace)->firstOrFail(); - - return Inertia::render('onboarding/Connect', [ - 'platforms' => $platforms, - 'accounts' => SocialAccountResource::collection($accounts)->resolve(), - 'plan' => [ - 'name' => $plan->name, - 'interval' => 'monthly', - ], - ]); + return redirect()->route('app.calendar'); } - public function checkout(Request $request, StartSubscriptionCheckout $checkout): SymfonyResponse|RedirectResponse + private function redirectIfSelfHosted(): ?RedirectResponse { - if (config('trypost.self_hosted')) { - return redirect()->route('app.calendar'); + if (! config('trypost.self_hosted')) { + return null; } - $user = $request->user(); - $account = $user->account; - - if ($account?->subscribed(Account::SUBSCRIPTION_NAME)) { - return redirect()->route('app.calendar'); - } - - $workspace = $user->currentWorkspace; - - if (! $workspace || ! $workspace->socialAccounts()->exists()) { - return redirect()->route('app.onboarding.connect') - ->with('flash.banner', __('onboarding.connect.must_connect')) - ->with('flash.bannerStyle', 'danger'); - } - - $plan = Plan::where('slug', Slug::Workspace)->firstOrFail(); - - return $checkout->redirect( - $account, - (string) $plan->stripe_monthly_price_id, - route('app.onboarding.connect'), - ); + return redirect()->route('app.calendar'); } } diff --git a/app/Http/Controllers/App/PostController.php b/app/Http/Controllers/App/PostController.php index c1ebc395b..a9a4d5d2b 100644 --- a/app/Http/Controllers/App/PostController.php +++ b/app/Http/Controllers/App/PostController.php @@ -31,11 +31,12 @@ use Illuminate\Http\RedirectResponse; use Illuminate\Http\Request; use Inertia\Inertia; -use Inertia\Response; +use Inertia\Response as InertiaResponse; +use Symfony\Component\HttpFoundation\Response; class PostController extends Controller { - public function index(Request $request, ?string $status = null): Response|RedirectResponse + public function index(Request $request, ?string $status = null): InertiaResponse|RedirectResponse { $workspace = $request->user()->currentWorkspace; @@ -83,7 +84,7 @@ public function index(Request $request, ?string $status = null): Response|Redire ]); } - public function calendar(Request $request): Response|RedirectResponse + public function calendar(Request $request): InertiaResponse|RedirectResponse { $workspace = $request->user()->currentWorkspace; @@ -139,7 +140,7 @@ public function calendar(Request $request): Response|RedirectResponse ]); } - public function create(Request $request): Response + public function create(Request $request): InertiaResponse { $workspace = $request->user()->currentWorkspace; @@ -166,7 +167,7 @@ public function create(Request $request): Response ]); } - public function store(StorePostRequest $request): RedirectResponse|\Symfony\Component\HttpFoundation\Response + public function store(StorePostRequest $request): RedirectResponse|Response { $workspace = $request->user()->currentWorkspace; @@ -201,13 +202,13 @@ public function platformMetrics(Request $request, Post $post, PostPlatform $post $this->authorize('view', $post); if ($postPlatform->post_id !== $post->id) { - abort(404); + abort(Response::HTTP_NOT_FOUND); } return response()->json(app(PostMetricsFetcher::class)->forPlatform($postPlatform)); } - public function show(Request $request, Post $post): Response|RedirectResponse + public function show(Request $request, Post $post): InertiaResponse|RedirectResponse { $workspace = $request->user()->currentWorkspace; @@ -229,7 +230,7 @@ public function show(Request $request, Post $post): Response|RedirectResponse ]); } - public function edit(Request $request, Post $post): Response|RedirectResponse + public function edit(Request $request, Post $post): InertiaResponse|RedirectResponse { $workspace = $request->user()->currentWorkspace; diff --git a/app/Http/Controllers/App/Settings/AccountController.php b/app/Http/Controllers/App/Settings/AccountController.php index 144990c83..be9cf0d83 100644 --- a/app/Http/Controllers/App/Settings/AccountController.php +++ b/app/Http/Controllers/App/Settings/AccountController.php @@ -8,14 +8,14 @@ use Illuminate\Http\RedirectResponse; use Illuminate\Http\Request; use Inertia\Inertia; -use Inertia\Response; -use Symfony\Component\HttpFoundation\Response as SymfonyResponse; +use Inertia\Response as InertiaResponse; +use Symfony\Component\HttpFoundation\Response; class AccountController extends Controller { - public function edit(Request $request): Response + public function edit(Request $request): InertiaResponse { - abort_unless($request->user()->isAccountOwner(), SymfonyResponse::HTTP_FORBIDDEN); + abort_unless($request->user()->isAccountOwner(), Response::HTTP_FORBIDDEN); $account = $request->user()->account; @@ -31,7 +31,7 @@ public function edit(Request $request): Response public function update(Request $request): RedirectResponse { - abort_unless($request->user()->isAccountOwner(), SymfonyResponse::HTTP_FORBIDDEN); + abort_unless($request->user()->isAccountOwner(), Response::HTTP_FORBIDDEN); $isSelfHosted = config('trypost.self_hosted'); diff --git a/app/Http/Controllers/App/Settings/UsageController.php b/app/Http/Controllers/App/Settings/UsageController.php index 7940ef58a..b27d6ed45 100644 --- a/app/Http/Controllers/App/Settings/UsageController.php +++ b/app/Http/Controllers/App/Settings/UsageController.php @@ -9,18 +9,18 @@ use Illuminate\Http\RedirectResponse; use Illuminate\Http\Request; use Inertia\Inertia; -use Inertia\Response; -use Symfony\Component\HttpFoundation\Response as SymfonyResponse; +use Inertia\Response as InertiaResponse; +use Symfony\Component\HttpFoundation\Response; class UsageController extends Controller { - public function index(Request $request): Response|RedirectResponse + public function index(Request $request): InertiaResponse|RedirectResponse { if (config('trypost.self_hosted')) { return redirect()->route('app.calendar'); } - abort_unless($request->user()->isAccountOwner(), SymfonyResponse::HTTP_FORBIDDEN); + abort_unless($request->user()->isAccountOwner(), Response::HTTP_FORBIDDEN); $account = $request->user()->account; diff --git a/app/Http/Controllers/App/WelcomeController.php b/app/Http/Controllers/App/WelcomeController.php new file mode 100644 index 000000000..3b93ae06f --- /dev/null +++ b/app/Http/Controllers/App/WelcomeController.php @@ -0,0 +1,231 @@ +redirectIfUnavailable($request)) { + return $redirect; + } + + return Inertia::render('welcome/Persona', [ + 'personas' => array_map(fn (Persona $persona): string => $persona->value, Persona::cases()), + 'selected' => $request->user()->persona?->value, + ]); + } + + public function storePersona(StoreWelcomePersonaRequest $request, PostHogService $postHog): RedirectResponse + { + if ($redirect = $this->redirectIfUnavailable($request)) { + return $redirect; + } + + $user = $request->user(); + $persona = (string) $request->validated('persona'); + + $user->update(['persona' => $persona]); + + $postHog->identify($user->id, [ + 'persona' => $persona, + ]); + $postHog->capture( + $user->id, + WelcomeEvent::PersonaSaved->value, + ['persona' => $persona], + $user->account, + ); + + return redirect()->route('app.welcome.goals'); + } + + public function goals(Request $request): InertiaResponse|RedirectResponse + { + if ($redirect = $this->redirectIfStepIncomplete($request)) { + return $redirect; + } + + $user = $request->user(); + + return Inertia::render('welcome/Goals', [ + 'goals' => array_map(fn (Goal $goal): string => $goal->value, Goal::cases()), + 'selected' => $user->goals ?? [], + ]); + } + + public function storeGoals(StoreWelcomeGoalsRequest $request, PostHogService $postHog): RedirectResponse + { + if ($redirect = $this->redirectIfStepIncomplete($request)) { + return $redirect; + } + + $user = $request->user(); + $goals = array_values($request->validated('goals')); + + $user->update(['goals' => $goals]); + + $postHog->identify($user->id, [ + 'goals' => $goals, + ]); + $postHog->capture( + $user->id, + WelcomeEvent::GoalsSaved->value, + ['goals' => $goals], + $user->account, + ); + + return redirect()->route('app.welcome.referral-source'); + } + + public function referralSource(Request $request): InertiaResponse|RedirectResponse + { + if ($redirect = $this->redirectIfStepIncomplete($request, requireGoals: true)) { + return $redirect; + } + + $user = $request->user(); + $plan = Plan::where('slug', Slug::Workspace)->firstOrFail(); + + return Inertia::render('welcome/ReferralSource', [ + 'sources' => array_map(fn (ReferralSource $source): string => $source->value, ReferralSource::cases()), + 'selected' => $user->referral_source?->value, + 'canCheckout' => $user->isAccountOwner(), + 'plan' => [ + 'name' => $plan->name, + 'interval' => 'monthly', + ], + ]); + } + + public function storeReferralSource( + StoreWelcomeReferralSourceRequest $request, + StartSubscriptionCheckout $checkout, + PostHogService $postHog, + ): Response|RedirectResponse { + if ($redirect = $this->redirectIfStepIncomplete($request, requireGoals: true)) { + return $redirect; + } + + $user = $request->user(); + + abort_unless($user->isAccountOwner(), Response::HTTP_FORBIDDEN); + + $referralSource = (string) $request->validated('referral_source'); + + $user->update(['referral_source' => $referralSource]); + + $postHog->identify($user->id, [ + 'referral_source' => $referralSource, + ]); + $postHog->capture( + $user->id, + WelcomeEvent::ReferralSaved->value, + ['referral_source' => $referralSource], + $user->account, + ); + + return $this->startCheckout($request, $checkout); + } + + private function startCheckout( + Request $request, + StartSubscriptionCheckout $checkout, + ): Response|RedirectResponse { + $user = $request->user(); + $plan = Plan::where('slug', Slug::Workspace)->firstOrFail(); + $priceId = $plan->stripe_monthly_price_id; + + abort_if($priceId === null, Response::HTTP_INTERNAL_SERVER_ERROR, 'Monthly price is not configured.'); + + return $checkout->redirect( + $user->account, + $priceId, + route('app.welcome.referral-source'), + ); + } + + public function subscriptionRequired(Request $request): InertiaResponse|RedirectResponse + { + $user = $request->user(); + + if ($user->account?->hasAppAccess()) { + $status = $this->resolveOnboardingStatus->handle($user); + + return redirect()->route($status['show_residual'] ? 'app.onboarding' : 'app.calendar'); + } + + if ($user->isAccountOwner()) { + return redirect()->route('app.welcome.persona'); + } + + return Inertia::render('welcome/SubscriptionRequired', [ + 'ownerName' => $user->account?->owner?->name, + ]); + } + + private function redirectIfStepIncomplete(Request $request, bool $requireGoals = false): ?RedirectResponse + { + if ($redirect = $this->redirectIfUnavailable($request)) { + return $redirect; + } + + $user = $request->user(); + + if (! $user->persona) { + return redirect()->route('app.welcome.persona'); + } + + if ($requireGoals && ! $user->goals) { + return redirect()->route('app.welcome.goals'); + } + + return null; + } + + private function redirectIfUnavailable(Request $request): ?RedirectResponse + { + $user = $request->user(); + + // Match EnsureAccountReady — generic-trial (no-card) users already have + // app access and must not be sent through Stripe checkout again. + // Self-hosted always has app access, so welcome/checkout is skipped too. + if ($user->account?->hasAppAccess()) { + $status = $this->resolveOnboardingStatus->handle($user); + + return redirect()->route($status['show_residual'] ? 'app.onboarding' : 'app.calendar'); + } + + // Members can't check out — hold them on a dedicated screen instead of + // walking an ICP flow they can never finish. + if (! $user->isAccountOwner()) { + return redirect()->route('app.welcome.subscription-required'); + } + + return null; + } +} diff --git a/app/Http/Controllers/App/WorkspaceController.php b/app/Http/Controllers/App/WorkspaceController.php index 4e5ff2121..0b4f4cd30 100644 --- a/app/Http/Controllers/App/WorkspaceController.php +++ b/app/Http/Controllers/App/WorkspaceController.php @@ -24,9 +24,9 @@ use Illuminate\Http\Request; use Illuminate\Http\Resources\Json\AnonymousResourceCollection; use Inertia\Inertia; -use Inertia\Response; +use Inertia\Response as InertiaResponse; use RuntimeException; -use Symfony\Component\HttpFoundation\Response as SymfonyResponse; +use Symfony\Component\HttpFoundation\Response; class WorkspaceController extends Controller { @@ -34,7 +34,7 @@ public function searchMembers(Request $request): AnonymousResourceCollection { $workspace = $request->user()->currentWorkspace; - abort_if(! $workspace, SymfonyResponse::HTTP_FORBIDDEN); + abort_if(! $workspace, Response::HTTP_FORBIDDEN); $this->authorize('view', $workspace); @@ -50,7 +50,7 @@ public function searchMembers(Request $request): AnonymousResourceCollection return WorkspaceMemberResource::collection($members); } - public function index(Request $request): Response + public function index(Request $request): InertiaResponse { $user = $request->user(); @@ -66,7 +66,7 @@ public function index(Request $request): Response ]); } - public function create(Request $request): Response|RedirectResponse + public function create(Request $request): InertiaResponse|RedirectResponse { $this->authorize('create', Workspace::class); @@ -86,7 +86,7 @@ public function create(Request $request): Response|RedirectResponse * Block creating a paid additional workspace without an active subscription. * Guards both the form (`create`) and the write (`store`) so a direct POST * can't bootstrap a second billable workspace — which would also inflate the - * checkout quantity past the fixed first-month coupon. + * Stripe Checkout seat quantity before the owner has paid. */ private function denyAdditionalWorkspaceWithoutSubscription(User $user): ?RedirectResponse { @@ -94,7 +94,7 @@ private function denyAdditionalWorkspaceWithoutSubscription(User $user): ?Redire // workspace on their empty invite-signup shell would leave it non-empty // and billable after accept abandons it — send them back to the invite. if (Invite::query()->where('email', $user->email)->whereNull('accepted_at')->exists()) { - abort(403); + abort(Response::HTTP_FORBIDDEN); } if (! config('trypost.self_hosted') @@ -112,7 +112,7 @@ public function autofillBrand(AutofillBrandRequest $request, AutofillBrand $auto try { $metadata = $autofill($request->validated('url')); } catch (RuntimeException $e) { - return response()->json(['message' => $e->getMessage()], SymfonyResponse::HTTP_UNPROCESSABLE_ENTITY); + return response()->json(['message' => $e->getMessage()], Response::HTTP_UNPROCESSABLE_ENTITY); } return response()->json($metadata->toArray()); @@ -145,7 +145,7 @@ public function switch(Request $request, Workspace $workspace): RedirectResponse $this->authorize('view', $workspace); if (! $user->belongsToWorkspace($workspace)) { - abort(403); + abort(Response::HTTP_FORBIDDEN); } $user->switchWorkspace($workspace); @@ -153,7 +153,7 @@ public function switch(Request $request, Workspace $workspace): RedirectResponse return redirect()->route('app.calendar'); } - public function settings(Request $request): Response|RedirectResponse + public function settings(Request $request): InertiaResponse|RedirectResponse { $user = $request->user(); $workspace = $user->currentWorkspace; @@ -180,7 +180,7 @@ public function settings(Request $request): Response|RedirectResponse ]); } - public function brandSettings(Request $request): Response|RedirectResponse + public function brandSettings(Request $request): InertiaResponse|RedirectResponse { $user = $request->user(); $workspace = $user->currentWorkspace; diff --git a/app/Http/Controllers/App/WorkspaceInviteController.php b/app/Http/Controllers/App/WorkspaceInviteController.php index 821224426..7e559dde5 100644 --- a/app/Http/Controllers/App/WorkspaceInviteController.php +++ b/app/Http/Controllers/App/WorkspaceInviteController.php @@ -4,6 +4,8 @@ namespace App\Http\Controllers\App; +use App\Actions\AccessToken\RevokeMcpOAuthGrants; +use App\Actions\AccessToken\RevokeWorkspaceApiKeys; use App\Actions\Invite\CreateInvite; use App\Actions\Invite\DeleteInvite; use App\Actions\Invite\RemoveMember; @@ -15,11 +17,12 @@ use Illuminate\Http\Request; use Illuminate\Validation\Rule; use Inertia\Inertia; -use Inertia\Response; +use Inertia\Response as InertiaResponse; +use Symfony\Component\HttpFoundation\Response; class WorkspaceInviteController extends Controller { - public function index(Request $request): Response|RedirectResponse + public function index(Request $request): InertiaResponse|RedirectResponse { $workspace = $request->user()->currentWorkspace; @@ -103,7 +106,7 @@ public function destroy(Request $request, Invite $invite): RedirectResponse $this->authorize('manageTeam', $workspace); if ($invite->account_id !== $workspace->account_id) { - abort(404); + abort(Response::HTTP_NOT_FOUND); } DeleteInvite::execute($invite); @@ -165,11 +168,24 @@ public function updateRole(Request $request, string $userId): RedirectResponse $validated = $request->validate([ 'role' => ['required', Rule::in(array_column(WorkspaceRole::cases(), 'value'))], ]); + $role = WorkspaceRole::from((string) data_get($validated, 'role')); $workspace->members()->updateExistingPivot($userId, [ - 'role' => data_get($validated, 'role'), + 'role' => $role->value, ]); + RevokeWorkspaceApiKeys::forUserUnlessAdmin($userId, $workspace, $role); + + // Viewers cannot use MCP on this workspace. Only revoke account-scoped + // OAuth grants when they also lack createPost everywhere else. + if ($role === WorkspaceRole::Viewer) { + $member = User::query()->find($userId); + + if ($member instanceof User) { + RevokeMcpOAuthGrants::forUserIfLacksCreatePost($member->fresh()); + } + } + session()->flash('flash.banner', __('settings.members.flash.role_updated')); session()->flash('flash.bannerStyle', 'success'); diff --git a/app/Http/Controllers/App/WorkspaceLabelController.php b/app/Http/Controllers/App/WorkspaceLabelController.php index bd904c221..d33a5fcec 100644 --- a/app/Http/Controllers/App/WorkspaceLabelController.php +++ b/app/Http/Controllers/App/WorkspaceLabelController.php @@ -11,11 +11,12 @@ use Illuminate\Http\RedirectResponse; use Illuminate\Http\Request; use Inertia\Inertia; -use Inertia\Response; +use Inertia\Response as InertiaResponse; +use Symfony\Component\HttpFoundation\Response; class WorkspaceLabelController extends Controller { - public function index(Request $request): Response|RedirectResponse + public function index(Request $request): InertiaResponse|RedirectResponse { $workspace = $request->user()->currentWorkspace; @@ -73,7 +74,7 @@ public function update(Request $request, WorkspaceLabel $label): RedirectRespons $this->authorize('createPost', $workspace); if ($label->workspace_id !== $workspace->id) { - abort(404); + abort(Response::HTTP_NOT_FOUND); } $validated = $request->validate([ @@ -100,7 +101,7 @@ public function destroy(Request $request, WorkspaceLabel $label): RedirectRespon $this->authorize('createPost', $workspace); if ($label->workspace_id !== $workspace->id) { - abort(404); + abort(Response::HTTP_NOT_FOUND); } DeleteLabel::execute($label); diff --git a/app/Http/Controllers/App/WorkspaceSignatureController.php b/app/Http/Controllers/App/WorkspaceSignatureController.php index f0d583ca2..01bb5dfee 100644 --- a/app/Http/Controllers/App/WorkspaceSignatureController.php +++ b/app/Http/Controllers/App/WorkspaceSignatureController.php @@ -11,11 +11,12 @@ use Illuminate\Http\RedirectResponse; use Illuminate\Http\Request; use Inertia\Inertia; -use Inertia\Response; +use Inertia\Response as InertiaResponse; +use Symfony\Component\HttpFoundation\Response; class WorkspaceSignatureController extends Controller { - public function index(Request $request): Response|RedirectResponse + public function index(Request $request): InertiaResponse|RedirectResponse { $workspace = $request->user()->currentWorkspace; @@ -73,7 +74,7 @@ public function update(Request $request, WorkspaceSignature $signature): Redirec $this->authorize('createPost', $workspace); if ($signature->workspace_id !== $workspace->id) { - abort(404); + abort(Response::HTTP_NOT_FOUND); } $validated = $request->validate([ @@ -100,7 +101,7 @@ public function destroy(Request $request, WorkspaceSignature $signature): Redire $this->authorize('createPost', $workspace); if ($signature->workspace_id !== $workspace->id) { - abort(404); + abort(Response::HTTP_NOT_FOUND); } DeleteSignature::execute($signature); diff --git a/app/Http/Controllers/Auth/BlueskyController.php b/app/Http/Controllers/Auth/BlueskyController.php index cad4d5ccf..6b9de1c20 100644 --- a/app/Http/Controllers/Auth/BlueskyController.php +++ b/app/Http/Controllers/Auth/BlueskyController.php @@ -107,6 +107,10 @@ public function store(Request $request): InertiaResponse } catch (ValidationException $e) { throw $e; } catch (\Exception $e) { + if ($this->isNetworkConflict($e)) { + return $this->networkTakenResponse($this->platform); + } + Log::error('Bluesky connection error', [ 'error' => $e->getMessage(), 'trace' => $e->getTraceAsString(), diff --git a/app/Http/Controllers/Auth/FacebookController.php b/app/Http/Controllers/Auth/FacebookController.php index c868d250f..ad9ad3a26 100644 --- a/app/Http/Controllers/Auth/FacebookController.php +++ b/app/Http/Controllers/Auth/FacebookController.php @@ -6,7 +6,6 @@ use App\Enums\SocialAccount\Platform as SocialPlatform; use App\Enums\SocialAccount\Status; -use App\Exceptions\SocialAccount\NetworkAlreadyConnectedException; use App\Models\Workspace; use Illuminate\Http\RedirectResponse; use Illuminate\Http\Request; @@ -127,9 +126,11 @@ public function callback(Request $request): InertiaResponse|RedirectResponse ]); return redirect()->route('app.social.facebook.select-page'); - } catch (NetworkAlreadyConnectedException) { - return $this->popupCallback(false, __('accounts.popup_callback.network_taken'), $this->platform->value); } catch (\Exception $e) { + if ($this->isNetworkConflict($e)) { + return $this->networkTakenResponse($this->platform); + } + Log::error('Facebook OAuth Error', [ 'error' => $e->getMessage(), 'trace' => $e->getTraceAsString(), @@ -254,9 +255,11 @@ public function select(Request $request): InertiaResponse session()->forget(['facebook_oauth', 'social_reconnect_id']); return $this->popupCallback(true, __('accounts.popup_callback.connected'), $this->platform->value); - } catch (NetworkAlreadyConnectedException) { - return $this->popupCallback(false, __('accounts.popup_callback.network_taken'), $this->platform->value); } catch (\Exception $e) { + if ($this->isNetworkConflict($e)) { + return $this->networkTakenResponse($this->platform); + } + Log::error('Facebook page selection error', [ 'error' => $e->getMessage(), ]); diff --git a/app/Http/Controllers/Auth/InstagramController.php b/app/Http/Controllers/Auth/InstagramController.php index f8a0c0546..648dde48f 100644 --- a/app/Http/Controllers/Auth/InstagramController.php +++ b/app/Http/Controllers/Auth/InstagramController.php @@ -6,7 +6,6 @@ use App\Enums\SocialAccount\Platform as SocialPlatform; use App\Enums\SocialAccount\Status; -use App\Exceptions\SocialAccount\NetworkAlreadyConnectedException; use App\Models\Workspace; use Illuminate\Http\Request; use Illuminate\Support\Facades\Log; @@ -95,9 +94,11 @@ public function callback(Request $request): InertiaResponse ); return $this->popupCallback(true, __('accounts.popup_callback.connected'), $this->platform->value); - } catch (NetworkAlreadyConnectedException) { - return $this->popupCallback(false, __('accounts.popup_callback.network_taken'), $this->platform->value); } catch (\Exception $e) { + if ($this->isNetworkConflict($e)) { + return $this->networkTakenResponse($this->platform); + } + Log::error('Instagram OAuth Error', [ 'error' => $e->getMessage(), 'trace' => $e->getTraceAsString(), diff --git a/app/Http/Controllers/Auth/InstagramFacebookController.php b/app/Http/Controllers/Auth/InstagramFacebookController.php index 36fdebf9f..f43c7062d 100644 --- a/app/Http/Controllers/Auth/InstagramFacebookController.php +++ b/app/Http/Controllers/Auth/InstagramFacebookController.php @@ -6,7 +6,6 @@ use App\Enums\SocialAccount\Platform as SocialPlatform; use App\Enums\SocialAccount\Status; -use App\Exceptions\SocialAccount\NetworkAlreadyConnectedException; use App\Models\Workspace; use Illuminate\Http\RedirectResponse; use Illuminate\Http\Request; @@ -106,9 +105,11 @@ public function callback(Request $request): InertiaResponse|RedirectResponse ]); return redirect()->route('app.social.instagram-facebook.select-page'); - } catch (NetworkAlreadyConnectedException) { - return $this->popupCallback(false, __('accounts.popup_callback.network_taken'), $this->platform->value); } catch (\Exception $e) { + if ($this->isNetworkConflict($e)) { + return $this->networkTakenResponse($this->platform); + } + Log::error('Instagram via Facebook OAuth Error', [ 'error' => $e->getMessage(), 'trace' => $e->getTraceAsString(), @@ -180,9 +181,11 @@ public function select(Request $request): InertiaResponse session()->forget(['instagram_facebook_oauth', 'social_reconnect_id']); return $result; - } catch (NetworkAlreadyConnectedException) { - return $this->popupCallback(false, __('accounts.popup_callback.network_taken'), $this->platform->value); } catch (\Exception $e) { + if ($this->isNetworkConflict($e)) { + return $this->networkTakenResponse($this->platform); + } + Log::error('Instagram via Facebook page selection error', ['error' => $e->getMessage()]); return $this->popupCallback(false, __('accounts.popup_callback.error_connecting'), $this->platform->value); diff --git a/app/Http/Controllers/Auth/LinkedInController.php b/app/Http/Controllers/Auth/LinkedInController.php index 743885f4a..78faab749 100644 --- a/app/Http/Controllers/Auth/LinkedInController.php +++ b/app/Http/Controllers/Auth/LinkedInController.php @@ -7,7 +7,6 @@ use App\Enums\SocialAccount\LinkedInIdentityType; use App\Enums\SocialAccount\Platform as SocialPlatform; use App\Enums\SocialAccount\Status; -use App\Exceptions\SocialAccount\NetworkAlreadyConnectedException; use App\Models\Workspace; use Illuminate\Http\RedirectResponse; use Illuminate\Http\Request; @@ -168,9 +167,11 @@ public function select(Request $request): InertiaResponse session()->forget('linkedin_pending'); return $this->popupCallback(true, __('accounts.popup_callback.connected'), $this->platform->value); - } catch (NetworkAlreadyConnectedException) { - return $this->popupCallback(false, __('accounts.popup_callback.network_taken'), $this->platform->value); } catch (\Exception $e) { + if ($this->isNetworkConflict($e)) { + return $this->networkTakenResponse($this->platform); + } + Log::error('LinkedIn selection error', [ 'error' => $e->getMessage(), ]); diff --git a/app/Http/Controllers/Auth/MastodonController.php b/app/Http/Controllers/Auth/MastodonController.php index 2dd0966e5..35c0d85a3 100644 --- a/app/Http/Controllers/Auth/MastodonController.php +++ b/app/Http/Controllers/Auth/MastodonController.php @@ -206,6 +206,10 @@ public function callback(Request $request): InertiaResponse return $this->popupCallback(true, __('accounts.popup_callback.connected'), $this->platform->value); } catch (\Exception $e) { + if ($this->isNetworkConflict($e)) { + return $this->networkTakenResponse($this->platform); + } + Log::error('Mastodon callback error', [ 'error' => $e->getMessage(), 'trace' => $e->getTraceAsString(), diff --git a/app/Http/Controllers/Auth/PinterestController.php b/app/Http/Controllers/Auth/PinterestController.php index 9da9b746a..4ab095f69 100644 --- a/app/Http/Controllers/Auth/PinterestController.php +++ b/app/Http/Controllers/Auth/PinterestController.php @@ -74,6 +74,10 @@ public function callback(Request $request): InertiaResponse return $this->popupCallback(true, __('accounts.popup_callback.connected'), $this->platform->value); } catch (\Exception $e) { + if ($this->isNetworkConflict($e)) { + return $this->networkTakenResponse($this->platform); + } + Log::error('Pinterest OAuth Error', [ 'error' => $e->getMessage(), 'trace' => $e->getTraceAsString(), diff --git a/app/Http/Controllers/Auth/SocialController.php b/app/Http/Controllers/Auth/SocialController.php index ec2ed9788..1bd399b8f 100644 --- a/app/Http/Controllers/Auth/SocialController.php +++ b/app/Http/Controllers/Auth/SocialController.php @@ -13,13 +13,15 @@ use App\Http\Resources\App\SocialAccountResource; use App\Models\SocialAccount; use App\Models\Workspace; +use Exception; use Illuminate\Http\RedirectResponse; use Illuminate\Http\Request; use Illuminate\Support\Facades\Log; use Inertia\Inertia; -use Inertia\Response; +use Inertia\Response as InertiaResponse; use Laravel\Socialite\Facades\Socialite; -use Symfony\Component\HttpFoundation\Response as SymfonyResponse; +use Symfony\Component\HttpFoundation\Response; +use Throwable; class SocialController extends Controller { @@ -28,24 +30,17 @@ class SocialController extends Controller protected function ensurePlatformEnabled(): void { if (isset($this->platform) && ! $this->platform->isEnabled()) { - abort(SymfonyResponse::HTTP_FORBIDDEN, 'This platform is currently unavailable.'); + abort(Response::HTTP_FORBIDDEN, 'This platform is currently unavailable.'); } } - public function index(Request $request): Response + public function index(Request $request): InertiaResponse { $workspace = $request->user()->currentWorkspace; $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(); + $platforms = SocialPlatform::connectableOptions(); return Inertia::render('accounts/Index', [ 'workspace' => $workspace, @@ -63,7 +58,7 @@ public function disconnect(Request $request, SocialAccount $account): RedirectRe $this->authorize('manageAccounts', $workspace); if ($account->workspace_id !== $workspace->id) { - abort(403); + abort(Response::HTTP_FORBIDDEN); } // Drop pending platform rows from drafts/scheduled posts so the account @@ -88,7 +83,7 @@ public function toggleActive(Request $request, SocialAccount $account): Redirect $this->authorize('manageAccounts', $workspace); if ($account->workspace_id !== $workspace->id) { - abort(403); + abort(Response::HTTP_FORBIDDEN); } ToggleSocialAccount::execute($account); @@ -100,7 +95,7 @@ public function toggleActive(Request $request, SocialAccount $account): Redirect return back(); } - protected function redirectToProvider(Request $request, string $driver, array $scopes): SymfonyResponse + protected function redirectToProvider(Request $request, string $driver, array $scopes): Response { $workspace = $request->user()->currentWorkspace; @@ -118,7 +113,7 @@ protected function handleCallback( Request $request, SocialPlatform $platform, string $driver - ): Response { + ): InertiaResponse { $workspaceId = session('social_connect_workspace'); if (! $workspaceId) { @@ -156,9 +151,11 @@ protected function handleCallback( ); return $this->popupCallback(true, __('accounts.popup_callback.connected'), $platform->value); - } catch (NetworkAlreadyConnectedException) { - return $this->popupCallback(false, __('accounts.popup_callback.network_taken'), $platform->value); - } catch (\Exception $e) { + } catch (Exception $e) { + if ($this->isNetworkConflict($e)) { + return $this->networkTakenResponse($platform); + } + Log::error('Social OAuth Error', [ 'platform' => $platform->value, 'error' => $e->getMessage(), @@ -168,6 +165,16 @@ protected function handleCallback( } } + protected function isNetworkConflict(Throwable $exception): bool + { + return NetworkAlreadyConnectedException::matches($exception); + } + + protected function networkTakenResponse(SocialPlatform $platform): InertiaResponse + { + return $this->popupCallback(false, __('accounts.popup_callback.network_taken'), $platform->value); + } + protected function forgetSocialConnectSession(): void { session()->forget('social_connect_workspace'); @@ -178,7 +185,7 @@ protected function forgetSocialConnectSession(): void * popup. Used by both the GET OAuth callbacks (a fresh popup page load) and * the XHR selection submits (an Inertia visit that swaps to this page). */ - protected function popupCallback(bool $success, string $message, ?string $platform = null): Response + protected function popupCallback(bool $success, string $message, ?string $platform = null): InertiaResponse { $this->forgetSocialConnectSession(); diff --git a/app/Http/Controllers/Auth/TelegramController.php b/app/Http/Controllers/Auth/TelegramController.php index 309a4606c..c80238a05 100644 --- a/app/Http/Controllers/Auth/TelegramController.php +++ b/app/Http/Controllers/Auth/TelegramController.php @@ -8,7 +8,7 @@ use App\Services\Social\Telegram\TelegramConnectCode; use Illuminate\Http\JsonResponse; use Illuminate\Http\Request; -use Symfony\Component\HttpFoundation\Response as SymfonyResponse; +use Symfony\Component\HttpFoundation\Response; class TelegramController extends SocialController { @@ -25,7 +25,7 @@ public function connect(Request $request): JsonResponse $this->ensurePlatformEnabled(); $workspace = $request->user()->currentWorkspace; - abort_if($workspace === null, SymfonyResponse::HTTP_CONFLICT, 'No active workspace.'); + abort_if($workspace === null, Response::HTTP_CONFLICT, 'No active workspace.'); $this->authorize('manageAccounts', $workspace); diff --git a/app/Http/Controllers/Auth/ThreadsController.php b/app/Http/Controllers/Auth/ThreadsController.php index 955009ea0..2e44c9192 100644 --- a/app/Http/Controllers/Auth/ThreadsController.php +++ b/app/Http/Controllers/Auth/ThreadsController.php @@ -6,7 +6,6 @@ use App\Enums\SocialAccount\Platform as SocialPlatform; use App\Enums\SocialAccount\Status; -use App\Exceptions\SocialAccount\NetworkAlreadyConnectedException; use App\Models\Workspace; use App\Services\Social\TokenRedactor; use Illuminate\Http\Request; @@ -157,9 +156,11 @@ public function callback(Request $request): InertiaResponse session()->forget(['threads_oauth_state', 'social_reconnect_id']); return $this->popupCallback(true, __('accounts.popup_callback.connected'), $this->platform->value); - } catch (NetworkAlreadyConnectedException) { - return $this->popupCallback(false, __('accounts.popup_callback.network_taken'), $this->platform->value); } catch (\Exception $e) { + if ($this->isNetworkConflict($e)) { + return $this->networkTakenResponse($this->platform); + } + Log::error('Threads OAuth Error', [ 'error' => $e->getMessage(), 'trace' => $e->getTraceAsString(), diff --git a/app/Http/Controllers/Auth/TikTokController.php b/app/Http/Controllers/Auth/TikTokController.php index 8c7a3a317..d5d8b395f 100644 --- a/app/Http/Controllers/Auth/TikTokController.php +++ b/app/Http/Controllers/Auth/TikTokController.php @@ -87,6 +87,10 @@ public function callback(Request $request): InertiaResponse return $this->popupCallback(true, __('accounts.popup_callback.connected'), $this->platform->value); } catch (\Exception $e) { + if ($this->isNetworkConflict($e)) { + return $this->networkTakenResponse($this->platform); + } + Log::error('TikTok OAuth Error', [ 'error' => $e->getMessage(), ]); diff --git a/app/Http/Controllers/Auth/YouTubeController.php b/app/Http/Controllers/Auth/YouTubeController.php index 6a86cf388..e13a430ef 100644 --- a/app/Http/Controllers/Auth/YouTubeController.php +++ b/app/Http/Controllers/Auth/YouTubeController.php @@ -6,7 +6,6 @@ use App\Enums\SocialAccount\Platform as SocialPlatform; use App\Enums\SocialAccount\Status; -use App\Exceptions\SocialAccount\NetworkAlreadyConnectedException; use App\Models\Workspace; use Illuminate\Http\RedirectResponse; use Illuminate\Http\Request; @@ -112,9 +111,11 @@ public function callback(Request $request): InertiaResponse|RedirectResponse ]); return redirect()->route('app.social.youtube.select-channel'); - } catch (NetworkAlreadyConnectedException) { - return $this->popupCallback(false, __('accounts.popup_callback.network_taken'), $this->platform->value); } catch (\Exception $e) { + if ($this->isNetworkConflict($e)) { + return $this->networkTakenResponse($this->platform); + } + Log::error('YouTube OAuth Error', [ 'error' => $e->getMessage(), 'trace' => $e->getTraceAsString(), @@ -247,9 +248,11 @@ public function select(Request $request): InertiaResponse session()->forget(['youtube_oauth', 'social_reconnect_id']); return $this->popupCallback(true, __('accounts.popup_callback.connected'), $this->platform->value); - } catch (NetworkAlreadyConnectedException) { - return $this->popupCallback(false, __('accounts.popup_callback.network_taken'), $this->platform->value); } catch (\Exception $e) { + if ($this->isNetworkConflict($e)) { + return $this->networkTakenResponse($this->platform); + } + Log::error('YouTube channel selection error', [ 'error' => $e->getMessage(), ]); diff --git a/app/Http/Controllers/Webhooks/TelegramWebhookController.php b/app/Http/Controllers/Webhooks/TelegramWebhookController.php index d00e87177..5fe10569e 100644 --- a/app/Http/Controllers/Webhooks/TelegramWebhookController.php +++ b/app/Http/Controllers/Webhooks/TelegramWebhookController.php @@ -10,8 +10,7 @@ use App\Models\Workspace; use App\Services\Social\Telegram\TelegramConnectCode; use Illuminate\Http\Request; -use Illuminate\Http\Response; -use Symfony\Component\HttpFoundation\Response as SymfonyResponse; +use Symfony\Component\HttpFoundation\Response; class TelegramWebhookController extends Controller { @@ -26,7 +25,7 @@ public function handle(Request $request): Response abort_if( $secret === '' || ! hash_equals($secret, (string) $request->header('X-Telegram-Bot-Api-Secret-Token')), - SymfonyResponse::HTTP_FORBIDDEN, + Response::HTTP_FORBIDDEN, ); $update = $request->all(); diff --git a/app/Http/Middleware/Api/LoadWorkspaceFromToken.php b/app/Http/Middleware/Api/LoadWorkspaceFromToken.php index dd9743646..8e345e179 100644 --- a/app/Http/Middleware/Api/LoadWorkspaceFromToken.php +++ b/app/Http/Middleware/Api/LoadWorkspaceFromToken.php @@ -4,22 +4,34 @@ namespace App\Http\Middleware\Api; +use App\Models\AccessToken; use App\Models\Workspace; use Closure; use Illuminate\Http\Request; +use Laravel\Passport\AccessToken as PassportAccessToken; use Symfony\Component\HttpFoundation\Response; class LoadWorkspaceFromToken { - public function handle(Request $request, Closure $next): Response + public function handle(Request $request, Closure $next, ?string $context = null): Response { $user = $request->user(); - $token = $user?->token(); + $authenticatedToken = $user?->token(); - if (! $token) { + if (! $authenticatedToken instanceof PassportAccessToken) { return response()->json(['message' => 'Token not found.'], Response::HTTP_UNAUTHORIZED); } + $token = AccessToken::query()->find($authenticatedToken->oauth_access_token_id); + + if ($token === null) { + return response()->json(['message' => 'Token not found.'], Response::HTTP_UNAUTHORIZED); + } + + if ($token->expires_at?->isPast()) { + return response()->json(['message' => 'Token expired.'], Response::HTTP_UNAUTHORIZED); + } + // Personal API tokens (created from settings) bind to a specific // workspace at creation. OAuth tokens (e.g. ChatGPT MCP) don't — // they follow the user's current workspace. @@ -31,7 +43,32 @@ public function handle(Request $request, Closure $next): Response return response()->json(['message' => 'No workspace selected.'], Response::HTTP_UNAUTHORIZED); } - if (! config('trypost.self_hosted') && ! $workspace->account?->hasActiveSubscription()) { + if (! $user->can('view', $workspace)) { + return response()->json(['message' => 'Workspace access denied.'], Response::HTTP_FORBIDDEN); + } + + if ($context === 'mcp') { + if (! $token->isActiveMcpGrant() || ! $authenticatedToken->can('mcp:use')) { + return response()->json(['message' => 'MCP OAuth authorization required.'], Response::HTTP_FORBIDDEN); + } + + if (! $user->can('createPost', $workspace)) { + return response()->json(['message' => 'Insufficient workspace permissions.'], Response::HTTP_FORBIDDEN); + } + } else { + if (! $token->isPersonalAccessToken()) { + return response()->json(['message' => 'Personal access token required.'], Response::HTTP_FORBIDDEN); + } + + if (! $user->can('manageTeam', $workspace)) { + return response()->json(['message' => 'Insufficient workspace permissions.'], Response::HTTP_FORBIDDEN); + } + } + + // Match web access (EnsureAccountReady): Stripe subscription OR generic + // no-card trial. MCP onboarding is a first-class checklist step for + // those accounts — requiring subscribed() alone returned 402 after OAuth. + if (! config('trypost.self_hosted') && ! $workspace->account?->hasAppAccess()) { return response()->json(['message' => 'Active subscription required.'], Response::HTTP_PAYMENT_REQUIRED); } diff --git a/app/Http/Middleware/App/EnsureAccountReady.php b/app/Http/Middleware/App/EnsureAccountReady.php index 7e15bfe6c..9e41604bd 100644 --- a/app/Http/Middleware/App/EnsureAccountReady.php +++ b/app/Http/Middleware/App/EnsureAccountReady.php @@ -4,7 +4,6 @@ namespace App\Http\Middleware\App; -use App\Models\Account; use Closure; use Illuminate\Http\Request; use Symfony\Component\HttpFoundation\Response; @@ -24,14 +23,15 @@ public function handle(Request $request, Closure $next): Response if (! config('trypost.self_hosted')) { $account = $user->account; - $requiresCardForTrial = (bool) config('trypost.billing.require_card_for_trial', true); - $hasAccess = $account && ( - $account->subscribed(Account::SUBSCRIPTION_NAME) - || (! $requiresCardForTrial && $account->isOnTrial()) - ); - - if (! $hasAccess) { - return redirect()->route('app.onboarding'); + + if (! $account?->hasAppAccess()) { + // Members can't finish Welcome checkout — send them straight to + // the hold screen instead of hopping through persona first. + if (! $user->isAccountOwner()) { + return redirect()->route('app.welcome.subscription-required'); + } + + return redirect()->route('app.welcome.persona'); } } diff --git a/app/Http/Middleware/App/HandleInertiaRequests.php b/app/Http/Middleware/App/HandleInertiaRequests.php index 167185be8..563b337c7 100644 --- a/app/Http/Middleware/App/HandleInertiaRequests.php +++ b/app/Http/Middleware/App/HandleInertiaRequests.php @@ -4,6 +4,7 @@ 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; @@ -48,6 +49,9 @@ public function share(Request $request): array ], 'usage' => $account && ! $isSelfHosted ? $account->usage() : null, 'features' => $account && ! $isSelfHosted ? $account->featureLimits() : null, + 'onboardingResidual' => fn (): array|false => $user + ? app(ResolveOnboardingStatus::class)->residual($user) + : false, 'sidebarOpen' => ! $request->hasCookie('sidebar_state') || $request->cookie('sidebar_state') === 'true', 'flash' => $request->session()->get('flash', []), 'applicationUrl' => config('app.url'), diff --git a/app/Http/Requests/Api/ApiKey/StoreApiKeyRequest.php b/app/Http/Requests/Api/ApiKey/StoreApiKeyRequest.php index 404a552e5..796ecc61b 100644 --- a/app/Http/Requests/Api/ApiKey/StoreApiKeyRequest.php +++ b/app/Http/Requests/Api/ApiKey/StoreApiKeyRequest.php @@ -4,13 +4,18 @@ namespace App\Http\Requests\Api\ApiKey; +use App\Actions\ApiKey\CreateApiKey; use Illuminate\Foundation\Http\FormRequest; class StoreApiKeyRequest extends FormRequest { public function authorize(): bool { - return true; + $user = $this->user(); + $workspace = $user?->currentWorkspace; + + return $workspace !== null + && $user->can('manageTeam', $workspace); } /** @@ -20,7 +25,7 @@ public function rules(): array { return [ 'name' => ['required', 'string', 'max:255'], - 'expires_at' => ['nullable', 'date', 'after:today'], + 'expires_at' => CreateApiKey::expiresAtRules(), ]; } } diff --git a/app/Http/Requests/App/ApiKey/StoreApiKeyRequest.php b/app/Http/Requests/App/ApiKey/StoreApiKeyRequest.php index b4f881ca1..08971f62f 100644 --- a/app/Http/Requests/App/ApiKey/StoreApiKeyRequest.php +++ b/app/Http/Requests/App/ApiKey/StoreApiKeyRequest.php @@ -4,13 +4,18 @@ namespace App\Http\Requests\App\ApiKey; +use App\Actions\ApiKey\CreateApiKey; use Illuminate\Foundation\Http\FormRequest; class StoreApiKeyRequest extends FormRequest { public function authorize(): bool { - return true; + $user = $this->user(); + $workspace = $user?->currentWorkspace; + + return $workspace !== null + && $user->can('manageTeam', $workspace); } /** @@ -20,7 +25,7 @@ public function rules(): array { return [ 'name' => ['required', 'string', 'max:255'], - 'expires_at' => ['nullable', 'date', 'after:today'], + 'expires_at' => CreateApiKey::expiresAtRules(), ]; } } diff --git a/app/Http/Requests/App/Onboarding/StoreOnboardingGoalsRequest.php b/app/Http/Requests/App/Welcome/StoreWelcomeGoalsRequest.php similarity index 81% rename from app/Http/Requests/App/Onboarding/StoreOnboardingGoalsRequest.php rename to app/Http/Requests/App/Welcome/StoreWelcomeGoalsRequest.php index e127eab3c..987fa2c4e 100644 --- a/app/Http/Requests/App/Onboarding/StoreOnboardingGoalsRequest.php +++ b/app/Http/Requests/App/Welcome/StoreWelcomeGoalsRequest.php @@ -2,13 +2,13 @@ declare(strict_types=1); -namespace App\Http\Requests\App\Onboarding; +namespace App\Http\Requests\App\Welcome; use App\Enums\User\Goal; use Illuminate\Foundation\Http\FormRequest; use Illuminate\Validation\Rule; -class StoreOnboardingGoalsRequest extends FormRequest +class StoreWelcomeGoalsRequest extends FormRequest { public function authorize(): bool { diff --git a/app/Http/Requests/App/Onboarding/StoreOnboardingRequest.php b/app/Http/Requests/App/Welcome/StoreWelcomePersonaRequest.php similarity index 81% rename from app/Http/Requests/App/Onboarding/StoreOnboardingRequest.php rename to app/Http/Requests/App/Welcome/StoreWelcomePersonaRequest.php index ee0d74283..c22f29fc7 100644 --- a/app/Http/Requests/App/Onboarding/StoreOnboardingRequest.php +++ b/app/Http/Requests/App/Welcome/StoreWelcomePersonaRequest.php @@ -2,13 +2,13 @@ declare(strict_types=1); -namespace App\Http\Requests\App\Onboarding; +namespace App\Http\Requests\App\Welcome; use App\Enums\User\Persona; use Illuminate\Foundation\Http\FormRequest; use Illuminate\Validation\Rule; -class StoreOnboardingRequest extends FormRequest +class StoreWelcomePersonaRequest extends FormRequest { public function authorize(): bool { diff --git a/app/Http/Requests/App/Onboarding/StoreOnboardingReferralSourceRequest.php b/app/Http/Requests/App/Welcome/StoreWelcomeReferralSourceRequest.php similarity index 80% rename from app/Http/Requests/App/Onboarding/StoreOnboardingReferralSourceRequest.php rename to app/Http/Requests/App/Welcome/StoreWelcomeReferralSourceRequest.php index 47c9a5ae4..960089ec0 100644 --- a/app/Http/Requests/App/Onboarding/StoreOnboardingReferralSourceRequest.php +++ b/app/Http/Requests/App/Welcome/StoreWelcomeReferralSourceRequest.php @@ -2,13 +2,13 @@ declare(strict_types=1); -namespace App\Http\Requests\App\Onboarding; +namespace App\Http\Requests\App\Welcome; use App\Enums\User\ReferralSource; use Illuminate\Foundation\Http\FormRequest; use Illuminate\Validation\Rule; -class StoreOnboardingReferralSourceRequest extends FormRequest +class StoreWelcomeReferralSourceRequest extends FormRequest { public function authorize(): bool { diff --git a/app/Jobs/PostHog/SendEvent.php b/app/Jobs/PostHog/SendEvent.php index 0057938f7..c045cd900 100644 --- a/app/Jobs/PostHog/SendEvent.php +++ b/app/Jobs/PostHog/SendEvent.php @@ -12,6 +12,7 @@ use Illuminate\Queue\SerializesModels; use Illuminate\Support\Facades\Log; use PostHog\PostHog; +use RuntimeException; class SendEvent implements ShouldQueue { @@ -44,6 +45,8 @@ public function handle(): void default => Log::warning('PostHog SendEvent: unknown method', ['method' => $this->method]), }; - PostHog::flush(); + if (! PostHog::flush()) { + throw new RuntimeException('PostHog event flush failed.'); + } } } diff --git a/app/Mcp/Tools/ApiKey/CreateApiKeyTool.php b/app/Mcp/Tools/ApiKey/CreateApiKeyTool.php index 23609ebf7..5393246d3 100644 --- a/app/Mcp/Tools/ApiKey/CreateApiKeyTool.php +++ b/app/Mcp/Tools/ApiKey/CreateApiKeyTool.php @@ -4,8 +4,8 @@ namespace App\Mcp\Tools\ApiKey; +use App\Actions\ApiKey\CreateApiKey; use App\Http\Resources\Api\ApiKeyResource; -use App\Models\AccessToken; use Illuminate\Contracts\JsonSchema\JsonSchema; use Laravel\Mcp\Request; use Laravel\Mcp\Response; @@ -16,26 +16,25 @@ #[Description('Create a new Personal Access Token (API key) for the current workspace. The plain token value is returned ONCE — store it immediately, it cannot be retrieved later.')] class CreateApiKeyTool extends Tool { - public function handle(Request $request): ResponseFactory + public function handle(Request $request): Response|ResponseFactory { + $user = $request->user(); + $workspace = $user->currentWorkspace; + + if ($workspace === null || $user->cannot('manageTeam', $workspace)) { + return Response::error('Not authorized to manage API keys.'); + } + $validated = $request->validate([ 'name' => ['required', 'string', 'max:255'], - 'expires_at' => ['nullable', 'date', 'after:now'], + 'expires_at' => CreateApiKey::expiresAtRules(), ]); - $user = $request->user(); - - $result = $user->createToken(data_get($validated, 'name')); - - $token = AccessToken::find($result->token->id); - $token->forceFill([ - 'workspace_id' => $user->current_workspace_id, - 'expires_at' => data_get($validated, 'expires_at'), - ])->saveQuietly(); + $created = CreateApiKey::execute($user, $workspace, $validated); return Response::structured(array_merge( - (new ApiKeyResource($token))->resolve(), - ['token' => $result->accessToken], + (new ApiKeyResource($created['token']))->resolve(), + ['token' => $created['plain_token']], )); } @@ -43,7 +42,7 @@ public function schema(JsonSchema $schema): array { return [ 'name' => $schema->string()->required()->description('A human-readable name to identify the key (e.g. "My integration").'), - 'expires_at' => $schema->string()->description('Optional ISO 8601 expiration date (e.g. 2026-12-31). Must be in the future.'), + 'expires_at' => $schema->string()->description('Optional expiration date (YYYY-MM-DD or ISO 8601). Omit for a key that never expires.'), ]; } } diff --git a/app/Mcp/Tools/ApiKey/DeleteApiKeyTool.php b/app/Mcp/Tools/ApiKey/DeleteApiKeyTool.php index 3192e71f6..b0773ce65 100644 --- a/app/Mcp/Tools/ApiKey/DeleteApiKeyTool.php +++ b/app/Mcp/Tools/ApiKey/DeleteApiKeyTool.php @@ -19,13 +19,19 @@ class DeleteApiKeyTool extends Tool { public function handle(Request $request): Response|ResponseFactory { + $user = $request->user(); + + if ($user->cannot('manageTeam', $user->currentWorkspace)) { + return Response::error('Not authorized to manage API keys.'); + } + $validated = $request->validate(['api_key_id' => ['required', 'string']]); // workspace_id filter excludes OAuth-flow tokens (which have null // workspace_id), so the caller can't accidentally revoke their own // ChatGPT/MCP session token through this tool. - $token = AccessToken::where('user_id', $request->user()->id) - ->where('workspace_id', $request->user()->current_workspace_id) + $token = AccessToken::where('user_id', $user->id) + ->where('workspace_id', $user->current_workspace_id) ->where('revoked', false) ->find(data_get($validated, 'api_key_id')); diff --git a/app/Mcp/Tools/ApiKey/ListApiKeysTool.php b/app/Mcp/Tools/ApiKey/ListApiKeysTool.php index 583f407b5..9476e49a1 100644 --- a/app/Mcp/Tools/ApiKey/ListApiKeysTool.php +++ b/app/Mcp/Tools/ApiKey/ListApiKeysTool.php @@ -17,13 +17,19 @@ #[Description('List all Personal Access Tokens (API keys) for the current workspace. Returns metadata only — the secret token value is shown only once at creation. OAuth tokens (e.g. ChatGPT MCP sessions) are excluded.')] class ListApiKeysTool extends Tool { - public function handle(Request $request): ResponseFactory + public function handle(Request $request): Response|ResponseFactory { + $user = $request->user(); + + if ($user->cannot('manageTeam', $user->currentWorkspace)) { + return Response::error('Not authorized to manage API keys.'); + } + // Filtering by workspace_id excludes OAuth-flow tokens (whose // workspace_id is null and resolved at request time via // LoadWorkspaceFromToken middleware). - $tokens = AccessToken::where('user_id', $request->user()->id) - ->where('workspace_id', $request->user()->current_workspace_id) + $tokens = AccessToken::where('user_id', $user->id) + ->where('workspace_id', $user->current_workspace_id) ->where('revoked', false) ->latest() ->get(); diff --git a/app/Models/AccessToken.php b/app/Models/AccessToken.php index 0f81b83c0..67495a35f 100644 --- a/app/Models/AccessToken.php +++ b/app/Models/AccessToken.php @@ -4,9 +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 { /** @@ -41,4 +45,97 @@ public function workspace(): BelongsTo { return $this->belongsTo(Workspace::class); } + + /** + * Active OAuth grants used by MCP clients (excludes personal access API keys). + * + * @param Builder $query + * @return Builder + */ + public function scopeActiveMcpOAuth(Builder $query): Builder + { + return $query + ->mcpOAuth() + ->where('revoked', false) + ->where(function (Builder $expires): void { + $expires->whereNull('expires_at') + ->orWhere('expires_at', '>', now()); + }); + } + + /** + * @param Builder $query + * @return Builder + */ + public function scopeMcpOAuth(Builder $query): Builder + { + return $query + ->whereJsonContains('scopes', 'mcp:use') + ->whereHas( + 'client', + fn (Builder $client): Builder => $client + ->where('revoked', false) + ->whereJsonDoesntContain('grant_types', 'personal_access'), + ); + } + + /** + * Whether this token was issued by a live personal-access client (REST API keys). + */ + public function isPersonalAccessToken(): bool + { + $this->loadMissing('client'); + + return $this->client !== null + && ! $this->client->revoked + && $this->client->hasGrantType('personal_access'); + } + + /** + * Whether this is a non-revoked, unexpired MCP OAuth grant with mcp:use. + */ + public function isActiveMcpGrant(): bool + { + $this->loadMissing('client'); + + if ($this->revoked) { + return false; + } + + if ($this->expires_at !== null && $this->expires_at->isPast()) { + return false; + } + + if (! in_array('mcp:use', $this->scopes ?? [], true)) { + return false; + } + + return $this->client !== null + && ! $this->client->revoked + && ! $this->client->hasGrantType('personal_access'); + } + + /** + * Whether this MCP grant can actually use the product (active token + a + * workspace the owner can create posts in). Used by the onboarding checklist. + */ + public function isUsableMcpGrant(?User $user = null, ?Workspace $workspace = null): bool + { + if (! $this->isActiveMcpGrant()) { + return false; + } + + $user ??= User::query() + ->with('currentWorkspace') + ->find($this->user_id); + + if (! $user instanceof User) { + return false; + } + + $workspace ??= $this->workspace ?? $user->currentWorkspace; + + return $workspace instanceof Workspace + && $user->can('createPost', $workspace); + } } diff --git a/app/Models/Account.php b/app/Models/Account.php index b8e31b00a..8c5e19d33 100644 --- a/app/Models/Account.php +++ b/app/Models/Account.php @@ -42,6 +42,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 @@ -78,6 +81,28 @@ public function hasActiveSubscription(): bool return $this->subscribed(self::SUBSCRIPTION_NAME); } + /** + * Whether the account may use the app (active subscription, or a generic + * trial when REQUIRE_CARD_FOR_TRIAL is disabled). + */ + public function hasAppAccess(): bool + { + if (config('trypost.self_hosted')) { + return true; + } + + $requiresCardForTrial = (bool) config('trypost.billing.require_card_for_trial', true); + + return $this->subscribed(self::SUBSCRIPTION_NAME) + || (! $requiresCardForTrial && $this->isOnTrial()); + } + + public function hasFinishedOnboarding(): bool + { + return $this->onboarding_completed_at !== null + || $this->onboarding_dismissed_at !== null; + } + /** * Align the Stripe subscription quantity with the number of workspaces the * account owns. Each workspace is a billed unit. No-op in self-hosted mode diff --git a/app/Observers/AccessTokenObserver.php b/app/Observers/AccessTokenObserver.php new file mode 100644 index 000000000..0ee73d0c4 --- /dev/null +++ b/app/Observers/AccessTokenObserver.php @@ -0,0 +1,48 @@ +broadcastIfMcpOAuth($accessToken); + } + + public function updated(AccessToken $accessToken): void + { + if (! $accessToken->wasChanged('revoked')) { + return; + } + + $this->broadcastIfMcpOAuth($accessToken); + } + + private function broadcastIfMcpOAuth(AccessToken $accessToken): void + { + // Ignore revocation mid-flight so disconnect still clears residual. + $looksLikeMcp = in_array('mcp:use', $accessToken->scopes ?? [], true) + && ! $accessToken->isPersonalAccessToken(); + + if (! $looksLikeMcp) { + return; + } + + $user = User::query()->with('account')->find($accessToken->user_id); + + if ($user?->account === null) { + return; + } + + OnboardingStatusUpdated::dispatchForAccount($user->account, $user); + } +} diff --git a/app/Observers/PostObserver.php b/app/Observers/PostObserver.php index 1486cfc04..bb9ba1080 100644 --- a/app/Observers/PostObserver.php +++ b/app/Observers/PostObserver.php @@ -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\Post; +use App\Models\User; +use Illuminate\Support\Facades\Auth; use Illuminate\Support\Facades\DB; class PostObserver @@ -16,6 +19,48 @@ class PostObserver public function created(Post $post): void { DB::afterCommit(fn () => PostCreated::dispatch($post)); + + $account = $post->workspace?->account; + + if ($account === null || $account->hasFinishedOnboarding()) { + return; + } + + OnboardingStatusUpdated::dispatchForWorkspace( + $post->workspace_id, + $this->actorFor($post), + ); + } + + public function deleted(Post $post): void + { + $account = $post->workspace?->account; + + if ($account === null || $account->hasFinishedOnboarding()) { + return; + } + + OnboardingStatusUpdated::dispatchForWorkspace( + $post->workspace_id, + $this->actorFor($post), + ); + } + + /** + * Prefer the authenticated request user (may carry an in-memory API/MCP + * workspace) over the persisted post author for checklist sync. + */ + private function actorFor(Post $post): ?User + { + $user = Auth::user(); + + if ($user instanceof User + && (string) $user->account_id === (string) $post->workspace?->account_id + ) { + return $user; + } + + return $post->user; } public function saved(Post $post): void diff --git a/app/Observers/SocialAccountObserver.php b/app/Observers/SocialAccountObserver.php index fb41f5971..7e71b17f0 100644 --- a/app/Observers/SocialAccountObserver.php +++ b/app/Observers/SocialAccountObserver.php @@ -5,21 +5,68 @@ 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; +use App\Models\User; use App\Services\PostHogService; +use Illuminate\Support\Facades\Auth; class SocialAccountObserver { /** * Enforce one connected account per social network per workspace. Variants * of the same network (LinkedIn profile/page, Instagram standalone/Facebook) - * collapse via Platform::network(). Reconnecting an existing account goes - * through updateOrCreate's update path and never reaches this hook. Bypassed - * in self-hosted mode, which has no per-workspace limits. + * collapse via Platform::networkPlatformValues(). Reconnecting an existing + * account goes through updateOrCreate's update path and never reaches this + * hook. Bypassed in self-hosted mode, which has no per-workspace limits. */ public function creating(SocialAccount $socialAccount): void + { + $this->assertUniqueWithinNetwork($socialAccount); + } + + public function created(SocialAccount $socialAccount): void + { + $this->syncUsage($socialAccount); + + if ($socialAccount->status !== Status::Connected) { + return; + } + + $this->broadcastOnboardingStatus($socialAccount); + } + + public function deleted(SocialAccount $socialAccount): void + { + $this->syncUsage($socialAccount); + + if ($socialAccount->status !== Status::Connected) { + return; + } + + $this->broadcastOnboardingStatus($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) { + return; + } + + $this->broadcastOnboardingStatus($socialAccount); + } + + private function assertUniqueWithinNetwork(SocialAccount $socialAccount): void { if (config('trypost.self_hosted')) { return; @@ -36,19 +83,40 @@ public function creating(SocialAccount $socialAccount): void ->whereIn('platform', $platform->networkPlatformValues()) ->exists(); - if ($conflict) { - throw new NetworkAlreadyConnectedException($platform); + if (! $conflict) { + return; } + + throw new NetworkAlreadyConnectedException($platform); } - public function created(SocialAccount $socialAccount): void + private function broadcastOnboardingStatus(SocialAccount $socialAccount): void { - $this->syncUsage($socialAccount); + $account = $socialAccount->workspace?->account; + + if ($account === null || $account->hasFinishedOnboarding()) { + return; + } + + OnboardingStatusUpdated::dispatchForWorkspace( + $socialAccount->workspace_id, + $this->actorFor($socialAccount), + ); } - public function deleted(SocialAccount $socialAccount): void + private function actorFor(SocialAccount $socialAccount): ?User { - $this->syncUsage($socialAccount); + $user = Auth::user(); + + if (! $user instanceof User) { + return null; + } + + if ((string) $user->account_id !== (string) $socialAccount->workspace?->account_id) { + return null; + } + + return $user; } private function syncUsage(SocialAccount $socialAccount): void diff --git a/app/Policies/AccountPolicy.php b/app/Policies/AccountPolicy.php index b7042f16e..78460f5fd 100644 --- a/app/Policies/AccountPolicy.php +++ b/app/Policies/AccountPolicy.php @@ -32,10 +32,7 @@ public function useAi(User $user, Account $account): Response return Response::allow(); } - $requiresCardForTrial = (bool) config('trypost.billing.require_card_for_trial', true); - - $hasAccess = $account->subscribed(Account::SUBSCRIPTION_NAME) - || (! $requiresCardForTrial && $account->isOnTrial()); + $hasAccess = $account->hasAppAccess(); if (! $hasAccess) { return Response::deny(__('billing.flash.subscription_required')); diff --git a/app/Providers/AppServiceProvider.php b/app/Providers/AppServiceProvider.php index 61349bc7c..55b360d0c 100644 --- a/app/Providers/AppServiceProvider.php +++ b/app/Providers/AppServiceProvider.php @@ -102,6 +102,11 @@ protected function configurePassport(): void { Passport::useTokenModel(AccessToken::class); + // API keys may omit an application expiry ("never"). Passport still + // embeds a JWT `exp`, so keep that far ahead and enforce optional + // `oauth_access_tokens.expires_at` in LoadWorkspaceFromToken. + Passport::personalAccessTokensExpireIn(now()->addYears(100)); + Passport::tokensCan([ 'mcp:use' => 'Use MCP server', ]); @@ -160,6 +165,11 @@ protected function configureRateLimiting(): void return Limit::perMinute(60)->by($request->workspace?->id ?: $request->ip()); }); + RateLimiter::for( + 'mcp-oauth-registration', + fn (Request $request): Limit => Limit::perMinute(30)->by($request->ip()), + ); + // Signed media uploads (api.uploads.store). MCP hosts share egress IPs // across tenants — key by workspace_id from the signed URL, with a high // IP backstop so one client cannot flood every workspace. diff --git a/app/Services/PostHogService.php b/app/Services/PostHogService.php index 074dbb585..a0809db42 100644 --- a/app/Services/PostHogService.php +++ b/app/Services/PostHogService.php @@ -7,6 +7,8 @@ use App\Jobs\PostHog\SendEvent; use App\Models\Account; use Illuminate\Support\Facades\Log; +use Illuminate\Support\Str; +use Throwable; class PostHogService { @@ -19,8 +21,12 @@ public static function isEnabled(): bool /** * @param array $properties */ - public function capture(string $distinctId, string $event, array $properties = [], ?Account $account = null): void - { + public function capture( + string $distinctId, + string $event, + array $properties = [], + ?Account $account = null, + ): void { if (! self::isEnabled()) { return; } @@ -29,6 +35,8 @@ public function capture(string $distinctId, string $event, array $properties = [ 'distinctId' => $distinctId, 'event' => $event, 'properties' => $properties, + 'uuid' => (string) Str::uuid(), + 'timestamp' => now()->toIso8601String(), ]; if ($account) { @@ -78,7 +86,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()]); } } diff --git a/app/Support/Billing/CheckoutConversionData.php b/app/Support/Billing/CheckoutConversionData.php new file mode 100644 index 000000000..759f916f6 --- /dev/null +++ b/app/Support/Billing/CheckoutConversionData.php @@ -0,0 +1,141 @@ + self::isTrial($session) ? 'trial' : 'purchase', + 'value' => $amountTotal / 100, + 'currency' => strtoupper((string) $currency), + 'transaction_id' => (string) $transactionId, + ]; + } + + private static function isTrial(object|array $session): bool + { + return data_get($session, 'payment_status') === 'no_payment_required' + && (int) data_get($session, 'metadata.trypost_trial_days', 0) > 0; + } + + private static function customerId(object|array $session): ?string + { + $customer = data_get($session, 'customer'); + $customerId = is_string($customer) + ? $customer + : data_get($customer, 'id'); + + return is_string($customerId) ? $customerId : null; + } + + /** + * @param array{kind: 'purchase'|'trial', value: float, currency: string, transaction_id: string}|null $payload + * @return array{ + * outcome: 'pending'|'purchase'|'terminal', + * payload: array{kind: 'purchase'|'trial', value: float, currency: string, transaction_id: string}|null + * } + */ + private static function result(string $outcome, ?array $payload = null): array + { + return [ + 'outcome' => $outcome, + 'payload' => $payload, + ]; + } +} diff --git a/app/Support/Billing/CheckoutPurchaseTracker.php b/app/Support/Billing/CheckoutPurchaseTracker.php new file mode 100644 index 000000000..bf5046685 --- /dev/null +++ b/app/Support/Billing/CheckoutPurchaseTracker.php @@ -0,0 +1,110 @@ +stripe_id === null || $sessionId === '') { + return [ + 'conversion' => null, + 'conversionResolved' => true, + ]; + } + + $trackedKey = $this->trackedKey($account->id, $sessionId); + + if (Cache::has($trackedKey)) { + return [ + 'conversion' => null, + 'conversionResolved' => true, + ]; + } + + try { + $session = $account->stripe()->checkout->sessions->retrieve($sessionId); + $classification = CheckoutConversionData::classify( + $session, + (string) $account->stripe_id, + true, + $account->plan?->stripe_monthly_price_id, + ); + + if ($classification['outcome'] === CheckoutConversionData::OUTCOME_PENDING) { + return [ + 'conversion' => null, + 'conversionResolved' => false, + ]; + } + + if ( + $classification['outcome'] === CheckoutConversionData::OUTCOME_TERMINAL + || $classification['payload'] === null + ) { + Cache::add($trackedKey, true, $this->expiresAt()); + + return [ + 'conversion' => null, + 'conversionResolved' => true, + ]; + } + + /** @var array{kind: 'purchase'|'trial', value: float, currency: string, transaction_id: string} $payload */ + $payload = $classification['payload']; + + // Consume on first delivery — the client keeps the prop in memory for polls. + Cache::add($trackedKey, true, $this->expiresAt()); + + return [ + 'conversion' => [ + ...$payload, + 'verified_at' => now()->toIso8601String(), + ], + 'conversionResolved' => true, + ]; + } catch (InvalidRequestException) { + Cache::add($trackedKey, true, $this->expiresAt()); + + return [ + 'conversion' => null, + 'conversionResolved' => true, + ]; + } catch (Throwable) { + return [ + 'conversion' => null, + 'conversionResolved' => false, + ]; + } + } + + private function trackedKey(int|string $accountId, string $sessionId): string + { + return "checkout_tracked:{$accountId}:{$sessionId}"; + } + + private function expiresAt(): DateTimeInterface + { + return now()->addHours(self::CACHE_TTL_HOURS); + } +} diff --git a/app/Support/Billing/ConfigureSubscriptionCheckout.php b/app/Support/Billing/ConfigureSubscriptionCheckout.php new file mode 100644 index 000000000..406266f35 --- /dev/null +++ b/app/Support/Billing/ConfigureSubscriptionCheckout.php @@ -0,0 +1,76 @@ + 0) { + $subscription->trialDays($trialDays); + } + + if (config('cashier.allow_promotion_codes')) { + $subscription->allowPromotionCodes(); + } + + return $subscription; + } + + /** + * Trial length the checkout will actually apply — the single source of truth + * for both the builder and any UI that advertises the trial. 0 disables the + * trial (immediate charge); 1 is clamped to Stripe's 48-hour minimum. + */ + public static function checkoutTrialDays(Account $account): int + { + $trialDays = (int) config('cashier.trial_days'); + + if ($trialDays <= 0 || ! self::qualifiesForCheckoutTrial($account)) { + return 0; + } + + return max(self::MIN_CHECKOUT_TRIAL_DAYS, $trialDays); + } + + /** + * Checkout trials are for genuinely new card-required signups. Accounts whose + * only prior subscriptions died incomplete (never paid) still qualify; any + * subscription that made it past incomplete, and no-card generic-trial + * installs, skip the trial and keep promotion codes. + */ + public static function qualifiesForCheckoutTrial(Account $account): bool + { + if (! (bool) config('trypost.billing.require_card_for_trial', true)) { + return false; + } + + return ! $account->subscriptions() + ->where('type', Account::SUBSCRIPTION_NAME) + ->whereNotIn('stripe_status', [ + StripeSubscription::STATUS_INCOMPLETE, + StripeSubscription::STATUS_INCOMPLETE_EXPIRED, + ]) + ->exists(); + } +} diff --git a/app/Support/Billing/FirstMonthCheckoutDiscount.php b/app/Support/Billing/FirstMonthCheckoutDiscount.php deleted file mode 100644 index 2fba2131c..000000000 --- a/app/Support/Billing/FirstMonthCheckoutDiscount.php +++ /dev/null @@ -1,66 +0,0 @@ -allowPromotionCodes(); - } - - $couponId = config('cashier.first_month_coupon_id'); - - if (! is_string($couponId) || $couponId === '') { - throw new RuntimeException( - 'STRIPE_FIRST_MONTH_COUPON_ID must be set when REQUIRE_CARD_FOR_TRIAL is enabled, ' - .'otherwise checkout would charge the full price instead of the $1 first month.' - ); - } - - return $subscription->withCoupon($couponId); - } - - /** - * The fixed-amount first-month coupon only applies to a genuinely new - * customer checking out a single workspace: the fixed `amount_off` is only - * correct for a quantity of one, and the $1 offer is for first-time signups - * — not a returning account re-subscribing with workspaces it kept from a - * lapsed subscription. A subscription that never left `incomplete` never - * became real, so a new customer retrying after a failed first attempt - * still qualifies; any started subscription (even canceled) does not. - */ - private static function qualifiesForPaidFirstMonth(Account $account): bool - { - if (! (bool) config('trypost.billing.require_card_for_trial', true)) { - return false; - } - - return $account->workspaces()->count() === 1 - && ! $account->subscriptions() - ->whereNotIn('stripe_status', [ - StripeSubscription::STATUS_INCOMPLETE, - StripeSubscription::STATUS_INCOMPLETE_EXPIRED, - ]) - ->exists(); - } -} diff --git a/compose.prod.yaml b/compose.prod.yaml index d5bafaab9..ffacc9ea4 100644 --- a/compose.prod.yaml +++ b/compose.prod.yaml @@ -50,6 +50,16 @@ services: REVERB_PORT: "8080" REVERB_SCHEME: http + # ===== Passport (OAuth / API keys / MCP) ===== + # REQUIRED for any durable deploy (and always for multi-node / load + # balancers). File keys under storage/ are NOT persisted by the volumes + # below — without these env vars a container recreate issues new keys and + # invalidates every API/MCP token. Generate once: + # docker compose -f compose.prod.yaml run --rm app php artisan passport:keys --show + # then paste the PEM contents here (use \n for newlines). + PASSPORT_PRIVATE_KEY: "" + PASSPORT_PUBLIC_KEY: "" + # ===== Storage ===== # Default: local disk, persisted in the "storage" volume below. FILESYSTEM_DISK: public diff --git a/config/cashier.php b/config/cashier.php index 523c76b09..596d5509e 100644 --- a/config/cashier.php +++ b/config/cashier.php @@ -131,25 +131,48 @@ | Trial Period |-------------------------------------------------------------------------- | - | The number of days for the trial period. Set to 0 to disable trials. + | Days of Stripe Checkout trial when REQUIRE_CARD_FOR_TRIAL is enabled, and + | the generic no-card trial length when it is disabled. Set to 0 to disable + | Checkout trials (customer is charged immediately at checkout). + | + | Empty / missing env falls back to 8 (an empty string must not become 0). + | Values of 1 are clamped to 2 at checkout — Stripe requires ≥ 48 hours. | */ - 'trial_days' => env('CASHIER_TRIAL_DAYS', 8), + // Empty / missing env falls back to 8 (an empty string must not become 0). + 'trial_days' => (static function (): int { + $value = env('CASHIER_TRIAL_DAYS', 8); + + if ($value === null || $value === '') { + return 8; + } + + $days = filter_var($value, FILTER_VALIDATE_INT); + + return $days === false ? 8 : max(0, $days); + })(), /* |-------------------------------------------------------------------------- - | Paid First Month Coupon + | Allow Promotion Codes |-------------------------------------------------------------------------- | - | Stripe Coupon ID applied at checkout so the first invoice comes out to - | $1 instead of the full monthly price — a real charge validates the - | card up front instead of a $0 trial authorization. Must be an - | `amount_off` coupon with `duration: once`, so it discounts only the - | first invoice and the full price bills automatically afterward. + | Show Stripe Checkout's promotion-code field so customers can redeem + | promotion codes created in the Stripe Dashboard. | */ - 'first_month_coupon_id' => env('STRIPE_FIRST_MONTH_COUPON_ID'), + // Empty / missing env falls back to true so `CASHIER_ALLOW_PROMOTION_CODES=` + // does not silently hide the field. + 'allow_promotion_codes' => (static function (): bool { + $value = env('CASHIER_ALLOW_PROMOTION_CODES', true); + + if ($value === null || $value === '') { + return true; + } + + return filter_var($value, FILTER_VALIDATE_BOOLEAN, FILTER_NULL_ON_FAILURE) ?? true; + })(), ]; diff --git a/database/migrations/2026_07_24_160704_add_onboarding_timestamps_to_accounts_table.php b/database/migrations/2026_07_24_160704_add_onboarding_timestamps_to_accounts_table.php new file mode 100644 index 000000000..0942f5554 --- /dev/null +++ b/database/migrations/2026_07_24_160704_add_onboarding_timestamps_to_accounts_table.php @@ -0,0 +1,32 @@ +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']); + }); + } +}; diff --git a/database/migrations/2026_07_29_183500_backfill_onboarding_dismissed_for_accounts_with_app_access.php b/database/migrations/2026_07_29_183500_backfill_onboarding_dismissed_for_accounts_with_app_access.php new file mode 100644 index 000000000..8f07b24a9 --- /dev/null +++ b/database/migrations/2026_07_29_183500_backfill_onboarding_dismissed_for_accounts_with_app_access.php @@ -0,0 +1,98 @@ +stampInChunks( + DB::table('accounts') + ->whereNull('onboarding_dismissed_at') + ->whereNull('onboarding_completed_at'), + $now, + ); + + return; + } + + $query = DB::table('accounts') + ->whereNull('onboarding_dismissed_at') + ->whereNull('onboarding_completed_at') + ->where(function (Builder $accounts) use ($now): void { + $accounts->whereExists(function (Builder $subscriptions) use ($now): void { + $subscriptions->selectRaw('1') + ->from('subscriptions') + ->whereColumn('subscriptions.account_id', 'accounts.id') + ->where('subscriptions.type', 'default') + ->where('subscriptions.id', function (Builder $latestSubscription): void { + $latestSubscription->select('latest.id') + ->from('subscriptions as latest') + ->whereColumn('latest.account_id', 'accounts.id') + ->where('latest.type', 'default') + ->latest('latest.created_at') + ->latest('latest.id') + ->limit(1); + }) + ->where(function (Builder $valid) use ($now): void { + $valid + ->where('subscriptions.ends_at', '>', $now) + ->orWhere('subscriptions.trial_ends_at', '>', $now) + ->orWhere(function (Builder $active) use ($now): void { + $active->where(function (Builder $notEnded) use ($now): void { + $notEnded->whereNull('subscriptions.ends_at') + ->orWhere('subscriptions.ends_at', '>', $now); + })->whereNotIn('subscriptions.stripe_status', [ + 'incomplete', + 'incomplete_expired', + 'unpaid', + ]); + }); + }); + }); + + if (! (bool) config('trypost.billing.require_card_for_trial', true)) { + $accounts->orWhere('accounts.trial_ends_at', '>', $now); + } + }); + + $this->stampInChunks($query, $now); + } + + /** + * Update in id-ordered chunks so a large accounts table is not locked by a + * single statement. chunkById paginates on the id column, so rows stamped + * by earlier chunks falling out of the WHERE is harmless. + */ + private function stampInChunks(Builder $query, CarbonInterface $now): void + { + $query->chunkById(500, function ($accounts) use ($now): void { + DB::table('accounts') + ->whereIn('id', $accounts->pluck('id')) + ->update([ + 'onboarding_dismissed_at' => $now, + 'updated_at' => $now, + ]); + }); + } + + public function down(): void + { + // Data backfill — rows stamped here are indistinguishable from a real + // user skip, so a rollback intentionally keeps them. + } +}; diff --git a/database/seeders/DatabaseSeeder.php b/database/seeders/DatabaseSeeder.php index b1f2022a0..5222ec9a8 100644 --- a/database/seeders/DatabaseSeeder.php +++ b/database/seeders/DatabaseSeeder.php @@ -14,8 +14,8 @@ class DatabaseSeeder extends Seeder public function run(): void { $this->call([ - PlanSeeder::class, PassportSeeder::class, + PlanSeeder::class, ]); } } diff --git a/database/seeders/PassportSeeder.php b/database/seeders/PassportSeeder.php index 32c5473eb..417c3a2bf 100644 --- a/database/seeders/PassportSeeder.php +++ b/database/seeders/PassportSeeder.php @@ -5,20 +5,24 @@ namespace Database\Seeders; use Illuminate\Database\Seeder; +use Illuminate\Support\Facades\Cache; use Laravel\Passport\ClientRepository; +use RuntimeException; class PassportSeeder extends Seeder { public function run(ClientRepository $clients): void { - try { - $clients->personalAccessClient('users'); + Cache::lock('passport:personal-access-client', 30)->block(10, function () use ($clients): void { + try { + $clients->personalAccessClient('users'); - return; - } catch (\RuntimeException) { - // No client yet — fall through to create. - } + return; + } catch (RuntimeException) { + // No client yet — fall through to create. + } - $clients->createPersonalAccessGrantClient(name: 'TryPost Personal Access Client'); + $clients->createPersonalAccessGrantClient(name: 'TryPost Personal Access Client'); + }); } } diff --git a/docker/entrypoint.sh b/docker/entrypoint.sh index 23aa100fa..8fdc403ab 100755 --- a/docker/entrypoint.sh +++ b/docker/entrypoint.sh @@ -7,6 +7,17 @@ cd /var/www/html TARGET="${TRYPOST_TARGET:-dev}" +# One-off commands from `docker compose run app ...` must bypass the long-lived +# application bootstrap and execute exactly as requested. +if [ "$#" -gt 0 ]; then + exec "$@" +fi + +if [ "${TARGET}" = "production" ] && [ -z "${APP_KEY:-}" ]; then + echo "[entrypoint] APP_KEY is required in production" >&2 + exit 1 +fi + # 1) Bootstrap .env from the Docker template on first dev boot. The bind-mount # in dev hides /var/www/html/.env.docker.example, so prefer docker/ first. if [ "${TRYPOST_DOCKER_BOOTSTRAP:-0}" = "1" ] && [ ! -f .env ]; then @@ -65,7 +76,7 @@ done # 7) Run migrations (graceful: succeeds even when nothing to migrate). echo "[entrypoint] running migrations" -php artisan migrate --force --graceful || true +php artisan migrate --force # 8) storage:link if missing. if [ ! -L public/storage ]; then @@ -73,17 +84,31 @@ if [ ! -L public/storage ]; then php artisan storage:link --force || true fi -# 9) Passport keys on first boot. -if [ ! -f storage/oauth-private.key ]; then - echo "[entrypoint] generating Passport keys" - php artisan passport:keys --force || true +# 9) Passport keys. Prefer PASSPORT_PRIVATE_KEY / PASSPORT_PUBLIC_KEY from +# the environment (required for durable / multi-node deploys — storage/oauth-* +# is not on a persisted volume in compose.prod.yaml). Fall back to generating +# files under storage/ only for local/dev when those env vars are unset. +if [ -n "${PASSPORT_PRIVATE_KEY:-}" ] && [ -n "${PASSPORT_PUBLIC_KEY:-}" ]; then + echo "[entrypoint] using Passport keys from environment" +elif [ "${TRYPOST_TARGET:-}" = "production" ] || [ "${APP_ENV:-}" = "production" ]; then + echo "[entrypoint] ERROR: PASSPORT_PRIVATE_KEY and PASSPORT_PUBLIC_KEY must be set in production." >&2 + echo "[entrypoint] Generate once with: php artisan passport:keys --show" >&2 + exit 1 +elif [ ! -f storage/oauth-private.key ] || [ ! -f storage/oauth-public.key ]; then + echo "[entrypoint] generating Passport keys (dev fallback)" + php artisan passport:keys --force fi -# 10) Wayfinder TS regen — Vite needs the files before it boots. +# 10) Personal access client for REST API keys. The seeder is idempotent, so +# fresh self-hosted installs and existing deployments are both safe. +echo "[entrypoint] ensuring Passport personal access client" +php artisan db:seed --class='Database\Seeders\PassportSeeder' --force + +# 11) Wayfinder TS regen — Vite needs the files before it boots. echo "[entrypoint] regenerating wayfinder helpers" php artisan wayfinder:generate --with-form || true -# 11) Cache strategy: prod = pre-cache; dev = clear. +# 12) Cache strategy: prod = pre-cache; dev = clear. if [ "${TARGET}" = "production" ]; then php artisan config:cache php artisan route:cache @@ -96,7 +121,7 @@ else php artisan event:clear fi -# 12) Permissions. Production php-fpm pool runs as www-data (Alpine default), +# 13) Permissions. Production php-fpm pool runs as www-data (Alpine default), # so storage and bootstrap/cache must be writable by that user — Laravel # needs to write session files, view cache, log files, etc. if [ "${TARGET}" = "production" ]; then diff --git a/eslint.config.js b/eslint.config.js index b92074a22..f933ef5a9 100644 --- a/eslint.config.js +++ b/eslint.config.js @@ -1,6 +1,5 @@ import { defineConfigWithVueTs, vueTsConfigs } from '@vue/eslint-config-typescript'; import prettier from 'eslint-config-prettier'; -import importPlugin from 'eslint-plugin-import'; import vue from 'eslint-plugin-vue'; export default defineConfigWithVueTs( @@ -14,39 +13,16 @@ export default defineConfigWithVueTs( 'bootstrap/ssr', 'tailwind.config.js', 'resources/js/components/ui/*', - // Wayfinder regenerates these on every build with import order - // matching PHP file scan, not alphabetical. Excluding them avoids - // a perpetual fight between the generator and import/order. + // Wayfinder regenerates these on every build; keep them out of ESLint + // so generator output never fights hand-written lint rules. 'resources/js/actions/**', 'resources/js/routes/**', ], }, { - plugins: { - import: importPlugin, - }, - settings: { - 'import/resolver': { - typescript: { - alwaysTryTypes: true, - project: './tsconfig.json', - }, - }, - }, rules: { 'vue/multi-word-component-names': 'off', '@typescript-eslint/no-explicit-any': 'off', - 'import/order': [ - 'error', - { - groups: ['builtin', 'external', 'internal', 'parent', 'sibling', 'index'], - 'newlines-between': 'always', - alphabetize: { - order: 'asc', - caseInsensitive: true, - }, - }, - ], }, }, prettier, diff --git a/lang/ar/billing.php b/lang/ar/billing.php index d32643acc..a3fe5e6a4 100644 --- a/lang/ar/billing.php +++ b/lang/ar/billing.php @@ -68,9 +68,12 @@ 'title' => 'جارٍ معالجة اشتراكك', 'description' => 'يرجى الانتظار بينما نُعِدّ حسابك. لن يستغرق هذا سوى لحظة.', 'success_title' => 'كل شيء جاهز!', - 'success_description' => 'اشتراكك نشط. جارٍ إعادة توجيهك إلى مساحات عملك...', + 'success_description' => 'اشتراكك نشط. جارٍ تحويلك…', 'cancelled_title' => 'تم إلغاء الدفع', 'cancelled_description' => 'تم إلغاء عملية الدفع. لم تُجرَ أي رسوم.', 'retry' => 'إعادة المحاولة', + 'taking_long' => 'يستغرق هذا وقتًا أطول من المتوقع — انتظر، ما زلنا نجهّز حسابك.', + 'continue' => 'المتابعة إلى التطبيق', + 'live' => 'مباشر', ], ]; diff --git a/lang/ar/mcp.php b/lang/ar/mcp.php new file mode 100644 index 000000000..7220a00cb --- /dev/null +++ b/lang/ar/mcp.php @@ -0,0 +1,41 @@ + 'MCP', + 'subtitle' => 'اربط مساعدي الذكاء الاصطناعي لإنشاء المنشورات وإدارتها بحساب TryPost الخاص بك.', + 'step_add' => 'الصق الاسم أو الرابط أو الإعداد أدناه في تطبيقك. يفتح تسجيل الدخول في المتصفح عند أول اتصال.', + 'name_label' => 'الاسم', + 'url_label' => 'رابط الخادم', + 'config_label' => 'الإعداد', + 'connected_title' => 'التطبيقات المتصلة', + 'connected_description' => 'المساعدون الذين سجّل دخولهم أي شخص على هذا الحساب. يمكنك قطع اتصال تطبيقاتك فقط.', + 'connected_empty' => 'لا يوجد اتصال بعد. استخدم Claude أو ChatGPT أو عميلًا آخر أعلاه.', + 'connected_by' => 'متصل بواسطة :name', + 'disconnect' => 'قطع الاتصال', + 'disconnect_title' => 'قطع اتصال التطبيق', + 'disconnect_confirm' => 'يؤدي هذا إلى تسجيل خروج التطبيق من TryPost. سيحتاج إلى إعادة الاتصال قبل استخدام MCP مجددًا.', + 'disconnected' => 'تم قطع اتصال التطبيق.', + 'copied' => 'تم النسخ', + 'last_used' => 'آخر استخدام', + 'never' => 'أبدًا', + 'documentation_title' => 'التوثيق', + 'documentation_description' => 'أدلة الإعداد لكل عميل، والأدوات المتاحة، وحل المشكلات.', + 'view_docs' => 'عرض التوثيق', + 'connector_name' => 'TryPost', + + 'other_clients_title' => 'تطبيقات أخرى', + 'other_clients_description' => 'Cursor وVS Code وClaude Code وأي تطبيق يدعم MCP.', + + 'clients' => [ + 'cursor' => 'أضف TryPost كخادم MCP بعيد في Cursor.', + 'cursor_name' => 'Cursor', + 'vscode' => 'الصق الإعداد أدناه في إعدادات MCP في VS Code.', + 'vscode_name' => 'VS Code', + 'claude_code' => 'الصق الإعداد أدناه في إعدادات MCP في Claude Code.', + 'claude_code_name' => 'Claude Code', + 'other' => 'يعمل مع أي عميل يقرأ إعداد mcpServers.', + 'other_name' => 'أخرى', + ], +]; diff --git a/lang/ar/onboarding.php b/lang/ar/onboarding.php index 5103f1d30..e71f972b3 100644 --- a/lang/ar/onboarding.php +++ b/lang/ar/onboarding.php @@ -3,55 +3,47 @@ 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', + 'welcome_anonymous' => 'مرحبًا بك في TryPost', + 'description' => 'اتبع الخطوات أدناه لمعرفة كيف يعمل TryPost ونشر منشورك الأول.', + 'skip_step' => 'تخطي هذه الخطوة', + 'continue' => 'المتابعة إلى TryPost', + 'status' => [ + 'complete' => 'مكتمل', + 'todo' => 'مطلوب', + 'skipped' => 'تم تخطيه', ], - 'goals_title' => 'ما هدفك من استخدام TryPost؟', - 'goals_description' => 'اختر كل ما يناسبك وسنقوم بإعداد TryPost من أجلك.', - 'goals' => [ - 'save_time' => 'توفير الوقت بالنشر في كل مكان دفعة واحدة', - 'ai_content' => 'إنشاء منشورات أسرع بالذكاء الاصطناعي', - 'plan_calendar' => 'التخطيط لمنشوراتي على التقويم', - 'stay_on_brand' => 'الحفاظ على اتساق كل منشور مع العلامة التجارية', - 'grow_audience' => 'تنمية جمهوري وزيادة التفاعل', - 'drive_sales' => 'الحصول على المزيد من الزيارات والمبيعات', - 'manage_clients' => 'إدارة عدة علامات تجارية أو عملاء', - 'team_collaboration' => 'العمل مع فريقي', - 'automate_api' => 'أتمتة النشر عبر الواجهة البرمجية أو MCP أو الكود', - 'track_performance' => 'معرفة أداء منشوراتي', - 'just_exploring' => 'مجرد استكشاف في الوقت الحالي', - 'other' => 'شيء آخر', + 'mcp' => [ + 'title' => 'اربط مساعد الذكاء الاصطناعي', + 'description' => 'أضِف TryPost كخادم MCP ليتمكّن مساعدك من إنشاء منشورات التواصل وإدارتها نيابةً عنك.', + 'copy_step' => 'انسخ عنوان خادم TryPost', + 'open_step' => 'افتح مساعد الذكاء الاصطناعي', + 'copy' => 'نسخ الرابط', + 'copied' => 'تم نسخ رابط MCP.', + 'connect' => 'الاتصال عبر :client', + 'clients' => [ + 'claude' => 'افتح Settings → Connectors، أضِف موصلًا مخصصًا، ثم الصق الرابط أعلاه.', + 'chatgpt' => 'افتح Settings → Apps & Connectors، أنشئ موصلًا مخصصًا، ثم الصق الرابط أعلاه.', + ], ], - '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 وابدأ بتخطيط محتواك.', ], ]; diff --git a/lang/ar/settings.php b/lang/ar/settings.php index 36c2ecad1..d3dc72c88 100644 --- a/lang/ar/settings.php +++ b/lang/ar/settings.php @@ -129,6 +129,7 @@ 'brand' => 'العلامة التجارية', 'users' => 'الأعضاء', 'api_keys' => 'مفاتيح API', + 'mcp' => 'MCP', ], 'title' => 'إعدادات مساحة العمل', 'logo_heading' => 'شعار مساحة العمل', diff --git a/lang/ar/sidebar.php b/lang/ar/sidebar.php index a9587e13e..5573673f3 100644 --- a/lang/ar/sidebar.php +++ b/lang/ar/sidebar.php @@ -8,6 +8,9 @@ 'create_workspace' => 'إنشاء مساحة عمل', 'create_post' => 'إنشاء منشور', 'profile' => 'الملف الشخصي', + 'my_account' => 'حسابي', + 'account_settings' => 'الحساب والفوترة', + 'workspace_settings' => 'إعدادات مساحة العمل', 'log_out' => 'تسجيل الخروج', 'workspace' => 'مساحة العمل: :name', @@ -30,6 +33,8 @@ 'analytics' => 'التحليلات', 'automations' => 'الأتمتة', 'settings' => 'الإعدادات', + 'onboarding' => 'البدء', + 'onboarding_hint' => 'أكمل الإعداد', 'posts' => [ 'calendar' => 'التقويم', @@ -44,7 +49,9 @@ 'signatures' => 'التوقيعات', 'labels' => 'التسميات', 'assets' => 'الوسائط', + 'settings' => 'الإعدادات', 'api_keys' => 'مفاتيح API', + 'mcp' => 'MCP', ], 'notifications' => 'الإشعارات', diff --git a/lang/ar/welcome.php b/lang/ar/welcome.php new file mode 100644 index 000000000..50ec7db99 --- /dev/null +++ b/lang/ar/welcome.php @@ -0,0 +1,57 @@ + 'ما الذي يصفك بشكل أفضل؟', + 'description' => 'اختر الخيار الأقرب وسنخصص تجربتك.', + 'continue' => 'متابعة', + 'checkout_owner_only' => 'اطلب من مالك الحساب إكمال الدفع وبدء الاشتراك.', + 'subscription_required_title' => 'في انتظار مالك الحساب', + 'subscription_required_description' => 'هذا الحساب لا يملك اشتراكًا نشطًا بعد. اطلب من مالك الحساب إكمال الدفع — ستحصل على وصول كامل فور تفعيل الاشتراك.', + 'subscription_required_owner' => 'مالك حسابك هو :name.', + 'subscription_required_auto' => 'يتم تحديث هذه الصفحة تلقائيًا — لا حاجة لإعادة التحميل.', + 'progress' => 'تقدم الترحيب', + 'go_to_step' => 'الانتقال إلى الخطوة :step', + 'step_current' => 'الخطوة :step (الحالية)', + 'personas' => [ + 'creator' => 'صانع محتوى', + 'freelancer' => 'مستقل', + 'developer' => 'مطوّر', + 'startup' => 'شركة ناشئة', + 'agency' => 'وكالة', + 'small_business' => 'نشاط تجاري صغير', + 'marketer' => 'مسوّق', + 'online_store' => 'متجر إلكتروني', + 'other' => 'أخرى', + ], + 'goals_title' => 'ما هدفك؟', + 'goals_description' => 'اختر كل ما يناسبك وسنقوم بإعداد TryPost من أجلك.', + 'goals' => [ + 'save_time' => 'توفير الوقت بالنشر في كل مكان دفعة واحدة', + 'ai_content' => 'إنشاء منشورات أسرع بالذكاء الاصطناعي', + 'plan_calendar' => 'التخطيط لمنشوراتي على التقويم', + 'stay_on_brand' => 'الحفاظ على اتساق كل منشور مع العلامة التجارية', + 'grow_audience' => 'تنمية جمهوري وزيادة التفاعل', + 'drive_sales' => 'الحصول على المزيد من الزيارات والمبيعات', + 'manage_clients' => 'إدارة عدة علامات تجارية أو عملاء', + 'just_exploring' => 'مجرد استكشاف في الوقت الحالي', + 'other' => 'شيء آخر', + ], + 'referral_source_title' => 'كيف وجدتنا؟', + 'referral_source_description' => 'يساعدنا هذا على فهم كيفية اكتشاف الأشخاص لـ TryPost.', + 'referral_source' => [ + 'google' => 'Google أو البحث', + 'x' => 'X (Twitter)', + 'linkedin' => 'LinkedIn', + 'youtube' => 'YouTube', + 'tiktok' => 'TikTok', + 'instagram' => 'Instagram أو Threads', + 'reddit' => 'Reddit', + 'product_hunt' => 'Product Hunt', + 'ai_assistant' => 'مساعد ذكاء اصطناعي (ChatGPT، Claude…)', + 'friend' => 'صديق أو زميل', + 'blog' => 'مدونة أو نشرة إخبارية أو مقال', + 'other' => 'شيء آخر', + ], +]; diff --git a/lang/de/billing.php b/lang/de/billing.php index b977e9f3c..d37ccc103 100644 --- a/lang/de/billing.php +++ b/lang/de/billing.php @@ -70,9 +70,12 @@ 'title' => 'Dein Abonnement wird verarbeitet', 'description' => 'Bitte warte, während wir dein Konto einrichten. Das dauert nur einen Moment.', 'success_title' => 'Alles bereit!', - 'success_description' => 'Dein Abonnement ist aktiv. Du wirst zu deinen Workspaces weitergeleitet...', + 'success_description' => 'Dein Abo ist aktiv. Weiterleitung…', 'cancelled_title' => 'Bezahlvorgang abgebrochen', 'cancelled_description' => 'Dein Bezahlvorgang wurde abgebrochen. Es wurden keine Kosten berechnet.', 'retry' => 'Erneut versuchen', + 'taking_long' => 'Das dauert länger als erwartet — bitte warten, die Einrichtung läuft noch.', + 'continue' => 'Weiter zur App', + 'live' => 'Live', ], ]; diff --git a/lang/de/mcp.php b/lang/de/mcp.php new file mode 100644 index 000000000..958b89989 --- /dev/null +++ b/lang/de/mcp.php @@ -0,0 +1,41 @@ + 'MCP', + 'subtitle' => 'Verbinde KI-Assistenten, damit sie Beiträge mit deinem TryPost-Konto erstellen und verwalten können.', + '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', + 'config_label' => 'Config', + 'connected_title' => 'Verbundene Apps', + 'connected_description' => 'Assistenten, die jemand in diesem Konto angemeldet hat. Du kannst nur deine eigenen trennen.', + 'connected_empty' => 'Noch nichts verbunden. Nutze Claude, ChatGPT oder einen anderen Client oben.', + 'connected_by' => 'Verbunden von :name', + 'disconnect' => 'Trennen', + 'disconnect_title' => 'App trennen', + 'disconnect_confirm' => 'Dadurch wird die App von TryPost abgemeldet. Sie muss sich neu verbinden, bevor sie MCP wieder nutzen kann.', + 'disconnected' => 'App getrennt.', + 'copied' => 'Kopiert', + 'last_used' => 'Zuletzt verwendet', + 'never' => 'Nie', + 'documentation_title' => 'Dokumentation', + 'documentation_description' => 'Einrichtungsguides pro Client, verfügbare Tools und Fehlerhilfe.', + 'view_docs' => 'Dokumentation ansehen', + 'connector_name' => 'TryPost', + + 'other_clients_title' => 'Andere Apps', + 'other_clients_description' => 'Cursor, VS Code, Claude Code und alles andere, das MCP spricht.', + + 'clients' => [ + 'cursor' => 'Füge TryPost in Cursor als Remote-MCP-Server hinzu.', + 'cursor_name' => 'Cursor', + 'vscode' => 'Füge die Konfiguration unten in die MCP-Einstellungen von VS Code ein.', + 'vscode_name' => 'VS Code', + 'claude_code' => 'Füge die Konfiguration unten in die MCP-Einstellungen von Claude Code ein.', + 'claude_code_name' => 'Claude Code', + 'other' => 'Funktioniert mit jedem Client, der eine mcpServers-Config liest.', + 'other_name' => 'Andere', + ], +]; diff --git a/lang/de/onboarding.php b/lang/de/onboarding.php index 9ccf8ad72..f2ab035da 100644 --- a/lang/de/onboarding.php +++ b/lang/de/onboarding.php @@ -3,55 +3,47 @@ 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', + 'welcome_anonymous' => 'Willkommen bei TryPost', + '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 schneller mit KI erstellen', - 'plan_calendar' => 'Meine Beiträge in einem Kalender planen', - 'stay_on_brand' => 'Jeden Beitrag markenkonform halten', - 'grow_audience' => 'Meine Reichweite und mein Engagement steigern', - 'drive_sales' => 'Mehr Traffic und Verkäufe erzielen', - 'manage_clients' => 'Mehrere Marken oder Kunden verwalten', - 'team_collaboration' => 'Mit meinem Team arbeiten', - 'automate_api' => 'Das Posten per API, MCP oder Code automatisieren', - 'track_performance' => 'Sehen, wie meine Beiträge performen', - 'just_exploring' => 'Ich schaue mich vorerst nur um', - 'other' => 'Etwas anderes', + '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.', + 'copy_step' => 'Kopiere deine TryPost-Server-URL', + 'open_step' => 'Öffne deinen KI-Assistenten', + 'copy' => 'URL kopieren', + 'copied' => 'MCP-URL kopiert.', + 'connect' => 'Mit :client verbinden', + 'clients' => [ + 'claude' => 'Öffne Settings → Connectors, füge einen benutzerdefinierten Connector hinzu und füge die URL oben ein.', + 'chatgpt' => 'Öffne Settings → Apps & Connectors, erstelle einen benutzerdefinierten Connector und füge die URL oben ein.', + ], ], - '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.', ], ]; diff --git a/lang/de/settings.php b/lang/de/settings.php index 9ef23eb1b..e1bfc9991 100644 --- a/lang/de/settings.php +++ b/lang/de/settings.php @@ -131,6 +131,7 @@ 'brand' => 'Marke', 'users' => 'Mitglieder', 'api_keys' => 'API-Keys', + 'mcp' => 'MCP', ], 'title' => 'Workspace-Einstellungen', 'logo_heading' => 'Workspace-Logo', diff --git a/lang/de/sidebar.php b/lang/de/sidebar.php index c9a593f97..7c075db6d 100644 --- a/lang/de/sidebar.php +++ b/lang/de/sidebar.php @@ -8,6 +8,9 @@ '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', @@ -30,6 +33,8 @@ 'analytics' => 'Analytics', 'automations' => 'Automatisierungen', 'settings' => 'Einstellungen', + 'onboarding' => 'Erste Schritte', + 'onboarding_hint' => 'Einrichtung abschließen', 'posts' => [ 'calendar' => 'Kalender', @@ -44,7 +49,9 @@ 'signatures' => 'Signaturen', 'labels' => 'Labels', 'assets' => 'Assets', + 'settings' => 'Einstellungen', 'api_keys' => 'API-Keys', + 'mcp' => 'MCP', ], 'notifications' => 'Benachrichtigungen', diff --git a/lang/de/welcome.php b/lang/de/welcome.php new file mode 100644 index 000000000..c245891f1 --- /dev/null +++ b/lang/de/welcome.php @@ -0,0 +1,57 @@ + 'Was beschreibt dich am besten?', + 'description' => 'Wähle die passende Option, und wir passen dein Erlebnis an.', + 'continue' => 'Weiter', + 'checkout_owner_only' => 'Bitte den Kontoinhaber, den Checkout abzuschließen und das Abo zu starten.', + 'subscription_required_title' => 'Warten auf den Kontoinhaber', + 'subscription_required_description' => 'Dieses Konto hat noch kein aktives Abo. Bitte den Kontoinhaber, den Checkout abzuschließen — du erhältst vollen Zugriff, sobald es aktiv ist.', + 'subscription_required_owner' => 'Der Kontoinhaber ist :name.', + 'subscription_required_auto' => 'Diese Seite aktualisiert sich automatisch — kein Neuladen nötig.', + 'progress' => 'Willkommensfortschritt', + 'go_to_step' => 'Zu Schritt :step gehen', + 'step_current' => 'Schritt :step (aktuell)', + 'personas' => [ + 'creator' => 'Content Creator', + 'freelancer' => 'Freelancer', + 'developer' => 'Entwickler', + 'startup' => 'Startup', + 'agency' => 'Agentur', + 'small_business' => 'Kleinunternehmen', + 'marketer' => 'Marketer', + 'online_store' => 'Onlineshop', + 'other' => 'Sonstiges', + ], + 'goals_title' => 'Was ist dein Ziel?', + 'goals_description' => 'Wähle alles aus, was passt, und wir richten TryPost für dich ein.', + 'goals' => [ + 'save_time' => 'Zeit sparen, indem ich überall gleichzeitig poste', + 'ai_content' => 'Beiträge schneller mit KI erstellen', + 'plan_calendar' => 'Meine Beiträge in einem Kalender planen', + 'stay_on_brand' => 'Jeden Beitrag markenkonform halten', + 'grow_audience' => 'Meine Reichweite und mein Engagement steigern', + 'drive_sales' => 'Mehr Traffic und Verkäufe erzielen', + 'manage_clients' => 'Mehrere Marken oder Kunden verwalten', + 'just_exploring' => 'Ich schaue mich vorerst nur um', + 'other' => 'Etwas anderes', + ], + 'referral_source_title' => 'Wie hast du uns gefunden?', + 'referral_source_description' => 'Das hilft uns zu verstehen, wie Menschen TryPost entdecken.', + 'referral_source' => [ + 'google' => 'Google oder Suche', + 'x' => 'X (Twitter)', + 'linkedin' => 'LinkedIn', + 'youtube' => 'YouTube', + 'tiktok' => 'TikTok', + 'instagram' => 'Instagram oder Threads', + 'reddit' => 'Reddit', + 'product_hunt' => 'Product Hunt', + 'ai_assistant' => 'KI-Assistent (ChatGPT, Claude…)', + 'friend' => 'Freund oder Kollege', + 'blog' => 'Blog, Newsletter oder Artikel', + 'other' => 'Etwas anderes', + ], +]; diff --git a/lang/el/billing.php b/lang/el/billing.php index fa643fe71..eb62ea664 100644 --- a/lang/el/billing.php +++ b/lang/el/billing.php @@ -68,9 +68,12 @@ 'title' => 'Επεξεργασία της συνδρομής σας', 'description' => 'Παρακαλούμε περιμένετε όσο ρυθμίζουμε τον λογαριασμό σας. Θα πάρει μόνο μια στιγμή.', 'success_title' => 'Είστε έτοιμοι!', - 'success_description' => 'Η συνδρομή σας είναι ενεργή. Σας ανακατευθύνουμε στα workspaces σας...', + 'success_description' => 'Η συνδρομή σας είναι ενεργή. Ανακατεύθυνση…', 'cancelled_title' => 'Η πληρωμή ακυρώθηκε', 'cancelled_description' => 'Η πληρωμή σας ακυρώθηκε. Δεν έγινε καμία χρέωση.', 'retry' => 'Δοκιμάστε ξανά', + 'taking_long' => 'Παίρνει περισσότερο από το αναμενόμενο — περιμένετε, συνεχίζουμε τη ρύθμιση.', + 'continue' => 'Συνέχεια στην εφαρμογή', + 'live' => 'Ζωντανά', ], ]; diff --git a/lang/el/mcp.php b/lang/el/mcp.php new file mode 100644 index 000000000..4e0ace84f --- /dev/null +++ b/lang/el/mcp.php @@ -0,0 +1,41 @@ + 'MCP', + 'subtitle' => 'Συνδέστε βοηθούς AI για να δημιουργούν και να διαχειρίζονται αναρτήσεις με τον λογαριασμό TryPost σας.', + 'step_add' => 'Επικολλήστε το όνομα, το URL ή το config παρακάτω στην εφαρμογή σας. Η σύνδεση ανοίγει στο πρόγραμμα περιήγησης την πρώτη φορά.', + 'name_label' => 'Όνομα', + 'url_label' => 'URL διακομιστή', + 'config_label' => 'Config', + 'connected_title' => 'Συνδεδεμένες εφαρμογές', + 'connected_description' => 'Βοηθοί με σύνδεση από οποιονδήποτε σε αυτόν τον λογαριασμό. Μπορείτε να αποσυνδέσετε μόνο τους δικούς σας.', + 'connected_empty' => 'Τίποτα συνδεδεμένο ακόμα. Χρησιμοποιήστε Claude, ChatGPT ή άλλο client παραπάνω.', + 'connected_by' => 'Συνδέθηκε από :name', + 'disconnect' => 'Αποσύνδεση', + 'disconnect_title' => 'Αποσύνδεση εφαρμογής', + 'disconnect_confirm' => 'Αυτό αποσυνδέει την εφαρμογή από το TryPost. Θα χρειαστεί να συνδεθεί ξανά πριν χρησιμοποιήσει το MCP.', + 'disconnected' => 'Η εφαρμογή αποσυνδέθηκε.', + 'copied' => 'Αντιγράφηκε', + 'last_used' => 'Τελευταία χρήση', + 'never' => 'Ποτέ', + 'documentation_title' => 'Τεκμηρίωση', + 'documentation_description' => 'Οδηγοί ανά client, διαθέσιμα tools και αντιμετώπιση προβλημάτων.', + 'view_docs' => 'Δείτε την τεκμηρίωση', + 'connector_name' => 'TryPost', + + 'other_clients_title' => 'Άλλες εφαρμογές', + 'other_clients_description' => 'Cursor, VS Code, Claude Code και ό,τι άλλο μιλάει MCP.', + + 'clients' => [ + 'cursor' => 'Προσθέστε το TryPost ως απομακρυσμένο MCP server στο Cursor.', + 'cursor_name' => 'Cursor', + 'vscode' => 'Επικολλήστε το config παρακάτω στις ρυθμίσεις MCP του VS Code.', + 'vscode_name' => 'VS Code', + 'claude_code' => 'Επικολλήστε το config παρακάτω στις ρυθμίσεις MCP του Claude Code.', + 'claude_code_name' => 'Claude Code', + 'other' => 'Λειτουργεί με κάθε client που διαβάζει config mcpServers.', + 'other_name' => 'Άλλα', + ], +]; diff --git a/lang/el/onboarding.php b/lang/el/onboarding.php index d0eccc361..c22643771 100644 --- a/lang/el/onboarding.php +++ b/lang/el/onboarding.php @@ -3,55 +3,47 @@ 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', + 'welcome_anonymous' => 'Καλώς ήρθες στο TryPost', + 'description' => 'Ακολούθησε τα παρακάτω βήματα για να δεις πώς λειτουργεί το TryPost και να δημοσιεύσεις την πρώτη σου ανάρτηση.', + 'skip_step' => 'Παράλειψη αυτού του βήματος', + 'continue' => 'Συνέχεια στο TryPost', + 'status' => [ + 'complete' => 'Ολοκληρώθηκε', + 'todo' => 'Εκκρεμεί', + 'skipped' => 'Παραλείφθηκε', ], - 'goals_title' => 'Ποιος είναι ο στόχος σας με το TryPost;', - 'goals_description' => 'Επιλέξτε ό,τι σας ταιριάζει και θα ρυθμίσουμε το TryPost για εσάς.', - 'goals' => [ - 'save_time' => 'Εξοικονόμηση χρόνου δημοσιεύοντας παντού ταυτόχρονα', - 'ai_content' => 'Δημιουργία δημοσιεύσεων ταχύτερα με AI', - 'plan_calendar' => 'Προγραμματισμός των δημοσιεύσεών μου σε ημερολόγιο', - 'stay_on_brand' => 'Διατήρηση κάθε δημοσίευσης εναρμονισμένης με τη μάρκα', - 'grow_audience' => 'Ανάπτυξη του κοινού και της αλληλεπίδρασής μου', - 'drive_sales' => 'Περισσότερη επισκεψιμότητα και πωλήσεις', - 'manage_clients' => 'Διαχείριση πολλών μαρκών ή πελατών', - 'team_collaboration' => 'Συνεργασία με την ομάδα μου', - 'automate_api' => 'Αυτοματοποίηση δημοσιεύσεων με το API, το MCP ή κώδικα', - 'track_performance' => 'Παρακολούθηση της απόδοσης των δημοσιεύσεών μου', - 'just_exploring' => 'Απλώς εξερευνώ προς το παρόν', - 'other' => 'Κάτι άλλο', + 'mcp' => [ + 'title' => 'Σύνδεσε τον βοηθό AI σου', + 'description' => 'Πρόσθεσε το TryPost ως διακομιστή MCP ώστε ο βοηθός σου να δημιουργεί και να διαχειρίζεται social posts για εσένα.', + 'copy_step' => 'Αντίγραψε το URL του διακομιστή TryPost', + 'open_step' => 'Άνοιξε τον βοηθό AI σου', + 'copy' => 'Αντιγραφή URL', + 'copied' => 'Το URL MCP αντιγράφηκε.', + 'connect' => 'Σύνδεση με :client', + 'clients' => [ + 'claude' => 'Άνοιξε Settings → Connectors, πρόσθεσε έναν προσαρμοσμένο connector και επικόλλησε το παραπάνω URL.', + 'chatgpt' => 'Άνοιξε Settings → Apps & Connectors, δημιούργησε έναν προσαρμοσμένο connector και επικόλλησε το παραπάνω 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' => '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 και ξεκίνα να σχεδιάζεις το περιεχόμενό σου.', ], ]; diff --git a/lang/el/settings.php b/lang/el/settings.php index 29dda286d..3ce5941f8 100644 --- a/lang/el/settings.php +++ b/lang/el/settings.php @@ -129,6 +129,7 @@ 'brand' => 'Μάρκα', 'users' => 'Μέλη', 'api_keys' => 'Κλειδιά API', + 'mcp' => 'MCP', ], 'title' => 'Ρυθμίσεις workspace', 'logo_heading' => 'Λογότυπο workspace', diff --git a/lang/el/sidebar.php b/lang/el/sidebar.php index a19adb710..e7c3f56fe 100644 --- a/lang/el/sidebar.php +++ b/lang/el/sidebar.php @@ -8,6 +8,9 @@ 'create_workspace' => 'Δημιουργία workspace', 'create_post' => 'Δημιουργία δημοσίευσης', 'profile' => 'Προφίλ', + 'my_account' => 'Ο λογαριασμός μου', + 'account_settings' => 'Λογαριασμός και χρέωση', + 'workspace_settings' => 'Ρυθμίσεις workspace', 'log_out' => 'Αποσύνδεση', 'workspace' => 'Workspace: :name', @@ -30,6 +33,8 @@ 'analytics' => 'Στατιστικά', 'automations' => 'Αυτοματισμοί', 'settings' => 'Ρυθμίσεις', + 'onboarding' => 'Ξεκινώντας', + 'onboarding_hint' => 'Ολοκλήρωση ρύθμισης', 'posts' => [ 'calendar' => 'Ημερολόγιο', @@ -44,7 +49,9 @@ 'signatures' => 'Υπογραφές', 'labels' => 'Ετικέτες', 'assets' => 'Στοιχεία', + 'settings' => 'Ρυθμίσεις', 'api_keys' => 'Κλειδιά API', + 'mcp' => 'MCP', ], 'notifications' => 'Ειδοποιήσεις', diff --git a/lang/el/welcome.php b/lang/el/welcome.php new file mode 100644 index 000000000..2f8b8b2a5 --- /dev/null +++ b/lang/el/welcome.php @@ -0,0 +1,57 @@ + 'Τι σας περιγράφει καλύτερα;', + 'description' => 'Επιλέξτε την πιο κοντινή επιλογή και θα προσαρμόσουμε την εμπειρία σας.', + 'continue' => 'Συνέχεια', + 'checkout_owner_only' => 'Ζητήστε από τον κάτοχο του λογαριασμού να ολοκληρώσει την πληρωμή και να ξεκινήσει τη συνδρομή.', + 'subscription_required_title' => 'Αναμονή για τον κάτοχο του λογαριασμού', + 'subscription_required_description' => 'Αυτός ο λογαριασμός δεν έχει ακόμη ενεργή συνδρομή. Ζητήστε από τον κάτοχο να ολοκληρώσει την πληρωμή — θα έχετε πλήρη πρόσβαση μόλις ενεργοποιηθεί.', + 'subscription_required_owner' => 'Ο κάτοχος του λογαριασμού σας είναι ο/η :name.', + 'subscription_required_auto' => 'Αυτή η σελίδα ενημερώνεται αυτόματα — δεν χρειάζεται ανανέωση.', + 'progress' => 'Πρόοδος καλωσορίσματος', + 'go_to_step' => 'Μετάβαση στο βήμα :step', + 'step_current' => 'Βήμα :step (τρέχον)', + 'personas' => [ + 'creator' => 'Δημιουργός περιεχομένου', + 'freelancer' => 'Ελεύθερος επαγγελματίας', + 'developer' => 'Προγραμματιστής', + 'startup' => 'Startup', + 'agency' => 'Πρακτορείο', + 'small_business' => 'Μικρή επιχείρηση', + 'marketer' => 'Marketer', + 'online_store' => 'Ηλεκτρονικό κατάστημα', + 'other' => 'Άλλο', + ], + 'goals_title' => 'Ποιος είναι ο στόχος σας;', + 'goals_description' => 'Επιλέξτε ό,τι σας ταιριάζει και θα ρυθμίσουμε το TryPost για εσάς.', + 'goals' => [ + 'save_time' => 'Εξοικονόμηση χρόνου δημοσιεύοντας παντού ταυτόχρονα', + 'ai_content' => 'Δημιουργία δημοσιεύσεων ταχύτερα με AI', + 'plan_calendar' => 'Προγραμματισμός των δημοσιεύσεών μου σε ημερολόγιο', + 'stay_on_brand' => 'Διατήρηση κάθε δημοσίευσης εναρμονισμένης με τη μάρκα', + 'grow_audience' => 'Ανάπτυξη του κοινού και της αλληλεπίδρασής μου', + 'drive_sales' => 'Περισσότερη επισκεψιμότητα και πωλήσεις', + 'manage_clients' => 'Διαχείριση πολλών μαρκών ή πελατών', + 'just_exploring' => 'Απλώς εξερευνώ προς το παρόν', + 'other' => 'Κάτι άλλο', + ], + 'referral_source_title' => 'Πώς μας βρήκατε;', + 'referral_source_description' => 'Αυτό μας βοηθά να καταλάβουμε πώς οι άνθρωποι ανακαλύπτουν το TryPost.', + 'referral_source' => [ + 'google' => 'Google ή αναζήτηση', + 'x' => 'X (Twitter)', + 'linkedin' => 'LinkedIn', + 'youtube' => 'YouTube', + 'tiktok' => 'TikTok', + 'instagram' => 'Instagram ή Threads', + 'reddit' => 'Reddit', + 'product_hunt' => 'Product Hunt', + 'ai_assistant' => 'Βοηθός AI (ChatGPT, Claude…)', + 'friend' => 'Φίλος ή συνάδελφος', + 'blog' => 'Blog, newsletter ή άρθρο', + 'other' => 'Κάτι άλλο', + ], +]; diff --git a/lang/en/billing.php b/lang/en/billing.php index ed4ffa1f7..e11cd4c9b 100644 --- a/lang/en/billing.php +++ b/lang/en/billing.php @@ -68,9 +68,12 @@ 'title' => 'Processing your subscription', 'description' => 'Please wait while we set up your account. This will only take a moment.', 'success_title' => 'You\'re all set!', - 'success_description' => 'Your subscription is active. Redirecting you to your workspaces...', + 'success_description' => 'Your subscription is active. Redirecting…', 'cancelled_title' => 'Checkout cancelled', 'cancelled_description' => 'Your checkout was cancelled. No charges were made.', 'retry' => 'Try again', + 'taking_long' => 'This is taking longer than expected — hang tight, we are still setting things up.', + 'continue' => 'Continue to app', + 'live' => 'Live', ], ]; diff --git a/lang/en/mcp.php b/lang/en/mcp.php new file mode 100644 index 000000000..c04037086 --- /dev/null +++ b/lang/en/mcp.php @@ -0,0 +1,41 @@ + 'MCP', + 'subtitle' => 'Connect AI assistants so they can create and manage posts with your TryPost account.', + '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', + 'config_label' => 'Config', + 'connected_title' => 'Connected apps', + 'connected_description' => 'Assistants signed in by anyone on this account. You can only disconnect your own apps.', + 'connected_empty' => 'Nothing connected yet. Use Claude, ChatGPT, or another client above.', + 'connected_by' => 'Connected by :name', + 'disconnect' => 'Disconnect', + 'disconnect_title' => 'Disconnect app', + 'disconnect_confirm' => 'This signs the app out of TryPost. It will need to reconnect before it can use MCP again.', + 'disconnected' => 'App disconnected.', + 'copied' => 'Copied', + 'last_used' => 'Last used', + 'never' => 'Never', + 'documentation_title' => 'Documentation', + 'documentation_description' => 'Client setup guides, available tools, and troubleshooting.', + 'view_docs' => 'View docs', + 'connector_name' => 'TryPost', + + 'other_clients_title' => 'Other apps', + 'other_clients_description' => 'Cursor, VS Code, Claude Code, and anything else that speaks MCP.', + + 'clients' => [ + 'cursor' => 'Add TryPost as a remote MCP server in Cursor.', + 'cursor_name' => 'Cursor', + 'vscode' => 'Paste the config below into VS Code\'s MCP settings.', + 'vscode_name' => 'VS Code', + 'claude_code' => 'Paste the config below into Claude Code\'s MCP settings.', + 'claude_code_name' => 'Claude Code', + 'other' => 'Works with any client that reads an mcpServers config.', + 'other_name' => 'Other', + ], +]; diff --git a/lang/en/onboarding.php b/lang/en/onboarding.php index e76cf6717..38c441123 100644 --- a/lang/en/onboarding.php +++ b/lang/en/onboarding.php @@ -3,55 +3,47 @@ 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', + 'welcome_anonymous' => 'Welcome to TryPost', + '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' => 'Create posts faster with AI', - 'plan_calendar' => 'Plan my posts on a calendar', - 'stay_on_brand' => 'Keep every post on brand', - 'grow_audience' => 'Grow my audience and engagement', - 'drive_sales' => 'Get more traffic and sales', - 'manage_clients' => 'Manage several brands or clients', - 'team_collaboration' => 'Work with my team', - 'automate_api' => 'Automate posting with the API, MCP or code', - 'track_performance' => 'See how my posts perform', - 'just_exploring' => 'Just exploring for now', - 'other' => 'Something else', + 'mcp' => [ + 'title' => 'Connect your AI assistant', + 'description' => 'Add TryPost as an MCP server so your assistant can create and manage social posts for you.', + 'copy_step' => 'Copy your TryPost server URL', + 'open_step' => 'Open your AI assistant', + 'copy' => 'Copy URL', + 'copied' => 'MCP URL copied.', + 'connect' => 'Connect with :client', + 'clients' => [ + 'claude' => 'Open Settings → Connectors, add a custom connector, then paste the URL above.', + 'chatgpt' => 'Open Settings → Apps & Connectors, create a custom connector, then paste the URL above.', + ], ], - '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.', ], ]; diff --git a/lang/en/settings.php b/lang/en/settings.php index 52556caae..b4b7dc806 100644 --- a/lang/en/settings.php +++ b/lang/en/settings.php @@ -129,6 +129,7 @@ 'brand' => 'Brand', 'users' => 'Members', 'api_keys' => 'API Keys', + 'mcp' => 'MCP', ], 'title' => 'Workspace settings', 'logo_heading' => 'Workspace logo', diff --git a/lang/en/sidebar.php b/lang/en/sidebar.php index f9cddbd69..fb38f6ac2 100644 --- a/lang/en/sidebar.php +++ b/lang/en/sidebar.php @@ -8,6 +8,9 @@ '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', @@ -30,6 +33,8 @@ 'analytics' => 'Analytics', 'automations' => 'Automations', 'settings' => 'Settings', + 'onboarding' => 'Getting started', + 'onboarding_hint' => 'Finish setup', 'posts' => [ 'calendar' => 'Calendar', @@ -44,7 +49,9 @@ 'signatures' => 'Signatures', 'labels' => 'Labels', 'assets' => 'Assets', + 'settings' => 'Settings', 'api_keys' => 'API Keys', + 'mcp' => 'MCP', ], 'notifications' => 'Notifications', diff --git a/lang/en/welcome.php b/lang/en/welcome.php new file mode 100644 index 000000000..da7552825 --- /dev/null +++ b/lang/en/welcome.php @@ -0,0 +1,57 @@ + 'What best describes you?', + 'description' => 'Choose the closest match and we\'ll tailor your experience.', + 'continue' => 'Continue', + 'checkout_owner_only' => 'Ask the account owner to finish checkout and start your subscription.', + 'subscription_required_title' => 'Waiting for the account owner', + 'subscription_required_description' => 'This account doesn\'t have an active subscription yet. Ask the account owner to finish checkout — you\'ll get full access as soon as it is active.', + 'subscription_required_owner' => 'Your account owner is :name.', + 'subscription_required_auto' => 'This page updates automatically — no need to refresh.', + 'progress' => 'Welcome progress', + 'go_to_step' => 'Go to step :step', + 'step_current' => 'Step :step (current)', + 'personas' => [ + 'creator' => 'Content creator', + 'freelancer' => 'Freelancer', + 'developer' => 'Developer', + 'startup' => 'Startup', + 'agency' => 'Agency', + 'small_business' => 'Small business', + 'marketer' => 'Marketer', + 'online_store' => 'Online store', + 'other' => 'Other', + ], + 'goals_title' => 'What\'s your goal?', + 'goals_description' => 'Pick everything that fits and we\'ll set TryPost up for you.', + 'goals' => [ + 'save_time' => 'Save time by posting everywhere at once', + 'ai_content' => 'Create posts faster with AI', + 'plan_calendar' => 'Plan my posts on a calendar', + 'stay_on_brand' => 'Keep every post on brand', + 'grow_audience' => 'Grow my audience and engagement', + 'drive_sales' => 'Get more traffic and sales', + 'manage_clients' => 'Manage several brands or clients', + 'just_exploring' => 'Just exploring for now', + 'other' => 'Something else', + ], + 'referral_source_title' => 'How did you find us?', + 'referral_source_description' => 'This helps us understand how people discover TryPost.', + 'referral_source' => [ + 'google' => 'Google or search', + 'x' => 'X (Twitter)', + 'linkedin' => 'LinkedIn', + 'youtube' => 'YouTube', + 'tiktok' => 'TikTok', + 'instagram' => 'Instagram or Threads', + 'reddit' => 'Reddit', + 'product_hunt' => 'Product Hunt', + 'ai_assistant' => 'AI assistant (ChatGPT, Claude…)', + 'friend' => 'Friend or colleague', + 'blog' => 'Blog, newsletter or article', + 'other' => 'Something else', + ], +]; diff --git a/lang/es/billing.php b/lang/es/billing.php index deb54eb12..1808cbe0e 100644 --- a/lang/es/billing.php +++ b/lang/es/billing.php @@ -68,9 +68,12 @@ 'title' => 'Procesando tu suscripción', 'description' => 'Espera mientras configuramos tu cuenta. Solo tomará un momento.', 'success_title' => '¡Todo listo!', - 'success_description' => 'Tu suscripción está activa. Redirigiendo a tus workspaces...', + 'success_description' => 'Tu suscripción está activa. Redirigiendo…', 'cancelled_title' => 'Pago cancelado', 'cancelled_description' => 'Tu pago fue cancelado. No se realizaron cargos.', 'retry' => 'Intentar de nuevo', + 'taking_long' => 'Esto está tardando más de lo previsto: espera, seguimos configurando tu cuenta.', + 'continue' => 'Continuar a la app', + 'live' => 'En vivo', ], ]; diff --git a/lang/es/mcp.php b/lang/es/mcp.php new file mode 100644 index 000000000..598bcad80 --- /dev/null +++ b/lang/es/mcp.php @@ -0,0 +1,41 @@ + 'MCP', + 'subtitle' => 'Conecta asistentes de IA para que creen y gestionen posts con tu cuenta de TryPost.', + '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', + 'config_label' => 'Config', + 'connected_title' => 'Apps conectadas', + 'connected_description' => 'Asistentes con sesión de cualquiera en esta cuenta. Solo puedes desconectar los tuyos.', + 'connected_empty' => 'Nada conectado aún. Usa Claude, ChatGPT u otro cliente arriba.', + 'connected_by' => 'Conectado por :name', + 'disconnect' => 'Desconectar', + 'disconnect_title' => 'Desconectar app', + 'disconnect_confirm' => 'Esto cierra la sesión de la app en TryPost. Tendrá que reconectar para usar MCP otra vez.', + 'disconnected' => 'App desconectada.', + 'copied' => 'Copiado', + 'last_used' => 'Último uso', + 'never' => 'Nunca', + 'documentation_title' => 'Documentación', + 'documentation_description' => 'Guías por cliente, tools disponibles y solución de problemas.', + 'view_docs' => 'Ver documentación', + 'connector_name' => 'TryPost', + + 'other_clients_title' => 'Otras apps', + 'other_clients_description' => 'Cursor, VS Code, Claude Code y cualquier app que hable MCP.', + + 'clients' => [ + 'cursor' => 'Añade TryPost como servidor MCP remoto en Cursor.', + 'cursor_name' => 'Cursor', + 'vscode' => 'Pega la configuración de abajo en los ajustes MCP de VS Code.', + 'vscode_name' => 'VS Code', + 'claude_code' => 'Pega la configuración de abajo en los ajustes MCP de Claude Code.', + 'claude_code_name' => 'Claude Code', + 'other' => 'Funciona con cualquier cliente que lea una config mcpServers.', + 'other_name' => 'Otros', + ], +]; diff --git a/lang/es/onboarding.php b/lang/es/onboarding.php index 4fb16d047..1ed035ddd 100644 --- a/lang/es/onboarding.php +++ b/lang/es/onboarding.php @@ -3,55 +3,47 @@ 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', + 'welcome_anonymous' => 'Bienvenido a TryPost', + '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' => 'Crear publicaciones más rápido con IA', - 'plan_calendar' => 'Planificar mis publicaciones en un calendario', - 'stay_on_brand' => 'Mantener la coherencia de mi marca', - 'grow_audience' => 'Hacer crecer mi audiencia y engagement', - 'drive_sales' => 'Conseguir más tráfico y ventas', - 'manage_clients' => 'Gestionar varias marcas o clientes', - 'team_collaboration' => 'Trabajar con mi equipo', - 'automate_api' => 'Automatizar publicaciones con la API, MCP o código', - 'track_performance' => 'Ver cómo rinden mis publicaciones', - 'just_exploring' => 'Solo estoy explorando por ahora', - 'other' => 'Otra cosa', + '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.', + 'copy_step' => 'Copia la URL del servidor TryPost', + 'open_step' => 'Abre tu asistente de IA', + 'copy' => 'Copiar URL', + 'copied' => 'URL de MCP copiada.', + 'connect' => 'Conectar con :client', + 'clients' => [ + 'claude' => 'Abre Settings → Connectors, añade un conector personalizado y pega la URL de arriba.', + 'chatgpt' => 'Abre Settings → Apps & Connectors, crea un conector personalizado y pega la URL de arriba.', + ], ], - '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.', ], ]; diff --git a/lang/es/settings.php b/lang/es/settings.php index 466776ab1..44310f2b3 100644 --- a/lang/es/settings.php +++ b/lang/es/settings.php @@ -129,6 +129,7 @@ 'brand' => 'Marca', 'users' => 'Miembros', 'api_keys' => 'API Keys', + 'mcp' => 'MCP', ], 'title' => 'Configuración del workspace', 'logo_heading' => 'Logo del workspace', diff --git a/lang/es/sidebar.php b/lang/es/sidebar.php index c6f768d49..38bea5fb6 100644 --- a/lang/es/sidebar.php +++ b/lang/es/sidebar.php @@ -8,6 +8,9 @@ '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', @@ -30,6 +33,8 @@ 'analytics' => 'Analytics', 'automations' => 'Automatizaciones', 'settings' => 'Configuración', + 'onboarding' => 'Primeros pasos', + 'onboarding_hint' => 'Termina la configuración', 'posts' => [ 'calendar' => 'Calendario', @@ -44,7 +49,9 @@ 'signatures' => 'Firmas', 'labels' => 'Etiquetas', 'assets' => 'Medios', + 'settings' => 'Configuración', 'api_keys' => 'API Keys', + 'mcp' => 'MCP', ], 'notifications' => 'Notificaciones', diff --git a/lang/es/welcome.php b/lang/es/welcome.php new file mode 100644 index 000000000..f566db6af --- /dev/null +++ b/lang/es/welcome.php @@ -0,0 +1,57 @@ + '¿Qué te describe mejor?', + 'description' => 'Elige la opción más cercana y personalizaremos tu experiencia.', + 'continue' => 'Continuar', + 'checkout_owner_only' => 'Pide al propietario de la cuenta que complete el checkout e inicie la suscripción.', + 'subscription_required_title' => 'Esperando al propietario de la cuenta', + 'subscription_required_description' => 'Esta cuenta aún no tiene una suscripción activa. Pide al propietario que complete el checkout: tendrás acceso total en cuanto esté activa.', + 'subscription_required_owner' => 'El propietario de tu cuenta es :name.', + 'subscription_required_auto' => 'Esta página se actualiza automáticamente, no hace falta recargar.', + 'progress' => 'Progreso de bienvenida', + 'go_to_step' => 'Ir al paso :step', + 'step_current' => 'Paso :step (actual)', + 'personas' => [ + 'creator' => 'Creador de contenido', + 'freelancer' => 'Freelancer', + 'developer' => 'Desarrollador', + 'startup' => 'Startup', + 'agency' => 'Agencia', + 'small_business' => 'Pequeña empresa', + 'marketer' => 'Profesional de marketing', + 'online_store' => 'Tienda online', + 'other' => 'Otro', + ], + 'goals_title' => '¿Cuál es tu objetivo?', + 'goals_description' => 'Marca todo lo que encaje y adaptamos TryPost a ti.', + 'goals' => [ + 'save_time' => 'Ahorrar tiempo publicando en todas mis redes a la vez', + 'ai_content' => 'Crear publicaciones más rápido con IA', + 'plan_calendar' => 'Planificar mis publicaciones en un calendario', + 'stay_on_brand' => 'Mantener la coherencia de mi marca', + 'grow_audience' => 'Hacer crecer mi audiencia y engagement', + 'drive_sales' => 'Conseguir más tráfico y ventas', + 'manage_clients' => 'Gestionar varias marcas o clientes', + 'just_exploring' => 'Solo estoy explorando por ahora', + 'other' => 'Otra cosa', + ], + 'referral_source_title' => '¿Cómo nos encontraste?', + 'referral_source_description' => 'Esto nos ayuda a entender cómo la gente descubre TryPost.', + 'referral_source' => [ + 'google' => 'Google o búsqueda', + 'x' => 'X (Twitter)', + 'linkedin' => 'LinkedIn', + 'youtube' => 'YouTube', + 'tiktok' => 'TikTok', + 'instagram' => 'Instagram o Threads', + 'reddit' => 'Reddit', + 'product_hunt' => 'Product Hunt', + 'ai_assistant' => 'Asistente de IA (ChatGPT, Claude…)', + 'friend' => 'Amigo o colega', + 'blog' => 'Blog, newsletter o artículo', + 'other' => 'Otra cosa', + ], +]; diff --git a/lang/fr/billing.php b/lang/fr/billing.php index d423e220c..bd61e2c3e 100644 --- a/lang/fr/billing.php +++ b/lang/fr/billing.php @@ -68,9 +68,12 @@ 'title' => 'Traitement de votre abonnement', 'description' => 'Veuillez patienter pendant que nous configurons votre compte. Cela ne prendra qu\'un instant.', 'success_title' => 'Tout est prêt !', - 'success_description' => 'Votre abonnement est actif. Redirection vers vos espaces de travail...', + 'success_description' => 'Votre abonnement est actif. Redirection…', 'cancelled_title' => 'Paiement annulé', 'cancelled_description' => 'Votre paiement a été annulé. Aucun montant n\'a été débité.', 'retry' => 'Réessayer', + 'taking_long' => 'Cela prend plus de temps que prévu — patientez, la configuration est toujours en cours.', + 'continue' => 'Continuer vers l’app', + 'live' => 'En direct', ], ]; diff --git a/lang/fr/mcp.php b/lang/fr/mcp.php new file mode 100644 index 000000000..21b10dfe0 --- /dev/null +++ b/lang/fr/mcp.php @@ -0,0 +1,41 @@ + 'MCP', + 'subtitle' => 'Connectez des assistants IA pour créer et gérer des posts avec votre compte TryPost.', + 'step_add' => 'Collez le nom, l’URL ou la config ci-dessous dans votre app. La connexion s’ouvre dans le navigateur la première fois.', + 'name_label' => 'Nom', + 'url_label' => 'URL du serveur', + 'config_label' => 'Config', + 'connected_title' => 'Apps connectées', + 'connected_description' => 'Assistants connectés par n’importe qui sur ce compte. Vous ne pouvez déconnecter que les vôtres.', + 'connected_empty' => 'Rien de connecté pour l’instant. Utilisez Claude, ChatGPT ou un autre client ci-dessus.', + 'connected_by' => 'Connecté par :name', + 'disconnect' => 'Déconnecter', + 'disconnect_title' => 'Déconnecter l’app', + 'disconnect_confirm' => 'Cela déconnecte l’app de TryPost. Elle devra se reconnecter pour utiliser MCP à nouveau.', + 'disconnected' => 'App déconnectée.', + 'copied' => 'Copié', + 'last_used' => 'Dernière utilisation', + 'never' => 'Jamais', + 'documentation_title' => 'Documentation', + 'documentation_description' => 'Guides par client, tools disponibles et dépannage.', + 'view_docs' => 'Voir la documentation', + 'connector_name' => 'TryPost', + + 'other_clients_title' => 'Autres apps', + 'other_clients_description' => 'Cursor, VS Code, Claude Code et toute app qui parle MCP.', + + 'clients' => [ + 'cursor' => 'Ajoutez TryPost comme serveur MCP distant dans Cursor.', + 'cursor_name' => 'Cursor', + 'vscode' => 'Collez la configuration ci-dessous dans les paramètres MCP de VS Code.', + 'vscode_name' => 'VS Code', + 'claude_code' => 'Collez la configuration ci-dessous dans les paramètres MCP de Claude Code.', + 'claude_code_name' => 'Claude Code', + 'other' => 'Fonctionne avec tout client qui lit une config mcpServers.', + 'other_name' => 'Autres', + ], +]; diff --git a/lang/fr/onboarding.php b/lang/fr/onboarding.php index 7d103c102..1abeb55c1 100644 --- a/lang/fr/onboarding.php +++ b/lang/fr/onboarding.php @@ -3,55 +3,47 @@ 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', + 'welcome_anonymous' => 'Bienvenue sur TryPost', + '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' => 'Créer des publications plus vite avec l\'IA', - 'plan_calendar' => 'Planifier mes publications sur un calendrier', - 'stay_on_brand' => 'Garder chaque publication fidèle à ma marque', - 'grow_audience' => 'Développer mon audience et mon engagement', - 'drive_sales' => 'Obtenir plus de trafic et de ventes', - 'manage_clients' => 'Gérer plusieurs marques ou clients', - 'team_collaboration' => 'Travailler avec mon équipe', - 'automate_api' => 'Automatiser la publication avec l\'API, le MCP ou du code', - 'track_performance' => 'Voir les performances de mes publications', - 'just_exploring' => 'Je découvre pour l\'instant', - 'other' => 'Autre chose', + '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.', + 'copy_step' => 'Copiez l’URL du serveur TryPost', + 'open_step' => 'Ouvrez votre assistant IA', + 'copy' => 'Copier l’URL', + 'copied' => 'URL MCP copiée.', + 'connect' => 'Connecter avec :client', + 'clients' => [ + 'claude' => 'Ouvrez Settings → Connectors, ajoutez un connecteur personnalisé, puis collez l’URL ci-dessus.', + 'chatgpt' => 'Ouvrez Settings → Apps & Connectors, créez un connecteur personnalisé, puis collez l’URL ci-dessus.', + ], ], - '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 d’exemple', + '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 d’exemple 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.', ], ]; diff --git a/lang/fr/settings.php b/lang/fr/settings.php index 8bfad5800..2cede7c15 100644 --- a/lang/fr/settings.php +++ b/lang/fr/settings.php @@ -129,6 +129,7 @@ 'brand' => 'Marque', 'users' => 'Membres', 'api_keys' => 'Clés API', + 'mcp' => 'MCP', ], 'title' => 'Paramètres de l\'espace de travail', 'logo_heading' => 'Logo de l\'espace de travail', diff --git a/lang/fr/sidebar.php b/lang/fr/sidebar.php index a63250252..4bbfb5303 100644 --- a/lang/fr/sidebar.php +++ b/lang/fr/sidebar.php @@ -8,6 +8,9 @@ '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', @@ -30,6 +33,8 @@ 'analytics' => 'Statistiques', 'automations' => 'Automatisations', 'settings' => 'Paramètres', + 'onboarding' => 'Premiers pas', + 'onboarding_hint' => 'Terminer la configuration', 'posts' => [ 'calendar' => 'Calendrier', @@ -44,7 +49,9 @@ 'signatures' => 'Signatures', 'labels' => 'Étiquettes', 'assets' => 'Médias', + 'settings' => 'Paramètres', 'api_keys' => 'Clés API', + 'mcp' => 'MCP', ], 'notifications' => 'Notifications', diff --git a/lang/fr/welcome.php b/lang/fr/welcome.php new file mode 100644 index 000000000..ca6d73ebe --- /dev/null +++ b/lang/fr/welcome.php @@ -0,0 +1,57 @@ + 'Qu\'est-ce qui vous décrit le mieux ?', + 'description' => 'Choisissez l\'option la plus proche et nous personnaliserons votre expérience.', + 'continue' => 'Continuer', + 'checkout_owner_only' => 'Demandez au propriétaire du compte de finaliser le paiement et de démarrer l\'abonnement.', + 'subscription_required_title' => 'En attente du propriétaire du compte', + 'subscription_required_description' => 'Ce compte n\'a pas encore d\'abonnement actif. Demandez au propriétaire de finaliser le paiement — vous aurez un accès complet dès qu\'il sera actif.', + 'subscription_required_owner' => 'Le propriétaire de votre compte est :name.', + 'subscription_required_auto' => 'Cette page se met à jour automatiquement — inutile de la recharger.', + 'progress' => 'Progression d’accueil', + 'go_to_step' => 'Aller à l’étape :step', + 'step_current' => 'Étape :step (actuelle)', + 'personas' => [ + 'creator' => 'Créateur de contenu', + 'freelancer' => 'Freelance', + 'developer' => 'Développeur', + 'startup' => 'Startup', + 'agency' => 'Agence', + 'small_business' => 'Petite entreprise', + 'marketer' => 'Marketeur', + 'online_store' => 'Boutique en ligne', + 'other' => 'Autre', + ], + 'goals_title' => 'Quel est votre objectif ?', + 'goals_description' => 'Choisissez tout ce qui vous correspond et nous configurerons TryPost pour vous.', + 'goals' => [ + 'save_time' => 'Gagner du temps en publiant partout à la fois', + 'ai_content' => 'Créer des publications plus vite avec l\'IA', + 'plan_calendar' => 'Planifier mes publications sur un calendrier', + 'stay_on_brand' => 'Garder chaque publication fidèle à ma marque', + 'grow_audience' => 'Développer mon audience et mon engagement', + 'drive_sales' => 'Obtenir plus de trafic et de ventes', + 'manage_clients' => 'Gérer plusieurs marques ou clients', + 'just_exploring' => 'Je découvre pour l\'instant', + 'other' => 'Autre chose', + ], + 'referral_source_title' => 'Comment nous avez-vous connus ?', + 'referral_source_description' => 'Cela nous aide à comprendre comment les gens découvrent TryPost.', + 'referral_source' => [ + 'google' => 'Google ou recherche', + 'x' => 'X (Twitter)', + 'linkedin' => 'LinkedIn', + 'youtube' => 'YouTube', + 'tiktok' => 'TikTok', + 'instagram' => 'Instagram ou Threads', + 'reddit' => 'Reddit', + 'product_hunt' => 'Product Hunt', + 'ai_assistant' => 'Assistant IA (ChatGPT, Claude…)', + 'friend' => 'Ami ou collègue', + 'blog' => 'Blog, newsletter ou article', + 'other' => 'Autre chose', + ], +]; diff --git a/lang/it/billing.php b/lang/it/billing.php index 2af61f74e..8d1577197 100644 --- a/lang/it/billing.php +++ b/lang/it/billing.php @@ -68,9 +68,12 @@ 'title' => 'Elaborazione del tuo abbonamento', 'description' => 'Attendi mentre configuriamo il tuo account. Ci vorrà solo un momento.', 'success_title' => 'Tutto pronto!', - 'success_description' => 'Il tuo abbonamento è attivo. Ti stiamo reindirizzando ai tuoi workspace...', + 'success_description' => 'Il tuo abbonamento è attivo. Reindirizzamento…', 'cancelled_title' => 'Pagamento annullato', 'cancelled_description' => 'Il tuo pagamento è stato annullato. Non è stato effettuato alcun addebito.', 'retry' => 'Riprova', + 'taking_long' => 'Sta richiedendo più tempo del previsto: attendi, stiamo ancora configurando tutto.', + 'continue' => 'Continua all’app', + 'live' => 'Dal vivo', ], ]; diff --git a/lang/it/mcp.php b/lang/it/mcp.php new file mode 100644 index 000000000..75bb16a28 --- /dev/null +++ b/lang/it/mcp.php @@ -0,0 +1,41 @@ + 'MCP', + 'subtitle' => 'Collega assistenti IA così possono creare e gestire post con il tuo account TryPost.', + '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', + 'config_label' => 'Config', + 'connected_title' => 'App collegate', + 'connected_description' => 'Assistenti con accesso di chiunque su questo account. Puoi disconnettere solo i tuoi.', + 'connected_empty' => 'Nessuna connessione ancora. Usa Claude, ChatGPT o un altro client sopra.', + 'connected_by' => 'Connesso da :name', + 'disconnect' => 'Scollega', + 'disconnect_title' => 'Scollega app', + 'disconnect_confirm' => 'Questo scollega l’app da TryPost. Dovrà riconnettersi prima di usare di nuovo MCP.', + 'disconnected' => 'App scollegata.', + 'copied' => 'Copiato', + 'last_used' => 'Ultimo uso', + 'never' => 'Mai', + 'documentation_title' => 'Documentazione', + 'documentation_description' => 'Guide per client, tools disponibili e risoluzione problemi.', + 'view_docs' => 'Vedi documentazione', + 'connector_name' => 'TryPost', + + 'other_clients_title' => 'Altre app', + 'other_clients_description' => 'Cursor, VS Code, Claude Code e qualsiasi app che parla MCP.', + + 'clients' => [ + 'cursor' => 'Aggiungi TryPost come server MCP remoto in Cursor.', + 'cursor_name' => 'Cursor', + 'vscode' => 'Incolla la config qui sotto nelle impostazioni MCP di VS Code.', + 'vscode_name' => 'VS Code', + 'claude_code' => 'Incolla la config qui sotto nelle impostazioni MCP di Claude Code.', + 'claude_code_name' => 'Claude Code', + 'other' => 'Funziona con qualsiasi client che legge una config mcpServers.', + 'other_name' => 'Altri', + ], +]; diff --git a/lang/it/onboarding.php b/lang/it/onboarding.php index 0beba0331..f06b38c9f 100644 --- a/lang/it/onboarding.php +++ b/lang/it/onboarding.php @@ -3,55 +3,47 @@ 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', + 'welcome_anonymous' => 'Benvenuto su TryPost', + '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' => 'Creare post più velocemente con l\'IA', - 'plan_calendar' => 'Pianificare i miei post su un calendario', - 'stay_on_brand' => 'Mantenere ogni post in linea con il brand', - 'grow_audience' => 'Far crescere il mio pubblico e il coinvolgimento', - 'drive_sales' => 'Ottenere più traffico e vendite', - 'manage_clients' => 'Gestire più brand o clienti', - 'team_collaboration' => 'Lavorare con il mio team', - 'automate_api' => 'Automatizzare la pubblicazione con API, MCP o codice', - 'track_performance' => 'Vedere come vanno i miei post', - 'just_exploring' => 'Sto solo dando un\'occhiata', - 'other' => 'Qualcos\'altro', + '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.', + 'copy_step' => 'Copia l’URL del server TryPost', + 'open_step' => 'Apri il tuo assistente IA', + 'copy' => 'Copia URL', + 'copied' => 'URL MCP copiato.', + 'connect' => 'Collega con :client', + 'clients' => [ + 'claude' => 'Apri Settings → Connectors, aggiungi un connettore personalizzato e incolla l’URL qui sopra.', + 'chatgpt' => 'Apri Settings → Apps & Connectors, crea un connettore personalizzato e incolla l’URL qui sopra.', + ], ], - '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.', ], ]; diff --git a/lang/it/settings.php b/lang/it/settings.php index 3c3708c1f..08f8e3417 100644 --- a/lang/it/settings.php +++ b/lang/it/settings.php @@ -129,6 +129,7 @@ 'brand' => 'Brand', 'users' => 'Membri', 'api_keys' => 'Chiavi API', + 'mcp' => 'MCP', ], 'title' => 'Impostazioni del workspace', 'logo_heading' => 'Logo del workspace', diff --git a/lang/it/sidebar.php b/lang/it/sidebar.php index fa517924a..cb5aa47ed 100644 --- a/lang/it/sidebar.php +++ b/lang/it/sidebar.php @@ -8,6 +8,9 @@ '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', @@ -30,6 +33,8 @@ 'analytics' => 'Statistiche', 'automations' => 'Automazioni', 'settings' => 'Impostazioni', + 'onboarding' => 'Primi passi', + 'onboarding_hint' => 'Completa la configurazione', 'posts' => [ 'calendar' => 'Calendario', @@ -44,7 +49,9 @@ 'signatures' => 'Firme', 'labels' => 'Etichette', 'assets' => 'Risorse', + 'settings' => 'Impostazioni', 'api_keys' => 'Chiavi API', + 'mcp' => 'MCP', ], 'notifications' => 'Notifiche', diff --git a/lang/it/welcome.php b/lang/it/welcome.php new file mode 100644 index 000000000..946e5167d --- /dev/null +++ b/lang/it/welcome.php @@ -0,0 +1,57 @@ + 'Cosa ti descrive meglio?', + 'description' => 'Scegli l\'opzione più vicina e personalizzeremo la tua esperienza.', + 'continue' => 'Continua', + 'checkout_owner_only' => 'Chiedi al proprietario dell\'account di completare il checkout e avviare l\'abbonamento.', + 'subscription_required_title' => 'In attesa del proprietario dell\'account', + 'subscription_required_description' => 'Questo account non ha ancora un abbonamento attivo. Chiedi al proprietario di completare il checkout: avrai accesso completo non appena sarà attivo.', + 'subscription_required_owner' => 'Il proprietario del tuo account è :name.', + 'subscription_required_auto' => 'Questa pagina si aggiorna automaticamente: non serve ricaricarla.', + 'progress' => 'Progresso di benvenuto', + 'go_to_step' => 'Vai al passaggio :step', + 'step_current' => 'Passaggio :step (attuale)', + 'personas' => [ + 'creator' => 'Creatore di contenuti', + 'freelancer' => 'Freelance', + 'developer' => 'Sviluppatore', + 'startup' => 'Startup', + 'agency' => 'Agenzia', + 'small_business' => 'Piccola impresa', + 'marketer' => 'Marketer', + 'online_store' => 'Negozio online', + 'other' => 'Altro', + ], + 'goals_title' => 'Qual è il tuo obiettivo?', + 'goals_description' => 'Scegli tutto ciò che fa per te e configureremo TryPost per te.', + 'goals' => [ + 'save_time' => 'Risparmiare tempo pubblicando ovunque in una volta', + 'ai_content' => 'Creare post più velocemente con l\'IA', + 'plan_calendar' => 'Pianificare i miei post su un calendario', + 'stay_on_brand' => 'Mantenere ogni post in linea con il brand', + 'grow_audience' => 'Far crescere il mio pubblico e il coinvolgimento', + 'drive_sales' => 'Ottenere più traffico e vendite', + 'manage_clients' => 'Gestire più brand o clienti', + 'just_exploring' => 'Sto solo dando un\'occhiata', + 'other' => 'Qualcos\'altro', + ], + 'referral_source_title' => 'Come ci hai trovato?', + 'referral_source_description' => 'Questo ci aiuta a capire come le persone scoprono TryPost.', + 'referral_source' => [ + 'google' => 'Google o ricerca', + 'x' => 'X (Twitter)', + 'linkedin' => 'LinkedIn', + 'youtube' => 'YouTube', + 'tiktok' => 'TikTok', + 'instagram' => 'Instagram o Threads', + 'reddit' => 'Reddit', + 'product_hunt' => 'Product Hunt', + 'ai_assistant' => 'Assistente IA (ChatGPT, Claude…)', + 'friend' => 'Amico o collega', + 'blog' => 'Blog, newsletter o articolo', + 'other' => 'Qualcos\'altro', + ], +]; diff --git a/lang/ja/billing.php b/lang/ja/billing.php index 3b73b4ce2..f0a441d59 100644 --- a/lang/ja/billing.php +++ b/lang/ja/billing.php @@ -68,9 +68,12 @@ 'title' => 'サブスクリプションを処理しています', 'description' => 'アカウントを設定していますので、しばらくお待ちください。すぐに完了します。', 'success_title' => '準備が整いました!', - 'success_description' => 'サブスクリプションが有効になりました。ワークスペースにリダイレクトしています...', + 'success_description' => 'サブスクリプションが有効になりました。リダイレクトしています…', 'cancelled_title' => 'チェックアウトがキャンセルされました', 'cancelled_description' => 'チェックアウトはキャンセルされました。料金は請求されていません。', 'retry' => 'もう一度試す', + 'taking_long' => '通常より時間がかかっています。そのままお待ちください — 設定を続けています。', + 'continue' => 'アプリへ進む', + 'live' => 'ライブ', ], ]; diff --git a/lang/ja/mcp.php b/lang/ja/mcp.php new file mode 100644 index 000000000..42a906b32 --- /dev/null +++ b/lang/ja/mcp.php @@ -0,0 +1,41 @@ + 'MCP', + 'subtitle' => 'TryPostアカウントで投稿の作成・管理ができるよう、AIアシスタントを接続します。', + 'step_add' => '下の名前・URL・設定をアプリに貼り付けてください。初回接続時はブラウザでログインが開きます。', + 'name_label' => '名前', + 'url_label' => 'サーバーURL', + 'config_label' => '設定', + 'connected_title' => '接続済みアプリ', + 'connected_description' => 'このアカウントの誰かがサインインしたアシスタント。切断できるのは自分の接続だけです。', + 'connected_empty' => 'まだ接続がありません。上の Claude、ChatGPT、または他のクライアントを使ってください。', + 'connected_by' => ':name が接続', + 'disconnect' => '切断', + 'disconnect_title' => 'アプリを切断', + 'disconnect_confirm' => 'TryPostからアプリを切断します。再度MCPを使うには再接続が必要です。', + 'disconnected' => 'アプリを切断しました。', + 'copied' => 'コピーしました', + 'last_used' => '最終使用', + 'never' => 'なし', + 'documentation_title' => 'ドキュメント', + 'documentation_description' => 'クライアント別のセットアップ、利用可能なツール、トラブルシューティング。', + 'view_docs' => 'ドキュメントを見る', + 'connector_name' => 'TryPost', + + 'other_clients_title' => 'その他のアプリ', + 'other_clients_description' => 'Cursor、VS Code、Claude Code、その他MCP対応アプリ。', + + 'clients' => [ + 'cursor' => 'CursorでTryPostをリモートMCPサーバーとして追加します。', + 'cursor_name' => 'Cursor', + 'vscode' => '下の設定をVS CodeのMCP設定に貼り付けます。', + 'vscode_name' => 'VS Code', + 'claude_code' => '下の設定をClaude CodeのMCP設定に貼り付けます。', + 'claude_code_name' => 'Claude Code', + 'other' => 'mcpServers設定を読むクライアントならどれでも使えます。', + 'other_name' => 'その他', + ], +]; diff --git a/lang/ja/onboarding.php b/lang/ja/onboarding.php index db92adcf2..d067b6e18 100644 --- a/lang/ja/onboarding.php +++ b/lang/ja/onboarding.php @@ -3,55 +3,47 @@ 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さん', + 'welcome_anonymous' => 'TryPostへようこそ', + 'description' => '以下のステップでTryPostの使い方を確認し、最初の投稿を公開しましょう。', + 'skip_step' => 'このステップをスキップ', + 'continue' => 'TryPostへ進む', + 'status' => [ + 'complete' => '完了', + 'todo' => '未完了', + 'skipped' => 'スキップ済み', ], - 'goals_title' => 'TryPost での目標は何ですか?', - 'goals_description' => '当てはまるものをすべて選んでください。TryPost をあなた向けに設定します。', - 'goals' => [ - 'save_time' => 'すべての場所へ一度に投稿して時間を節約する', - 'ai_content' => 'AI でより速く投稿を作成する', - 'plan_calendar' => 'カレンダーで投稿を計画する', - 'stay_on_brand' => 'すべての投稿をブランドに沿ったものにする', - 'grow_audience' => 'オーディエンスとエンゲージメントを増やす', - 'drive_sales' => 'トラフィックと売上を増やす', - 'manage_clients' => '複数のブランドやクライアントを管理する', - 'team_collaboration' => 'チームで作業する', - 'automate_api' => 'API、MCP、コードで投稿を自動化する', - 'track_performance' => '投稿のパフォーマンスを確認する', - 'just_exploring' => '今はまだ様子を見ている', - 'other' => 'その他', + 'mcp' => [ + 'title' => 'AIアシスタントを接続', + 'description' => 'TryPostをMCPサーバーとして追加すると、アシスタントがSNS投稿の作成・管理を行えます。', + 'copy_step' => 'TryPostサーバーURLをコピー', + 'open_step' => 'AIアシスタントを開く', + 'copy' => 'URLをコピー', + 'copied' => 'MCP URLをコピーしました。', + 'connect' => ':clientで接続', + 'clients' => [ + 'claude' => 'Settings → Connectors を開き、カスタムコネクタを追加して上のURLを貼り付けます。', + 'chatgpt' => 'Settings → Apps & Connectors を開き、カスタムコネクタを作成して上の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へ進み、コンテンツの計画を始めましょう。', ], ]; diff --git a/lang/ja/settings.php b/lang/ja/settings.php index cdf5aa5c8..94e76beb7 100644 --- a/lang/ja/settings.php +++ b/lang/ja/settings.php @@ -129,6 +129,7 @@ 'brand' => 'ブランド', 'users' => 'メンバー', 'api_keys' => 'API キー', + 'mcp' => 'MCP', ], 'title' => 'ワークスペース設定', 'logo_heading' => 'ワークスペースのロゴ', diff --git a/lang/ja/sidebar.php b/lang/ja/sidebar.php index 09ff7c59d..c297404b8 100644 --- a/lang/ja/sidebar.php +++ b/lang/ja/sidebar.php @@ -8,6 +8,9 @@ 'create_workspace' => 'ワークスペースを作成', 'create_post' => '投稿を作成', 'profile' => 'プロフィール', + 'my_account' => 'マイアカウント', + 'account_settings' => 'アカウントと請求', + 'workspace_settings' => 'ワークスペース設定', 'log_out' => 'ログアウト', 'workspace' => 'ワークスペース: :name', @@ -30,6 +33,8 @@ 'analytics' => 'アナリティクス', 'automations' => 'オートメーション', 'settings' => '設定', + 'onboarding' => 'はじめに', + 'onboarding_hint' => 'セットアップを完了', 'posts' => [ 'calendar' => 'カレンダー', @@ -44,7 +49,9 @@ 'signatures' => '署名', 'labels' => 'ラベル', 'assets' => 'アセット', + 'settings' => '設定', 'api_keys' => 'API キー', + 'mcp' => 'MCP', ], 'notifications' => '通知', diff --git a/lang/ja/welcome.php b/lang/ja/welcome.php new file mode 100644 index 000000000..6b75d4ff8 --- /dev/null +++ b/lang/ja/welcome.php @@ -0,0 +1,57 @@ + 'あなたに一番近いのは?', + 'description' => '近いものを選ぶと、体験を最適化します。', + 'continue' => '続ける', + 'checkout_owner_only' => 'アカウントのオーナーにチェックアウトを完了してサブスクリプションを開始するよう依頼してください。', + 'subscription_required_title' => 'アカウントのオーナーを待っています', + 'subscription_required_description' => 'このアカウントにはまだ有効なサブスクリプションがありません。オーナーにチェックアウトの完了を依頼してください — 有効になり次第、フルアクセスできます。', + 'subscription_required_owner' => 'アカウントのオーナーは :name です。', + 'subscription_required_auto' => 'このページは自動で更新されます — 再読み込みは不要です。', + 'progress' => 'ようこそ進捗', + 'go_to_step' => 'ステップ :step へ', + 'step_current' => 'ステップ :step(現在)', + 'personas' => [ + 'creator' => 'コンテンツクリエイター', + 'freelancer' => 'フリーランス', + 'developer' => '開発者', + 'startup' => 'スタートアップ', + 'agency' => '代理店', + 'small_business' => '中小企業', + 'marketer' => 'マーケター', + 'online_store' => 'オンラインストア', + 'other' => 'その他', + ], + 'goals_title' => '目標は何ですか?', + 'goals_description' => '当てはまるものをすべて選んでください。TryPost をあなた向けに設定します。', + 'goals' => [ + 'save_time' => 'すべての場所へ一度に投稿して時間を節約する', + 'ai_content' => 'AI でより速く投稿を作成する', + 'plan_calendar' => 'カレンダーで投稿を計画する', + 'stay_on_brand' => 'すべての投稿をブランドに沿ったものにする', + 'grow_audience' => 'オーディエンスとエンゲージメントを増やす', + 'drive_sales' => 'トラフィックと売上を増やす', + 'manage_clients' => '複数のブランドやクライアントを管理する', + 'just_exploring' => '今はまだ様子を見ている', + 'other' => 'その他', + ], + 'referral_source_title' => 'どこで私たちを知りましたか?', + 'referral_source_description' => 'これは、人々がどのように TryPost を見つけるかを理解するのに役立ちます。', + 'referral_source' => [ + 'google' => 'Google または検索', + 'x' => 'X (Twitter)', + 'linkedin' => 'LinkedIn', + 'youtube' => 'YouTube', + 'tiktok' => 'TikTok', + 'instagram' => 'Instagram または Threads', + 'reddit' => 'Reddit', + 'product_hunt' => 'Product Hunt', + 'ai_assistant' => 'AI アシスタント(ChatGPT、Claude など)', + 'friend' => '友人または同僚', + 'blog' => 'ブログ、ニュースレター、記事', + 'other' => 'その他', + ], +]; diff --git a/lang/ko/billing.php b/lang/ko/billing.php index 43b33b21f..9d2642e2b 100644 --- a/lang/ko/billing.php +++ b/lang/ko/billing.php @@ -68,9 +68,12 @@ 'title' => '구독을 처리하는 중', 'description' => '계정을 설정하는 동안 잠시 기다려 주세요. 잠깐이면 됩니다.', 'success_title' => '모든 준비가 끝났습니다!', - 'success_description' => '구독이 활성화되었습니다. 워크스페이스로 이동하는 중...', + 'success_description' => '구독이 활성화되었습니다. 이동 중…', 'cancelled_title' => '결제 취소됨', 'cancelled_description' => '결제가 취소되었습니다. 요금이 청구되지 않았습니다.', 'retry' => '다시 시도', + 'taking_long' => '예상보다 오래 걸리고 있습니다. 잠시만 기다려 주세요 — 계속 설정 중입니다.', + 'continue' => '앱으로 계속', + 'live' => '라이브', ], ]; diff --git a/lang/ko/mcp.php b/lang/ko/mcp.php new file mode 100644 index 000000000..1048d656b --- /dev/null +++ b/lang/ko/mcp.php @@ -0,0 +1,41 @@ + 'MCP', + 'subtitle' => 'TryPost 계정으로 게시물을 만들고 관리할 수 있도록 AI 어시스턴트를 연결하세요.', + 'step_add' => '아래 이름, URL 또는 설정을 앱에 붙여넣으세요. 처음 연결할 때 브라우저에서 로그인이 열립니다.', + 'name_label' => '이름', + 'url_label' => '서버 URL', + 'config_label' => '설정', + 'connected_title' => '연결된 앱', + 'connected_description' => '이 계정의 누구나 로그인한 어시스턴트입니다. 본인 연결만 해제할 수 있습니다.', + 'connected_empty' => '아직 연결된 앱이 없습니다. 위의 Claude, ChatGPT 또는 다른 클라이언트를 사용하세요.', + 'connected_by' => ':name님이 연결함', + 'disconnect' => '연결 해제', + 'disconnect_title' => '앱 연결 해제', + 'disconnect_confirm' => 'TryPost에서 앱 로그인을 해제합니다. MCP를 다시 쓰려면 다시 연결해야 합니다.', + 'disconnected' => '앱 연결이 해제되었습니다.', + 'copied' => '복사됨', + 'last_used' => '최근 사용', + 'never' => '없음', + 'documentation_title' => '문서', + 'documentation_description' => '클라이언트별 설정 가이드, 사용 가능한 도구, 문제 해결.', + 'view_docs' => '문서 보기', + 'connector_name' => 'TryPost', + + 'other_clients_title' => '다른 앱', + 'other_clients_description' => 'Cursor, VS Code, Claude Code 및 MCP를 지원하는 모든 앱.', + + 'clients' => [ + 'cursor' => 'Cursor에서 TryPost를 원격 MCP 서버로 추가하세요.', + 'cursor_name' => 'Cursor', + 'vscode' => '아래 설정을 VS Code MCP 설정에 붙여넣으세요.', + 'vscode_name' => 'VS Code', + 'claude_code' => '아래 설정을 Claude Code MCP 설정에 붙여넣으세요.', + 'claude_code_name' => 'Claude Code', + 'other' => 'mcpServers 설정을 읽는 모든 클라이언트에서 동작합니다.', + 'other_name' => '기타', + ], +]; diff --git a/lang/ko/onboarding.php b/lang/ko/onboarding.php index fc954799f..c1bb79dd6 100644 --- a/lang/ko/onboarding.php +++ b/lang/ko/onboarding.php @@ -3,55 +3,47 @@ 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', + 'welcome_anonymous' => 'TryPost에 오신 걸 환영해요', + 'description' => '아래 단계를 따라 TryPost 사용법을 확인하고 첫 게시물을 발행하세요.', + 'skip_step' => '이 단계 건너뛰기', + 'continue' => 'TryPost로 계속', + 'status' => [ + 'complete' => '완료', + 'todo' => '할 일', + 'skipped' => '건너뜀', ], - 'goals_title' => 'TryPost로 이루려는 목표는 무엇인가요?', - 'goals_description' => '해당되는 항목을 모두 선택하면 TryPost를 맞춤 설정해 드립니다.', - 'goals' => [ - 'save_time' => '한 번에 여러 곳에 게시하여 시간 절약', - 'ai_content' => 'AI로 더 빠르게 게시물 작성', - 'plan_calendar' => '캘린더에서 게시물 계획', - 'stay_on_brand' => '모든 게시물을 브랜드에 맞게 유지', - 'grow_audience' => '팔로워와 참여 늘리기', - 'drive_sales' => '더 많은 트래픽과 판매 유도', - 'manage_clients' => '여러 브랜드 또는 클라이언트 관리', - 'team_collaboration' => '팀과 협업', - 'automate_api' => 'API, MCP 또는 코드로 게시 자동화', - 'track_performance' => '게시물 성과 확인', - 'just_exploring' => '지금은 둘러보는 중', - 'other' => '다른 것', + 'mcp' => [ + 'title' => 'AI 어시스턴트 연결', + 'description' => 'TryPost를 MCP 서버로 추가하면 어시스턴트가 소셜 게시물을 만들고 관리할 수 있어요.', + 'copy_step' => 'TryPost 서버 URL 복사', + 'open_step' => 'AI 어시스턴트 열기', + 'copy' => 'URL 복사', + 'copied' => 'MCP URL이 복사되었어요.', + 'connect' => ':client로 연결', + 'clients' => [ + 'claude' => 'Settings → Connectors를 열고 커스텀 커넥터를 추가한 뒤 위 URL을 붙여넣으세요.', + 'chatgpt' => 'Settings → Apps & Connectors를 열고 커스텀 커넥터를 만든 뒤 위 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로 가서 콘텐츠 계획을 시작하세요.', ], ]; diff --git a/lang/ko/settings.php b/lang/ko/settings.php index d20f1cdf9..77fb6569b 100644 --- a/lang/ko/settings.php +++ b/lang/ko/settings.php @@ -129,6 +129,7 @@ 'brand' => '브랜드', 'users' => '멤버', 'api_keys' => 'API 키', + 'mcp' => 'MCP', ], 'title' => '워크스페이스 설정', 'logo_heading' => '워크스페이스 로고', diff --git a/lang/ko/sidebar.php b/lang/ko/sidebar.php index 3d47a2bfe..4a31b4ff8 100644 --- a/lang/ko/sidebar.php +++ b/lang/ko/sidebar.php @@ -8,6 +8,9 @@ 'create_workspace' => '워크스페이스 만들기', 'create_post' => '게시물 만들기', 'profile' => '프로필', + 'my_account' => '내 계정', + 'account_settings' => '계정 및 결제', + 'workspace_settings' => '워크스페이스 설정', 'log_out' => '로그아웃', 'workspace' => '워크스페이스: :name', @@ -30,6 +33,8 @@ 'analytics' => '분석', 'automations' => '자동화', 'settings' => '설정', + 'onboarding' => '시작하기', + 'onboarding_hint' => '설정 마치기', 'posts' => [ 'calendar' => '캘린더', @@ -44,7 +49,9 @@ 'signatures' => '서명', 'labels' => '라벨', 'assets' => '에셋', + 'settings' => '설정', 'api_keys' => 'API 키', + 'mcp' => 'MCP', ], 'notifications' => '알림', diff --git a/lang/ko/welcome.php b/lang/ko/welcome.php new file mode 100644 index 000000000..22d2b2207 --- /dev/null +++ b/lang/ko/welcome.php @@ -0,0 +1,57 @@ + '무엇을 가장 잘 설명하나요?', + 'description' => '가장 가까운 항목을 선택하면 맞춤 경험을 제공해 드립니다.', + 'continue' => '계속', + 'checkout_owner_only' => '계정 소유자에게 결제와 구독 시작을 요청하세요.', + 'subscription_required_title' => '계정 소유자를 기다리는 중', + 'subscription_required_description' => '이 계정에는 아직 활성 구독이 없습니다. 소유자에게 결제 완료를 요청하세요 — 활성화되는 즉시 모든 기능을 사용할 수 있습니다.', + 'subscription_required_owner' => '계정 소유자는 :name 님입니다.', + 'subscription_required_auto' => '이 페이지는 자동으로 업데이트됩니다 — 새로고침할 필요가 없습니다.', + 'progress' => '환영 진행률', + 'go_to_step' => ':step단계로 이동', + 'step_current' => ':step단계 (현재)', + 'personas' => [ + 'creator' => '콘텐츠 크리에이터', + 'freelancer' => '프리랜서', + 'developer' => '개발자', + 'startup' => '스타트업', + 'agency' => '에이전시', + 'small_business' => '소상공인', + 'marketer' => '마케터', + 'online_store' => '온라인 스토어', + 'other' => '기타', + ], + 'goals_title' => '목표가 무엇인가요?', + 'goals_description' => '해당되는 항목을 모두 선택하면 TryPost를 맞춤 설정해 드립니다.', + 'goals' => [ + 'save_time' => '한 번에 여러 곳에 게시하여 시간 절약', + 'ai_content' => 'AI로 더 빠르게 게시물 작성', + 'plan_calendar' => '캘린더에서 게시물 계획', + 'stay_on_brand' => '모든 게시물을 브랜드에 맞게 유지', + 'grow_audience' => '팔로워와 참여 늘리기', + 'drive_sales' => '더 많은 트래픽과 판매 유도', + 'manage_clients' => '여러 브랜드 또는 클라이언트 관리', + 'just_exploring' => '지금은 둘러보는 중', + 'other' => '다른 것', + ], + 'referral_source_title' => '저희를 어떻게 알게 되셨나요?', + 'referral_source_description' => '사람들이 TryPost를 어떻게 발견하는지 파악하는 데 도움이 됩니다.', + 'referral_source' => [ + 'google' => 'Google 또는 검색', + 'x' => 'X (Twitter)', + 'linkedin' => 'LinkedIn', + 'youtube' => 'YouTube', + 'tiktok' => 'TikTok', + 'instagram' => 'Instagram 또는 Threads', + 'reddit' => 'Reddit', + 'product_hunt' => 'Product Hunt', + 'ai_assistant' => 'AI 어시스턴트 (ChatGPT, Claude 등)', + 'friend' => '친구 또는 동료', + 'blog' => '블로그, 뉴스레터 또는 기사', + 'other' => '기타', + ], +]; diff --git a/lang/nl/billing.php b/lang/nl/billing.php index 722e46c16..9111bec60 100644 --- a/lang/nl/billing.php +++ b/lang/nl/billing.php @@ -68,9 +68,12 @@ 'title' => 'Je abonnement wordt verwerkt', 'description' => 'Wacht even terwijl we je account instellen. Dit duurt maar een moment.', 'success_title' => 'Je bent helemaal klaar!', - 'success_description' => 'Je abonnement is actief. Je wordt doorgestuurd naar je workspaces...', + 'success_description' => 'Je abonnement is actief. Doorverwijzen…', 'cancelled_title' => 'Afrekenen geannuleerd', 'cancelled_description' => 'Je afrekenen is geannuleerd. Er zijn geen kosten in rekening gebracht.', 'retry' => 'Opnieuw proberen', + 'taking_long' => 'Dit duurt langer dan verwacht — even geduld, we zijn nog bezig met de installatie.', + 'continue' => 'Doorgaan naar de app', + 'live' => 'Live', ], ]; diff --git a/lang/nl/mcp.php b/lang/nl/mcp.php new file mode 100644 index 000000000..f77435348 --- /dev/null +++ b/lang/nl/mcp.php @@ -0,0 +1,41 @@ + 'MCP', + 'subtitle' => 'Koppel AI-assistenten zodat ze posts kunnen maken en beheren met je TryPost-account.', + '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', + 'config_label' => 'Config', + 'connected_title' => 'Gekoppelde apps', + 'connected_description' => 'Assistenten waarmee iemand op dit account is ingelogd. Je kunt alleen je eigen apps ontkoppelen.', + 'connected_empty' => 'Nog niets gekoppeld. Gebruik Claude, ChatGPT of een andere client hierboven.', + 'connected_by' => 'Verbonden door :name', + 'disconnect' => 'Ontkoppelen', + 'disconnect_title' => 'App ontkoppelen', + 'disconnect_confirm' => 'Dit logt de app uit bij TryPost. Hij moet opnieuw verbinden om MCP weer te gebruiken.', + 'disconnected' => 'App ontkoppeld.', + 'copied' => 'Gekopieerd', + 'last_used' => 'Laatst gebruikt', + 'never' => 'Nooit', + 'documentation_title' => 'Documentatie', + 'documentation_description' => 'Handleidingen per client, beschikbare tools en probleemoplossing.', + 'view_docs' => 'Documentatie bekijken', + 'connector_name' => 'TryPost', + + 'other_clients_title' => 'Andere apps', + 'other_clients_description' => 'Cursor, VS Code, Claude Code en alles wat MCP spreekt.', + + 'clients' => [ + 'cursor' => 'Voeg TryPost toe als remote MCP-server in Cursor.', + 'cursor_name' => 'Cursor', + 'vscode' => 'Plak de config hieronder in de MCP-instellingen van VS Code.', + 'vscode_name' => 'VS Code', + 'claude_code' => 'Plak de config hieronder in de MCP-instellingen van Claude Code.', + 'claude_code_name' => 'Claude Code', + 'other' => 'Werkt met elke client die een mcpServers-config leest.', + 'other_name' => 'Overig', + ], +]; diff --git a/lang/nl/onboarding.php b/lang/nl/onboarding.php index 424cfe421..9ff1b1743 100644 --- a/lang/nl/onboarding.php +++ b/lang/nl/onboarding.php @@ -3,55 +3,47 @@ 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', + 'welcome_anonymous' => 'Welkom bij TryPost', + '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' => 'Sneller posts maken met AI', - 'plan_calendar' => 'Mijn posts plannen op een kalender', - 'stay_on_brand' => 'Elke post in lijn met mijn merk houden', - 'grow_audience' => 'Mijn publiek en betrokkenheid laten groeien', - 'drive_sales' => 'Meer verkeer en verkopen krijgen', - 'manage_clients' => 'Meerdere merken of klanten beheren', - 'team_collaboration' => 'Samenwerken met mijn team', - 'automate_api' => 'Posten automatiseren met de API, MCP of code', - 'track_performance' => 'Zien hoe mijn posts presteren', - 'just_exploring' => 'Voorlopig gewoon aan het verkennen', - 'other' => 'Iets anders', + 'mcp' => [ + 'title' => 'Koppel je AI-assistent', + 'description' => 'Voeg TryPost toe als MCP-server zodat je assistent social posts voor je kan maken en beheren.', + 'copy_step' => 'Kopieer je TryPost-server-URL', + 'open_step' => 'Open je AI-assistent', + 'copy' => 'URL kopiëren', + 'copied' => 'MCP-URL gekopieerd.', + 'connect' => 'Verbinden met :client', + 'clients' => [ + 'claude' => 'Open Settings → Connectors, voeg een aangepaste connector toe en plak de URL hierboven.', + 'chatgpt' => 'Open Settings → Apps & Connectors, maak een aangepaste connector aan en plak de URL hierboven.', + ], ], - '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.', ], ]; diff --git a/lang/nl/settings.php b/lang/nl/settings.php index 78d7e05a1..46c1eb79d 100644 --- a/lang/nl/settings.php +++ b/lang/nl/settings.php @@ -129,6 +129,7 @@ 'brand' => 'Merk', 'users' => 'Leden', 'api_keys' => 'API-sleutels', + 'mcp' => 'MCP', ], 'title' => 'Workspace-instellingen', 'logo_heading' => 'Workspace-logo', diff --git a/lang/nl/sidebar.php b/lang/nl/sidebar.php index daca60f21..fe8805cfd 100644 --- a/lang/nl/sidebar.php +++ b/lang/nl/sidebar.php @@ -8,6 +8,9 @@ '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', @@ -30,6 +33,8 @@ 'analytics' => 'Statistieken', 'automations' => 'Automatiseringen', 'settings' => 'Instellingen', + 'onboarding' => 'Aan de slag', + 'onboarding_hint' => 'Setup afronden', 'posts' => [ 'calendar' => 'Kalender', @@ -44,7 +49,9 @@ 'signatures' => 'Handtekeningen', 'labels' => 'Labels', 'assets' => 'Assets', + 'settings' => 'Instellingen', 'api_keys' => 'API-sleutels', + 'mcp' => 'MCP', ], 'notifications' => 'Meldingen', diff --git a/lang/nl/welcome.php b/lang/nl/welcome.php new file mode 100644 index 000000000..6e0e1ce79 --- /dev/null +++ b/lang/nl/welcome.php @@ -0,0 +1,57 @@ + 'Wat omschrijft jou het beste?', + 'description' => 'Kies de dichtstbijzijnde optie, dan stemmen we je ervaring af.', + 'continue' => 'Doorgaan', + 'checkout_owner_only' => 'Vraag de accounteigenaar om de checkout af te ronden en het abonnement te starten.', + 'subscription_required_title' => 'Wachten op de accounteigenaar', + 'subscription_required_description' => 'Dit account heeft nog geen actief abonnement. Vraag de eigenaar om de checkout af te ronden — je krijgt volledige toegang zodra het actief is.', + 'subscription_required_owner' => 'De accounteigenaar is :name.', + 'subscription_required_auto' => 'Deze pagina vernieuwt automatisch — verversen is niet nodig.', + 'progress' => 'Welkomstvoortgang', + 'go_to_step' => 'Ga naar stap :step', + 'step_current' => 'Stap :step (huidig)', + 'personas' => [ + 'creator' => 'Contentmaker', + 'freelancer' => 'Freelancer', + 'developer' => 'Ontwikkelaar', + 'startup' => 'Startup', + 'agency' => 'Bureau', + 'small_business' => 'Klein bedrijf', + 'marketer' => 'Marketeer', + 'online_store' => 'Webshop', + 'other' => 'Anders', + ], + 'goals_title' => 'Wat is je doel?', + 'goals_description' => 'Kies alles wat past en we stellen TryPost voor je in.', + 'goals' => [ + 'save_time' => 'Tijd besparen door overal tegelijk te posten', + 'ai_content' => 'Sneller posts maken met AI', + 'plan_calendar' => 'Mijn posts plannen op een kalender', + 'stay_on_brand' => 'Elke post in lijn met mijn merk houden', + 'grow_audience' => 'Mijn publiek en betrokkenheid laten groeien', + 'drive_sales' => 'Meer verkeer en verkopen krijgen', + 'manage_clients' => 'Meerdere merken of klanten beheren', + 'just_exploring' => 'Voorlopig gewoon aan het verkennen', + 'other' => 'Iets anders', + ], + 'referral_source_title' => 'Hoe heb je ons gevonden?', + 'referral_source_description' => 'Dit helpt ons te begrijpen hoe mensen TryPost ontdekken.', + 'referral_source' => [ + 'google' => 'Google of zoekmachine', + 'x' => 'X (Twitter)', + 'linkedin' => 'LinkedIn', + 'youtube' => 'YouTube', + 'tiktok' => 'TikTok', + 'instagram' => 'Instagram of Threads', + 'reddit' => 'Reddit', + 'product_hunt' => 'Product Hunt', + 'ai_assistant' => 'AI-assistent (ChatGPT, Claude…)', + 'friend' => 'Vriend of collega', + 'blog' => 'Blog, nieuwsbrief of artikel', + 'other' => 'Iets anders', + ], +]; diff --git a/lang/pl/billing.php b/lang/pl/billing.php index 4283acf93..4306c03b7 100644 --- a/lang/pl/billing.php +++ b/lang/pl/billing.php @@ -68,9 +68,12 @@ 'title' => 'Przetwarzanie Twojej subskrypcji', 'description' => 'Poczekaj, aż skonfigurujemy Twoje konto. Zajmie to tylko chwilę.', 'success_title' => 'Wszystko gotowe!', - 'success_description' => 'Twoja subskrypcja jest aktywna. Przekierowujemy Cię do Twoich przestrzeni roboczych...', + 'success_description' => 'Twoja subskrypcja jest aktywna. Przekierowanie…', 'cancelled_title' => 'Anulowano płatność', 'cancelled_description' => 'Twoja płatność została anulowana. Nie pobrano żadnych opłat.', 'retry' => 'Spróbuj ponownie', + 'taking_long' => 'To trwa dłużej niż oczekiwano — poczekaj, wciąż wszystko konfigurujemy.', + 'continue' => 'Przejdź do aplikacji', + 'live' => 'Na żywo', ], ]; diff --git a/lang/pl/mcp.php b/lang/pl/mcp.php new file mode 100644 index 000000000..3c24c5d05 --- /dev/null +++ b/lang/pl/mcp.php @@ -0,0 +1,41 @@ + 'MCP', + 'subtitle' => 'Połącz asystentów AI, aby tworzyli i zarządzali postami na koncie TryPost.', + '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', + 'config_label' => 'Config', + 'connected_title' => 'Połączone aplikacje', + 'connected_description' => 'Asystenci zalogowani przez kogokolwiek na tym koncie. Możesz rozłączyć tylko własne.', + 'connected_empty' => 'Nic jeszcze nie połączono. Użyj Claude, ChatGPT lub innego klienta powyżej.', + 'connected_by' => 'Połączono przez :name', + 'disconnect' => 'Rozłącz', + 'disconnect_title' => 'Rozłącz aplikację', + 'disconnect_confirm' => 'To wyloguje aplikację z TryPost. Musi połączyć się ponownie, zanim znów użyje MCP.', + 'disconnected' => 'Aplikacja rozłączona.', + 'copied' => 'Skopiowano', + 'last_used' => 'Ostatnie użycie', + 'never' => 'Nigdy', + 'documentation_title' => 'Dokumentacja', + 'documentation_description' => 'Przewodniki per klient, dostępne tools i rozwiązywanie problemów.', + 'view_docs' => 'Zobacz dokumentację', + 'connector_name' => 'TryPost', + + 'other_clients_title' => 'Inne aplikacje', + 'other_clients_description' => 'Cursor, VS Code, Claude Code i wszystko, co mówi MCP.', + + 'clients' => [ + 'cursor' => 'Dodaj TryPost jako zdalny serwer MCP w Cursorze.', + 'cursor_name' => 'Cursor', + 'vscode' => 'Wklej poniższą konfigurację w ustawieniach MCP VS Code.', + 'vscode_name' => 'VS Code', + 'claude_code' => 'Wklej poniższą konfigurację w ustawieniach MCP Claude Code.', + 'claude_code_name' => 'Claude Code', + 'other' => 'Działa z każdym klientem, który czyta config mcpServers.', + 'other_name' => 'Inne', + ], +]; diff --git a/lang/pl/onboarding.php b/lang/pl/onboarding.php index 91092398d..5d4221838 100644 --- a/lang/pl/onboarding.php +++ b/lang/pl/onboarding.php @@ -3,55 +3,47 @@ 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', + 'welcome_anonymous' => 'Witaj w TryPost', + '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' => 'Twórz posty szybciej dzięki AI', - 'plan_calendar' => 'Planuj posty w kalendarzu', - 'stay_on_brand' => 'Utrzymuj każdy post spójny z marką', - 'grow_audience' => 'Powiększaj grono odbiorców i zaangażowanie', - 'drive_sales' => 'Zdobywaj więcej ruchu i sprzedaży', - 'manage_clients' => 'Zarządzaj wieloma markami lub klientami', - 'team_collaboration' => 'Pracuj z moim zespołem', - 'automate_api' => 'Automatyzuj publikowanie za pomocą API, MCP lub kodu', - 'track_performance' => 'Sprawdzaj, jak radzą sobie moje posty', - 'just_exploring' => 'Na razie tylko się rozglądam', - 'other' => 'Coś innego', + '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.', + 'copy_step' => 'Skopiuj URL serwera TryPost', + 'open_step' => 'Otwórz asystenta AI', + 'copy' => 'Kopiuj URL', + 'copied' => 'URL MCP skopiowany.', + 'connect' => 'Połącz z :client', + 'clients' => [ + 'claude' => 'Otwórz Settings → Connectors, dodaj niestandardowy connector, a następnie wklej powyższy URL.', + 'chatgpt' => 'Otwórz Settings → Apps & Connectors, utwórz niestandardowy connector, a następnie wklej powyższy URL.', + ], ], - '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.', ], ]; diff --git a/lang/pl/settings.php b/lang/pl/settings.php index bde5ddc37..89520348f 100644 --- a/lang/pl/settings.php +++ b/lang/pl/settings.php @@ -129,6 +129,7 @@ 'brand' => 'Marka', 'users' => 'Członkowie', 'api_keys' => 'Klucze API', + 'mcp' => 'MCP', ], 'title' => 'Ustawienia przestrzeni roboczej', 'logo_heading' => 'Logo przestrzeni roboczej', diff --git a/lang/pl/sidebar.php b/lang/pl/sidebar.php index 9e83547d1..b99d1b0f2 100644 --- a/lang/pl/sidebar.php +++ b/lang/pl/sidebar.php @@ -8,6 +8,9 @@ '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', @@ -30,6 +33,8 @@ 'analytics' => 'Analityka', 'automations' => 'Automatyzacje', 'settings' => 'Ustawienia', + 'onboarding' => 'Pierwsze kroki', + 'onboarding_hint' => 'Dokończ konfigurację', 'posts' => [ 'calendar' => 'Kalendarz', @@ -44,7 +49,9 @@ 'signatures' => 'Sygnatury', 'labels' => 'Etykiety', 'assets' => 'Zasoby', + 'settings' => 'Ustawienia', 'api_keys' => 'Klucze API', + 'mcp' => 'MCP', ], 'notifications' => 'Powiadomienia', diff --git a/lang/pl/welcome.php b/lang/pl/welcome.php new file mode 100644 index 000000000..f6147806c --- /dev/null +++ b/lang/pl/welcome.php @@ -0,0 +1,57 @@ + 'Co najlepiej Cię opisuje?', + 'description' => 'Wybierz najbliższą opcję, a my dopasujemy Twoje doświadczenie.', + 'continue' => 'Kontynuuj', + 'checkout_owner_only' => 'Poproś właściciela konta o dokończenie płatności i rozpoczęcie subskrypcji.', + 'subscription_required_title' => 'Oczekiwanie na właściciela konta', + 'subscription_required_description' => 'To konto nie ma jeszcze aktywnej subskrypcji. Poproś właściciela o dokończenie płatności — uzyskasz pełny dostęp, gdy tylko będzie aktywna.', + 'subscription_required_owner' => 'Właścicielem Twojego konta jest :name.', + 'subscription_required_auto' => 'Ta strona odświeża się automatycznie — nie musisz jej przeładowywać.', + 'progress' => 'Postęp powitalny', + 'go_to_step' => 'Przejdź do kroku :step', + 'step_current' => 'Krok :step (bieżący)', + 'personas' => [ + 'creator' => 'Twórca treści', + 'freelancer' => 'Freelancer', + 'developer' => 'Programista', + 'startup' => 'Startup', + 'agency' => 'Agencja', + 'small_business' => 'Mała firma', + 'marketer' => 'Marketingowiec', + 'online_store' => 'Sklep internetowy', + 'other' => 'Inne', + ], + 'goals_title' => 'Jaki jest Twój cel?', + 'goals_description' => 'Wybierz wszystko, co pasuje, a my skonfigurujemy TryPost dla Ciebie.', + 'goals' => [ + 'save_time' => 'Oszczędzaj czas, publikując wszędzie naraz', + 'ai_content' => 'Twórz posty szybciej dzięki AI', + 'plan_calendar' => 'Planuj posty w kalendarzu', + 'stay_on_brand' => 'Utrzymuj każdy post spójny z marką', + 'grow_audience' => 'Powiększaj grono odbiorców i zaangażowanie', + 'drive_sales' => 'Zdobywaj więcej ruchu i sprzedaży', + 'manage_clients' => 'Zarządzaj wieloma markami lub klientami', + 'just_exploring' => 'Na razie tylko się rozglądam', + 'other' => 'Coś innego', + ], + 'referral_source_title' => 'Jak nas znalazłeś?', + 'referral_source_description' => 'To pomaga nam zrozumieć, jak ludzie odkrywają TryPost.', + 'referral_source' => [ + 'google' => 'Google lub wyszukiwarka', + 'x' => 'X (Twitter)', + 'linkedin' => 'LinkedIn', + 'youtube' => 'YouTube', + 'tiktok' => 'TikTok', + 'instagram' => 'Instagram lub Threads', + 'reddit' => 'Reddit', + 'product_hunt' => 'Product Hunt', + 'ai_assistant' => 'Asystent AI (ChatGPT, Claude…)', + 'friend' => 'Znajomy lub współpracownik', + 'blog' => 'Blog, newsletter lub artykuł', + 'other' => 'Coś innego', + ], +]; diff --git a/lang/pt-BR/billing.php b/lang/pt-BR/billing.php index 52e5e9f78..4946afdfb 100644 --- a/lang/pt-BR/billing.php +++ b/lang/pt-BR/billing.php @@ -68,9 +68,12 @@ 'title' => 'Processando sua assinatura', 'description' => 'Aguarde enquanto configuramos sua conta. Isso levará apenas um momento.', 'success_title' => 'Tudo pronto!', - 'success_description' => 'Sua assinatura está ativa. Redirecionando para seus workspaces...', + 'success_description' => 'Sua assinatura está ativa. Redirecionando…', 'cancelled_title' => 'Pagamento cancelado', 'cancelled_description' => 'Seu pagamento foi cancelado. Nenhuma cobrança foi realizada.', 'retry' => 'Tentar novamente', + 'taking_long' => 'Isso está demorando mais que o normal — aguarde, ainda estamos configurando tudo.', + 'continue' => 'Continuar para o app', + 'live' => 'Ao vivo', ], ]; diff --git a/lang/pt-BR/mcp.php b/lang/pt-BR/mcp.php new file mode 100644 index 000000000..8818a660c --- /dev/null +++ b/lang/pt-BR/mcp.php @@ -0,0 +1,41 @@ + 'MCP', + 'subtitle' => 'Conecte assistentes de IA pra criarem e gerenciarem posts com sua conta TryPost.', + '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', + 'config_label' => 'Config', + 'connected_title' => 'Apps conectados', + 'connected_description' => 'Assistentes com login de qualquer pessoa nesta conta. Você só desconecta os seus.', + 'connected_empty' => 'Nada conectado ainda. Use Claude, ChatGPT ou outro cliente acima.', + 'connected_by' => 'Conectado por :name', + 'disconnect' => 'Desconectar', + 'disconnect_title' => 'Desconectar app', + 'disconnect_confirm' => 'Isso desconecta o app do TryPost. Ele precisa reconectar pra usar o MCP de novo.', + 'disconnected' => 'App desconectado.', + 'copied' => 'Copiado', + 'last_used' => 'Último uso', + 'never' => 'Nunca', + 'documentation_title' => 'Documentação', + 'documentation_description' => 'Guias por cliente, tools disponíveis e solução de problemas.', + 'view_docs' => 'Ver documentação', + 'connector_name' => 'TryPost', + + 'other_clients_title' => 'Outros apps', + 'other_clients_description' => 'Cursor, VS Code, Claude Code e qualquer app que fale MCP.', + + 'clients' => [ + 'cursor' => 'Adicione o TryPost como servidor MCP remoto no Cursor.', + 'cursor_name' => 'Cursor', + 'vscode' => 'Cole o config abaixo nas configurações MCP do VS Code.', + 'vscode_name' => 'VS Code', + 'claude_code' => 'Cole o config abaixo nas configurações MCP do Claude Code.', + 'claude_code_name' => 'Claude Code', + 'other' => 'Funciona com qualquer cliente que leia um config mcpServers.', + 'other_name' => 'Outros', + ], +]; diff --git a/lang/pt-BR/onboarding.php b/lang/pt-BR/onboarding.php index 12ad79629..7c9494b1b 100644 --- a/lang/pt-BR/onboarding.php +++ b/lang/pt-BR/onboarding.php @@ -3,55 +3,47 @@ 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', + 'welcome_anonymous' => 'Boas-vindas ao TryPost', + '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' => 'Criar posts mais rápido com IA', - 'plan_calendar' => 'Planejar meus posts num calendário', - 'stay_on_brand' => 'Manter a consistência da minha marca', - 'grow_audience' => 'Crescer minha audiência e engajamento', - 'drive_sales' => 'Conseguir mais tráfego e vendas', - 'manage_clients' => 'Gerenciar várias marcas ou clientes', - 'team_collaboration' => 'Trabalhar com meu time', - 'automate_api' => 'Automatizar publicações com a API, MCP ou código', - 'track_performance' => 'Ver o desempenho dos meus posts', - 'just_exploring' => 'Só dando uma olhada por enquanto', - 'other' => 'Outra coisa', + 'mcp' => [ + 'title' => 'Conecte seu assistente de IA', + 'description' => 'Adicione o TryPost como servidor MCP para o assistente criar e gerenciar posts por você.', + 'copy_step' => 'Copie a URL do servidor TryPost', + 'open_step' => 'Abra seu assistente de IA', + 'copy' => 'Copiar URL', + 'copied' => 'URL do MCP copiada.', + 'connect' => 'Conectar com :client', + 'clients' => [ + 'claude' => 'Abra Settings → Connectors, adicione um connector customizado e cole a URL acima.', + 'chatgpt' => 'Abra Settings → Apps & Connectors, crie um connector customizado e cole a URL acima.', + ], ], - '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.', ], ]; diff --git a/lang/pt-BR/settings.php b/lang/pt-BR/settings.php index 3ba148481..1ec2a7936 100644 --- a/lang/pt-BR/settings.php +++ b/lang/pt-BR/settings.php @@ -129,6 +129,7 @@ 'brand' => 'Marca', 'users' => 'Membros', 'api_keys' => 'API Keys', + 'mcp' => 'MCP', ], 'title' => 'Configurações do workspace', 'logo_heading' => 'Logo do workspace', diff --git a/lang/pt-BR/sidebar.php b/lang/pt-BR/sidebar.php index 0717d6215..fb417429d 100644 --- a/lang/pt-BR/sidebar.php +++ b/lang/pt-BR/sidebar.php @@ -8,6 +8,9 @@ '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', @@ -30,6 +33,8 @@ 'analytics' => 'Analytics', 'automations' => 'Automações', 'settings' => 'Configurações', + 'onboarding' => 'Primeiros passos', + 'onboarding_hint' => 'Complete a configuração', 'posts' => [ 'calendar' => 'Calendário', @@ -44,7 +49,9 @@ 'signatures' => 'Assinaturas', 'labels' => 'Etiquetas', 'assets' => 'Mídias', + 'settings' => 'Configurações', 'api_keys' => 'API Keys', + 'mcp' => 'MCP', ], 'notifications' => 'Notificações', diff --git a/lang/pt-BR/welcome.php b/lang/pt-BR/welcome.php new file mode 100644 index 000000000..3d8009b1c --- /dev/null +++ b/lang/pt-BR/welcome.php @@ -0,0 +1,57 @@ + 'O que melhor descreve você?', + 'description' => 'Escolha a opção mais próxima e a gente personaliza sua experiência.', + 'continue' => 'Continuar', + 'checkout_owner_only' => 'Peça ao dono da conta para concluir o checkout e iniciar a assinatura.', + 'subscription_required_title' => 'Aguardando o dono da conta', + 'subscription_required_description' => 'Esta conta ainda não tem uma assinatura ativa. Peça ao dono da conta para concluir o checkout — você terá acesso total assim que ela estiver ativa.', + 'subscription_required_owner' => 'O dono da sua conta é :name.', + 'subscription_required_auto' => 'Esta página atualiza automaticamente — não precisa recarregar.', + 'progress' => 'Progresso do onboarding', + 'go_to_step' => 'Ir para a etapa :step', + 'step_current' => 'Etapa :step (atual)', + 'personas' => [ + 'creator' => 'Criador de conteúdo', + 'freelancer' => 'Freelancer', + 'developer' => 'Desenvolvedor', + 'startup' => 'Startup', + 'agency' => 'Agência', + 'small_business' => 'Pequena empresa', + 'marketer' => 'Profissional de marketing', + 'online_store' => 'Loja online', + 'other' => 'Outro', + ], + 'goals_title' => 'Qual o seu objetivo?', + 'goals_description' => 'Marque tudo que faz sentido e a gente ajusta o TryPost pra você.', + 'goals' => [ + 'save_time' => 'Economizar tempo postando em todas as redes de uma vez', + 'ai_content' => 'Criar posts mais rápido com IA', + 'plan_calendar' => 'Planejar meus posts num calendário', + 'stay_on_brand' => 'Manter a consistência da minha marca', + 'grow_audience' => 'Crescer minha audiência e engajamento', + 'drive_sales' => 'Conseguir mais tráfego e vendas', + 'manage_clients' => 'Gerenciar várias marcas ou clientes', + 'just_exploring' => 'Só dando uma olhada por enquanto', + 'other' => 'Outra coisa', + ], + 'referral_source_title' => 'Como você nos encontrou?', + 'referral_source_description' => 'Isso nos ajuda a entender como as pessoas descobrem o TryPost.', + 'referral_source' => [ + 'google' => 'Google ou busca', + 'x' => 'X (Twitter)', + 'linkedin' => 'LinkedIn', + 'youtube' => 'YouTube', + 'tiktok' => 'TikTok', + 'instagram' => 'Instagram ou Threads', + 'reddit' => 'Reddit', + 'product_hunt' => 'Product Hunt', + 'ai_assistant' => 'Assistente de IA (ChatGPT, Claude…)', + 'friend' => 'Amigo ou colega', + 'blog' => 'Blog, newsletter ou artigo', + 'other' => 'Outra coisa', + ], +]; diff --git a/lang/ru/billing.php b/lang/ru/billing.php index c6e7d9255..654f51d6f 100644 --- a/lang/ru/billing.php +++ b/lang/ru/billing.php @@ -68,9 +68,12 @@ 'title' => 'Обрабатываем вашу подписку', 'description' => 'Подождите, пока мы настраиваем ваш аккаунт. Это займёт всего мгновение.', 'success_title' => 'Всё готово!', - 'success_description' => 'Ваша подписка активна. Перенаправляем вас к рабочим пространствам...', + 'success_description' => 'Ваша подписка активна. Перенаправление…', 'cancelled_title' => 'Оформление отменено', 'cancelled_description' => 'Оформление отменено. Списаний не было.', 'retry' => 'Попробовать снова', + 'taking_long' => 'Это занимает больше времени, чем ожидалось, — подождите, мы всё ещё настраиваем аккаунт.', + 'continue' => 'Перейти в приложение', + 'live' => 'Live', ], ]; diff --git a/lang/ru/mcp.php b/lang/ru/mcp.php new file mode 100644 index 000000000..4f8acfde4 --- /dev/null +++ b/lang/ru/mcp.php @@ -0,0 +1,41 @@ + 'MCP', + 'subtitle' => 'Подключите ИИ-ассистентов, чтобы они создавали и управляли постами в вашем аккаунте TryPost.', + 'step_add' => 'Вставьте имя, URL или config ниже в своё приложение. Вход откроется в браузере при первом подключении.', + 'name_label' => 'Имя', + 'url_label' => 'URL сервера', + 'config_label' => 'Config', + 'connected_title' => 'Подключённые приложения', + 'connected_description' => 'Ассистенты, вошедшие от имени кого угодно в этом аккаунте. Отключить можно только свои.', + 'connected_empty' => 'Пока ничего не подключено. Используйте Claude, ChatGPT или другого клиента выше.', + 'connected_by' => 'Подключил :name', + 'disconnect' => 'Отключить', + 'disconnect_title' => 'Отключить приложение', + 'disconnect_confirm' => 'Это выйдет из аккаунта TryPost в приложении. Нужно будет подключиться снова, чтобы снова использовать MCP.', + 'disconnected' => 'Приложение отключено.', + 'copied' => 'Скопировано', + 'last_used' => 'Последнее использование', + 'never' => 'Никогда', + 'documentation_title' => 'Документация', + 'documentation_description' => 'Гайды по клиентам, доступные tools и решение проблем.', + 'view_docs' => 'Открыть документацию', + 'connector_name' => 'TryPost', + + 'other_clients_title' => 'Другие приложения', + 'other_clients_description' => 'Cursor, VS Code, Claude Code и всё, что говорит на MCP.', + + 'clients' => [ + 'cursor' => 'Добавьте TryPost как удалённый MCP-сервер в Cursor.', + 'cursor_name' => 'Cursor', + 'vscode' => 'Вставьте конфиг ниже в настройки MCP VS Code.', + 'vscode_name' => 'VS Code', + 'claude_code' => 'Вставьте конфиг ниже в настройки MCP Claude Code.', + 'claude_code_name' => 'Claude Code', + 'other' => 'Работает с любым клиентом, который читает config mcpServers.', + 'other_name' => 'Другие', + ], +]; diff --git a/lang/ru/onboarding.php b/lang/ru/onboarding.php index d7b64adde..ab443c3f7 100644 --- a/lang/ru/onboarding.php +++ b/lang/ru/onboarding.php @@ -3,55 +3,47 @@ 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', + 'welcome_anonymous' => 'Добро пожаловать в TryPost', + 'description' => 'Выполните шаги ниже, чтобы увидеть, как работает TryPost, и опубликовать первый пост.', + 'skip_step' => 'Пропустить этот шаг', + 'continue' => 'Перейти в TryPost', + 'status' => [ + 'complete' => 'Готово', + 'todo' => 'Сделать', + 'skipped' => 'Пропущено', ], - 'goals_title' => 'Какова ваша цель с TryPost?', - 'goals_description' => 'Выберите всё, что подходит, и мы настроим TryPost для вас.', - 'goals' => [ - 'save_time' => 'Экономить время, публикуя всюду сразу', - 'ai_content' => 'Создавать посты быстрее с помощью ИИ', - 'plan_calendar' => 'Планировать посты в календаре', - 'stay_on_brand' => 'Держать каждый пост в стиле бренда', - 'grow_audience' => 'Наращивать аудиторию и вовлечённость', - 'drive_sales' => 'Получать больше трафика и продаж', - 'manage_clients' => 'Управлять несколькими брендами или клиентами', - 'team_collaboration' => 'Работать с командой', - 'automate_api' => 'Автоматизировать публикацию с помощью API, MCP или кода', - 'track_performance' => 'Отслеживать эффективность постов', - 'just_exploring' => 'Пока просто знакомлюсь', - 'other' => 'Что-то ещё', + 'mcp' => [ + 'title' => 'Подключите ИИ-ассистента', + 'description' => 'Добавьте TryPost как MCP-сервер, чтобы ассистент мог создавать и вести соцпосты за вас.', + 'copy_step' => 'Скопируйте URL сервера TryPost', + 'open_step' => 'Откройте ИИ-ассистента', + 'copy' => 'Копировать URL', + 'copied' => 'URL MCP скопирован.', + 'connect' => 'Подключить через :client', + 'clients' => [ + 'claude' => 'Откройте Settings → Connectors, добавьте свой connector и вставьте URL выше.', + 'chatgpt' => 'Откройте Settings → Apps & Connectors, создайте свой connector и вставьте 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' => 'ИИ-ассистент (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 и начните планировать контент.', ], ]; diff --git a/lang/ru/settings.php b/lang/ru/settings.php index 63b69024c..79cf9d15f 100644 --- a/lang/ru/settings.php +++ b/lang/ru/settings.php @@ -129,6 +129,7 @@ 'brand' => 'Бренд', 'users' => 'Участники', 'api_keys' => 'API-ключи', + 'mcp' => 'MCP', ], 'title' => 'Настройки рабочего пространства', 'logo_heading' => 'Логотип рабочего пространства', diff --git a/lang/ru/sidebar.php b/lang/ru/sidebar.php index 4ebffe4e7..f9fb3c55b 100644 --- a/lang/ru/sidebar.php +++ b/lang/ru/sidebar.php @@ -8,6 +8,9 @@ 'create_workspace' => 'Создать рабочее пространство', 'create_post' => 'Создать пост', 'profile' => 'Профиль', + 'my_account' => 'Мой аккаунт', + 'account_settings' => 'Аккаунт и оплата', + 'workspace_settings' => 'Настройки workspace', 'log_out' => 'Выйти', 'workspace' => 'Рабочее пространство: :name', @@ -30,6 +33,8 @@ 'analytics' => 'Аналитика', 'automations' => 'Автоматизации', 'settings' => 'Настройки', + 'onboarding' => 'Начало работы', + 'onboarding_hint' => 'Завершите настройку', 'posts' => [ 'calendar' => 'Календарь', @@ -44,7 +49,9 @@ 'signatures' => 'Подписи', 'labels' => 'Метки', 'assets' => 'Медиафайлы', + 'settings' => 'Настройки', 'api_keys' => 'API-ключи', + 'mcp' => 'MCP', ], 'notifications' => 'Уведомления', diff --git a/lang/ru/welcome.php b/lang/ru/welcome.php new file mode 100644 index 000000000..103f26f9a --- /dev/null +++ b/lang/ru/welcome.php @@ -0,0 +1,57 @@ + 'Что лучше всего вас описывает?', + 'description' => 'Выберите ближайший вариант — мы настроим опыт под вас.', + 'continue' => 'Продолжить', + 'checkout_owner_only' => 'Попросите владельца аккаунта завершить оплату и оформить подписку.', + 'subscription_required_title' => 'Ожидание владельца аккаунта', + 'subscription_required_description' => 'У этого аккаунта пока нет активной подписки. Попросите владельца завершить оплату — вы получите полный доступ сразу после её активации.', + 'subscription_required_owner' => 'Владелец вашего аккаунта — :name.', + 'subscription_required_auto' => 'Эта страница обновляется автоматически — перезагружать не нужно.', + 'progress' => 'Прогресс приветствия', + 'go_to_step' => 'Перейти к шагу :step', + 'step_current' => 'Шаг :step (текущий)', + 'personas' => [ + 'creator' => 'Автор контента', + 'freelancer' => 'Фрилансер', + 'developer' => 'Разработчик', + 'startup' => 'Стартап', + 'agency' => 'Агентство', + 'small_business' => 'Малый бизнес', + 'marketer' => 'Маркетолог', + 'online_store' => 'Интернет-магазин', + 'other' => 'Другое', + ], + 'goals_title' => 'Какова ваша цель?', + 'goals_description' => 'Выберите всё, что подходит, и мы настроим TryPost для вас.', + 'goals' => [ + 'save_time' => 'Экономить время, публикуя всюду сразу', + 'ai_content' => 'Создавать посты быстрее с помощью ИИ', + 'plan_calendar' => 'Планировать посты в календаре', + 'stay_on_brand' => 'Держать каждый пост в стиле бренда', + 'grow_audience' => 'Наращивать аудиторию и вовлечённость', + 'drive_sales' => 'Получать больше трафика и продаж', + 'manage_clients' => 'Управлять несколькими брендами или клиентами', + 'just_exploring' => 'Пока просто знакомлюсь', + 'other' => 'Что-то ещё', + ], + 'referral_source_title' => 'Как вы нас нашли?', + 'referral_source_description' => 'Это помогает нам понять, как люди узнают о TryPost.', + 'referral_source' => [ + 'google' => 'Google или поиск', + 'x' => 'X (Twitter)', + 'linkedin' => 'LinkedIn', + 'youtube' => 'YouTube', + 'tiktok' => 'TikTok', + 'instagram' => 'Instagram или Threads', + 'reddit' => 'Reddit', + 'product_hunt' => 'Product Hunt', + 'ai_assistant' => 'ИИ-ассистент (ChatGPT, Claude…)', + 'friend' => 'Друг или коллега', + 'blog' => 'Блог, рассылка или статья', + 'other' => 'Что-то другое', + ], +]; diff --git a/lang/tr/billing.php b/lang/tr/billing.php index e50e65562..43c7e6d1e 100644 --- a/lang/tr/billing.php +++ b/lang/tr/billing.php @@ -70,9 +70,12 @@ 'title' => 'Aboneliğiniz işleniyor', 'description' => 'Hesabınızı ayarlarken lütfen bekleyin. Bu yalnızca bir an sürecek.', 'success_title' => 'Her şey hazır!', - 'success_description' => 'Aboneliğiniz etkin. Çalışma alanlarınıza yönlendiriliyorsunuz...', + 'success_description' => 'Aboneliğiniz etkin. Yönlendiriliyor…', 'cancelled_title' => 'Ödeme iptal edildi', 'cancelled_description' => 'Ödemeniz iptal edildi. Herhangi bir ücret alınmadı.', 'retry' => 'Tekrar dene', + 'taking_long' => 'Bu beklenenden uzun sürüyor — bekleyin, kurulum hâlâ devam ediyor.', + 'continue' => 'Uygulamaya devam et', + 'live' => 'Canlı', ], ]; diff --git a/lang/tr/mcp.php b/lang/tr/mcp.php new file mode 100644 index 000000000..c39fb668b --- /dev/null +++ b/lang/tr/mcp.php @@ -0,0 +1,41 @@ + 'MCP', + 'subtitle' => 'TryPost hesabınızla gönderi oluşturup yönetmeleri için yapay zeka asistanlarını bağlayın.', + 'step_add' => 'Adı, URL’yi veya config’i 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 URL’si', + 'config_label' => 'Config', + 'connected_title' => 'Bağlı uygulamalar', + 'connected_description' => 'Bu hesaptaki herhangi birinin giriş yaptığı asistanlar. Yalnızca kendininkileri bağlantıyı kesebilirsiniz.', + 'connected_empty' => 'Henüz bağlı bir şey yok. Yukarıdan Claude, ChatGPT veya başka bir istemci kullanın.', + 'connected_by' => ':name tarafından bağlandı', + 'disconnect' => 'Bağlantıyı kes', + 'disconnect_title' => 'Uygulama bağlantısını kes', + 'disconnect_confirm' => 'Bu, uygulamayı TryPost’tan çıkarır. MCP’yi yeniden kullanmak için tekrar bağlanması gerekir.', + 'disconnected' => 'Uygulama bağlantısı kesildi.', + 'copied' => 'Kopyalandı', + 'last_used' => 'Son kullanım', + 'never' => 'Hiç', + 'documentation_title' => 'Dokümantasyon', + 'documentation_description' => 'İstemci kurulum rehberleri, kullanılabilir tools ve sorun giderme.', + 'view_docs' => 'Dokümantasyonu görüntüle', + 'connector_name' => 'TryPost', + + 'other_clients_title' => 'Diğer uygulamalar', + 'other_clients_description' => 'Cursor, VS Code, Claude Code ve MCP konuşan diğer her şey.', + + 'clients' => [ + 'cursor' => 'Cursor’da TryPost’u uzak MCP sunucusu olarak ekleyin.', + 'cursor_name' => 'Cursor', + 'vscode' => 'Aşağıdaki yapılandırmayı VS Code\'un MCP ayarlarına yapıştırın.', + 'vscode_name' => 'VS Code', + 'claude_code' => 'Aşağıdaki yapılandırmayı Claude Code\'un MCP ayarlarına yapıştırın.', + 'claude_code_name' => 'Claude Code', + 'other' => 'mcpServers config okuyan her istemciyle çalışır.', + 'other_name' => 'Diğer', + ], +]; diff --git a/lang/tr/onboarding.php b/lang/tr/onboarding.php index b54630a4c..c8c0c2624 100644 --- a/lang/tr/onboarding.php +++ b/lang/tr/onboarding.php @@ -3,55 +3,47 @@ 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' => 'TryPost’a hoş geldin, :name', + 'welcome_anonymous' => 'TryPost’a hoş geldin', + 'description' => 'TryPost’un 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' => 'TryPost’a 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' => 'AI ile daha hızlı gönderi oluşturmak', - 'plan_calendar' => 'Gönderilerimi bir takvimde planlamak', - 'stay_on_brand' => 'Her gönderiyi marka çizgisinde tutmak', - 'grow_audience' => 'Kitlemi ve etkileşimimi büyütmek', - 'drive_sales' => 'Daha fazla trafik ve satış elde etmek', - 'manage_clients' => 'Birden fazla marka veya müşteri yönetmek', - 'team_collaboration' => 'Ekibimle çalışmak', - 'automate_api' => 'API, MCP veya kodla paylaşımı otomatikleştirmek', - 'track_performance' => 'Gönderilerimin performansını görmek', - 'just_exploring' => 'Şimdilik sadece keşfetmek', - 'other' => 'Başka bir şey', + 'mcp' => [ + 'title' => 'AI asistanını bağla', + 'description' => 'Asistanının senin için sosyal gönderiler oluşturup yönetebilmesi için TryPost’u MCP sunucusu olarak ekle.', + 'copy_step' => 'TryPost sunucu URL’ini kopyala', + 'open_step' => 'AI asistanını aç', + 'copy' => 'URL’yi kopyala', + 'copied' => 'MCP URL’si kopyalandı.', + 'connect' => ':client ile bağlan', + 'clients' => [ + 'claude' => 'Settings → Connectors’ı aç, özel bir connector ekle ve yukarıdaki URL’yi yapıştır.', + 'chatgpt' => 'Settings → Apps & Connectors’ı aç, özel bir connector oluştur ve yukarıdaki URL’yi yapıştır.', + ], ], - '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' => 'TryPost’un 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ıç prompt’unu bağlı asistanınla dene veya gönderiyi doğrudan TryPost’ta 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' => 'Prompt’u 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. TryPost’a devam et ve içeriğini planlamaya başla.', ], ]; diff --git a/lang/tr/settings.php b/lang/tr/settings.php index 359d60eff..f2852da05 100644 --- a/lang/tr/settings.php +++ b/lang/tr/settings.php @@ -131,6 +131,7 @@ 'brand' => 'Marka', 'users' => 'Üyeler', 'api_keys' => 'API Anahtarları', + 'mcp' => 'MCP', ], 'title' => 'Çalışma alanı ayarları', 'logo_heading' => 'Çalışma alanı logosu', diff --git a/lang/tr/sidebar.php b/lang/tr/sidebar.php index c7936fc96..19e4bf003 100644 --- a/lang/tr/sidebar.php +++ b/lang/tr/sidebar.php @@ -8,6 +8,9 @@ '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', @@ -30,6 +33,8 @@ 'analytics' => 'Analitik', 'automations' => 'Otomasyonlar', 'settings' => 'Ayarlar', + 'onboarding' => 'Başlarken', + 'onboarding_hint' => 'Kurulumu bitir', 'posts' => [ 'calendar' => 'Takvim', @@ -44,7 +49,9 @@ 'signatures' => 'İmzalar', 'labels' => 'Etiketler', 'assets' => 'Varlıklar', + 'settings' => 'Ayarlar', 'api_keys' => 'API Anahtarları', + 'mcp' => 'MCP', ], 'notifications' => 'Bildirimler', diff --git a/lang/tr/welcome.php b/lang/tr/welcome.php new file mode 100644 index 000000000..55b7c54a4 --- /dev/null +++ b/lang/tr/welcome.php @@ -0,0 +1,57 @@ + 'Sizi en iyi ne tanımlar?', + 'description' => 'En yakın seçeneği seçin, deneyiminizi kişiselleştirelim.', + 'continue' => 'Devam et', + 'checkout_owner_only' => 'Hesap sahibinden ödemeyi tamamlayıp aboneliği başlatmasını isteyin.', + 'subscription_required_title' => 'Hesap sahibi bekleniyor', + 'subscription_required_description' => 'Bu hesabın henüz etkin bir aboneliği yok. Hesap sahibinden ödemeyi tamamlamasını isteyin — abonelik etkinleşir etkinleşmez tam erişiminiz olur.', + 'subscription_required_owner' => 'Hesap sahibiniz :name.', + 'subscription_required_auto' => 'Bu sayfa otomatik olarak güncellenir — yenilemenize gerek yok.', + 'progress' => 'Karşılama ilerlemesi', + 'go_to_step' => ':step. adıma git', + 'step_current' => 'Adım :step (şu anki)', + 'personas' => [ + 'creator' => 'İçerik üreticisi', + 'freelancer' => 'Serbest çalışan', + 'developer' => 'Geliştirici', + 'startup' => 'Girişim', + 'agency' => 'Ajans', + 'small_business' => 'Küçük işletme', + 'marketer' => 'Pazarlamacı', + 'online_store' => 'Çevrimiçi mağaza', + 'other' => 'Diğer', + ], + 'goals_title' => 'Hedefiniz nedir?', + 'goals_description' => 'Size uyan her şeyi seçin, biz de TryPost\'u sizin için ayarlayalım.', + 'goals' => [ + 'save_time' => 'Her yere aynı anda paylaşarak zaman kazanmak', + 'ai_content' => 'AI ile daha hızlı gönderi oluşturmak', + 'plan_calendar' => 'Gönderilerimi bir takvimde planlamak', + 'stay_on_brand' => 'Her gönderiyi marka çizgisinde tutmak', + 'grow_audience' => 'Kitlemi ve etkileşimimi büyütmek', + 'drive_sales' => 'Daha fazla trafik ve satış elde etmek', + 'manage_clients' => 'Birden fazla marka veya müşteri yönetmek', + 'just_exploring' => 'Şimdilik sadece keşfetmek', + 'other' => 'Başka bir şey', + ], + 'referral_source_title' => 'Bizi nasıl buldunuz?', + 'referral_source_description' => 'İnsanların TryPost\'u nasıl keşfettiğini anlamamıza yardımcı olur.', + 'referral_source' => [ + 'google' => 'Google veya arama', + 'x' => 'X (Twitter)', + 'linkedin' => 'LinkedIn', + 'youtube' => 'YouTube', + 'tiktok' => 'TikTok', + 'instagram' => 'Instagram veya Threads', + 'reddit' => 'Reddit', + 'product_hunt' => 'Product Hunt', + 'ai_assistant' => 'Yapay zeka asistanı (ChatGPT, Claude…)', + 'friend' => 'Arkadaş veya meslektaş', + 'blog' => 'Blog, bülten veya makale', + 'other' => 'Başka bir şey', + ], +]; diff --git a/lang/zh/billing.php b/lang/zh/billing.php index c17249c33..189e9252b 100644 --- a/lang/zh/billing.php +++ b/lang/zh/billing.php @@ -68,9 +68,12 @@ 'title' => '正在处理你的订阅', 'description' => '正在为你设置账户,请稍候。这只需片刻。', 'success_title' => '一切就绪!', - 'success_description' => '你的订阅已生效。正在为你跳转到工作区…', + 'success_description' => '您的订阅已激活。正在跳转…', 'cancelled_title' => '结账已取消', 'cancelled_description' => '你的结账已取消,未产生任何费用。', 'retry' => '重试', + 'taking_long' => '耗时比预期更长 — 请稍候,我们仍在为您设置。', + 'continue' => '继续进入应用', + 'live' => '实时', ], ]; diff --git a/lang/zh/mcp.php b/lang/zh/mcp.php new file mode 100644 index 000000000..66addb6e4 --- /dev/null +++ b/lang/zh/mcp.php @@ -0,0 +1,41 @@ + 'MCP', + 'subtitle' => '连接 AI 助手,让它们用你的 TryPost 账户创建和管理帖子。', + 'step_add' => '将下方的名称、URL 或配置粘贴到你的应用中。首次连接时会在浏览器中打开登录。', + 'name_label' => '名称', + 'url_label' => '服务器 URL', + 'config_label' => '配置', + 'connected_title' => '已连接的应用', + 'connected_description' => '此账户中任何人已登录的助手。你只能断开自己的连接。', + 'connected_empty' => '还没有连接。请使用上方的 Claude、ChatGPT 或其他客户端。', + 'connected_by' => '由 :name 连接', + 'disconnect' => '断开连接', + 'disconnect_title' => '断开应用', + 'disconnect_confirm' => '这将使应用退出 TryPost。再次使用 MCP 前需要重新连接。', + 'disconnected' => '应用已断开连接。', + 'copied' => '已复制', + 'last_used' => '最近使用', + 'never' => '从未', + 'documentation_title' => '文档', + 'documentation_description' => '各客户端设置指南、可用工具和问题排查。', + 'view_docs' => '查看文档', + 'connector_name' => 'TryPost', + + 'other_clients_title' => '其他应用', + 'other_clients_description' => 'Cursor、VS Code、Claude Code 以及任何支持 MCP 的应用。', + + 'clients' => [ + 'cursor' => '在 Cursor 中将 TryPost 添加为远程 MCP 服务器。', + 'cursor_name' => 'Cursor', + 'vscode' => '将下方配置粘贴到 VS Code 的 MCP 设置中。', + 'vscode_name' => 'VS Code', + 'claude_code' => '将下方配置粘贴到 Claude Code 的 MCP 设置中。', + 'claude_code_name' => 'Claude Code', + 'other' => '适用于任何读取 mcpServers 配置的客户端。', + 'other_name' => '其他', + ], +]; diff --git a/lang/zh/onboarding.php b/lang/zh/onboarding.php index 9761ce99a..6d2152553 100644 --- a/lang/zh/onboarding.php +++ b/lang/zh/onboarding.php @@ -3,55 +3,47 @@ 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', + 'welcome_anonymous' => '欢迎使用 TryPost', + 'description' => '按下面的步骤了解 TryPost 的用法,并发布你的第一条内容。', + 'skip_step' => '跳过此步骤', + 'continue' => '继续前往 TryPost', + 'status' => [ + 'complete' => '已完成', + 'todo' => '待完成', + 'skipped' => '已跳过', ], - 'goals_title' => '你使用 TryPost 的目标是什么?', - 'goals_description' => '选择所有符合的选项,我们会为你配置好 TryPost。', - 'goals' => [ - 'save_time' => '一次发布到所有平台,节省时间', - 'ai_content' => '借助 AI 更快地创建帖子', - 'plan_calendar' => '在日历上规划我的帖子', - 'stay_on_brand' => '让每一条帖子都符合品牌调性', - 'grow_audience' => '增长我的受众和互动', - 'drive_sales' => '获得更多流量和销量', - 'manage_clients' => '管理多个品牌或客户', - 'team_collaboration' => '与我的团队协作', - 'automate_api' => '通过 API、MCP 或代码自动发帖', - 'track_performance' => '查看我的帖子表现', - 'just_exploring' => '目前只是随便看看', - 'other' => '其他需求', + 'mcp' => [ + 'title' => '连接你的 AI 助手', + 'description' => '将 TryPost 添加为 MCP 服务器,让助手帮你创建和管理社交内容。', + 'copy_step' => '复制你的 TryPost 服务器 URL', + 'open_step' => '打开你的 AI 助手', + 'copy' => '复制 URL', + 'copied' => '已复制 MCP URL。', + 'connect' => '使用 :client 连接', + 'clients' => [ + 'claude' => '打开 Settings → Connectors,添加自定义连接器,然后粘贴上方 URL。', + 'chatgpt' => '打开 Settings → Apps & Connectors,创建自定义连接器,然后粘贴上方 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,开始规划你的内容。', ], ]; diff --git a/lang/zh/settings.php b/lang/zh/settings.php index 314d8a49e..e830106c5 100644 --- a/lang/zh/settings.php +++ b/lang/zh/settings.php @@ -129,6 +129,7 @@ 'brand' => '品牌', 'users' => '成员', 'api_keys' => 'API 密钥', + 'mcp' => 'MCP', ], 'title' => '工作区设置', 'logo_heading' => '工作区徽标', diff --git a/lang/zh/sidebar.php b/lang/zh/sidebar.php index 46a3eda6f..9b98f2c35 100644 --- a/lang/zh/sidebar.php +++ b/lang/zh/sidebar.php @@ -8,6 +8,9 @@ 'create_workspace' => '创建工作区', 'create_post' => '创建帖子', 'profile' => '个人资料', + 'my_account' => '我的账户', + 'account_settings' => '账户与账单', + 'workspace_settings' => '工作区设置', 'log_out' => '退出登录', 'workspace' => '工作区::name', @@ -30,6 +33,8 @@ 'analytics' => '分析', 'automations' => '自动化', 'settings' => '设置', + 'onboarding' => '开始使用', + 'onboarding_hint' => '完成设置', 'posts' => [ 'calendar' => '日历', @@ -44,7 +49,9 @@ 'signatures' => '签名', 'labels' => '标签', 'assets' => '素材库', + 'settings' => '设置', 'api_keys' => 'API 密钥', + 'mcp' => 'MCP', ], 'notifications' => '通知', diff --git a/lang/zh/welcome.php b/lang/zh/welcome.php new file mode 100644 index 000000000..32dd0976d --- /dev/null +++ b/lang/zh/welcome.php @@ -0,0 +1,57 @@ + '哪项最能描述你?', + 'description' => '选择最接近的一项,我们会为你定制体验。', + 'continue' => '继续', + 'checkout_owner_only' => '请让账户所有者完成结账并开始订阅。', + 'subscription_required_title' => '等待账户所有者', + 'subscription_required_description' => '此账户还没有有效订阅。请让账户所有者完成结账 — 订阅生效后您即可获得完整访问权限。', + 'subscription_required_owner' => '您的账户所有者是 :name。', + 'subscription_required_auto' => '此页面会自动更新 — 无需刷新。', + 'progress' => '欢迎进度', + 'go_to_step' => '前往第 :step 步', + 'step_current' => '第 :step 步(当前)', + 'personas' => [ + 'creator' => '内容创作者', + 'freelancer' => '自由职业者', + 'developer' => '开发者', + 'startup' => '初创公司', + 'agency' => '代理机构', + 'small_business' => '小型企业', + 'marketer' => '营销人员', + 'online_store' => '网店', + 'other' => '其他', + ], + 'goals_title' => '你的目标是什么?', + 'goals_description' => '选择所有符合的选项,我们会为你配置好 TryPost。', + 'goals' => [ + 'save_time' => '一次发布到所有平台,节省时间', + 'ai_content' => '借助 AI 更快地创建帖子', + 'plan_calendar' => '在日历上规划我的帖子', + 'stay_on_brand' => '让每一条帖子都符合品牌调性', + 'grow_audience' => '增长我的受众和互动', + 'drive_sales' => '获得更多流量和销量', + 'manage_clients' => '管理多个品牌或客户', + 'just_exploring' => '目前只是随便看看', + 'other' => '其他需求', + ], + 'referral_source_title' => '您是如何找到我们的?', + 'referral_source_description' => '这有助于我们了解人们是如何发现 TryPost 的。', + 'referral_source' => [ + 'google' => 'Google 或搜索', + 'x' => 'X (Twitter)', + 'linkedin' => 'LinkedIn', + 'youtube' => 'YouTube', + 'tiktok' => 'TikTok', + 'instagram' => 'Instagram 或 Threads', + 'reddit' => 'Reddit', + 'product_hunt' => 'Product Hunt', + 'ai_assistant' => 'AI 助手(ChatGPT、Claude 等)', + 'friend' => '朋友或同事', + 'blog' => '博客、新闻通讯或文章', + 'other' => '其他', + ], +]; diff --git a/package.json b/package.json index 5920e3a07..66e352081 100644 --- a/package.json +++ b/package.json @@ -8,7 +8,9 @@ "dev": "vite", "format": "prettier --write resources/", "format:check": "prettier --check resources/", - "lint": "eslint . --fix" + "lint": "eslint . --fix", + "lint:check": "eslint .", + "types": "vue-tsc --noEmit" }, "devDependencies": { "@eslint/js": "^9.19.0", diff --git a/public/images/ai/chatgpt-black.svg b/public/images/ai/chatgpt-black.svg new file mode 100644 index 000000000..9c80705e5 --- /dev/null +++ b/public/images/ai/chatgpt-black.svg @@ -0,0 +1 @@ +ChatGPT \ No newline at end of file diff --git a/public/images/ai/chatgpt-white.svg b/public/images/ai/chatgpt-white.svg new file mode 100644 index 000000000..fb4d0ac4d --- /dev/null +++ b/public/images/ai/chatgpt-white.svg @@ -0,0 +1 @@ +OpenAI \ No newline at end of file diff --git a/public/images/ai/claude.svg b/public/images/ai/claude.svg new file mode 100644 index 000000000..5d8d7461d --- /dev/null +++ b/public/images/ai/claude.svg @@ -0,0 +1 @@ + \ No newline at end of file diff --git a/public/images/ai/cursor.svg b/public/images/ai/cursor.svg new file mode 100644 index 000000000..a1bb0420c --- /dev/null +++ b/public/images/ai/cursor.svg @@ -0,0 +1,6 @@ + + + diff --git a/public/images/ai/other-clients.svg b/public/images/ai/other-clients.svg new file mode 100644 index 000000000..c9136d9f5 --- /dev/null +++ b/public/images/ai/other-clients.svg @@ -0,0 +1,6 @@ + + + diff --git a/public/images/ai/vscode.svg b/public/images/ai/vscode.svg new file mode 100644 index 000000000..73df1aefb --- /dev/null +++ b/public/images/ai/vscode.svg @@ -0,0 +1,6 @@ + + + diff --git a/resources/css/app.css b/resources/css/app.css index eb6235849..473b3610e 100644 --- a/resources/css/app.css +++ b/resources/css/app.css @@ -14,134 +14,137 @@ variant to a `.dark` class that we never apply. */ @custom-variant dark (&:where(.dark, .dark *)); - :root { - /* TryPost design system — warm cream + ink + signature violet. + /* TryPost design system — warm cream + ink + signature violet. Mirrors the marketing site (~/Herd/trypost-site) so the app and the site share one identity. All structural borders are ink-black. */ - --background: #faf8f5; - --foreground: #0a0a0a; - --card: #ffffff; - --card-foreground: #0a0a0a; - --popover: #ffffff; - --popover-foreground: #0a0a0a; - --primary: #7c3aed; - --primary-foreground: #ffffff; - --secondary: #f4f4f0; - --secondary-foreground: #0a0a0a; - --muted: #f4f4f0; - --muted-foreground: #52525b; - --accent: #ede9fe; - --accent-foreground: #5b21b6; - --destructive: #e54b4f; - --destructive-foreground: #ffffff; - --success: #16a34a; - --error: #e54b4f; - --warning: #d97706; - --info: #2563eb; - --border: #0a0a0a; - --input: #0a0a0a; - --ring: #7c3aed; - --chart-1: #7c3aed; - --chart-2: #8b5cf6; - --chart-3: #a78bfa; - --chart-4: #5b21b6; - --chart-5: #4c1d95; - --sidebar: #ffffff; - --sidebar-foreground: #0a0a0a; - --sidebar-primary: #7c3aed; - --sidebar-primary-foreground: #ffffff; - --sidebar-accent: #ede9fe; - --sidebar-accent-foreground: #5b21b6; - --sidebar-border: #0a0a0a; - --sidebar-ring: #7c3aed; - --font-sans: Figtree, ui-sans-serif, system-ui, -apple-system, - BlinkMacSystemFont, 'Segoe UI', Roboto, 'Helvetica Neue', Arial, - 'Noto Sans', sans-serif, 'Apple Color Emoji', 'Segoe UI Emoji', - 'Segoe UI Symbol', 'Noto Color Emoji'; - --font-serif: 'Instrument Serif', ui-serif, Georgia, Cambria, - 'Times New Roman', Times, serif; - --font-display: 'Instrument Serif', ui-serif, Georgia, Cambria, - 'Times New Roman', Times, serif; - --font-mono: 'JetBrains Mono', ui-monospace, SFMono-Regular, Menlo, Monaco, - Consolas, 'Liberation Mono', 'Courier New', monospace; - --radius: 0.75rem; - /* Gumroad-style offset shadows: solid ink, no blur. */ - --shadow-2xs: 1px 1px 0 0 #0a0a0a; - --shadow-xs: 2px 2px 0 0 #0a0a0a; - --shadow-sm: 3px 3px 0 0 #0a0a0a; - --shadow: 4px 4px 0 0 #0a0a0a; - --shadow-md: 5px 5px 0 0 #0a0a0a; - --shadow-lg: 6px 6px 0 0 #0a0a0a; - --shadow-xl: 8px 8px 0 0 #0a0a0a; - --shadow-2xl: 10px 10px 0 0 #0a0a0a; - --tracking-normal: 0em; - --spacing: 0.25rem; + --background: #faf8f5; + --foreground: #0a0a0a; + --card: #ffffff; + --card-foreground: #0a0a0a; + --popover: #ffffff; + --popover-foreground: #0a0a0a; + --primary: #7c3aed; + --primary-foreground: #ffffff; + --secondary: #f4f4f0; + --secondary-foreground: #0a0a0a; + --muted: #f4f4f0; + --muted-foreground: #52525b; + --accent: #ede9fe; + --accent-foreground: #5b21b6; + --destructive: #e54b4f; + --destructive-foreground: #ffffff; + --success: #16a34a; + --error: #e54b4f; + --warning: #d97706; + --info: #2563eb; + --border: #0a0a0a; + --input: #0a0a0a; + --ring: #7c3aed; + --chart-1: #7c3aed; + --chart-2: #8b5cf6; + --chart-3: #a78bfa; + --chart-4: #5b21b6; + --chart-5: #4c1d95; + --sidebar: #ffffff; + --sidebar-foreground: #0a0a0a; + --sidebar-primary: #7c3aed; + --sidebar-primary-foreground: #ffffff; + --sidebar-accent: #ede9fe; + --sidebar-accent-foreground: #5b21b6; + --sidebar-border: #0a0a0a; + --sidebar-ring: #7c3aed; + --font-sans: + Figtree, ui-sans-serif, system-ui, -apple-system, BlinkMacSystemFont, + 'Segoe UI', Roboto, 'Helvetica Neue', Arial, 'Noto Sans', sans-serif, + 'Apple Color Emoji', 'Segoe UI Emoji', 'Segoe UI Symbol', + 'Noto Color Emoji'; + --font-serif: + 'Instrument Serif', ui-serif, Georgia, Cambria, 'Times New Roman', + Times, serif; + --font-display: + 'Instrument Serif', ui-serif, Georgia, Cambria, 'Times New Roman', + Times, serif; + --font-mono: + 'JetBrains Mono', ui-monospace, SFMono-Regular, Menlo, Monaco, Consolas, + 'Liberation Mono', 'Courier New', monospace; + --radius: 0.75rem; + /* Gumroad-style offset shadows: solid ink, no blur. */ + --shadow-2xs: 1px 1px 0 0 #0a0a0a; + --shadow-xs: 2px 2px 0 0 #0a0a0a; + --shadow-sm: 3px 3px 0 0 #0a0a0a; + --shadow: 4px 4px 0 0 #0a0a0a; + --shadow-md: 5px 5px 0 0 #0a0a0a; + --shadow-lg: 6px 6px 0 0 #0a0a0a; + --shadow-xl: 8px 8px 0 0 #0a0a0a; + --shadow-2xl: 10px 10px 0 0 #0a0a0a; + --tracking-normal: 0em; + --spacing: 0.25rem; } @theme inline { - --color-background: var(--background); - --color-foreground: var(--foreground); - --color-card: var(--card); - --color-card-foreground: var(--card-foreground); - --color-popover: var(--popover); - --color-popover-foreground: var(--popover-foreground); - --color-primary: var(--primary); - --color-primary-foreground: var(--primary-foreground); - --color-secondary: var(--secondary); - --color-secondary-foreground: var(--secondary-foreground); - --color-muted: var(--muted); - --color-muted-foreground: var(--muted-foreground); - --color-accent: var(--accent); - --color-accent-foreground: var(--accent-foreground); - --color-destructive: var(--destructive); - --color-destructive-foreground: var(--destructive-foreground); - --color-border: var(--border); - --color-input: var(--input); - --color-ring: var(--ring); - --color-chart-1: var(--chart-1); - --color-chart-2: var(--chart-2); - --color-chart-3: var(--chart-3); - --color-chart-4: var(--chart-4); - --color-chart-5: var(--chart-5); - --color-sidebar: var(--sidebar); - --color-sidebar-foreground: var(--sidebar-foreground); - --color-sidebar-primary: var(--sidebar-primary); - --color-sidebar-primary-foreground: var(--sidebar-primary-foreground); - --color-sidebar-accent: var(--sidebar-accent); - --color-sidebar-accent-foreground: var(--sidebar-accent-foreground); - --color-sidebar-border: var(--sidebar-border); - --color-sidebar-ring: var(--sidebar-ring); + --color-background: var(--background); + --color-foreground: var(--foreground); + --color-card: var(--card); + --color-card-foreground: var(--card-foreground); + --color-popover: var(--popover); + --color-popover-foreground: var(--popover-foreground); + --color-primary: var(--primary); + --color-primary-foreground: var(--primary-foreground); + --color-secondary: var(--secondary); + --color-secondary-foreground: var(--secondary-foreground); + --color-muted: var(--muted); + --color-muted-foreground: var(--muted-foreground); + --color-accent: var(--accent); + --color-accent-foreground: var(--accent-foreground); + --color-destructive: var(--destructive); + --color-destructive-foreground: var(--destructive-foreground); + --color-border: var(--border); + --color-input: var(--input); + --color-ring: var(--ring); + --color-chart-1: var(--chart-1); + --color-chart-2: var(--chart-2); + --color-chart-3: var(--chart-3); + --color-chart-4: var(--chart-4); + --color-chart-5: var(--chart-5); + --color-sidebar: var(--sidebar); + --color-sidebar-foreground: var(--sidebar-foreground); + --color-sidebar-primary: var(--sidebar-primary); + --color-sidebar-primary-foreground: var(--sidebar-primary-foreground); + --color-sidebar-accent: var(--sidebar-accent); + --color-sidebar-accent-foreground: var(--sidebar-accent-foreground); + --color-sidebar-border: var(--sidebar-border); + --color-sidebar-ring: var(--sidebar-ring); - --font-sans: var(--font-sans); - --font-mono: var(--font-mono); - --font-serif: var(--font-serif); - --font-display: var(--font-display); + --font-sans: var(--font-sans); + --font-mono: var(--font-mono); + --font-serif: var(--font-serif); + --font-display: var(--font-display); - --radius-sm: calc(var(--radius) - 4px); - --radius-md: calc(var(--radius) - 2px); - --radius-lg: var(--radius); - --radius-xl: calc(var(--radius) + 4px); + --radius-sm: calc(var(--radius) - 4px); + --radius-md: calc(var(--radius) - 2px); + --radius-lg: var(--radius); + --radius-xl: calc(var(--radius) + 4px); - --shadow-2xs: var(--shadow-2xs); - --shadow-xs: var(--shadow-xs); - --shadow-sm: var(--shadow-sm); - --shadow: var(--shadow); - --shadow-md: var(--shadow-md); - --shadow-lg: var(--shadow-lg); - --shadow-xl: var(--shadow-xl); - --shadow-2xl: var(--shadow-2xl); + --shadow-2xs: var(--shadow-2xs); + --shadow-xs: var(--shadow-xs); + --shadow-sm: var(--shadow-sm); + --shadow: var(--shadow); + --shadow-md: var(--shadow-md); + --shadow-lg: var(--shadow-lg); + --shadow-xl: var(--shadow-xl); + --shadow-2xl: var(--shadow-2xl); } @layer base { - * { - @apply border-border outline-ring/50; - } + * { + @apply border-border outline-ring/50; + } - body { - @apply bg-background text-foreground; - font-family: var(--font-sans); - } + body { + @apply bg-background text-foreground; + font-family: var(--font-sans); + } } /* Site headline + container utility classes — mirrors trypost-site's @@ -149,21 +152,20 @@ display headlines (Instrument Serif) instead of stacking Tailwind text-* utilities. */ .h1 { - font-family: var(--font-display); - @apply text-4xl sm:text-5xl lg:text-7xl xl:text-[5.5rem] font-normal tracking-tight leading-[1.05]; + font-family: var(--font-display); + @apply text-4xl leading-[1.05] font-normal tracking-tight sm:text-5xl lg:text-7xl xl:text-[5.5rem]; } .h2 { - font-family: var(--font-display); - @apply text-pretty text-3xl font-normal tracking-tight leading-[1.1] sm:text-5xl; + font-family: var(--font-display); + @apply text-3xl leading-[1.1] font-normal tracking-tight text-pretty sm:text-5xl; } .h3 { - font-family: var(--font-display); - @apply text-pretty text-2xl font-normal tracking-tight sm:text-4xl; + font-family: var(--font-display); + @apply text-2xl font-normal tracking-tight text-pretty sm:text-4xl; } .trypost-container { - @apply relative max-w-7xl w-full flex-1 mx-auto flex flex-col justify-center; + @apply relative mx-auto flex w-full max-w-7xl flex-1 flex-col justify-center; } - diff --git a/resources/css/automations.css b/resources/css/automations.css index 469d7dc7e..2876243a6 100644 --- a/resources/css/automations.css +++ b/resources/css/automations.css @@ -1,103 +1,149 @@ .automation-node { - position: relative; - min-width: 230px; - max-width: 260px; - background: var(--card); - border: 2px solid var(--foreground); - border-radius: 14px; - box-shadow: 3px 3px 0 var(--foreground); - transition: transform 120ms ease, box-shadow 120ms ease; + position: relative; + min-width: 230px; + max-width: 260px; + background: var(--card); + border: 2px solid var(--foreground); + border-radius: 14px; + box-shadow: 3px 3px 0 var(--foreground); + transition: + transform 120ms ease, + box-shadow 120ms ease; } .automation-node:hover { - transform: translate(-1px, -1px); - box-shadow: 4px 4px 0 var(--foreground); + transform: translate(-1px, -1px); + box-shadow: 4px 4px 0 var(--foreground); } .automation-node--wide { - min-width: 260px; - max-width: 260px; + min-width: 260px; + max-width: 260px; } .automation-node.is-selected { - transform: translate(-2px, -2px); - box-shadow: 5px 5px 0 #7c3aed; + transform: translate(-2px, -2px); + box-shadow: 5px 5px 0 #7c3aed; } .automation-node__header { - display: flex; - align-items: center; - gap: 0.625rem; - padding: 0.625rem 0.875rem; - border-bottom: 2px solid var(--foreground); - border-top-left-radius: 12px; - border-top-right-radius: 12px; + display: flex; + align-items: center; + gap: 0.625rem; + padding: 0.625rem 0.875rem; + border-bottom: 2px solid var(--foreground); + border-top-left-radius: 12px; + border-top-right-radius: 12px; } .automation-node__icon-tile { - display: inline-flex; - align-items: center; - justify-content: center; - width: 28px; - height: 28px; - border: 2px solid var(--foreground); - border-radius: 8px; - flex-shrink: 0; - transform: rotate(-3deg); + display: inline-flex; + align-items: center; + justify-content: center; + width: 28px; + height: 28px; + border: 2px solid var(--foreground); + border-radius: 8px; + flex-shrink: 0; + transform: rotate(-3deg); } -.automation-node__icon-tile--violet { background: #ede9fe; color: #5b21b6; } -.automation-node__icon-tile--blue { background: #dbeafe; color: #1d4ed8; } -.automation-node__icon-tile--amber { background: #fef3c7; color: #92400e; } -.automation-node__icon-tile--rose { background: #ffe4e6; color: #be123c; } -.automation-node__icon-tile--emerald { background: #d1fae5; color: #047857; } -.automation-node__icon-tile--slate { background: #e2e8f0; color: #334155; } -.automation-node__icon-tile--zinc { background: #e4e4e7; color: #27272a; } -.automation-node__icon-tile--cyan { background: #cffafe; color: #155e75; } +.automation-node__icon-tile--violet { + background: #ede9fe; + color: #5b21b6; +} +.automation-node__icon-tile--blue { + background: #dbeafe; + color: #1d4ed8; +} +.automation-node__icon-tile--amber { + background: #fef3c7; + color: #92400e; +} +.automation-node__icon-tile--rose { + background: #ffe4e6; + color: #be123c; +} +.automation-node__icon-tile--emerald { + background: #d1fae5; + color: #047857; +} +.automation-node__icon-tile--slate { + background: #e2e8f0; + color: #334155; +} +.automation-node__icon-tile--zinc { + background: #e4e4e7; + color: #27272a; +} +.automation-node__icon-tile--cyan { + background: #cffafe; + color: #155e75; +} -.automation-node--accent-violet .automation-node__header { background: #f5f3ff; } -.automation-node--accent-blue .automation-node__header { background: #eff6ff; } -.automation-node--accent-amber .automation-node__header { background: #fffbeb; } -.automation-node--accent-rose .automation-node__header { background: #fff1f2; } -.automation-node--accent-emerald .automation-node__header { background: #ecfdf5; } -.automation-node--accent-slate .automation-node__header { background: #f1f5f9; } -.automation-node--accent-zinc .automation-node__header { background: #f4f4f5; } -.automation-node--accent-cyan .automation-node__header { background: #ecfeff; } +.automation-node--accent-violet .automation-node__header { + background: #f5f3ff; +} +.automation-node--accent-blue .automation-node__header { + background: #eff6ff; +} +.automation-node--accent-amber .automation-node__header { + background: #fffbeb; +} +.automation-node--accent-rose .automation-node__header { + background: #fff1f2; +} +.automation-node--accent-emerald .automation-node__header { + background: #ecfdf5; +} +.automation-node--accent-slate .automation-node__header { + background: #f1f5f9; +} +.automation-node--accent-zinc .automation-node__header { + background: #f4f4f5; +} +.automation-node--accent-cyan .automation-node__header { + background: #ecfeff; +} .automation-node__title { - min-width: 0; - font-weight: 700; - font-size: 0.875rem; - color: var(--foreground); - line-height: 1.2; - letter-spacing: -0.005em; - white-space: nowrap; - overflow: hidden; - text-overflow: ellipsis; + min-width: 0; + font-weight: 700; + font-size: 0.875rem; + color: var(--foreground); + line-height: 1.2; + letter-spacing: -0.005em; + white-space: nowrap; + overflow: hidden; + text-overflow: ellipsis; } .automation-node__summary { - padding: 0.625rem 0.875rem 0.75rem 0.875rem; - font-size: 0.75rem; - font-weight: 500; - color: color-mix(in srgb, var(--foreground) 70%, transparent); - white-space: nowrap; - overflow: hidden; - text-overflow: ellipsis; - border-bottom-left-radius: 12px; - border-bottom-right-radius: 12px; + padding: 0.625rem 0.875rem 0.75rem 0.875rem; + font-size: 0.75rem; + font-weight: 500; + color: color-mix(in srgb, var(--foreground) 70%, transparent); + white-space: nowrap; + overflow: hidden; + text-overflow: ellipsis; + border-bottom-left-radius: 12px; + border-bottom-right-radius: 12px; } .automation-node__branches { - display: flex; - align-items: center; - justify-content: space-between; - padding: 0.375rem 0.875rem 0.625rem 0.875rem; - font-size: 0.625rem; - font-weight: 700; - text-transform: uppercase; - letter-spacing: 0.06em; + display: flex; + align-items: center; + justify-content: space-between; + padding: 0.375rem 0.875rem 0.625rem 0.875rem; + font-size: 0.625rem; + font-weight: 700; + text-transform: uppercase; + letter-spacing: 0.06em; } -.automation-node__branch--yes { color: #047857; } -.automation-node__branch--no { color: #be123c; } +.automation-node__branch--yes { + color: #047857; +} +.automation-node__branch--no { + color: #be123c; +} diff --git a/resources/css/json-viewer.css b/resources/css/json-viewer.css index 95ee2b83c..3867c84f2 100644 --- a/resources/css/json-viewer.css +++ b/resources/css/json-viewer.css @@ -1,13 +1,28 @@ .json-viewer__body { - font-family: ui-monospace, SFMono-Regular, "SF Mono", Menlo, Consolas, "Liberation Mono", monospace; - margin: 0; - background: #ffffff; - color: #24292f; + font-family: + ui-monospace, SFMono-Regular, 'SF Mono', Menlo, Consolas, + 'Liberation Mono', monospace; + margin: 0; + background: #ffffff; + color: #24292f; } -.json-viewer .hljs-attr { color: #0550ae; } -.json-viewer .hljs-string { color: #0a3069; } -.json-viewer .hljs-number { color: #0550ae; } -.json-viewer .hljs-literal { color: #cf222e; } -.json-viewer .hljs-punctuation { color: #57606a; } -.json-viewer .hljs-comment { color: #6e7781; font-style: italic; } +.json-viewer .hljs-attr { + color: #0550ae; +} +.json-viewer .hljs-string { + color: #0a3069; +} +.json-viewer .hljs-number { + color: #0550ae; +} +.json-viewer .hljs-literal { + color: #cf222e; +} +.json-viewer .hljs-punctuation { + color: #57606a; +} +.json-viewer .hljs-comment { + color: #6e7781; + font-style: italic; +} diff --git a/resources/js/app.ts b/resources/js/app.ts index 6bdb68d69..ccc1f5375 100644 --- a/resources/js/app.ts +++ b/resources/js/app.ts @@ -10,7 +10,11 @@ import { createApp, h } from 'vue'; import { initializeDataLayer } from './datalayer'; import dayjs from './dayjs'; import { syncContentTypeMediaRules } from './lib/contentTypeMediaRules'; -import { capturePageview, initializePostHog, syncPostHogContext } from './posthog'; +import { + capturePageview, + initializePostHog, + syncPostHogContext, +} from './posthog'; import type { Auth } from './types'; const appName = import.meta.env.VITE_APP_NAME || 'TryPost.it'; @@ -24,7 +28,8 @@ createInertiaApp({ ), setup({ el, App, props, plugin }) { // Get locale from shared Inertia props - const locale = (props.initialPage.props as { locale?: string })?.locale || 'en'; + const locale = + (props.initialPage.props as { locale?: string })?.locale || 'en'; // Set dayjs locale based on user's language dayjs.locale(locale.toLowerCase()); diff --git a/resources/js/components/AppSidebar.vue b/resources/js/components/AppSidebar.vue index 7aea78e49..b459bf2d7 100644 --- a/resources/js/components/AppSidebar.vue +++ b/resources/js/components/AppSidebar.vue @@ -1,5 +1,5 @@ \ No newline at end of file + diff --git a/resources/js/components/BrandForm.vue b/resources/js/components/BrandForm.vue index 87738d34c..4f0254b60 100644 --- a/resources/js/components/BrandForm.vue +++ b/resources/js/components/BrandForm.vue @@ -72,7 +72,8 @@ const logoPreview = ref(null); // Only the `style` group stacks; every spectrum group (pov, formality, …) is // single-select, so picking an option clears the others in that group. -const isTraitSelected = (value: string): boolean => (props.fields.brand_voice_traits ?? []).includes(value); +const isTraitSelected = (value: string): boolean => + (props.fields.brand_voice_traits ?? []).includes(value); const toggleTrait = (group: string, value: string): void => { const current = props.fields.brand_voice_traits ?? []; @@ -88,7 +89,10 @@ const toggleTrait = (group: string, value: string): void => { } const groupValues = props.availableVoiceTraits[group] ?? []; - props.fields.brand_voice_traits = [...current.filter((v) => !groupValues.includes(v)), value]; + props.fields.brand_voice_traits = [ + ...current.filter((v) => !groupValues.includes(v)), + value, + ]; }; const runAutofill = async () => { @@ -103,14 +107,18 @@ const runAutofill = async () => { autofillHttp.url = url; const data = await autofillHttp.post(autofillBrand.url()); - if (data?.name && props.showName && !props.fields.name) props.fields.name = data.name; - if (data?.brand_description) props.fields.brand_description = data.brand_description; - if (data?.content_language) props.fields.content_language = data.content_language; + if (data?.name && props.showName && !props.fields.name) + props.fields.name = data.name; + if (data?.brand_description) + props.fields.brand_description = data.brand_description; + if (data?.content_language) + props.fields.content_language = data.content_language; if (data?.brand_voice_traits?.length) { props.fields.brand_voice_traits = data.brand_voice_traits; } if (data?.brand_color) props.fields.brand_color = data.brand_color; - if (data?.background_color) props.fields.background_color = data.background_color; + if (data?.background_color) + props.fields.background_color = data.background_color; if (data?.text_color) props.fields.text_color = data.text_color; if (data?.logo_url) { logoPreview.value = data.logo_url; @@ -130,7 +138,9 @@ const runAutofill = async () => {