refactor: auth split layout, subscribe redesign, onboarding, i18n, cookie locale

Auth pages:
- Create AuthSplitLayout with animated feature slides (6 slides, 3 languages)
- All auth pages use split layout (form left, visual right)
- Add show/hide password toggle with tooltip on Register
- Legal footer only shown on Register via showLegal prop

Subscribe page:
- Redesign to match auth card pattern (centered, clean)
- Platform icons, feature checklist, dynamic trial days (trialDays - 1)
- Add "Switch workspace" link
- Full i18n (en, es, pt-BR)

Onboarding:
- Rename URLs: step1 -> role, step2 -> connect
- Add enforceStep() to prevent skipping/going back steps
- Redirect /onboarding to /onboarding/role
- Redesign Step2 with AuthSplitLayout and compact platform list
- 21 tests covering all step enforcement scenarios

Workspaces page:
- Redesign with AuthSplitLayout (list with avatars, current badge)

Language system:
- Move locale from DB to cookie (forever, unencrypted, session.domain)
- Create SetLocale middleware (sets cookie if missing, validates against config)
- Rename lang/pt-br to lang/pt-BR
- Add dayjs es locale

Other:
- Copy utils.ts from sendkit (formatNumber, formatMoney, copyToClipboard)
- ConfirmDeleteModal with text confirmation (sendkit pattern)
- i18n for ConfirmDeleteModal internal strings (common.php)
- EmptyState component for posts index
- Exact match for "All" posts in sidebar
- Posts breadcrumbs show current status filter
- DialogFooter buttons aligned left
- API Keys page redesign with Table, DropdownMenu, EmptyState
- Extract CreateApiKeyDialog and InviteMemberDialog to components
- Remove API Keys from sidebar
- DropdownMenuItem destructive variant for Remove action
This commit is contained in:
Paulo Castellano 2026-03-30 11:53:42 -03:00
parent 56b8c92e72
commit f3f72afecd
70 changed files with 1659 additions and 746 deletions

View file

@ -44,7 +44,7 @@ ## Skills Activation
- `cashier-stripe-development` — Handles Laravel Cashier Stripe integration including subscriptions, webhooks, Stripe Checkout, invoices, charges, refunds, trials, coupons, metered billing, and payment failure handling. Triggered when a user mentions Cashier, Billable, IncompletePayment, stripe_id, newSubscription, Stripe subscriptions, or billing. Also applies when setting up webhooks, handling SCA/3DS payment failures, testing with Stripe test cards, or troubleshooting incomplete subscriptions, CSRF webhook errors, or migration publish issues.
- `laravel-best-practices` — Apply this skill whenever writing, reviewing, or refactoring Laravel PHP code. This includes creating or modifying controllers, models, migrations, form requests, policies, jobs, scheduled commands, service classes, and Eloquent queries. Triggers for N+1 and query performance issues, caching strategies, authorization and security patterns, validation, error handling, queue and job configuration, route definitions, and architectural decisions. Also use for Laravel code reviews and refactoring existing Laravel code to follow best practices. Covers any task involving Laravel backend PHP code patterns.
- `configuring-horizon` — Use this skill whenever the user mentions Horizon by name in a Laravel context. Covers the full Horizon lifecycle: installing Horizon (horizon:install, Sail setup), configuring config/horizon.php (supervisor blocks, queue assignments, balancing strategies, minProcesses/maxProcesses), fixing the dashboard (authorization via Gate::define viewHorizon, blank metrics, horizon:snapshot scheduling), and troubleshooting production issues (worker crashes, timeout chain ordering, LongWaitDetected notifications, waits config). Also covers job tagging and silencing. Do not use for generic Laravel queues without Horizon, SQS or database drivers, standalone Redis setup, Linux supervisord, Telescope, or job batching.
- `mcp-development` — Use this skill for Laravel MCP development only. Trigger when creating or editing MCP tools, resources, prompts, or servers in Laravel projects. Covers: artisan make:mcp-* generators, mcp:inspector, routes/ai.php, Tool/Resource/Prompt classes, schema validation, shouldRegister(), OAuth setup, URI templates, read-only attributes, and MCP debugging. Do not use for non-Laravel MCP projects or generic AI features without MCP.
- `mcp-development` — Use this skill for Laravel MCP development only. Trigger when creating or editing MCP tools, resources, prompts, or servers in Laravel projects. Covers: artisan make:mcp-\* generators, mcp:inspector, routes/ai.php, Tool/Resource/Prompt classes, schema validation, shouldRegister(), OAuth setup, URI templates, read-only attributes, and MCP debugging. Do not use for non-Laravel MCP projects or generic AI features without MCP.
- `socialite-development` — Manages OAuth social authentication with Laravel Socialite. Activate when adding social login providers; configuring OAuth redirect/callback flows; retrieving authenticated user details; customizing scopes or parameters; setting up community providers; testing with Socialite fakes; or when the user mentions social login, OAuth, Socialite, or third-party authentication.
- `wayfinder-development` — Use this skill for Laravel Wayfinder which auto-generates typed functions for Laravel controllers and routes. ALWAYS use this skill when frontend code needs to call backend routes or controller actions. Trigger when: connecting any React/Vue/Svelte/Inertia frontend to Laravel controllers, routes, building end-to-end features with both frontend and backend, wiring up forms or links to backend endpoints, fixing route-related TypeScript errors, importing from @/actions or @/routes, or running wayfinder:generate. Use Wayfinder route functions instead of hardcoded URLs. Covers: wayfinder() vite plugin, .url()/.get()/.post()/.form(), query params, route model binding, tree-shaking. Do not use for backend-only task
- `pest-testing` — Use this skill for Pest PHP testing in Laravel projects only. Trigger whenever any test is being written, edited, fixed, or refactored — including fixing tests that broke after a code change, adding assertions, converting PHPUnit to Pest, adding datasets, and TDD workflows. Always activate when the user asks how to write something in Pest, mentions test files or directories (tests/Feature, tests/Unit, tests/Browser), or needs browser testing, smoke testing multiple pages for JS errors, or architecture tests. Covers: it()/expect() syntax, datasets, mocking, browser testing (visit/click/fill), smoke testing, arch(), Livewire component tests, RefreshDatabase, and all Pest 4 features. Do not use for factories, seeders, migrations, controllers, models, or non-test PHP code.
@ -115,7 +115,7 @@ ## Tinker
- Execute PHP in app context for debugging and testing code. Do not create models without user approval, prefer tests with factories instead. Prefer existing Artisan commands over custom tinker code.
- Always use single quotes to prevent shell expansion: `vendor/bin/sail artisan tinker --execute 'Your::code();'`
- Double quotes for PHP strings inside: `vendor/bin/sail artisan tinker --execute 'User::where("active", true)->count();'`
- Double quotes for PHP strings inside: `vendor/bin/sail artisan tinker --execute 'User::where("active", true)->count();'`
=== php rules ===
@ -228,6 +228,7 @@ ## Pest
# Inertia + Vue
Vue components must have a single root element.
- IMPORTANT: Activate `inertia-vue-development` when working with Inertia Vue client-side patterns.
</laravel-boost-guidelines>
@ -244,6 +245,8 @@ ## Icons (@tabler/icons-vue)
- All Tabler icons are prefixed with `Icon`, e.g. `IconCheck`, `IconChevronRight`, `IconMail`.
- Import icons from `@tabler/icons-vue`: `import { IconCheck, IconX } from '@tabler/icons-vue'`.
- Browse available icons at https://tabler.io/icons
- Button never use icon + text, it's icon or text, never together
- dropdown lists always use icon + text, and both have the same color
## Dates

View file

@ -19,7 +19,7 @@
</p>
<p align="center">
<a href="https://docs.trypost.it">📚 Documentation</a>
<a href="https://trypost.it/docs">📚 Documentation</a>
<a href="https://github.com/orgs/trypost-it/projects/1">🗺️ Roadmap</a>
<a href="https://github.com/trypost-it/trypost/discussions">💬 Community</a>
</p>
@ -30,23 +30,23 @@ ## 🤔 Why TryPost?
Tired of paying expensive monthly fees for social media scheduling tools? Want full control over your data? TryPost is the solution.
| | |
|---|---|
| 🔓 **100% Open Source** | Inspect the code, contribute, make it yours |
| 🏠 **Self-Hosted** | Your data stays on your servers |
| ♾️ **No Limits** | Schedule unlimited posts, connect unlimited accounts |
| 🔒 **Privacy First** | No tracking, no analytics, no data selling |
| | |
| ----------------------- | ---------------------------------------------------- |
| 🔓 **100% Open Source** | Inspect the code, contribute, make it yours |
| 🏠 **Self-Hosted** | Your data stays on your servers |
| ♾️ **No Limits** | Schedule unlimited posts, connect unlimited accounts |
| 🔒 **Privacy First** | No tracking, no analytics, no data selling |
## ✨ Features
| | |
|---|---|
| 📅 **Visual Calendar** | Drag and drop posts across your content calendar |
| ✍️ **Post Composer** | Create and preview posts for multiple platforms at once |
| 🖼️ **Media Library** | Upload images and videos with automatic optimization |
| 👥 **Team Collaboration** | Invite team members with role-based permissions |
| 🏢 **Workspaces** | Manage multiple brands or clients separately |
| 👀 **Multi-Platform Preview** | See exactly how your post will look on each platform |
| | |
| ----------------------------- | ------------------------------------------------------- |
| 📅 **Visual Calendar** | Drag and drop posts across your content calendar |
| ✍️ **Post Composer** | Create and preview posts for multiple platforms at once |
| 🖼️ **Media Library** | Upload images and videos with automatic optimization |
| 👥 **Team Collaboration** | Invite team members with role-based permissions |
| 🏢 **Workspaces** | Manage multiple brands or clients separately |
| 👀 **Multi-Platform Preview** | See exactly how your post will look on each platform |
## 🌐 Supported Platforms
@ -71,12 +71,12 @@ ## 🚀 Getting Started
Get TryPost running in minutes:
| | |
|---|---|
| 📖 [Installation Guide](https://docs.trypost.it/getting-started/installation) | Step-by-step setup |
| 🐳 [Docker Setup](https://docs.trypost.it/self-hosting/docker) | Run with Laravel Sail |
| ⚙️ [Configuration](https://docs.trypost.it/getting-started/configuration) | Environment setup |
| 🔗 [Platform Setup](https://docs.trypost.it/platforms/) | Connect your social accounts |
| | |
| ----------------------------------------------------------------------------- | ---------------------------- |
| 📖 [Installation Guide](https://trypost.it/docs/getting-started/installation) | Step-by-step setup |
| 🐳 [Docker Setup](https://trypost.it/docs/self-hosting/docker) | Run with Laravel Sail |
| ⚙️ [Configuration](https://trypost.it/docs/getting-started/configuration) | Environment setup |
| 🔗 [Platform Setup](https://trypost.it/docs/platforms/) | Connect your social accounts |
## 🤝 Contributing

13
app/Helpers/Asset.php Normal file
View file

@ -0,0 +1,13 @@
<?php
declare(strict_types=1);
if (! function_exists('email_asset')) {
/**
* Generate an absolute URL for email assets.
*/
function email_asset(string $path): string
{
return rtrim(config('app.email_asset_url'), '/').'/'.ltrim($path, '/');
}
}

View file

@ -7,6 +7,7 @@
use App\Enums\SocialAccount\Platform as SocialPlatform;
use App\Enums\User\Persona;
use App\Enums\User\Setup;
use App\Models\User;
use Illuminate\Http\RedirectResponse;
use Illuminate\Http\Request;
use Illuminate\Validation\Rule;
@ -16,8 +17,13 @@
class OnboardingController extends Controller
{
public function step1(): Response
public function step1(Request $request): Response|RedirectResponse
{
$redirect = $this->enforceStep($request->user(), Setup::Role);
if ($redirect) {
return $redirect;
}
return Inertia::render('onboarding/Step1', [
'personas' => Persona::toSelectArray(),
]);
@ -34,11 +40,16 @@ public function storeStep1(Request $request): RedirectResponse
'setup' => Setup::Connections,
]);
return redirect()->route('app.onboarding.step2');
return redirect()->route('app.onboarding.connect');
}
public function step2(Request $request): Response
public function step2(Request $request): Response|RedirectResponse
{
$redirect = $this->enforceStep($request->user(), Setup::Connections);
if ($redirect) {
return $redirect;
}
$user = $request->user();
$workspace = $user->currentWorkspace;
@ -88,7 +99,7 @@ public function storeStep2(Request $request): SymfonyResponse|RedirectResponse
$checkoutSession = $subscription->checkout([
'success_url' => route('app.onboarding.complete').'?session_id={CHECKOUT_SESSION_ID}',
'cancel_url' => route('app.onboarding.step2'),
'cancel_url' => route('app.onboarding.connect'),
]);
return Inertia::location($checkoutSession->url);
@ -107,4 +118,22 @@ public function complete(Request $request): RedirectResponse
return redirect()->route('app.calendar');
}
private function enforceStep(User $user, Setup $expectedStep): ?RedirectResponse
{
if ($user->setup === $expectedStep) {
return null;
}
if ($user->setup === Setup::Completed) {
return redirect()->route('app.calendar');
}
return match ($user->setup) {
Setup::Role => redirect()->route('app.onboarding.role'),
Setup::Connections => redirect()->route('app.onboarding.connect'),
Setup::Subscription => redirect()->route('app.subscribe'),
default => redirect()->route('app.onboarding.role'),
};
}
}

View file

@ -77,14 +77,12 @@ public function updateLanguage(Request $request): RedirectResponse
'locale' => ['required', 'string', 'in:'.implode(',', array_keys(config('languages.available')))],
]);
$request->user()->update([
'locale' => $request->locale,
]);
session()->flash('flash.banner', __('settings.flash.language_updated'));
session()->flash('flash.bannerStyle', 'success');
return back();
return back()->withCookie(
cookie()->forever('locale', $request->locale, '/', config('session.domain'))
);
}
public function destroy(ProfileDeleteRequest $request): RedirectResponse

View file

@ -21,6 +21,7 @@ public function index(Request $request): Response
$user = $request->user();
$workspaces = $user->workspaces()
->with('media')
->withCount(['socialAccounts', 'posts'])
->latest()
->get();

View file

@ -12,6 +12,7 @@
use App\Models\WorkspaceInvite;
use Illuminate\Http\RedirectResponse;
use Illuminate\Http\Request;
use Illuminate\Validation\Rule;
use Inertia\Inertia;
use Inertia\Response;
@ -133,4 +134,32 @@ public function removeMember(Request $request, string $userId): RedirectResponse
return back();
}
public function updateRole(Request $request, string $userId): RedirectResponse
{
$workspace = $request->user()->currentWorkspace;
if (! $workspace) {
return redirect()->route('app.workspaces.create');
}
$this->authorize('manageTeam', $workspace);
if ($workspace->user_id === $userId) {
return back()->withErrors(['role' => 'Cannot change the workspace owner role.']);
}
$validated = $request->validate([
'role' => ['required', Rule::enum(WorkspaceRole::class)],
]);
$workspace->members()->updateExistingPivot($userId, [
'role' => data_get($validated, 'role'),
]);
session()->flash('flash.banner', __('settings.members.flash.role_updated'));
session()->flash('flash.bannerStyle', 'success');
return back();
}
}

View file

@ -52,7 +52,6 @@ public function store(Request $request): RedirectResponse
'email' => $request->email,
'password' => $request->password,
'setup' => $isInviteRegistration ? Setup::Completed : Setup::Role,
'locale' => config('languages.default'),
'email_verified_at' => $isInviteRegistration ? now() : null,
]);
@ -81,6 +80,6 @@ public function store(Request $request): RedirectResponse
return redirect($redirect);
}
return redirect()->route('app.onboarding.step1');
return redirect()->route('app.onboarding.role');
}
}

View file

@ -179,7 +179,7 @@ protected function forgetSocialConnectSession(): void
protected function getRedirectRoute(): string
{
return session('social_connect_onboarding', false) ? 'onboarding.step2' : 'accounts';
return session('social_connect_onboarding', false) ? 'onboarding.connect' : 'accounts';
}
/**

View file

@ -31,10 +31,10 @@ public function handle(Request $request, Closure $next): Response
// Map setup status to allowed routes
$allowedRoutes = match ($user->setup) {
Setup::Role => ['onboarding.step1', 'onboarding.step1.store'],
Setup::Connections => ['onboarding.step2', 'onboarding.step2.store', 'social.*'],
Setup::Subscription => ['onboarding.complete', 'onboarding.step2'],
default => ['onboarding.step1', 'onboarding.step1.store'],
Setup::Role => ['onboarding.role', 'onboarding.role.store'],
Setup::Connections => ['onboarding.connect', 'onboarding.connect.store', 'social.*'],
Setup::Subscription => ['onboarding.complete', 'onboarding.connect'],
default => ['onboarding.role', 'onboarding.role.store'],
};
$currentRoute = $request->route()?->getName();
@ -48,10 +48,10 @@ public function handle(Request $request, Closure $next): Response
// Redirect to appropriate step
return match ($user->setup) {
Setup::Role => redirect()->route('app.onboarding.step1'),
Setup::Connections => redirect()->route('app.onboarding.step2'),
Setup::Subscription => redirect()->route('app.onboarding.step2'),
default => redirect()->route('app.onboarding.step1'),
Setup::Role => redirect()->route('app.onboarding.role'),
Setup::Connections => redirect()->route('app.onboarding.connect'),
Setup::Subscription => redirect()->route('app.onboarding.connect'),
default => redirect()->route('app.onboarding.role'),
};
}
}

View file

@ -7,7 +7,6 @@
use App\Http\Resources\App\HandleInertiaRequests\AuthUserResource;
use App\Http\Resources\App\HandleInertiaRequests\AuthWorkspaceResource;
use Illuminate\Http\Request;
use Illuminate\Support\Facades\App;
use Inertia\Middleware;
class HandleInertiaRequests extends Middleware
@ -26,10 +25,6 @@ public function share(Request $request): array
{
$user = $request->user();
if ($user?->locale) {
App::setLocale($user->locale);
}
$currentWorkspace = $user?->currentWorkspace?->load('media');
return [

View file

@ -0,0 +1,35 @@
<?php
declare(strict_types=1);
namespace App\Http\Middleware\App;
use Closure;
use Illuminate\Http\Request;
use Illuminate\Support\Facades\App;
use Symfony\Component\HttpFoundation\Response;
class SetLocale
{
/**
* @param Closure(Request): (Response) $next
*/
public function handle(Request $request, Closure $next): Response
{
$available = config('languages.available');
$locale = $request->cookie('locale');
$isValid = $locale && array_key_exists($locale, $available);
App::setLocale($isValid ? $locale : config('languages.default'));
$response = $next($request);
if (! $isValid) {
$response->withCookie(
cookie()->forever('locale', config('languages.default'), '/', config('session.domain')),
);
}
return $response;
}
}

View file

@ -21,7 +21,6 @@ public static function make(User $user): array
'photo_url' => $user->photo_url,
'email_verified_at' => $user->email_verified_at?->toIso8601String(),
'current_workspace_id' => $user->current_workspace_id,
'locale' => $user->locale,
'created_at' => $user->created_at->toIso8601String(),
'updated_at' => $user->updated_at->toIso8601String(),
];

View file

@ -25,7 +25,7 @@ public function __construct(
public Workspace $workspace,
public Collection $disconnectedAccounts
) {
$this->locale = $this->workspace->owner?->locale ?? 'en';
$this->locale = config('app.locale');
}
public function envelope(): Envelope

View file

@ -33,7 +33,6 @@ class User extends Authenticatable implements MustVerifyEmail
'setup',
'persona',
'current_workspace_id',
'locale',
'email_verified_at',
];
@ -80,14 +79,6 @@ protected function casts(): array
];
}
/**
* Get the user's locale code (e.g. 'en', 'es', 'pt-br').
*/
public function getLocaleCode(): string
{
return $this->locale ?? 'en';
}
/**
* Check if user has an active subscription.
* In self-hosted mode, always returns true (no subscription required).

View file

@ -4,6 +4,7 @@
use App\Http\Middleware\App\EnsureSubscribed;
use App\Http\Middleware\App\HandleAppearance;
use App\Http\Middleware\App\HandleInertiaRequests;
use App\Http\Middleware\App\SetLocale;
use App\Http\Middleware\Mcp\AuthenticateMcpToken;
use Illuminate\Foundation\Application;
use Illuminate\Foundation\Configuration\Exceptions;
@ -25,9 +26,10 @@
->withMiddleware(function (Middleware $middleware): void {
$middleware->trustProxies(at: '*');
$middleware->encryptCookies(except: ['appearance', 'sidebar_state']);
$middleware->encryptCookies(except: ['appearance', 'sidebar_state', 'locale']);
$middleware->web(append: [
SetLocale::class,
HandleAppearance::class,
HandleInertiaRequests::class,
AddLinkHeadersForPreloadedAssets::class,

View file

@ -70,7 +70,8 @@
},
"autoload": {
"files": [
"app/Helpers/upload.php"
"app/Helpers/Upload.php",
"app/Helpers/Asset.php"
],
"psr-4": {
"App\\": "app/",

View file

@ -56,6 +56,8 @@
'url' => env('APP_URL', 'http://localhost'),
'email_asset_url' => env('EMAIL_ASSET_URL', 'https://app.trypost.it'),
/*
|--------------------------------------------------------------------------
| Application Timezone

View file

@ -18,7 +18,7 @@
'available' => [
'en' => 'English',
'es' => 'Español',
'pt-br' => 'Português',
'pt-BR' => 'Português',
],
/*

View file

@ -33,7 +33,6 @@ public function definition(): array
'password' => static::$password ??= Hash::make('password'),
'remember_token' => Str::random(10),
'current_workspace_id' => null,
'locale' => 'en',
'stripe_id' => null,
'pm_type' => null,
'pm_last_four' => null,

View file

@ -0,0 +1,24 @@
<?php
declare(strict_types=1);
use Illuminate\Database\Migrations\Migration;
use Illuminate\Database\Schema\Blueprint;
use Illuminate\Support\Facades\Schema;
return new class extends Migration
{
public function up(): void
{
Schema::table('users', function (Blueprint $table) {
$table->dropColumn('locale');
});
}
public function down(): void
{
Schema::table('users', function (Blueprint $table) {
$table->string('locale', 10)->default('en')->after('current_workspace_id');
});
}
};

View file

@ -22,6 +22,35 @@
'welcome_trial' => 'Welcome to TryPost! Your trial has started.',
],
'legal' => 'By continuing, you agree to our <a href="https://trypost.it/terms" target="_blank">Terms of Service</a> and <a href="https://trypost.it/privacy" target="_blank">Privacy Policy</a>.',
'slides' => [
'calendar' => [
'title' => 'Visual Calendar',
'description' => 'Plan and schedule your content with an intuitive drag-and-drop calendar across all your social accounts.',
],
'scheduling' => [
'title' => 'Smart Scheduling',
'description' => 'Schedule posts across LinkedIn, X, Instagram, TikTok, YouTube, and more — all from one place.',
],
'media' => [
'title' => 'Rich Media',
'description' => 'Publish images, carousels, stories, and reels. Each platform gets the right format automatically.',
],
'video' => [
'title' => 'Video Publishing',
'description' => 'Upload videos once and publish to TikTok, YouTube Shorts, Instagram Reels, and Facebook Reels.',
],
'team' => [
'title' => 'Team Workspaces',
'description' => 'Invite your team, assign roles, and manage multiple brands from separate workspaces.',
],
'hashtags' => [
'title' => 'Hashtag Groups',
'description' => 'Save hashtag collections and add them to posts with one click. Organize with labels and filters.',
],
],
'login' => [
'title' => 'Log in to your account',
'description' => 'Enter your email and password below to log in',
@ -43,6 +72,8 @@
'name_placeholder' => 'Full name',
'email' => 'Email address',
'password' => 'Password',
'show_password' => 'Show password',
'hide_password' => 'Hide password',
'submit' => 'Create account',
'has_account' => 'Already have an account?',
'log_in' => 'Log in',

View file

@ -4,6 +4,23 @@
'title' => 'Subscription',
'description' => 'Manage your subscription and payment method',
'subscribe' => [
'page_title' => 'Start your free trial',
'title' => 'Start your free trial',
'description' => ':days days free. No credit card required to start.',
'start_trial' => 'Start :days-day free trial',
'cancel_anytime' => 'Cancel anytime. No questions asked.',
'switch_workspace' => 'Switch workspace',
'features' => [
'calendar' => 'Visual drag-and-drop calendar',
'scheduling' => 'Unlimited post scheduling',
'media' => 'Images, carousels & stories',
'video' => 'Video publishing across platforms',
'team' => 'Team collaboration & workspaces',
'hashtags' => 'Hashtag groups & labels',
],
],
'trial' => [
'title' => 'Trial period active',
'description' => 'Your trial ends on :date. After that, your subscription will be charged automatically.',

12
lang/en/common.php Normal file
View file

@ -0,0 +1,12 @@
<?php
declare(strict_types=1);
return [
'confirm_modal' => [
'cannot_be_undone' => 'This cannot be undone.',
'type' => 'Type',
'to_confirm' => 'to confirm.',
'copy_to_clipboard' => 'Copy to clipboard',
],
];

View file

@ -77,6 +77,8 @@
'cancel' => 'Cancel',
'remove' => 'Remove',
'make_admin' => 'Make admin',
'make_member' => 'Make member',
'invite' => [
'title' => 'Invite Member',
@ -122,6 +124,7 @@
'invite_sent' => 'Invite sent successfully!',
'invite_deleted' => 'Invite deleted.',
'member_removed' => 'Member removed successfully.',
'role_updated' => 'Member role updated.',
'wrong_email' => 'This invite is for a different email address.',
'already_member' => 'You are already a member of this workspace.',
'invite_accepted' => 'Welcome! You are now a member of the workspace.',

View file

@ -10,6 +10,35 @@
'welcome_trial' => '¡Bienvenido a TryPost! Tu prueba ha comenzado.',
],
'legal' => 'Al continuar, aceptas nuestros <a href="https://trypost.it/terms" target="_blank">Términos de Servicio</a> y <a href="https://trypost.it/privacy" target="_blank">Política de Privacidad</a>.',
'slides' => [
'calendar' => [
'title' => 'Calendario Visual',
'description' => 'Planifica y programa tu contenido con un calendario intuitivo de arrastrar y soltar en todas tus cuentas sociales.',
],
'scheduling' => [
'title' => 'Programación Inteligente',
'description' => 'Programa posts en LinkedIn, X, Instagram, TikTok, YouTube y más — todo desde un solo lugar.',
],
'media' => [
'title' => 'Contenido Multimedia',
'description' => 'Publica imágenes, carruseles, historias y reels. Cada plataforma recibe el formato correcto automáticamente.',
],
'video' => [
'title' => 'Publicación de Video',
'description' => 'Sube videos una vez y publícalos en TikTok, YouTube Shorts, Instagram Reels y Facebook Reels.',
],
'team' => [
'title' => 'Workspaces en Equipo',
'description' => 'Invita a tu equipo, asigna roles y gestiona múltiples marcas en workspaces separados.',
],
'hashtags' => [
'title' => 'Grupos de Hashtags',
'description' => 'Guarda colecciones de hashtags y agrégalos a tus posts con un clic. Organiza con etiquetas y filtros.',
],
],
'login' => [
'title' => 'Inicia sesión en tu cuenta',
'description' => 'Introduce tu correo y contraseña para iniciar sesión',
@ -31,6 +60,8 @@
'name_placeholder' => 'Nombre completo',
'email' => 'Correo electrónico',
'password' => 'Contraseña',
'show_password' => 'Mostrar contraseña',
'hide_password' => 'Ocultar contraseña',
'submit' => 'Crear cuenta',
'has_account' => '¿Ya tienes una cuenta?',
'log_in' => 'Iniciar sesión',

View file

@ -4,6 +4,23 @@
'title' => 'Suscripción',
'description' => 'Administra tu suscripción y método de pago',
'subscribe' => [
'page_title' => 'Comienza tu prueba gratuita',
'title' => 'Comienza tu prueba gratuita',
'description' => ':days días gratis. Sin tarjeta de crédito para empezar.',
'start_trial' => 'Comenzar prueba de :days días',
'cancel_anytime' => 'Cancela en cualquier momento. Sin preguntas.',
'switch_workspace' => 'Cambiar workspace',
'features' => [
'calendar' => 'Calendario visual con arrastrar y soltar',
'scheduling' => 'Programación ilimitada de posts',
'media' => 'Imágenes, carruseles e historias',
'video' => 'Publicación de videos en todas las plataformas',
'team' => 'Colaboración en equipo y workspaces',
'hashtags' => 'Grupos de hashtags y etiquetas',
],
],
'trial' => [
'title' => 'Periodo de prueba activo',
'description' => 'Tu prueba termina el :date. Después, tu suscripción se cobrará automáticamente.',

12
lang/es/common.php Normal file
View file

@ -0,0 +1,12 @@
<?php
declare(strict_types=1);
return [
'confirm_modal' => [
'cannot_be_undone' => 'Esta acción no se puede deshacer.',
'type' => 'Escribe',
'to_confirm' => 'para confirmar.',
'copy_to_clipboard' => 'Copiar al portapapeles',
],
];

View file

@ -77,6 +77,8 @@
'cancel' => 'Cancelar',
'remove' => 'Eliminar',
'make_admin' => 'Hacer administrador',
'make_member' => 'Hacer miembro',
'invite' => [
'title' => 'Invitar miembro',
@ -122,6 +124,7 @@
'invite_sent' => '¡Invitación enviada correctamente!',
'invite_deleted' => 'Invitación eliminada.',
'member_removed' => '¡Miembro eliminado correctamente!',
'role_updated' => 'Rol del miembro actualizado.',
'wrong_email' => 'Esta invitación es para otro correo electrónico.',
'already_member' => 'Ya eres miembro de este workspace.',
'invite_accepted' => '¡Bienvenido! Ahora eres miembro del workspace.',

1
lang/php_en.json Normal file

File diff suppressed because one or more lines are too long

1
lang/php_es.json Normal file

File diff suppressed because one or more lines are too long

1
lang/php_pt-BR.json Normal file

File diff suppressed because one or more lines are too long

View file

@ -22,6 +22,35 @@
'welcome_trial' => 'Bem-vindo ao TryPost! Seu período de teste começou.',
],
'legal' => 'Ao continuar, você concorda com nossos <a href="https://trypost.it/terms" target="_blank">Termos de Serviço</a> e <a href="https://trypost.it/privacy" target="_blank">Política de Privacidade</a>.',
'slides' => [
'calendar' => [
'title' => 'Calendário Visual',
'description' => 'Planeje e agende seu conteúdo com um calendário intuitivo de arrastar e soltar em todas as suas contas sociais.',
],
'scheduling' => [
'title' => 'Agendamento Inteligente',
'description' => 'Agende posts no LinkedIn, X, Instagram, TikTok, YouTube e mais — tudo em um só lugar.',
],
'media' => [
'title' => 'Mídia Rica',
'description' => 'Publique imagens, carrosséis, stories e reels. Cada plataforma recebe o formato correto automaticamente.',
],
'video' => [
'title' => 'Publicação de Vídeo',
'description' => 'Envie vídeos uma vez e publique no TikTok, YouTube Shorts, Instagram Reels e Facebook Reels.',
],
'team' => [
'title' => 'Workspaces em Equipe',
'description' => 'Convide sua equipe, atribua funções e gerencie múltiplas marcas em workspaces separados.',
],
'hashtags' => [
'title' => 'Grupos de Hashtags',
'description' => 'Salve coleções de hashtags e adicione aos posts com um clique. Organize com etiquetas e filtros.',
],
],
'login' => [
'title' => 'Entrar na sua conta',
'description' => 'Digite seu email e senha abaixo para entrar',
@ -43,6 +72,8 @@
'name_placeholder' => 'Nome completo',
'email' => 'Endereço de email',
'password' => 'Senha',
'show_password' => 'Mostrar senha',
'hide_password' => 'Esconder senha',
'submit' => 'Criar conta',
'has_account' => 'Já tem uma conta?',
'log_in' => 'Entrar',

View file

@ -4,6 +4,23 @@
'title' => 'Assinatura',
'description' => 'Gerencie sua assinatura e método de pagamento',
'subscribe' => [
'page_title' => 'Comece seu teste gratuito',
'title' => 'Comece seu teste gratuito',
'description' => ':days dias grátis. Sem cartão de crédito para começar.',
'start_trial' => 'Começar teste de :days dias',
'cancel_anytime' => 'Cancele a qualquer momento. Sem perguntas.',
'switch_workspace' => 'Trocar workspace',
'features' => [
'calendar' => 'Calendário visual com arrastar e soltar',
'scheduling' => 'Agendamento ilimitado de posts',
'media' => 'Imagens, carrosséis e stories',
'video' => 'Publicação de vídeos em todas as plataformas',
'team' => 'Colaboração em equipe e workspaces',
'hashtags' => 'Grupos de hashtags e etiquetas',
],
],
'trial' => [
'title' => 'Período de teste ativo',
'description' => 'Seu período de teste termina em :date. Após isso, sua assinatura será cobrada automaticamente.',

12
lang/pt-br/common.php Normal file
View file

@ -0,0 +1,12 @@
<?php
declare(strict_types=1);
return [
'confirm_modal' => [
'cannot_be_undone' => 'Esta ação não pode ser desfeita.',
'type' => 'Digite',
'to_confirm' => 'para confirmar.',
'copy_to_clipboard' => 'Copiar para a área de transferência',
],
];

View file

@ -77,6 +77,8 @@
'cancel' => 'Cancelar',
'remove' => 'Remover',
'make_admin' => 'Tornar administrador',
'make_member' => 'Tornar membro',
'invite' => [
'title' => 'Convidar Membro',
@ -122,6 +124,7 @@
'invite_sent' => 'Convite enviado com sucesso!',
'invite_deleted' => 'Convite excluído.',
'member_removed' => 'Membro removido com sucesso.',
'role_updated' => 'Função do membro atualizada.',
'wrong_email' => 'Este convite é para um endereço de e-mail diferente.',
'already_member' => 'Você já é membro deste workspace.',
'invite_accepted' => 'Bem-vindo! Você agora é membro do workspace.',

View file

@ -1,5 +1,5 @@
<div class="my-12 sm:my-8 text-center">
<a href="https://trypost.it" target="_blank">
<img src="@{{ asset('/images/emails/logo-header.png') }}" width="160" alt="Trypost">
<img src="@{{ email_asset('/images/emails/logo-header.png') }}" width="160" alt="Trypost">
</a>
</div>

View file

@ -2,7 +2,6 @@
import { Link, router, usePage } from '@inertiajs/vue3';
import {
IconAffiliate,
IconBook,
IconBrandDiscord,
IconCalendar,
IconChevronRight,
@ -10,7 +9,6 @@ import {
IconFileCheck,
IconFileText,
IconHash,
IconKey,
IconLifebuoy,
IconMessageCircle,
IconPencil,
@ -46,7 +44,6 @@ import {
useSidebar,
} from '@/components/ui/sidebar';
import { accounts, calendar } from '@/routes/app';
import { index as apiKeysIndex } from '@/routes/app/api-keys';
import { index as hashtags } from '@/routes/app/hashtags';
import { index as labels } from '@/routes/app/labels';
import { edit as editProfile } from '@/routes/app/profile';
@ -77,6 +74,7 @@ const postsNavItems = computed<NavItem[]>(() => [
title: trans('sidebar.posts.all'),
href: postsIndex.url(),
icon: IconFileText,
exact: true,
},
{
title: trans('sidebar.posts.scheduled'),
@ -114,11 +112,6 @@ const configNavItems = computed(() => {
href: labels.url(),
icon: IconTag,
},
{
title: 'API Keys',
href: apiKeysIndex.url(),
icon: IconKey,
},
];
if (canManageWorkspace.value) {
@ -147,16 +140,11 @@ const switchWorkspace = (workspaceId: string) => {
<SidebarMenuItem>
<DropdownMenu>
<DropdownMenuTrigger as-child>
<SidebarMenuButton
size="lg"
class="data-[state=open]:bg-sidebar-accent data-[state=open]:text-sidebar-accent-foreground"
>
<Avatar
:src="currentWorkspace?.logo_url"
:name="currentWorkspace?.name ?? '?'"
<SidebarMenuButton size="lg"
class="data-[state=open]:bg-sidebar-accent data-[state=open]:text-sidebar-accent-foreground">
<Avatar :src="currentWorkspace?.logo_url" :name="currentWorkspace?.name ?? '?'"
class="h-8 w-8 shrink-0 rounded-lg"
fallback-class="bg-sidebar-accent text-sidebar-accent-foreground"
/>
fallback-class="bg-sidebar-accent text-sidebar-accent-foreground" />
<div class="grid flex-1 text-left text-sm leading-tight">
<span class="truncate font-semibold">
{{ currentWorkspace?.name ?? $t('sidebar.select_workspace') }}
@ -165,29 +153,17 @@ const switchWorkspace = (workspaceId: string) => {
<IconChevronRight class="ml-auto size-4" />
</SidebarMenuButton>
</DropdownMenuTrigger>
<DropdownMenuContent
class="w-[--reka-dropdown-menu-trigger-width] min-w-56 rounded-lg"
align="start"
side="right"
:side-offset="4"
>
<DropdownMenuContent class="w-[--reka-dropdown-menu-trigger-width] min-w-56 rounded-lg"
align="start" side="right" :side-offset="4">
<DropdownMenuLabel class="text-xs text-muted-foreground">
{{ $t('sidebar.workspaces') }}
</DropdownMenuLabel>
<div class="space-y-0.5">
<DropdownMenuItem
v-for="workspace in workspaces"
:key="workspace.id"
class="gap-2"
<DropdownMenuItem v-for="workspace in workspaces" :key="workspace.id" class="gap-2"
:class="workspace.id === currentWorkspace?.id ? 'bg-accent' : ''"
@click="switchWorkspace(workspace.id)"
>
<Avatar
:src="workspace.logo_url"
:name="workspace.name"
class="h-5 w-5 shrink-0 rounded-md"
fallback-class="text-[10px]"
/>
@click="switchWorkspace(workspace.id)">
<Avatar :src="workspace.logo_url" :name="workspace.name"
class="h-5 w-5 shrink-0 rounded-md" fallback-class="text-[10px]" />
{{ workspace.name }}
</DropdownMenuItem>
</div>
@ -231,7 +207,8 @@ const switchWorkspace = (workspaceId: string) => {
</SidebarMenuItem>
<SidebarMenuItem>
<SidebarMenuButton as-child tooltip="Feedback">
<a href="https://github.com/trypost-it/trypost/discussions" target="_blank" rel="noopener noreferrer">
<a href="https://github.com/trypost-it/trypost/discussions" target="_blank"
rel="noopener noreferrer">
<IconMessageCircle />
<span>{{ $t('sidebar.support.share_feedback') }}</span>
</a>
@ -239,7 +216,7 @@ const switchWorkspace = (workspaceId: string) => {
</SidebarMenuItem>
<SidebarMenuItem>
<SidebarMenuButton as-child tooltip="Docs">
<a href="https://docs.trypost.it" target="_blank" rel="noopener noreferrer">
<a href="https://trypost.it/docs" target="_blank" rel="noopener noreferrer">
<IconLifebuoy />
<span>{{ $t('sidebar.support.docs') }}</span>
</a>
@ -249,4 +226,4 @@ const switchWorkspace = (workspaceId: string) => {
<NavUser />
</SidebarFooter>
</Sidebar>
</template>
</template>

View file

@ -1,135 +1,197 @@
<script setup lang="ts">
import { router } from '@inertiajs/vue3';
import { ref } from 'vue';
import { IconCopy, IconX } from '@tabler/icons-vue';
import { trans } from 'laravel-vue-i18n';
import { computed, ref } from 'vue';
import { Button } from '@/components/ui/button';
import {
AlertDialog,
AlertDialogAction,
AlertDialogCancel,
AlertDialogContent,
AlertDialogDescription,
AlertDialogFooter,
AlertDialogHeader,
AlertDialogTitle,
} from '@/components/ui/alert-dialog';
Dialog,
DialogContent,
DialogDescription,
DialogFooter,
DialogHeader,
DialogTitle,
} from '@/components/ui/dialog';
import { Input } from '@/components/ui/input';
import {
Tooltip,
TooltipContent,
TooltipProvider,
TooltipTrigger,
} from '@/components/ui/tooltip';
import { copyToClipboard } from '@/lib/utils';
const props = defineProps({
title: {
type: String,
default: 'Are you sure?',
},
title: {
type: String,
default: 'Are you sure?',
},
description: {
type: String,
default:
'Are you sure you want to perform this action? This action cannot be undone.',
},
description: {
type: String,
default: 'Are you sure you want to perform this action?',
},
action: {
type: String,
default: 'Delete',
},
action: {
type: String,
default: 'Delete',
},
cancel: {
type: String,
default: 'Cancel',
},
cancel: {
type: String,
default: 'Cancel',
},
method: {
type: String,
default: 'delete',
},
preserveState: {
type: Boolean,
default: false,
},
preserveScroll: {
type: Boolean,
default: false,
},
method: {
type: String,
default: 'delete',
},
});
const emit = defineEmits(['deleted', 'closed']);
const isOpen = ref<boolean>(false);
const loading = ref<boolean>(false);
const isOpen = ref(false);
const processing = ref(false);
const url = ref<string | null>(null);
const redirect = ref<string | null>(null);
const confirmInput = ref('');
const confirmText = ref('');
function remove() {
if (!url.value) return;
const requiresConfirmation = computed(() => confirmText.value.length > 0);
const isConfirmed = computed(
() =>
!requiresConfirmation.value || confirmInput.value === confirmText.value,
);
loading.value = true;
const remove = () => {
if (!url.value || !isConfirmed.value) return;
const targetUrl = redirect.value
? `${url.value}?redirect=${encodeURIComponent(redirect.value)}`
: url.value;
processing.value = true;
router[props.method as 'delete' | 'get' | 'post' | 'put' | 'patch'](
targetUrl,
{},
{
preserveState: props.preserveState,
preserveScroll: props.preserveScroll,
onSuccess: () => {
const options = {
preserveState: true,
preserveScroll: true,
onSuccess: () => {
close();
emit('deleted');
},
onFinish: () => {
processing.value = false;
},
};
const method = props.method as 'delete' | 'get' | 'post' | 'put' | 'patch';
if (method === 'delete' || method === 'get') {
router[method](url.value, options);
} else {
router[method](url.value, {}, options);
}
};
const open = (data: { url: string; confirmText?: string }) => {
url.value = data.url;
confirmText.value = data.confirmText ?? '';
processing.value = false;
confirmInput.value = '';
isOpen.value = true;
};
const close = () => {
isOpen.value = false;
processing.value = false;
confirmInput.value = '';
emit('closed');
};
const onOpenChange = (value: boolean) => {
isOpen.value = value;
if (!value) {
close();
emit('deleted');
},
onError: (errors) => {
console.error(errors);
},
onFinish: () => {
loading.value = false;
},
},
);
}
function open(data: { url: string; redirect?: string }) {
url.value = data.url;
redirect.value = data.redirect ?? null;
loading.value = false;
isOpen.value = true;
}
function close() {
isOpen.value = false;
loading.value = false;
emit('closed');
}
function onOpenChange(value: boolean) {
isOpen.value = value;
if (!value) {
close();
}
}
}
};
defineExpose({
open,
close,
open,
close,
});
</script>
<template>
<AlertDialog :open="isOpen" @update:open="onOpenChange">
<AlertDialogContent>
<AlertDialogHeader>
<AlertDialogTitle>{{ title }}</AlertDialogTitle>
<AlertDialogDescription>
{{ description }}
</AlertDialogDescription>
</AlertDialogHeader>
<AlertDialogFooter>
<AlertDialogCancel :disabled="loading">
{{ cancel }}
</AlertDialogCancel>
<AlertDialogAction :disabled="loading" variant="default" @click="remove">
{{ action }}
</AlertDialogAction>
</AlertDialogFooter>
</AlertDialogContent>
</AlertDialog>
</template>
<Dialog :open="isOpen" @update:open="onOpenChange">
<DialogContent :show-close-button="false" class="sm:max-w-md">
<Button
variant="ghost"
size="icon"
class="absolute top-4 right-4 size-7"
@click="close"
>
<IconX class="size-4" />
<span class="sr-only">Close</span>
</Button>
<DialogHeader>
<DialogTitle>{{ title }}</DialogTitle>
<DialogDescription class="space-y-1">
<span class="block">{{ description }}</span>
<span class="block font-semibold text-destructive">
{{ trans('common.confirm_modal.cannot_be_undone') }}
</span>
</DialogDescription>
</DialogHeader>
<div v-if="requiresConfirmation" class="py-2">
<p
class="mb-2 flex flex-wrap items-center gap-1 text-sm text-muted-foreground"
>
<span>{{ trans('common.confirm_modal.type') }}</span>
<code
class="inline-flex items-center gap-1 rounded-sm border bg-zinc-200 px-1.5 text-sm break-all text-foreground dark:bg-zinc-700"
>
{{ confirmText }}
<TooltipProvider>
<Tooltip>
<TooltipTrigger as-child>
<button
type="button"
tabindex="-1"
class="inline-flex shrink-0 items-center rounded text-muted-foreground hover:text-foreground"
@click="
copyToClipboard(
confirmText,
trans('common.confirm_modal.copy_to_clipboard'),
)
"
>
<IconCopy class="size-3" />
</button>
</TooltipTrigger>
<TooltipContent>
<p>{{ trans('common.confirm_modal.copy_to_clipboard') }}</p>
</TooltipContent>
</Tooltip>
</TooltipProvider>
</code>
<span>{{ trans('common.confirm_modal.to_confirm') }}</span>
</p>
<Input
v-model="confirmInput"
autocomplete="off"
/>
</div>
<DialogFooter>
<Button
variant="destructive"
:disabled="processing || !isConfirmed"
@click="remove"
>
{{ action }}
</Button>
<Button
variant="secondary"
@click="close"
>
{{ cancel }}
</Button>
</DialogFooter>
</DialogContent>
</Dialog>
</template>

View file

@ -0,0 +1,19 @@
<script setup lang="ts">
import type { Component } from 'vue';
defineProps<{
icon: Component;
title: string;
description: string;
}>();
</script>
<template>
<div
class="flex flex-1 flex-col items-center justify-center rounded-xl border border-dashed p-12"
>
<component :is="icon" class="mb-4 size-12 text-muted-foreground" />
<h3 class="mb-1 text-lg font-medium">{{ title }}</h3>
<p class="text-sm text-muted-foreground">{{ description }}</p>
</div>
</template>

View file

@ -28,7 +28,7 @@ const { urlIsActive } = useActiveUrl();
<SidebarMenuItem v-for="item in items" :key="item.title">
<SidebarMenuButton
as-child
:is-active="urlIsActive(item.activePattern ?? item.href)"
:is-active="urlIsActive(item.activePattern ?? item.href, { exact: item.exact })"
:tooltip="item.title"
>
<Link :href="item.href">

View file

@ -42,7 +42,7 @@ defineProps<Props>();
const page = usePage();
const languages = computed<Language[]>(() => page.props.languages as Language[]);
const currentLanguage = computed(() => languages.value?.find((l: Language) => l.code === page.props.auth.user?.locale));
const currentLanguage = computed(() => languages.value?.find((l: Language) => l.code === page.props.locale));
const { appearance, updateAppearance } = useAppearance();

View file

@ -0,0 +1,76 @@
<script setup lang="ts">
import { Form } from '@inertiajs/vue3';
import { ref } from 'vue';
import ApiKeyController from '@/actions/App/Http/Controllers/App/ApiKeyController';
import DatePicker from '@/components/DatePicker.vue';
import InputError from '@/components/InputError.vue';
import { Button } from '@/components/ui/button';
import {
Dialog,
DialogContent,
DialogDescription,
DialogFooter,
DialogHeader,
DialogTitle,
} from '@/components/ui/dialog';
import { Input } from '@/components/ui/input';
import { Label } from '@/components/ui/label';
const open = defineModel<boolean>('open', { default: false });
const expiresAt = ref('');
const onSuccess = () => {
expiresAt.value = '';
open.value = false;
};
</script>
<template>
<Dialog v-model:open="open">
<DialogContent class="sm:max-w-md">
<DialogHeader>
<DialogTitle>Create API Key</DialogTitle>
<DialogDescription>
Create a new API key for programmatic access to your workspace.
</DialogDescription>
</DialogHeader>
<Form
v-bind="ApiKeyController.store.form()"
class="space-y-4"
v-slot="{ errors, processing }"
@success="onSuccess"
>
<div class="grid gap-2">
<Label for="token-name">Name</Label>
<Input
id="token-name"
name="name"
placeholder="e.g. Production API Key"
/>
<InputError :message="errors.name" />
</div>
<div class="grid gap-2">
<Label>Expiration date (optional)</Label>
<DatePicker
name="token-expires"
v-model="expiresAt"
:show-time="false"
placeholder="No expiration"
/>
<input type="hidden" name="expires_at" :value="expiresAt" />
<InputError :message="errors.expires_at" />
</div>
<DialogFooter>
<Button type="submit" :disabled="processing">
Create
</Button>
<Button type="button" variant="secondary" @click="open = false">
Cancel
</Button>
</DialogFooter>
</Form>
</DialogContent>
</Dialog>
</template>

View file

@ -0,0 +1,93 @@
<script setup lang="ts">
import { Form } from '@inertiajs/vue3';
import { trans } from 'laravel-vue-i18n';
import { ref } from 'vue';
import InputError from '@/components/InputError.vue';
import { Button } from '@/components/ui/button';
import {
Dialog,
DialogContent,
DialogDescription,
DialogFooter,
DialogHeader,
DialogTitle,
} from '@/components/ui/dialog';
import { Input } from '@/components/ui/input';
import { Label } from '@/components/ui/label';
import {
Select,
SelectContent,
SelectItem,
SelectTrigger,
SelectValue,
} from '@/components/ui/select';
import { store as storeInvite } from '@/routes/app/invites';
const open = defineModel<boolean>('open', { default: false });
const inviteRole = ref('member');
const onSuccess = () => {
inviteRole.value = 'member';
open.value = false;
};
</script>
<template>
<Dialog v-model:open="open">
<DialogContent class="sm:max-w-md">
<DialogHeader>
<DialogTitle>{{ $t('settings.members.invite.title') }}</DialogTitle>
<DialogDescription>
{{ $t('settings.members.invite.description') }}
</DialogDescription>
</DialogHeader>
<Form
v-bind="storeInvite.form()"
class="space-y-4"
v-slot="{ errors, processing }"
@success="onSuccess"
>
<div class="grid gap-2">
<Label for="invite-email">{{ $t('settings.members.invite.email') }}</Label>
<Input
id="invite-email"
name="email"
type="email"
:placeholder="trans('settings.members.invite.email_placeholder')"
/>
<InputError :message="errors.email" />
</div>
<div class="grid gap-2">
<Label for="invite-role">{{ $t('settings.members.invite.role') }}</Label>
<Select v-model="inviteRole" name="role">
<SelectTrigger class="w-full">
<SelectValue :placeholder="trans('settings.members.invite.role_placeholder')" />
</SelectTrigger>
<SelectContent>
<SelectItem value="member">{{ $t('settings.members.roles.member') }}</SelectItem>
<SelectItem value="admin">{{ $t('settings.members.roles.admin') }}</SelectItem>
</SelectContent>
</Select>
<input type="hidden" name="role" :value="inviteRole" />
<InputError :message="errors.role" />
</div>
<DialogFooter>
<Button type="submit" :disabled="processing">
{{ $t('settings.members.invite.submit') }}
</Button>
<Button
variant="secondary"
type="button"
@click="open = false"
>
{{ $t('settings.members.cancel') }}
</Button>
</DialogFooter>
</Form>
</DialogContent>
</Dialog>
</template>

View file

@ -8,7 +8,7 @@ const props = defineProps<{ class?: HTMLAttributes["class"] }>()
<template>
<div
data-slot="dialog-footer"
:class="cn('flex flex-col-reverse gap-2 sm:flex-row sm:justify-end', props.class)"
:class="cn('flex flex-col-reverse gap-2 sm:flex-row sm:justify-start', props.class)"
>
<slot />
</div>

View file

@ -20,11 +20,15 @@ const toPathname = (url: string): string => {
export function useActiveUrl() {
function urlIsActive(
urlToCheck: NonNullable<InertiaLinkProps['href']>,
options?: { prefix?: boolean },
options?: { prefix?: boolean; exact?: boolean },
) {
const current = currentUrlReactive.value;
const pathname = toPathname(toUrl(urlToCheck));
if (options?.exact) {
return current === pathname;
}
if (options?.prefix) {
return current.startsWith(pathname);
}

View file

@ -12,7 +12,8 @@ import weekday from 'dayjs/plugin/weekday';
// Import locales
import 'dayjs/locale/en';
import "dayjs/locale/pt-br"
import 'dayjs/locale/es';
import 'dayjs/locale/pt-br';
// Extend dayjs with plugins
dayjs.extend(utc);
@ -27,12 +28,8 @@ dayjs.extend(weekday);
dayjs.extend(isBetween);
// Set Monday as first day of week (to match Carbon/Laravel)
dayjs.updateLocale('en', {
weekStart: 1,
});
dayjs.updateLocale('pt-br', {
weekStart: 1,
});
dayjs.updateLocale('en', { weekStart: 1 });
dayjs.updateLocale('es', { weekStart: 1 });
dayjs.updateLocale('pt-br', { weekStart: 1 });
export default dayjs;

View file

@ -1,14 +1,17 @@
<script setup lang="ts">
import AuthLayout from '@/layouts/auth/AuthSimpleLayout.vue';
import AuthLayout from '@/layouts/auth/AuthSplitLayout.vue';
defineProps<{
withDefaults(defineProps<{
title?: string;
description?: string;
}>();
showLegal?: boolean;
}>(), {
showLegal: false,
});
</script>
<template>
<AuthLayout :title="title" :description="description">
<AuthLayout :title="title" :description="description" :show-legal="showLegal">
<slot />
</AuthLayout>
</template>

View file

@ -0,0 +1,226 @@
<script setup lang="ts">
import {
IconCalendar,
IconClock,
IconHash,
IconPhoto,
IconUsers,
IconVideo,
} from '@tabler/icons-vue';
import { trans } from 'laravel-vue-i18n';
import { onBeforeUnmount, onMounted, ref, computed } from 'vue';
withDefaults(defineProps<{
title?: string;
description?: string;
showLegal?: boolean;
}>(), {
showLegal: false,
});
const slideKeys = ['calendar', 'scheduling', 'media', 'video', 'team', 'hashtags'] as const;
const slideIcons = {
calendar: IconCalendar,
scheduling: IconClock,
media: IconPhoto,
video: IconVideo,
team: IconUsers,
hashtags: IconHash,
};
const slides = computed(() =>
slideKeys.map((key) => ({
icon: slideIcons[key],
title: trans(`auth.slides.${key}.title`),
description: trans(`auth.slides.${key}.description`),
})),
);
const activeIndex = ref(0);
const isPaused = ref(false);
let intervalId: ReturnType<typeof setInterval> | null = null;
const activeSlide = computed(() => slides.value[activeIndex.value]);
const goTo = (index: number) => {
activeIndex.value = index;
restartInterval();
};
const startInterval = () => {
intervalId = setInterval(() => {
if (!isPaused.value) {
activeIndex.value = (activeIndex.value + 1) % slides.value.length;
}
}, 4000);
};
const restartInterval = () => {
if (intervalId) {
clearInterval(intervalId);
}
startInterval();
};
onMounted(() => {
startInterval();
});
onBeforeUnmount(() => {
if (intervalId) {
clearInterval(intervalId);
}
});
const platforms = [
{ name: 'LinkedIn', icon: '/images/accounts/linkedin.png' },
{ name: 'X', icon: '/images/accounts/x.png' },
{ name: 'Instagram', icon: '/images/accounts/instagram.png' },
{ name: 'Facebook', icon: '/images/accounts/facebook.png' },
{ name: 'TikTok', icon: '/images/accounts/tiktok.png' },
{ name: 'YouTube', icon: '/images/accounts/youtube.png' },
{ name: 'Threads', icon: '/images/accounts/threads.png' },
{ name: 'Pinterest', icon: '/images/accounts/pinterest.png' },
{ name: 'Bluesky', icon: '/images/accounts/bluesky.png' },
{ name: 'Mastodon', icon: '/images/accounts/mastodon.png' },
];
</script>
<template>
<div class="grid min-h-svh lg:grid-cols-2">
<div class="flex flex-col gap-4 p-6 md:p-10">
<div class="flex flex-1 items-center justify-center">
<div class="w-full max-w-md">
<div class="flex flex-col gap-6">
<div class="flex flex-col items-center gap-2 text-center">
<img
src="/images/trypost/logo-light.png"
alt="TryPost"
class="mb-2 h-10 dark:hidden"
/>
<img
src="/images/trypost/logo-dark.png"
alt="TryPost"
class="mb-2 hidden h-10 dark:block"
/>
<h1 v-if="title" class="text-2xl font-bold">{{ title }}</h1>
<p v-if="description" class="text-sm text-balance text-muted-foreground">
{{ description }}
</p>
</div>
<slot />
</div>
</div>
</div>
<!-- eslint-disable-next-line vue/no-v-html -->
<div
v-if="showLegal"
class="text-center text-xs text-muted-foreground [&_a]:underline [&_a]:underline-offset-4 [&_a]:hover:text-primary"
v-html="trans('auth.legal')"
/>
</div>
<div
class="relative hidden overflow-hidden lg:block"
@mouseenter="isPaused = true"
@mouseleave="isPaused = false"
>
<div class="absolute inset-0 bg-gradient-to-br from-zinc-900 via-zinc-950 to-zinc-950" />
<div
class="absolute inset-0 opacity-[0.015]"
style="background-image: url(&quot;data:image/svg+xml,%3Csvg width='40' height='40' viewBox='0 0 40 40' xmlns='http://www.w3.org/2000/svg'%3E%3Cpath d='M0 0h40v40H0z' fill='none'/%3E%3Cpath d='M0 0h1v40H0zM40 0v1H0V0z' fill='%23fff'/%3E%3C/svg%3E&quot;)"
/>
<div class="relative flex h-full flex-col items-center justify-center px-12 xl:px-16">
<!-- Platform logos -->
<div class="relative h-[280px] w-full max-w-md">
<template v-for="(slide, index) in slides" :key="index">
<Transition
enter-active-class="transition-all duration-500 ease-out"
leave-active-class="transition-all duration-500 ease-out"
enter-from-class="opacity-0 translate-y-4"
enter-to-class="opacity-100 translate-y-0"
leave-from-class="opacity-100 translate-y-0"
leave-to-class="opacity-0 -translate-y-4"
>
<div
v-if="activeIndex === index"
class="absolute inset-0 flex items-center justify-center"
>
<div class="w-full rounded-xl border border-white/10 bg-white/[0.04] p-6 shadow-2xl backdrop-blur-sm">
<div class="mb-5 flex items-center gap-2">
<div class="size-2.5 rounded-full bg-red-400/80" />
<div class="size-2.5 rounded-full bg-yellow-400/80" />
<div class="size-2.5 rounded-full bg-green-400/80" />
<div class="ml-3 h-5 w-48 rounded-md bg-white/5" />
</div>
<div class="flex items-center justify-center py-8">
<div class="flex size-20 items-center justify-center rounded-2xl border border-white/10 bg-white/[0.06]">
<component :is="slide.icon" class="size-10 text-white/80" />
</div>
</div>
<div class="flex flex-wrap justify-center gap-2 pt-2">
<img
v-for="platform in platforms"
:key="platform.name"
:src="platform.icon"
:alt="platform.name"
class="size-7 rounded-full border border-white/10 bg-white/5 p-0.5 opacity-60"
/>
</div>
</div>
</div>
</Transition>
</template>
</div>
<!-- Text content -->
<div class="w-full max-w-md text-center">
<div class="relative h-[88px]">
<TransitionGroup
enter-active-class="transition-all duration-400 ease-out"
leave-active-class="transition-all duration-300 ease-in"
enter-from-class="opacity-0 translate-y-2"
enter-to-class="opacity-100 translate-y-0"
leave-from-class="opacity-100"
leave-to-class="opacity-0"
>
<div :key="activeIndex" class="absolute inset-x-0 top-0">
<h3 class="text-xl font-semibold tracking-tight text-white">
{{ activeSlide.title }}
</h3>
<p class="mx-auto mt-2 max-w-sm text-sm leading-relaxed text-zinc-400">
{{ activeSlide.description }}
</p>
</div>
</TransitionGroup>
</div>
<!-- Dots -->
<div class="flex items-center justify-center gap-2">
<button
v-for="(_, index) in slides"
:key="index"
class="group relative flex h-5 cursor-pointer items-center justify-center"
@click="goTo(index)"
>
<span
class="block h-1.5 rounded-full transition-all duration-300"
:class="activeIndex === index
? 'w-6 bg-white'
: 'w-1.5 bg-zinc-600 group-hover:bg-zinc-500'
"
/>
</button>
</div>
</div>
</div>
</div>
</div>
</template>

View file

@ -1,11 +1,57 @@
import { InertiaLinkProps } from '@inertiajs/vue3';
import type { InertiaLinkProps } from '@inertiajs/vue3';
import { clsx, type ClassValue } from 'clsx';
import { twMerge } from 'tailwind-merge';
import { toast } from 'vue-sonner';
export function cn(...inputs: ClassValue[]) {
export const cn = (...inputs: ClassValue[]) => {
return twMerge(clsx(inputs));
}
};
export function toUrl(href: NonNullable<InertiaLinkProps['href']>) {
export const toUrl = (href: NonNullable<InertiaLinkProps['href']>) => {
return typeof href === 'string' ? href : href?.url;
}
};
export const formatNumber = (value: number): string => {
return value.toLocaleString('en-US');
};
export const formatNumberCompact = (value: number): string => {
return new Intl.NumberFormat('en-US', {
notation: 'compact',
compactDisplay: 'short',
maximumFractionDigits: 1,
}).format(value);
};
export const formatMoney = (cents: number): string => {
const dollars = cents / 100;
return dollars.toLocaleString('en-US', {
style: 'currency',
currency: 'USD',
minimumFractionDigits: 2,
maximumFractionDigits: 2,
});
};
export const formatMoneyCompact = (cents: number): string => {
const dollars = cents / 100;
return new Intl.NumberFormat('en-US', {
style: 'currency',
currency: 'USD',
notation: 'compact',
compactDisplay: 'short',
maximumFractionDigits: 1,
}).format(dollars);
};
export const copyToClipboard = async (
text: string,
message = 'Copied to clipboard',
) => {
try {
await navigator.clipboard.writeText(text);
toast.success(message);
} catch {
toast.error('Failed to copy to clipboard');
}
};

View file

@ -1,5 +1,7 @@
<script setup lang="ts">
import { Form, Head } from '@inertiajs/vue3';
import { IconEye, IconEyeOff } from '@tabler/icons-vue';
import { ref } from 'vue';
import InputError from '@/components/InputError.vue';
import TextLink from '@/components/TextLink.vue';
@ -7,6 +9,12 @@ import { Button } from '@/components/ui/button';
import { Input } from '@/components/ui/input';
import { Label } from '@/components/ui/label';
import { Spinner } from '@/components/ui/spinner';
import {
Tooltip,
TooltipContent,
TooltipProvider,
TooltipTrigger,
} from '@/components/ui/tooltip';
import AuthBase from '@/layouts/AuthLayout.vue';
import { login } from '@/routes';
import { store } from '@/routes/register';
@ -16,6 +24,8 @@ defineProps<{
redirect?: string | null;
}>();
const showPassword = ref(false);
// Get user's timezone from browser
const timezone = Intl.DateTimeFormat().resolvedOptions().timeZone;
</script>
@ -24,6 +34,7 @@ const timezone = Intl.DateTimeFormat().resolvedOptions().timeZone;
<AuthBase
:title="$t('auth.register.title')"
:description="$t('auth.register.description')"
:show-legal="true"
>
<Head :title="$t('auth.register.page_title')" />
@ -68,15 +79,36 @@ const timezone = Intl.DateTimeFormat().resolvedOptions().timeZone;
<div class="grid gap-2">
<Label for="password">{{ $t('auth.register.password') }}</Label>
<Input
id="password"
type="password"
required
:tabindex="3"
autocomplete="new-password"
name="password"
:placeholder="$t('auth.register.password')"
/>
<div class="relative">
<Input
id="password"
:type="showPassword ? 'text' : 'password'"
:tabindex="3"
autocomplete="new-password"
name="password"
:placeholder="$t('auth.register.password')"
/>
<div class="absolute inset-y-0 end-0 flex items-center pe-3">
<TooltipProvider>
<Tooltip>
<TooltipTrigger as-child>
<button
type="button"
:tabindex="-1"
class="cursor-pointer text-muted-foreground hover:text-foreground"
@click="showPassword = !showPassword"
>
<IconEyeOff v-if="showPassword" class="size-4" />
<IconEye v-else class="size-4" />
</button>
</TooltipTrigger>
<TooltipContent>
<p>{{ showPassword ? $t('auth.register.hide_password') : $t('auth.register.show_password') }}</p>
</TooltipContent>
</Tooltip>
</TooltipProvider>
</div>
</div>
<InputError :message="errors.password" />
</div>

View file

@ -1,100 +1,131 @@
<script setup lang="ts">
import { Head, router } from '@inertiajs/vue3';
import { IconSparkles, IconCalendar, IconUsers, IconPhoto, IconVideo, IconClock, IconChartBar } from '@tabler/icons-vue';
import { ref } from 'vue';
import { Head, Link, router } from '@inertiajs/vue3';
import { IconCheck } from '@tabler/icons-vue';
import { trans } from 'laravel-vue-i18n';
import { computed, ref } from 'vue';
import { Button } from '@/components/ui/button';
import { Separator } from '@/components/ui/separator';
import { checkout } from '@/routes/app/billing';
import { index as workspacesIndex } from '@/routes/app/workspaces';
interface Props {
trialDays: number;
}
defineProps<Props>();
const props = defineProps<Props>();
const processing = ref(false);
const displayDays = computed(() => props.trialDays - 1);
function subscribe() {
const subscribe = () => {
processing.value = true;
router.post(checkout.url());
}
};
const platforms = [
{ name: 'LinkedIn Profile', icon: '/images/accounts/linkedin.png' },
{ name: 'LinkedIn Page', icon: '/images/accounts/linkedin.png' },
{ name: 'X (Twitter)', icon: '/images/accounts/x.png' },
{ name: 'TikTok', icon: '/images/accounts/tiktok.png' },
{ name: 'YouTube', icon: '/images/accounts/youtube.png' },
{ name: 'LinkedIn', icon: '/images/accounts/linkedin.png' },
{ name: 'X', icon: '/images/accounts/x.png' },
{ name: 'Instagram', icon: '/images/accounts/instagram.png' },
{ name: 'Facebook', icon: '/images/accounts/facebook.png' },
{ name: 'TikTok', icon: '/images/accounts/tiktok.png' },
{ name: 'YouTube', icon: '/images/accounts/youtube.png' },
{ name: 'Threads', icon: '/images/accounts/threads.png' },
{ name: 'Pinterest', icon: '/images/accounts/pinterest.png' },
{ name: 'Bluesky', icon: '/images/accounts/bluesky.png' },
{ name: 'Mastodon', icon: '/images/accounts/mastodon.png' },
];
const features = [
{ icon: IconCalendar, title: 'Visual Calendar', description: 'Plan and schedule your content with an intuitive drag-and-drop calendar' },
{ icon: IconClock, title: 'Unlimited Scheduling', description: 'Schedule as many posts as you want, whenever you want' },
{ icon: IconPhoto, title: 'Images & Carousels', description: 'Share single images or create engaging carousel posts' },
{ icon: IconVideo, title: 'Video Publishing', description: 'Upload and publish videos across all your social accounts' },
{ icon: IconUsers, title: 'Team Collaboration', description: 'Invite your team members and work together seamlessly' },
{ icon: IconChartBar, title: 'Analytics', description: 'Track your post performance and engagement metrics' },
const featureKeys = [
'calendar',
'scheduling',
'media',
'video',
'team',
'hashtags',
];
</script>
<template>
<Head title="Start your free trial" />
<Head :title="$t('billing.subscribe.page_title')" />
<div class="min-h-screen bg-gradient-to-b from-background via-background to-muted/30">
<div class="container mx-auto px-4 py-12 max-w-4xl">
<!-- Header -->
<div class="text-center mb-12">
<div class="inline-flex items-center justify-center w-20 h-20 rounded-2xl bg-primary/10 mb-6">
<IconSparkles class="w-10 h-10 text-primary" />
</div>
<h1 class="text-4xl font-bold tracking-tight mb-3">Welcome to TryPost!</h1>
<p class="text-xl text-muted-foreground">
Start your free {{ trialDays }}-day trial and take control of your social media
</p>
<div class="flex min-h-screen items-center justify-center bg-background px-4">
<div class="w-full max-w-md">
<!-- Logo -->
<div class="mb-8 text-center">
<img
src="/images/trypost/logo-light.png"
alt="TryPost"
class="mx-auto h-8 w-auto dark:hidden"
/>
<img
src="/images/trypost/logo-dark.png"
alt="TryPost"
class="mx-auto hidden h-8 w-auto dark:block"
/>
</div>
<!-- Platforms -->
<div class="mb-12">
<h2 class="text-center text-sm font-medium text-muted-foreground uppercase tracking-wider mb-6">
Connect all your accounts
</h2>
<div class="flex flex-wrap justify-center gap-3">
<div
<!-- Card -->
<div class="rounded-xl border bg-card p-8">
<div class="mb-6 text-center">
<h1 class="text-2xl font-semibold tracking-tight">
{{ $t('billing.subscribe.title') }}
</h1>
<p class="mt-2 text-sm text-muted-foreground">
{{ trans('billing.subscribe.description', { days: displayDays }) }}
</p>
</div>
<!-- Platforms -->
<div class="mb-6 flex flex-wrap justify-center gap-2">
<img
v-for="platform in platforms"
:key="platform.name"
class="flex items-center gap-2.5 px-4 py-2.5 rounded-full bg-card border shadow-sm hover:shadow-md transition-shadow"
:src="platform.icon"
:alt="platform.name"
:title="platform.name"
class="size-10 rounded-full border bg-background p-0.5"
/>
</div>
<Separator class="mb-6" />
<!-- Features -->
<ul class="mb-6 space-y-3">
<li
v-for="key in featureKeys"
:key="key"
class="flex items-center gap-3 text-sm"
>
<img :src="platform.icon" :alt="platform.name" class="w-5 h-5 rounded-full object-cover" />
<span class="text-sm font-medium">{{ platform.name }}</span>
</div>
</div>
</div>
<div class="flex size-5 shrink-0 items-center justify-center rounded-full bg-primary/10">
<IconCheck class="size-3 text-primary" />
</div>
{{ $t(`billing.subscribe.features.${key}`) }}
</li>
</ul>
<!-- Features Grid -->
<div class="grid md:grid-cols-2 lg:grid-cols-3 gap-4 mb-6">
<div
v-for="feature in features"
:key="feature.title"
class="p-5 rounded-xl bg-card border hover:border-primary/50 hover:shadow-lg transition-all"
<!-- CTA -->
<Button
class="w-full"
size="lg"
:disabled="processing"
@click="subscribe"
>
<div class="w-10 h-10 rounded-lg bg-primary/10 flex items-center justify-center mb-3">
<component :is="feature.icon" class="w-5 h-5 text-primary" />
</div>
<h3 class="font-semibold mb-1">{{ feature.title }}</h3>
<p class="text-sm text-muted-foreground">{{ feature.description }}</p>
</div>
</div>
<!-- CTA -->
<div class="text-center">
<Button @click="subscribe" :disabled="processing" size="lg" class="px-8">
Start my free trial
{{ trans('billing.subscribe.start_trial', { days: displayDays }) }}
</Button>
</div>
<div class="mt-4 space-y-1 text-center">
<p class="text-xs text-muted-foreground">
{{ $t('billing.subscribe.cancel_anytime') }}
</p>
<Link
:href="workspacesIndex.url()"
class="inline-block text-xs text-muted-foreground underline underline-offset-4 hover:text-foreground"
>
{{ $t('billing.subscribe.switch_workspace') }}
</Link>
</div>
</div>
</div>
</template>

View file

@ -1,11 +1,27 @@
<script setup lang="ts">
import { Head, router } from '@inertiajs/vue3';
import { ref, computed } from 'vue';
import { IconCheck } from '@tabler/icons-vue';
import { trans } from 'laravel-vue-i18n';
import { computed, onMounted, onUnmounted, ref } from 'vue';
import { storeStep2 } from '@/actions/App/Http/Controllers/App/OnboardingController';
import SocialAccountsGrid, { type Platform } from '@/components/SocialAccountsGrid.vue';
import { Button } from '@/components/ui/button';
import OnboardingLayout from '@/layouts/OnboardingLayout.vue';
import AuthLayout from '@/layouts/AuthLayout.vue';
export interface SocialAccount {
id: string;
platform: string;
username: string;
display_name: string;
status: 'connected' | 'disconnected' | 'token_expired' | null;
}
export interface Platform {
value: string;
label: string;
connected: boolean;
account: SocialAccount | null;
}
interface Props {
platforms: Platform[];
@ -16,9 +32,49 @@ const props = defineProps<Props>();
const isSubmitting = ref(false);
const connectedCount = computed(() => {
return props.platforms.filter((p) => p.connected).length;
});
const connectedCount = computed(() =>
props.platforms.filter((p) => p.connected).length,
);
const getPlatformLogo = (platform: string): string => {
const logos: Record<string, string> = {
'linkedin': '/images/accounts/linkedin.png',
'linkedin-page': '/images/accounts/linkedin.png',
'x': '/images/accounts/x.png',
'tiktok': '/images/accounts/tiktok.png',
'instagram': '/images/accounts/instagram.png',
'facebook': '/images/accounts/facebook.png',
'youtube': '/images/accounts/youtube.png',
'threads': '/images/accounts/threads.png',
'bluesky': '/images/accounts/bluesky.png',
'pinterest': '/images/accounts/pinterest.png',
'mastodon': '/images/accounts/mastodon.png',
};
return logos[platform] || '/images/accounts/linkedin.png';
};
const openOAuthPopup = (platformValue: string) => {
const url = `/connect/${platformValue}`;
const width = 600;
const height = 700;
const left = window.screenX + (window.outerWidth - width) / 2;
const top = window.screenY + (window.outerHeight - height) / 2;
window.open(
url,
'oauth-popup',
`width=${width},height=${height},left=${left},top=${top},scrollbars=yes,resizable=yes`,
);
};
const handleOAuthMessage = (event: MessageEvent) => {
if (event.origin !== window.location.origin) return;
if (event.data?.type !== 'social-oauth-callback') return;
router.reload();
};
onMounted(() => window.addEventListener('message', handleOAuthMessage));
onUnmounted(() => window.removeEventListener('message', handleOAuthMessage));
const submit = () => {
isSubmitting.value = true;
@ -29,40 +85,61 @@ const submit = () => {
<template>
<Head title="Connect your accounts" />
<OnboardingLayout
<AuthLayout
title="Connect your accounts"
description="Connect at least one social network to get started"
:step="2"
wide
>
<div v-if="hasWorkspace" class="space-y-6">
<SocialAccountsGrid
:platforms="platforms"
:columns="2"
:show-disconnect="false"
:show-reconnect="false"
:show-view-profile="false"
/>
<div v-if="connectedCount > 0" class="flex flex-col items-center gap-4">
<Button
class="w-full"
size="lg"
:disabled="isSubmitting"
@click="submit"
<div v-if="hasWorkspace" class="space-y-4">
<div class="space-y-1.5">
<div
v-for="platform in platforms"
:key="platform.value"
class="flex items-center gap-3 rounded-lg border px-3 py-2.5 transition-colors"
:class="platform.connected ? 'border-green-500/30 bg-green-50/50 dark:bg-green-950/20' : ''"
>
Continue
</Button>
<img
:src="getPlatformLogo(platform.value)"
:alt="platform.label"
class="size-7 rounded object-contain"
/>
<div class="min-w-0 flex-1">
<p class="text-sm font-medium leading-tight">{{ platform.label }}</p>
<p v-if="platform.connected && platform.account" class="truncate text-xs text-muted-foreground">
@{{ platform.account.username || platform.account.display_name }}
</p>
</div>
<div v-if="platform.connected" class="flex size-5 shrink-0 items-center justify-center rounded-full bg-green-500 text-white">
<IconCheck class="size-3" />
</div>
<Button
v-else
variant="outline"
size="sm"
class="h-7 shrink-0 text-xs"
@click="openOAuthPopup(platform.value)"
>
{{ trans('accounts.connect') }}
</Button>
</div>
</div>
<Button
v-if="connectedCount > 0"
class="w-full"
:disabled="isSubmitting"
@click="submit"
>
Continue
</Button>
</div>
<div v-else class="flex flex-col items-center gap-4 py-8">
<p class="text-muted-foreground">
Something went wrong. Please try again.
</p>
<Button variant="outline" @click="router.visit('/onboarding/step1')">
<Button variant="outline" @click="router.visit('/onboarding/role')">
Go Back
</Button>
</div>
</OnboardingLayout>
</AuthLayout>
</template>

View file

@ -155,7 +155,6 @@ const props = defineProps<Props>();
const breadcrumbs = computed<BreadcrumbItemType[]>(() => [
{ title: trans('posts.title'), href: postsIndex.url() },
{ title: trans('posts.edit.title'), href: '#' },
]);
// Reactive state for real-time updates

View file

@ -1,11 +1,12 @@
<script setup lang="ts">
import { Head, Link, InfiniteScroll } from '@inertiajs/vue3';
import { IconClock, IconCircleCheck, IconAlertCircle, IconLoader2, IconFileText, IconPlus, IconEye, IconTrash } from '@tabler/icons-vue';
import { IconClock, IconCircleCheck, IconAlertCircle, IconLoader2, IconFileText, IconEye, IconTrash } from '@tabler/icons-vue';
import { trans } from 'laravel-vue-i18n';
import { computed, ref } from 'vue';
import { index as postsIndex, store as storePost, edit as editPost, destroy as destroyPost } from '@/actions/App/Http/Controllers/App/PostController';
import ConfirmDeleteModal from '@/components/ConfirmDeleteModal.vue';
import EmptyState from '@/components/EmptyState.vue';
import { Badge } from '@/components/ui/badge';
import { Button } from '@/components/ui/button';
import { Card, CardContent } from '@/components/ui/card';
@ -90,9 +91,20 @@ const pageDescription = computed(() => {
return trans('posts.manage_posts');
});
const breadcrumbs = computed<BreadcrumbItemType[]>(() => [
{ title: trans('posts.title'), href: postsIndex.url() },
]);
const breadcrumbs = computed<BreadcrumbItemType[]>(() => {
const items: BreadcrumbItemType[] = [
{ title: trans('posts.title'), href: postsIndex.url() },
];
if (props.currentStatus) {
items.push({
title: trans(`posts.status.${props.currentStatus}`),
href: postsIndex.url(props.currentStatus),
});
}
return items;
});
const getPlatformLogo = (platform: string): string => {
const logos: Record<string, string> = {
@ -180,23 +192,12 @@ const handleDelete = (post: Post) => {
</div>
</div>
<div v-if="posts.data.length === 0">
<Card>
<CardContent class="flex flex-col items-center justify-center py-12">
<IconFileText class="h-12 w-12 text-muted-foreground" />
<h3 class="mt-4 text-lg font-semibold">{{ $t('posts.no_posts') }}</h3>
<p class="mt-2 text-sm text-muted-foreground">
{{ $t('posts.start_creating') }}
</p>
<Link :href="storePost.url()" method="post" class="mt-4">
<Button>
<IconPlus class="h-4 w-4" />
{{ $t('posts.new_post') }}
</Button>
</Link>
</CardContent>
</Card>
</div>
<EmptyState
v-if="posts.data.length === 0"
:icon="IconFileText"
:title="$t('posts.no_posts')"
:description="$t('posts.start_creating')"
/>
<div v-else>
<div class="grid grid-cols-1 md:grid-cols-2 lg:grid-cols-3 gap-4">

View file

@ -1,27 +1,36 @@
<script setup lang="ts">
import { Head, useForm, usePage } from '@inertiajs/vue3';
import { IconKey, IconCopy, IconTrash, IconPlus } from '@tabler/icons-vue';
import { computed, ref, watch } from 'vue';
import { Head, usePage } from '@inertiajs/vue3';
import { IconCopy, IconDots, IconKey, IconTrash } from '@tabler/icons-vue';
import { trans } from 'laravel-vue-i18n';
import { computed, ref } from 'vue';
import ApiKeyController from '@/actions/App/Http/Controllers/App/ApiKeyController';
import CreateApiKeyDialog from '@/components/api-keys/CreateApiKeyDialog.vue';
import ConfirmDeleteModal from '@/components/ConfirmDeleteModal.vue';
import DatePicker from '@/components/DatePicker.vue';
import EmptyState from '@/components/EmptyState.vue';
import HeadingSmall from '@/components/HeadingSmall.vue';
import { Badge } from '@/components/ui/badge';
import { Button } from '@/components/ui/button';
import {
Dialog,
DialogContent,
DialogDescription,
DialogFooter,
DialogHeader,
DialogTitle,
} from '@/components/ui/dialog';
import { Input } from '@/components/ui/input';
import { Label } from '@/components/ui/label';
DropdownMenu,
DropdownMenuContent,
DropdownMenuItem,
DropdownMenuSeparator,
DropdownMenuTrigger,
} from '@/components/ui/dropdown-menu';
import {
Table,
TableBody,
TableCell,
TableHead,
TableHeader,
TableRow,
} from '@/components/ui/table';
import date from '@/date';
import { copyToClipboard } from '@/lib/utils';
import AppLayout from '@/layouts/AppLayout.vue';
import SettingsLayout from '@/layouts/settings/Layout.vue';
import { index as apiKeysIndex, store as storeApiKey, destroy as destroyApiKey } from '@/routes/app/api-keys';
import { index as apiKeysIndex } from '@/routes/app/api-keys';
import { type BreadcrumbItem } from '@/types';
interface ApiToken {
@ -34,72 +43,21 @@ interface ApiToken {
created_at: string;
}
interface Workspace {
id: string;
name: string;
}
interface Props {
workspace: Workspace;
apiTokens: ApiToken[];
}
defineProps<Props>();
const page = usePage();
const newToken = computed(() => (page.props.flash as Record<string, unknown>)?.plainToken as string | undefined);
const breadcrumbItems = computed<BreadcrumbItem[]>(() => [
{ title: 'API Keys', href: apiKeysIndex.url() },
]);
const isCreateDialogOpen = ref(false);
const isTokenDialogOpen = ref(false);
const plainToken = ref<string | null>(null);
const copied = ref(false);
const deleteModal = ref<InstanceType<typeof ConfirmDeleteModal> | null>(null);
const form = useForm({
name: '',
expires_at: '',
});
watch(() => page.props.flash, (flash: Record<string, unknown> | undefined) => {
if (flash?.plainToken) {
plainToken.value = flash.plainToken as string;
isCreateDialogOpen.value = false;
isTokenDialogOpen.value = true;
}
}, { deep: true });
const submitCreate = () => {
form.post(storeApiKey.url(), {
preserveScroll: true,
onSuccess: () => {
form.reset();
},
});
};
const copyToken = async () => {
if (!plainToken.value) return;
await navigator.clipboard.writeText(plainToken.value);
copied.value = true;
setTimeout(() => {
copied.value = false;
}, 2000);
};
const handleDelete = (tokenId: string) => {
deleteModal.value?.open({
url: destroyApiKey.url(tokenId),
});
};
const closeTokenDialog = () => {
isTokenDialogOpen.value = false;
plainToken.value = null;
copied.value = false;
};
const createDialogOpen = ref(false);
const confirmDeleteModal = ref<InstanceType<typeof ConfirmDeleteModal> | null>(null);
</script>
<template>
@ -115,154 +73,108 @@ const closeTokenDialog = () => {
title="API Keys"
description="Manage API keys for programmatic access to your workspace."
/>
<Button @click="isCreateDialogOpen = true">
<IconPlus class="mr-2 h-4 w-4" />
<Button @click="createDialogOpen = true">
Create API Key
</Button>
</div>
<div v-if="apiTokens.length === 0" class="flex flex-col items-center justify-center rounded-lg border border-dashed py-16">
<div class="flex h-16 w-16 items-center justify-center rounded-full bg-muted mb-4">
<IconKey class="h-8 w-8 text-muted-foreground" />
</div>
<h3 class="text-lg font-semibold mb-2">No API keys yet</h3>
<p class="text-muted-foreground mb-4 max-w-sm text-center">
Create an API key to access your workspace programmatically.
<!-- New token alert -->
<div
v-if="newToken"
class="rounded-lg border border-green-200 bg-green-50 p-4 dark:border-green-900 dark:bg-green-950"
>
<p class="mb-2 text-sm font-medium text-green-800 dark:text-green-200">
Your new API key has been created. Copy it now you won't be able to see it again.
</p>
<Button @click="isCreateDialogOpen = true">
<IconPlus class="mr-2 h-4 w-4" />
Create your first API key
</Button>
</div>
<div v-else class="space-y-3">
<div
v-for="token in apiTokens"
:key="token.id"
class="flex items-center justify-between rounded-lg border p-4"
>
<div class="flex items-center gap-4 min-w-0">
<div class="flex h-10 w-10 shrink-0 items-center justify-center rounded-full bg-muted">
<IconKey class="h-5 w-5 text-muted-foreground" />
</div>
<div class="min-w-0">
<div class="flex items-center gap-2">
<p class="font-medium truncate">{{ token.name }}</p>
<Badge
:variant="token.status === 'active' ? 'default' : 'destructive'"
class="shrink-0"
>
{{ token.status }}
</Badge>
</div>
<p class="text-sm text-muted-foreground font-mono">
{{ token.key_hint }}
</p>
<div class="flex items-center gap-3 mt-1 text-xs text-muted-foreground">
<span>Created {{ date.diffForHumans(token.created_at) }}</span>
<span v-if="token.last_used_at">
Last used {{ date.diffForHumans(token.last_used_at) }}
</span>
<span v-else>Never used</span>
<span v-if="token.expires_at">
Expires {{ date.diffForHumans(token.expires_at) }}
</span>
</div>
</div>
</div>
<Button
variant="ghost"
size="icon"
class="shrink-0"
@click="handleDelete(token.id)"
>
<IconTrash class="h-4 w-4 text-red-500" />
<div class="flex items-center gap-2">
<code class="flex-1 rounded bg-white px-3 py-2 font-mono text-sm dark:bg-black">
{{ newToken }}
</code>
<Button variant="outline" size="sm" @click="copyToClipboard(newToken!, 'API key copied to clipboard')">
Copy
</Button>
</div>
</div>
<div v-if="apiTokens.length > 0" class="rounded-md border">
<Table>
<TableHeader>
<TableRow>
<TableHead>Name</TableHead>
<TableHead>Key</TableHead>
<TableHead>Status</TableHead>
<TableHead>Expires</TableHead>
<TableHead>Last Used</TableHead>
<TableHead class="w-10" />
</TableRow>
</TableHeader>
<TableBody>
<TableRow v-for="token in apiTokens" :key="token.id">
<TableCell class="font-medium">
{{ token.name }}
</TableCell>
<TableCell>
<code class="text-xs text-muted-foreground">
{{ token.key_hint }}
</code>
</TableCell>
<TableCell>
<Badge :variant="token.status === 'active' ? 'default' : 'secondary'">
{{ token.status }}
</Badge>
</TableCell>
<TableCell class="text-muted-foreground">
{{ token.expires_at ? date.formatDate(token.expires_at) : 'Never' }}
</TableCell>
<TableCell class="text-muted-foreground">
{{ token.last_used_at ? date.diffForHumans(token.last_used_at) : 'Never' }}
</TableCell>
<TableCell>
<DropdownMenu>
<DropdownMenuTrigger as-child>
<Button variant="ghost" size="icon" class="h-8 w-8">
<IconDots class="size-4" />
</Button>
</DropdownMenuTrigger>
<DropdownMenuContent align="end">
<DropdownMenuItem
@click="copyToClipboard(token.id, 'API Key ID copied to clipboard')"
>
<IconCopy class="size-4" />
Copy API Key ID
</DropdownMenuItem>
<DropdownMenuSeparator />
<DropdownMenuItem
variant="destructive"
@click="confirmDeleteModal?.open({ url: ApiKeyController.destroy.url(token), confirmText: token.name })"
>
<IconTrash class="size-4" />
Delete
</DropdownMenuItem>
</DropdownMenuContent>
</DropdownMenu>
</TableCell>
</TableRow>
</TableBody>
</Table>
</div>
<EmptyState
v-else-if="!newToken"
:icon="IconKey"
title="No API keys yet"
description="Create an API key to access your workspace programmatically."
/>
</div>
</SettingsLayout>
</AppLayout>
<Dialog :open="isCreateDialogOpen" @update:open="isCreateDialogOpen = $event">
<DialogContent>
<DialogHeader>
<DialogTitle>Create API Key</DialogTitle>
<DialogDescription>
Create a new API key for programmatic access to your workspace.
</DialogDescription>
</DialogHeader>
<form @submit.prevent="submitCreate" class="space-y-4">
<div class="space-y-2">
<Label for="token-name">Name</Label>
<Input
id="token-name"
v-model="form.name"
placeholder="e.g. Production API Key"
:class="{ 'border-red-500': form.errors.name }"
/>
<p v-if="form.errors.name" class="text-sm text-red-500">
{{ form.errors.name }}
</p>
</div>
<div class="space-y-2">
<Label>Expiration date (optional)</Label>
<DatePicker
name="token-expires"
v-model="form.expires_at"
:show-time="false"
placeholder="No expiration"
/>
<p v-if="form.errors.expires_at" class="text-sm text-red-500">
{{ form.errors.expires_at }}
</p>
</div>
<DialogFooter>
<Button type="button" variant="outline" @click="isCreateDialogOpen = false">
Cancel
</Button>
<Button type="submit" :disabled="form.processing">
Create
</Button>
</DialogFooter>
</form>
</DialogContent>
</Dialog>
<Dialog :open="isTokenDialogOpen" @update:open="closeTokenDialog">
<DialogContent>
<DialogHeader>
<DialogTitle>API Key Created</DialogTitle>
<DialogDescription>
Copy your API key now. You will not be able to see it again.
</DialogDescription>
</DialogHeader>
<div class="space-y-4">
<div class="flex items-center gap-2">
<Input
:model-value="plainToken ?? ''"
readonly
class="font-mono text-sm"
/>
<Button variant="outline" size="icon" class="shrink-0" @click="copyToken">
<IconCopy class="h-4 w-4" />
</Button>
</div>
<p v-if="copied" class="text-sm text-green-600">Copied to clipboard!</p>
</div>
<DialogFooter>
<Button @click="closeTokenDialog">
Done
</Button>
</DialogFooter>
</DialogContent>
</Dialog>
<CreateApiKeyDialog v-model:open="createDialogOpen" />
<ConfirmDeleteModal
ref="deleteModal"
title="Delete API Key"
ref="confirmDeleteModal"
title="Delete API key"
description="Are you sure you want to delete this API key? Any applications using this key will lose access immediately."
action="Delete"
cancel="Cancel"
action="Delete API key"
/>
</template>

View file

@ -1,6 +1,6 @@
<script setup lang="ts">
import { Form, Head } from '@inertiajs/vue3';
import { IconClock, IconDots, IconTrash } from '@tabler/icons-vue';
import { Form, Head, router } from '@inertiajs/vue3';
import { IconClock, IconDots, IconShield, IconTrash, IconUser } from '@tabler/icons-vue';
import { trans } from 'laravel-vue-i18n';
import { computed, ref } from 'vue';
@ -8,19 +8,11 @@ import WorkspaceController from '@/actions/App/Http/Controllers/App/WorkspaceCon
import ConfirmDeleteModal from '@/components/ConfirmDeleteModal.vue';
import HeadingSmall from '@/components/HeadingSmall.vue';
import InputError from '@/components/InputError.vue';
import InviteMemberDialog from '@/components/members/InviteMemberDialog.vue';
import PhotoUpload from '@/components/PhotoUpload.vue';
import TimezoneCombobox from '@/components/TimezoneCombobox.vue';
import { Badge } from '@/components/ui/badge';
import { Button } from '@/components/ui/button';
import {
Dialog,
DialogContent,
DialogDescription,
DialogFooter,
DialogHeader,
DialogTitle,
DialogTrigger,
} from '@/components/ui/dialog';
import {
DropdownMenu,
DropdownMenuContent,
@ -29,13 +21,6 @@ import {
} from '@/components/ui/dropdown-menu';
import { Input } from '@/components/ui/input';
import { Label } from '@/components/ui/label';
import {
Select,
SelectContent,
SelectItem,
SelectTrigger,
SelectValue,
} from '@/components/ui/select';
import { Separator } from '@/components/ui/separator';
import {
Table,
@ -47,8 +32,8 @@ import {
} from '@/components/ui/table';
import AppLayout from '@/layouts/AppLayout.vue';
import SettingsLayout from '@/layouts/settings/Layout.vue';
import { store as storeInvite, destroy as destroyInvite } from '@/routes/app/invites';
import { remove as removeMemberRoute } from '@/routes/app/members';
import { destroy as destroyInvite } from '@/routes/app/invites';
import { remove as removeMemberRoute, updateRole } from '@/routes/app/members';
import { settings, uploadLogo, deleteLogo } from '@/routes/app/workspace';
import { type BreadcrumbItem } from '@/types';
@ -90,10 +75,13 @@ const breadcrumbItems = computed<BreadcrumbItem[]>(() => [
const timezone = ref(props.workspace.timezone);
const inviteDialogOpen = ref(false);
const inviteRole = ref('member');
const removeMemberModal = ref<InstanceType<typeof ConfirmDeleteModal> | null>(null);
const cancelInvitationModal = ref<InstanceType<typeof ConfirmDeleteModal> | null>(null);
const changeRole = (member: Member, role: string) => {
router.put(updateRole.url(member.id), { role });
};
</script>
<template>
@ -165,66 +153,9 @@ const cancelInvitationModal = ref<InstanceType<typeof ConfirmDeleteModal> | null
:description="$t('settings.workspace.members_description')"
/>
<Dialog v-model:open="inviteDialogOpen">
<DialogTrigger as-child>
<Button variant="secondary">
{{ $t('settings.members.invite.submit') }}
</Button>
</DialogTrigger>
<DialogContent>
<DialogHeader>
<DialogTitle>{{ $t('settings.members.invite.title') }}</DialogTitle>
<DialogDescription>
{{ $t('settings.members.invite.description') }}
</DialogDescription>
</DialogHeader>
<Form
v-bind="storeInvite.form()"
class="space-y-4"
v-slot="{ errors, processing }"
@success="inviteDialogOpen = false; inviteRole = 'member'"
>
<div class="grid gap-2">
<Label for="invite-email">{{ $t('settings.members.invite.email') }}</Label>
<Input
id="invite-email"
name="email"
type="email"
:placeholder="trans('settings.members.invite.email_placeholder')"
/>
<InputError :message="errors.email" />
</div>
<div class="grid gap-2">
<Label for="invite-role">{{ $t('settings.members.invite.role') }}</Label>
<Select v-model="inviteRole" name="role">
<SelectTrigger class="w-full">
<SelectValue :placeholder="trans('settings.members.invite.role_placeholder')" />
</SelectTrigger>
<SelectContent>
<SelectItem value="member">{{ $t('settings.members.roles.member') }}</SelectItem>
<SelectItem value="admin">{{ $t('settings.members.roles.admin') }}</SelectItem>
</SelectContent>
</Select>
<input type="hidden" name="role" :value="inviteRole" />
<InputError :message="errors.role" />
</div>
<DialogFooter>
<Button type="submit" :disabled="processing">
{{ $t('settings.members.invite.submit') }}
</Button>
<Button
variant="secondary"
type="button"
@click="inviteDialogOpen = false"
>
{{ $t('settings.members.cancel') }}
</Button>
</DialogFooter>
</Form>
</DialogContent>
</Dialog>
<Button variant="secondary" @click="inviteDialogOpen = true">
{{ $t('settings.members.invite.submit') }}
</Button>
</div>
<div class="rounded-md border">
@ -259,9 +190,24 @@ const cancelInvitationModal = ref<InstanceType<typeof ConfirmDeleteModal> | null
</DropdownMenuTrigger>
<DropdownMenuContent align="end">
<DropdownMenuItem
class="text-destructive"
v-if="member.role === 'member'"
@click="changeRole(member, 'admin')"
>
<IconShield class="size-3.5" />
{{ $t('settings.members.make_admin') }}
</DropdownMenuItem>
<DropdownMenuItem
v-if="member.role === 'admin'"
@click="changeRole(member, 'member')"
>
<IconUser class="size-3.5" />
{{ $t('settings.members.make_member') }}
</DropdownMenuItem>
<DropdownMenuItem
variant="destructive"
@click="removeMemberModal?.open({ url: removeMemberRoute.url(member.id) })"
>
<IconTrash class="size-3.5" />
{{ $t('settings.members.remove') }}
</DropdownMenuItem>
</DropdownMenuContent>
@ -303,6 +249,8 @@ const cancelInvitationModal = ref<InstanceType<typeof ConfirmDeleteModal> | null
</div>
</div>
<InviteMemberDialog v-model:open="inviteDialogOpen" />
<ConfirmDeleteModal
ref="removeMemberModal"
:title="trans('settings.members.remove_modal.title')"

View file

@ -1,108 +1,68 @@
<script setup lang="ts">
import { Head, Link, router } from '@inertiajs/vue3';
import { IconPlus, IconCalendar, IconUsers, IconSettings } from '@tabler/icons-vue';
import { Avatar } from '@/components/ui/avatar';
import { Badge } from '@/components/ui/badge';
import { Button } from '@/components/ui/button';
import { Card, CardContent, CardDescription, CardHeader, CardTitle } from '@/components/ui/card';
import AppLayout from '@/layouts/AppLayout.vue';
import { accounts, calendar } from '@/routes/app';
import { settings } from '@/routes/app/workspace';
import { create as createWorkspace, index as workspacesIndex, switchMethod } from '@/routes/app/workspaces';
import { type BreadcrumbItemType } from '@/types';
import AuthLayout from '@/layouts/AuthLayout.vue';
import { create as createWorkspace, switchMethod } from '@/routes/app/workspaces';
interface Workspace {
id: string;
name: string;
logo_url: string | null;
social_accounts_count: number;
posts_count: number;
created_at: string;
}
interface Props {
workspaces: Workspace[];
currentWorkspaceId: string | null;
}
defineProps<Props>();
const props = defineProps<Props>();
const breadcrumbs: BreadcrumbItemType[] = [
{ title: 'Workspaces', href: workspacesIndex.url() },
];
function switchAndNavigate(workspaceId: string, destination: string) {
router.post(switchMethod.url(workspaceId), {}, {
onSuccess: () => {
router.visit(destination);
},
const switchToWorkspace = (workspace: Workspace) => {
router.post(switchMethod.url(workspace.id), {}, {
preserveState: false,
});
}
};
</script>
<template>
<Head title="Workspaces" />
<AppLayout :breadcrumbs="breadcrumbs">
<div class="flex flex-col gap-6 p-6">
<div class="flex items-center justify-between">
<div>
<h1 class="text-2xl font-bold tracking-tight">Workspaces</h1>
<p class="text-muted-foreground">
Gerencie seus workspaces e redes sociais
<AuthLayout title="Your workspaces" description="Select a workspace to continue">
<div class="space-y-3">
<div
v-for="workspace in workspaces"
:key="workspace.id"
class="flex cursor-pointer items-center gap-3 rounded-lg border p-4 transition-colors hover:bg-accent/50"
:class="workspace.id === currentWorkspaceId ? 'border-primary/50 bg-accent/30' : ''"
@click="switchToWorkspace(workspace)"
>
<Avatar
:src="workspace.logo_url"
:name="workspace.name"
class="size-10 shrink-0 rounded-lg"
fallback-class="bg-muted text-muted-foreground"
/>
<div class="min-w-0 flex-1">
<p class="truncate font-medium">{{ workspace.name }}</p>
<p class="text-xs text-muted-foreground">
{{ workspace.social_accounts_count }} connections · {{ workspace.posts_count }} posts
</p>
</div>
<Link :href="createWorkspace.url()">
<Button>
<IconPlus class="mr-2 h-4 w-4" />
Novo Workspace
</Button>
</Link>
</div>
<div v-if="workspaces.length === 0" class="flex flex-col items-center justify-center rounded-lg border border-dashed p-12">
<div class="mx-auto flex max-w-[420px] flex-col items-center justify-center text-center">
<h3 class="mt-4 text-lg font-semibold">Nenhum workspace</h3>
<p class="mb-4 mt-2 text-sm text-muted-foreground">
Crie seu primeiro workspace para começar a agendar posts.
</p>
<Link :href="createWorkspace.url()">
<Button>
<IconPlus class="mr-2 h-4 w-4" />
Criar Workspace
</Button>
</Link>
</div>
</div>
<div v-else class="grid gap-4 md:grid-cols-2 lg:grid-cols-3">
<Card v-for="workspace in workspaces" :key="workspace.id" class="hover:bg-accent/50 transition-colors">
<CardHeader>
<CardTitle>{{ workspace.name }}</CardTitle>
<CardDescription>
{{ workspace.social_accounts_count }} redes conectadas
</CardDescription>
</CardHeader>
<CardContent>
<div class="flex items-center justify-between">
<div class="flex items-center gap-4 text-sm text-muted-foreground">
<span class="flex items-center gap-1">
<IconCalendar class="h-4 w-4" />
{{ workspace.posts_count }} posts
</span>
</div>
<div class="flex gap-2">
<Button variant="outline" size="sm" @click="switchAndNavigate(workspace.id, calendar.url())">
<IconCalendar class="h-4 w-4" />
</Button>
<Button variant="outline" size="sm" @click="switchAndNavigate(workspace.id, accounts.url())">
<IconUsers class="h-4 w-4" />
</Button>
<Button variant="outline" size="sm" @click="switchAndNavigate(workspace.id, settings.url())">
<IconSettings class="h-4 w-4" />
</Button>
</div>
</div>
</CardContent>
</Card>
<Badge v-if="workspace.id === currentWorkspaceId" variant="secondary" class="shrink-0">
Current
</Badge>
</div>
</div>
</AppLayout>
<Link :href="createWorkspace.url()">
<Button variant="outline" class="w-full">
Create workspace
</Button>
</Link>
</AuthLayout>
</template>

View file

@ -16,6 +16,8 @@ export interface NavItem {
href: NonNullable<InertiaLinkProps['href']>;
icon?: Component;
isActive?: boolean;
activePattern?: string;
exact?: boolean;
}
export interface SharedData {
@ -36,7 +38,6 @@ export interface User {
email: string;
has_photo: boolean;
photo_url: string | null;
locale: string;
email_verified_at: string | null;
created_at: string;
updated_at: string;

View file

@ -62,7 +62,7 @@
<td style="width: 552px; max-width: 100%">
<div class="sm-my-8" style="margin-top: 48px; margin-bottom: 48px; text-align: center">
<a href="https://trypost.it" target="_blank">
<img src="{{ asset('/images/emails/logo-header.png') }}" width="160" alt="Trypost" style="max-width: 100%; vertical-align: middle">
<img src="{{ email_asset('/images/emails/logo-header.png') }}" width="160" alt="Trypost" style="max-width: 100%; vertical-align: middle">
</a>
</div>
<table style="width: 100%" cellpadding="0" cellspacing="0" role="none">

View file

@ -59,7 +59,7 @@
<td style="width: 552px; max-width: 100%">
<div class="sm-my-8" style="margin-top: 48px; margin-bottom: 48px; text-align: center">
<a href="https://trypost.it" target="_blank">
<img src="{{ asset('/images/emails/logo-header.png') }}" width="160" alt="Trypost" style="max-width: 100%; vertical-align: middle">
<img src="{{ email_asset('/images/emails/logo-header.png') }}" width="160" alt="Trypost" style="max-width: 100%; vertical-align: middle">
</a>
</div>
<table style="width: 100%" cellpadding="0" cellspacing="0" role="none">

View file

@ -59,7 +59,7 @@
<td style="width: 552px; max-width: 100%">
<div class="sm-my-8" style="margin-top: 48px; margin-bottom: 48px; text-align: center">
<a href="https://trypost.it" target="_blank">
<img src="{{ asset('/images/emails/logo-header.png') }}" width="160" alt="Trypost" style="max-width: 100%; vertical-align: middle">
<img src="{{ email_asset('/images/emails/logo-header.png') }}" width="160" alt="Trypost" style="max-width: 100%; vertical-align: middle">
</a>
</div>
<table style="width: 100%" cellpadding="0" cellspacing="0" role="none">

View file

@ -62,7 +62,7 @@
<td style="width: 552px; max-width: 100%">
<div class="sm-my-8" style="margin-top: 48px; margin-bottom: 48px; text-align: center">
<a href="https://trypost.it" target="_blank">
<img src="{{ asset('/images/emails/logo-header.png') }}" width="160" alt="Trypost" style="max-width: 100%; vertical-align: middle">
<img src="{{ email_asset('/images/emails/logo-header.png') }}" width="160" alt="Trypost" style="max-width: 100%; vertical-align: middle">
</a>
</div>
<table style="width: 100%" cellpadding="0" cellspacing="0" role="none">

View file

@ -62,7 +62,7 @@
<td style="width: 552px; max-width: 100%">
<div class="sm-my-8" style="margin-top: 48px; margin-bottom: 48px; text-align: center">
<a href="https://trypost.it" target="_blank">
<img src="{{ asset('/images/emails/logo-header.png') }}" width="160" alt="Trypost" style="max-width: 100%; vertical-align: middle">
<img src="{{ email_asset('/images/emails/logo-header.png') }}" width="160" alt="Trypost" style="max-width: 100%; vertical-align: middle">
</a>
</div>
<table style="width: 100%" cellpadding="0" cellspacing="0" role="none">

View file

@ -47,10 +47,11 @@ function () {
// Onboarding routes
Route::middleware(['auth', 'verified'])->prefix('onboarding')->group(function () {
Route::get('step1', [OnboardingController::class, 'step1'])->name('app.onboarding.step1');
Route::post('step1', [OnboardingController::class, 'storeStep1'])->name('app.onboarding.step1.store');
Route::get('step2', [OnboardingController::class, 'step2'])->name('app.onboarding.step2');
Route::post('step2', [OnboardingController::class, 'storeStep2'])->name('app.onboarding.step2.store');
Route::get('/', fn () => redirect()->route('app.onboarding.role'));
Route::get('role', [OnboardingController::class, 'step1'])->name('app.onboarding.role');
Route::post('role', [OnboardingController::class, 'storeStep1'])->name('app.onboarding.role.store');
Route::get('connect', [OnboardingController::class, 'step2'])->name('app.onboarding.connect');
Route::post('connect', [OnboardingController::class, 'storeStep2'])->name('app.onboarding.connect.store');
Route::get('complete', [OnboardingController::class, 'complete'])->name('app.onboarding.complete');
});
@ -140,6 +141,7 @@ function () {
Route::post('settings/members/invites', [WorkspaceInviteController::class, 'store'])->name('app.invites.store');
Route::delete('settings/members/invites/{invite}', [WorkspaceInviteController::class, 'destroy'])->name('app.invites.destroy');
Route::delete('settings/members/{user}', [WorkspaceInviteController::class, 'removeMember'])->name('app.members.remove');
Route::put('settings/members/{user}/role', [WorkspaceInviteController::class, 'updateRole'])->name('app.members.update-role');
// Hashtags
Route::get('hashtags', [WorkspaceHashtagController::class, 'index'])->name('app.hashtags.index');

View file

@ -19,7 +19,7 @@
$response->assertSessionHasNoErrors();
$this->assertAuthenticated();
$response->assertRedirect(route('app.onboarding.step1', absolute: false));
$response->assertRedirect(route('app.onboarding.role', absolute: false));
});
test('new users get a default workspace on registration', function () {

View file

@ -29,7 +29,7 @@
$this->actingAs($user)
->get(route('app.calendar'))
->assertRedirect(route('app.onboarding.step1'));
->assertRedirect(route('app.onboarding.role'));
});
test('user on connections step is redirected to onboarding step 2', function () {
@ -42,7 +42,7 @@
$this->actingAs($user)
->get(route('app.calendar'))
->assertRedirect(route('app.onboarding.step2'));
->assertRedirect(route('app.onboarding.connect'));
});
test('user on subscription step is redirected to onboarding step 2', function () {
@ -55,14 +55,14 @@
$this->actingAs($user)
->get(route('app.calendar'))
->assertRedirect(route('app.onboarding.step2'));
->assertRedirect(route('app.onboarding.connect'));
});
test('user on role step can access onboarding step 1', function () {
$user = User::factory()->create(['setup' => Setup::Role]);
$this->actingAs($user)
->get(route('app.onboarding.step1'))
->get(route('app.onboarding.role'))
->assertOk();
});
@ -70,7 +70,7 @@
$user = User::factory()->create(['setup' => Setup::Connections]);
$this->actingAs($user)
->get(route('app.onboarding.step2'))
->get(route('app.onboarding.connect'))
->assertOk();
});

View file

@ -15,13 +15,13 @@
// Step 1 tests
test('step1 requires authentication', function () {
$response = $this->get(route('app.onboarding.step1'));
$response = $this->get(route('app.onboarding.role'));
$response->assertRedirect(route('login'));
});
test('step1 shows persona selection', function () {
$response = $this->actingAs($this->user)->get(route('app.onboarding.step1'));
$response = $this->actingAs($this->user)->get(route('app.onboarding.role'));
$response->assertOk();
$response->assertInertia(fn ($page) => $page
@ -32,7 +32,7 @@
// Store Step 1 tests
test('store step1 requires authentication', function () {
$response = $this->post(route('app.onboarding.step1.store'), [
$response = $this->post(route('app.onboarding.role.store'), [
'persona' => Persona::Founder->value,
]);
@ -40,11 +40,11 @@
});
test('store step1 saves persona and redirects to step2', function () {
$response = $this->actingAs($this->user)->post(route('app.onboarding.step1.store'), [
$response = $this->actingAs($this->user)->post(route('app.onboarding.role.store'), [
'persona' => Persona::Founder->value,
]);
$response->assertRedirect(route('app.onboarding.step2'));
$response->assertRedirect(route('app.onboarding.connect'));
$this->user->refresh();
expect($this->user->persona)->toBe(Persona::Founder);
@ -52,7 +52,7 @@
});
test('store step1 validates persona is required', function () {
$response = $this->actingAs($this->user)->post(route('app.onboarding.step1.store'), [
$response = $this->actingAs($this->user)->post(route('app.onboarding.role.store'), [
'persona' => '',
]);
@ -60,7 +60,7 @@
});
test('store step1 validates persona is valid enum', function () {
$response = $this->actingAs($this->user)->post(route('app.onboarding.step1.store'), [
$response = $this->actingAs($this->user)->post(route('app.onboarding.role.store'), [
'persona' => 'invalid',
]);
@ -69,7 +69,7 @@
// Step 2 tests
test('step2 requires authentication', function () {
$response = $this->get(route('app.onboarding.step2'));
$response = $this->get(route('app.onboarding.connect'));
$response->assertRedirect(route('login'));
});
@ -77,7 +77,7 @@
test('step2 shows social accounts connection', function () {
$this->user->update(['setup' => Setup::Connections]);
$response = $this->actingAs($this->user)->get(route('app.onboarding.step2'));
$response = $this->actingAs($this->user)->get(route('app.onboarding.connect'));
$response->assertOk();
$response->assertInertia(fn ($page) => $page
@ -97,7 +97,7 @@
'platform' => Platform::LinkedIn,
]);
$response = $this->actingAs($this->user)->get(route('app.onboarding.step2'));
$response = $this->actingAs($this->user)->get(route('app.onboarding.connect'));
$response->assertOk();
$response->assertInertia(fn ($page) => $page
@ -107,7 +107,7 @@
// Store Step 2 tests
test('store step2 requires authentication', function () {
$response = $this->post(route('app.onboarding.step2.store'));
$response = $this->post(route('app.onboarding.connect.store'));
$response->assertRedirect(route('login'));
});
@ -116,7 +116,7 @@
config(['trypost.self_hosted' => true]);
$this->user->update(['setup' => Setup::Connections]);
$response = $this->actingAs($this->user)->post(route('app.onboarding.step2.store'));
$response = $this->actingAs($this->user)->post(route('app.onboarding.connect.store'));
$response->assertRedirect(route('app.calendar'));
@ -141,3 +141,68 @@
$this->user->refresh();
expect($this->user->setup)->toBe(Setup::Completed);
});
// Step enforcement tests
test('step1 redirects to connect when user already completed role step', function () {
$this->user->update(['setup' => Setup::Connections]);
$response = $this->actingAs($this->user)->get(route('app.onboarding.role'));
$response->assertRedirect(route('app.onboarding.connect'));
});
test('step1 redirects to subscribe when user is on subscription step', function () {
$this->user->update(['setup' => Setup::Subscription]);
$response = $this->actingAs($this->user)->get(route('app.onboarding.role'));
$response->assertRedirect(route('app.subscribe'));
});
test('step1 redirects to calendar when setup is completed', function () {
$this->user->update(['setup' => Setup::Completed]);
$response = $this->actingAs($this->user)->get(route('app.onboarding.role'));
$response->assertRedirect(route('app.calendar'));
});
test('step2 redirects to role when user has not completed role step', function () {
$this->user->update(['setup' => Setup::Role]);
$response = $this->actingAs($this->user)->get(route('app.onboarding.connect'));
$response->assertRedirect(route('app.onboarding.role'));
});
test('step2 redirects to subscribe when user is on subscription step', function () {
$this->user->update(['setup' => Setup::Subscription]);
$response = $this->actingAs($this->user)->get(route('app.onboarding.connect'));
$response->assertRedirect(route('app.subscribe'));
});
test('step2 redirects to calendar when setup is completed', function () {
$this->user->update(['setup' => Setup::Completed]);
$response = $this->actingAs($this->user)->get(route('app.onboarding.connect'));
$response->assertRedirect(route('app.calendar'));
});
test('user on role step can access role page', function () {
$this->user->update(['setup' => Setup::Role]);
$response = $this->actingAs($this->user)->get(route('app.onboarding.role'));
$response->assertOk();
});
test('user on connections step can access connect page', function () {
$this->user->update(['setup' => Setup::Connections]);
$response = $this->actingAs($this->user)->get(route('app.onboarding.connect'));
$response->assertOk();
});

View file

@ -54,7 +54,7 @@
expect($user->refresh()->email_verified_at)->not->toBeNull();
});
test('user can update their locale', function () {
test('user can update their locale via cookie', function () {
$user = User::factory()->create();
$response = $this
@ -68,7 +68,7 @@
->assertSessionHasNoErrors()
->assertRedirect(route('app.posts.index'));
expect($user->refresh()->locale)->toBe('es');
$response->assertCookieNotExpired('locale');
});
test('user cannot update locale with invalid code', function () {

View file

@ -157,3 +157,73 @@
$response->assertSessionHasErrors('member');
});
// Update role tests
test('update role requires authentication', function () {
$member = User::factory()->create(['setup' => Setup::Completed]);
$this->workspace->members()->attach($member->id, ['role' => WorkspaceRole::Member->value]);
$response = $this->put(route('app.members.update-role', $member), [
'role' => WorkspaceRole::Admin->value,
]);
$response->assertRedirect(route('login'));
});
test('update role changes member to admin', function () {
$member = User::factory()->create(['setup' => Setup::Completed]);
$this->workspace->members()->attach($member->id, ['role' => WorkspaceRole::Member->value]);
$response = $this->actingAs($this->user)->put(route('app.members.update-role', $member), [
'role' => WorkspaceRole::Admin->value,
]);
$response->assertRedirect();
expect($this->workspace->members()->where('user_id', $member->id)->first()->pivot->role)->toBe(WorkspaceRole::Admin->value);
});
test('update role changes admin to member', function () {
$member = User::factory()->create(['setup' => Setup::Completed]);
$this->workspace->members()->attach($member->id, ['role' => WorkspaceRole::Admin->value]);
$response = $this->actingAs($this->user)->put(route('app.members.update-role', $member), [
'role' => WorkspaceRole::Member->value,
]);
$response->assertRedirect();
expect($this->workspace->members()->where('user_id', $member->id)->first()->pivot->role)->toBe(WorkspaceRole::Member->value);
});
test('update role fails for workspace owner', function () {
$response = $this->actingAs($this->user)->put(route('app.members.update-role', $this->user), [
'role' => WorkspaceRole::Member->value,
]);
$response->assertSessionHasErrors('role');
});
test('update role fails with invalid role', function () {
$member = User::factory()->create(['setup' => Setup::Completed]);
$this->workspace->members()->attach($member->id, ['role' => WorkspaceRole::Member->value]);
$response = $this->actingAs($this->user)->put(route('app.members.update-role', $member), [
'role' => 'invalid',
]);
$response->assertSessionHasErrors('role');
});
test('update role requires authorization', function () {
$member = User::factory()->create(['setup' => Setup::Completed]);
$this->workspace->members()->attach($member->id, ['role' => WorkspaceRole::Member->value]);
$nonAdmin = User::factory()->create(['setup' => Setup::Completed]);
$this->workspace->members()->attach($nonAdmin->id, ['role' => WorkspaceRole::Member->value]);
$nonAdmin->update(['current_workspace_id' => $this->workspace->id]);
$response = $this->actingAs($nonAdmin)->put(route('app.members.update-role', $member), [
'role' => WorkspaceRole::Admin->value,
]);
$response->assertForbidden();
});