feat: Implement user onboarding, subscription management, and refactor social integrations with new UI components and mail templates.

This commit is contained in:
Paulo Castellano 2026-01-16 23:46:30 -03:00
parent 7867fcce02
commit 7c00c3387e
108 changed files with 4101 additions and 1418 deletions

View file

@ -62,3 +62,10 @@ AWS_BUCKET=
AWS_USE_PATH_STYLE_ENDPOINT=false
VITE_APP_NAME="${APP_NAME}"
STRIPE_KEY=
STRIPE_SECRET=
STRIPE_WEBHOOK_SECRET=
STRIPE_PRICE_MONTHLY=
STRIPE_PRICE_YEARLY=
CASHIER_TRIAL_DAYS=7

15
README.md Normal file
View file

@ -0,0 +1,15 @@
## Migration with base seed
```sh
php artisan migrate:fresh --seed
```
# Start queue worker
```sh
php artisan horizon:watch
```
# Share with Ngrok:
```sh
ngrok http --host-header=rewrite trypost.test:443
```

View file

@ -3,6 +3,7 @@
namespace App\Actions\Fortify;
use App\Concerns\ProfileValidationRules;
use App\Enums\User\Setup;
use App\Models\User;
use Illuminate\Support\Facades\DB;
use Illuminate\Support\Facades\Validator;
@ -30,16 +31,21 @@ public function create(array $input): User
'name' => $input['name'],
'email' => $input['email'],
'password' => $input['password'],
'setup' => Setup::Role,
]);
// Create default workspace for new user
$workspace = $user->workspaces()->create([
'name' => 'Meu Workspace',
'name' => 'My Workspace',
'timezone' => 'UTC',
]);
// Add user as owner member
$workspace->members()->attach($user->id, ['role' => 'owner']);
// Set as current workspace
$user->update(['current_workspace_id' => $workspace->id]);
return $user;
});
}

28
app/Enums/Status.php Normal file
View file

@ -0,0 +1,28 @@
<?php
namespace App\Enums;
enum Status: string
{
case Connected = 'connected';
case Disconnected = 'disconnected';
case TokenExpired = 'token_expired';
public function label(): string
{
return match ($this) {
self::Connected => 'Connected',
self::Disconnected => 'Disconnected',
self::TokenExpired => 'Token Expired',
};
}
public function color(): string
{
return match ($this) {
self::Connected => 'green',
self::Disconnected => 'red',
self::TokenExpired => 'red',
};
}
}

View file

@ -0,0 +1,65 @@
<?php
namespace App\Enums\User;
enum Persona: string
{
case Founder = 'founder';
case Creator = 'creator';
case Agency = 'agency';
case Enterprise = 'enterprise';
case SmallBusiness = 'small_business';
case Personal = 'personal';
public function label(): string
{
return match ($this) {
self::Founder => 'Founder',
self::Creator => 'Creator',
self::Agency => 'Agency',
self::Enterprise => 'Enterprise',
self::SmallBusiness => 'Small Business',
self::Personal => 'Personal',
};
}
public function description(): string
{
return match ($this) {
self::Founder => 'Building a startup or new venture',
self::Creator => 'Content creator or influencer',
self::Agency => 'Marketing or social media agency',
self::Enterprise => 'Large company or corporation',
self::SmallBusiness => 'Small to medium business',
self::Personal => 'Personal brand or hobby',
};
}
public function icon(): string
{
return match ($this) {
self::Founder => 'rocket',
self::Creator => 'sparkles',
self::Agency => 'building',
self::Enterprise => 'building-2',
self::SmallBusiness => 'store',
self::Personal => 'user',
};
}
/**
* @return array<array{value: string, label: string, description: string, icon: string}>
*/
public static function toSelectArray(): array
{
return array_map(
fn (self $case) => [
'value' => $case->value,
'label' => $case->label(),
'description' => $case->description(),
'icon' => $case->icon(),
],
self::cases()
);
}
}

34
app/Enums/User/Setup.php Normal file
View file

@ -0,0 +1,34 @@
<?php
namespace App\Enums\User;
enum Setup: string
{
case Registering = 'registering';
case Role = 'role';
case Connections = 'connections';
case Subscription = 'subscription';
case Completed = 'completed';
public function label(): string
{
return match ($this) {
self::Registering => 'Registering',
self::Role => 'Select Role',
self::Connections => 'Connect Accounts',
self::Subscription => 'Start Subscription',
self::Completed => 'Completed',
};
}
public function stepNumber(): int
{
return match ($this) {
self::Registering => 0,
self::Role => 1,
self::Connections => 2,
self::Subscription => 3,
self::Completed => 4,
};
}
}

View file

@ -0,0 +1,32 @@
<?php
namespace App\Events;
use App\Models\User;
use Illuminate\Broadcasting\InteractsWithSockets;
use Illuminate\Broadcasting\PrivateChannel;
use Illuminate\Contracts\Broadcasting\ShouldBroadcastNow;
use Illuminate\Foundation\Events\Dispatchable;
use Illuminate\Queue\SerializesModels;
class SubscriptionCreated implements ShouldBroadcastNow
{
use Dispatchable, InteractsWithSockets, SerializesModels;
public function __construct(public User $user) {}
public function broadcastOn(): array
{
return [
new PrivateChannel('users.'.$this->user->id),
];
}
public function broadcastWith(): array
{
return [
'status' => 'success',
'message' => 'Subscription created successfully',
];
}
}

View file

@ -0,0 +1,15 @@
<?php
namespace App\Exceptions;
use Exception;
class TokenExpiredException extends Exception
{
public function __construct(
string $message = 'Access token has expired or been revoked',
public ?string $platformErrorCode = null
) {
parent::__construct($message);
}
}

View file

@ -3,11 +3,13 @@
namespace App\Http\Controllers\Auth;
use App\Enums\SocialPlatform;
use App\Enums\Status;
use App\Models\Workspace;
use Illuminate\Http\RedirectResponse;
use Illuminate\Http\Request;
use Illuminate\Support\Facades\Http;
use Illuminate\Support\Facades\Log;
use Illuminate\View\View;
use Inertia\Inertia;
use Laravel\Socialite\Facades\Socialite;
use Symfony\Component\HttpFoundation\Response;
@ -19,21 +21,38 @@ class FacebookController extends SocialController
protected SocialPlatform $platform = SocialPlatform::Facebook;
protected array $scopes = [
'public_profile',
'email',
'pages_show_list',
'pages_read_engagement',
'pages_manage_posts',
];
public function connect(Request $request, Workspace $workspace): Response
public function connect(Request $request): Response|RedirectResponse
{
$this->ensurePlatformEnabled();
$workspace = $request->user()->currentWorkspace;
if (! $workspace) {
return redirect()->route('workspaces.create');
}
$this->authorize('manageAccounts', $workspace);
if ($workspace->hasConnectedPlatform($this->platform->value)) {
$existingAccount = $workspace->socialAccounts()
->where('platform', $this->platform->value)
->first();
if ($existingAccount && ! $existingAccount->isDisconnected()) {
return back()->with('error', 'This platform is already connected.');
}
session(['social_connect_workspace' => $workspace->id]);
session([
'social_connect_workspace' => $workspace->id,
'social_reconnect_id' => $existingAccount?->id,
'social_connect_onboarding' => $request->boolean('onboarding'),
]);
return Inertia::location(
Socialite::driver($this->driver)
@ -43,25 +62,26 @@ public function connect(Request $request, Workspace $workspace): Response
);
}
public function callback(Request $request): RedirectResponse
public function callback(Request $request): View|RedirectResponse
{
$workspaceId = session('social_connect_workspace');
if (! $workspaceId) {
return redirect()->route('workspaces.index')
->with('error', 'Session expired. Please try again.');
return $this->popupCallback(false, 'Session expired. Please try again.', $this->platform->value);
}
$workspace = Workspace::find($workspaceId);
if (! $workspace || ! $request->user()->can('manageAccounts', $workspace)) {
return redirect()->route('workspaces.index')
->with('error', 'Workspace not found.');
return $this->popupCallback(false, 'Workspace not found.', $this->platform->value);
}
if ($workspace->hasConnectedPlatform($this->platform->value)) {
return redirect()->route('workspaces.accounts', $workspace)
->with('error', 'This platform is already connected.');
$reconnectId = session('social_reconnect_id');
$existingAccount = $reconnectId ? $workspace->socialAccounts()->find($reconnectId) : null;
// If account exists and is connected, don't allow duplicate
if (! $existingAccount && $workspace->hasConnectedPlatform($this->platform->value)) {
return $this->popupCallback(false, 'This platform is already connected.', $this->platform->value);
}
try {
@ -71,8 +91,7 @@ public function callback(Request $request): RedirectResponse
$pages = $this->fetchPages($socialUser->token);
if (empty($pages)) {
return redirect()->route('workspaces.accounts', $workspace)
->with('error', 'No Facebook Pages found. You need to be an admin of at least one page.');
return $this->popupCallback(false, 'No Facebook Pages found. You need to be an admin of at least one page.', $this->platform->value);
}
// If only one page, connect directly
@ -80,6 +99,31 @@ public function callback(Request $request): RedirectResponse
$page = $pages[0];
$avatarPath = uploadFromUrl($page['picture']);
if ($existingAccount) {
// Reconnect existing account
$existingAccount->update([
'platform_user_id' => $page['id'],
'username' => $page['username'] ?? null,
'display_name' => $page['name'],
'avatar_url' => $avatarPath,
'access_token' => $page['access_token'],
'refresh_token' => null,
'token_expires_at' => null,
'scopes' => $this->scopes,
'meta' => [
'page_id' => $page['id'],
'user_id' => $socialUser->getId(),
'user_token' => $socialUser->token,
],
]);
$existingAccount->markAsConnected();
session()->forget('social_reconnect_id');
return $this->popupCallback(true, 'Facebook Page reconnected!', $this->platform->value);
}
// Create new account
$workspace->socialAccounts()->create([
'platform' => $this->platform->value,
'platform_user_id' => $page['id'],
@ -90,6 +134,7 @@ public function callback(Request $request): RedirectResponse
'refresh_token' => null, // Page tokens don't expire if user token is long-lived
'token_expires_at' => null,
'scopes' => $this->scopes,
'status' => Status::Connected,
'meta' => [
'page_id' => $page['id'],
'user_id' => $socialUser->getId(),
@ -97,10 +142,9 @@ public function callback(Request $request): RedirectResponse
],
]);
session()->forget('social_connect_workspace');
session()->forget('social_reconnect_id');
return redirect()->route('workspaces.accounts', $workspace)
->with('success', 'Facebook Page connected successfully!');
return $this->popupCallback(true, 'Facebook Page connected!', $this->platform->value);
}
// Multiple pages - store data and show selection
@ -109,6 +153,7 @@ public function callback(Request $request): RedirectResponse
'user_token' => $socialUser->token,
'user_id' => $socialUser->getId(),
'pages' => $pages,
'reconnect_id' => $reconnectId,
],
]);
@ -119,8 +164,7 @@ public function callback(Request $request): RedirectResponse
'trace' => $e->getTraceAsString(),
]);
return redirect()->route('workspaces.accounts', $workspace)
->with('error', 'Error connecting account. Please try again.');
return $this->popupCallback(false, 'Error connecting account. Please try again.', $this->platform->value);
}
}
@ -130,14 +174,14 @@ public function selectPage(Request $request)
$workspaceId = session('social_connect_workspace');
if (! $oauthData || ! $workspaceId) {
return redirect()->route('workspaces.index')
return redirect()->route('dashboard')
->with('error', 'Session expired. Please try again.');
}
$workspace = Workspace::find($workspaceId);
if (! $workspace) {
return redirect()->route('workspaces.index')
return redirect()->route('dashboard')
->with('error', 'Workspace not found.');
}
@ -147,7 +191,7 @@ public function selectPage(Request $request)
]);
}
public function select(Request $request): RedirectResponse
public function select(Request $request): View
{
$request->validate([
'page_id' => 'required|string',
@ -157,27 +201,54 @@ public function select(Request $request): RedirectResponse
$workspaceId = session('social_connect_workspace');
if (! $oauthData || ! $workspaceId) {
return redirect()->route('workspaces.index')
->with('error', 'Session expired. Please try again.');
return $this->popupCallback(false, 'Session expired. Please try again.', $this->platform->value);
}
$workspace = Workspace::find($workspaceId);
if (! $workspace || ! $request->user()->can('manageAccounts', $workspace)) {
return redirect()->route('workspaces.index')
->with('error', 'Workspace not found.');
return $this->popupCallback(false, 'Workspace not found.', $this->platform->value);
}
try {
$selectedPage = collect($oauthData['pages'])->firstWhere('id', $request->page_id);
if (! $selectedPage) {
return redirect()->route('social.facebook.select-page')
->with('error', 'Page not found.');
return $this->popupCallback(false, 'Page not found.', $this->platform->value);
}
$avatarPath = uploadFromUrl($selectedPage['picture']);
$reconnectId = $oauthData['reconnect_id'] ?? null;
if ($reconnectId) {
// Reconnect existing account
$existingAccount = $workspace->socialAccounts()->find($reconnectId);
if ($existingAccount) {
$existingAccount->update([
'platform_user_id' => $selectedPage['id'],
'username' => $selectedPage['username'] ?? null,
'display_name' => $selectedPage['name'],
'avatar_url' => $avatarPath,
'access_token' => $selectedPage['access_token'],
'refresh_token' => null,
'token_expires_at' => null,
'scopes' => $this->scopes,
'meta' => [
'page_id' => $selectedPage['id'],
'user_id' => $oauthData['user_id'],
'user_token' => $oauthData['user_token'],
],
]);
$existingAccount->markAsConnected();
session()->forget(['facebook_oauth', 'social_reconnect_id']);
return $this->popupCallback(true, 'Facebook Page reconnected!', $this->platform->value);
}
}
// Create new account
$workspace->socialAccounts()->create([
'platform' => $this->platform->value,
'platform_user_id' => $selectedPage['id'],
@ -188,6 +259,7 @@ public function select(Request $request): RedirectResponse
'refresh_token' => null,
'token_expires_at' => null,
'scopes' => $this->scopes,
'status' => Status::Connected,
'meta' => [
'page_id' => $selectedPage['id'],
'user_id' => $oauthData['user_id'],
@ -195,24 +267,22 @@ public function select(Request $request): RedirectResponse
],
]);
session()->forget(['facebook_oauth', 'social_connect_workspace']);
session()->forget(['facebook_oauth', 'social_reconnect_id']);
return redirect()->route('workspaces.accounts', $workspace)
->with('success', 'Facebook Page connected successfully!');
return $this->popupCallback(true, 'Facebook Page connected!', $this->platform->value);
} catch (\Exception $e) {
Log::error('Facebook page selection error', [
'error' => $e->getMessage(),
]);
return redirect()->route('workspaces.accounts', $workspace)
->with('error', 'Error connecting page. Please try again.');
return $this->popupCallback(false, 'Error connecting page. Please try again.', $this->platform->value);
}
}
private function fetchPages(string $userToken): array
{
try {
$response = Http::get('https://graph.facebook.com/v21.0/me/accounts', [
$response = Http::get('https://graph.facebook.com/v24.0/me/accounts', [
'access_token' => $userToken,
'fields' => 'id,name,username,picture{url},access_token',
]);

View file

@ -3,11 +3,12 @@
namespace App\Http\Controllers\Auth;
use App\Enums\SocialPlatform;
use App\Enums\Status;
use App\Models\Workspace;
use Illuminate\Http\RedirectResponse;
use Illuminate\Http\Request;
use Illuminate\Support\Facades\Http;
use Illuminate\Support\Facades\Log;
use Illuminate\View\View;
use Inertia\Inertia;
use Laravel\Socialite\Facades\Socialite;
use Symfony\Component\HttpFoundation\Response;
@ -19,243 +20,125 @@ class InstagramController extends SocialController
protected SocialPlatform $platform = SocialPlatform::Instagram;
protected array $scopes = [
'instagram_basic',
'instagram_content_publish',
'pages_show_list',
'pages_read_engagement',
'instagram_business_basic',
'instagram_business_content_publish',
];
public function connect(Request $request, Workspace $workspace): Response
public function connect(Request $request): Response|RedirectResponse
{
$this->ensurePlatformEnabled();
$workspace = $request->user()->currentWorkspace;
if (! $workspace) {
return redirect()->route('workspaces.create');
}
$this->authorize('manageAccounts', $workspace);
if ($workspace->hasConnectedPlatform($this->platform->value)) {
$existingAccount = $workspace->socialAccounts()
->where('platform', $this->platform->value)
->first();
if ($existingAccount && ! $existingAccount->isDisconnected()) {
return back()->with('error', 'This platform is already connected.');
}
session(['social_connect_workspace' => $workspace->id]);
session([
'social_connect_workspace' => $workspace->id,
'social_reconnect_id' => $existingAccount?->id,
'social_connect_onboarding' => $request->boolean('onboarding'),
]);
return Inertia::location(
Socialite::driver($this->driver)
->scopes($this->scopes)
->redirect()
->getTargetUrl()
);
$url = Socialite::driver($this->driver)
->scopes($this->scopes)
->redirect()
->getTargetUrl();
return Inertia::location($url);
}
public function callback(Request $request): RedirectResponse
public function callback(Request $request): View
{
$workspaceId = session('social_connect_workspace');
if (! $workspaceId) {
return redirect()->route('workspaces.index')
->with('error', 'Session expired. Please try again.');
return $this->popupCallback(false, 'Session expired. Please try again.', $this->platform->value);
}
$workspace = Workspace::find($workspaceId);
if (! $workspace || ! $request->user()->can('manageAccounts', $workspace)) {
return redirect()->route('workspaces.index')
->with('error', 'Workspace not found.');
return $this->popupCallback(false, 'Workspace not found.', $this->platform->value);
}
if ($workspace->hasConnectedPlatform($this->platform->value)) {
return redirect()->route('workspaces.accounts', $workspace)
->with('error', 'This platform is already connected.');
$reconnectId = session('social_reconnect_id');
$existingAccount = $reconnectId ? $workspace->socialAccounts()->find($reconnectId) : null;
// If account exists and is connected, don't allow duplicate
if (! $existingAccount && $workspace->hasConnectedPlatform($this->platform->value)) {
return $this->popupCallback(false, 'This platform is already connected.', $this->platform->value);
}
try {
$socialUser = Socialite::driver($this->driver)->user();
// Fetch Instagram accounts linked to Facebook pages
$accounts = $this->fetchInstagramAccounts($socialUser->token);
// Instagram API with Instagram Login returns the user directly
$avatarPath = $socialUser->getAvatar() ? uploadFromUrl($socialUser->getAvatar()) : null;
if (empty($accounts)) {
return redirect()->route('workspaces.accounts', $workspace)
->with('error', 'No Instagram Business accounts found. Make sure your Instagram is connected to a Facebook Page.');
}
// Calculate token expiration (long-lived tokens last 60 days)
$expiresIn = $socialUser->expiresIn ?? 5184000; // 60 days in seconds
$tokenExpiresAt = now()->addSeconds($expiresIn);
// If only one account, connect directly
if (count($accounts) === 1) {
$account = $accounts[0];
$avatarPath = uploadFromUrl($account['profile_picture_url']);
$workspace->socialAccounts()->create([
'platform' => $this->platform->value,
'platform_user_id' => $account['id'],
'username' => $account['username'],
'display_name' => $account['name'] ?? $account['username'],
if ($existingAccount) {
// Reconnect existing account
$existingAccount->update([
'platform_user_id' => $socialUser->getId(),
'username' => $socialUser->getNickname(),
'display_name' => $socialUser->getName() ?? $socialUser->getNickname(),
'avatar_url' => $avatarPath,
'access_token' => $account['page_access_token'],
'refresh_token' => null,
'token_expires_at' => null,
'access_token' => $socialUser->token,
'refresh_token' => $socialUser->refreshToken,
'token_expires_at' => $tokenExpiresAt,
'scopes' => $this->scopes,
'meta' => [
'instagram_id' => $account['id'],
'page_id' => $account['page_id'],
'user_id' => $socialUser->getId(),
'user_token' => $socialUser->token,
'account_type' => $socialUser->user['account_type'] ?? null,
],
]);
$existingAccount->markAsConnected();
session()->forget('social_connect_workspace');
session()->forget('social_reconnect_id');
return redirect()->route('workspaces.accounts', $workspace)
->with('success', 'Instagram account connected successfully!');
return $this->popupCallback(true, 'Instagram account reconnected!', $this->platform->value);
}
// Multiple accounts - store data and show selection
session([
'instagram_oauth' => [
'user_token' => $socialUser->token,
'user_id' => $socialUser->getId(),
'accounts' => $accounts,
// Create new account
$workspace->socialAccounts()->create([
'platform' => $this->platform->value,
'platform_user_id' => $socialUser->getId(),
'username' => $socialUser->getNickname(),
'display_name' => $socialUser->getName() ?? $socialUser->getNickname(),
'avatar_url' => $avatarPath,
'access_token' => $socialUser->token,
'refresh_token' => $socialUser->refreshToken,
'token_expires_at' => $tokenExpiresAt,
'scopes' => $this->scopes,
'status' => Status::Connected,
'meta' => [
'account_type' => $socialUser->user['account_type'] ?? null,
],
]);
return redirect()->route('social.instagram.select-account');
session()->forget('social_reconnect_id');
return $this->popupCallback(true, 'Instagram account connected!', $this->platform->value);
} catch (\Exception $e) {
Log::error('Instagram OAuth Error', [
'error' => $e->getMessage(),
'trace' => $e->getTraceAsString(),
]);
return redirect()->route('workspaces.accounts', $workspace)
->with('error', 'Error connecting account. Please try again.');
}
}
public function selectAccount(Request $request)
{
$oauthData = session('instagram_oauth');
$workspaceId = session('social_connect_workspace');
if (! $oauthData || ! $workspaceId) {
return redirect()->route('workspaces.index')
->with('error', 'Session expired. Please try again.');
}
$workspace = Workspace::find($workspaceId);
if (! $workspace) {
return redirect()->route('workspaces.index')
->with('error', 'Workspace not found.');
}
return Inertia::render('accounts/InstagramAccountSelect', [
'workspace' => $workspace,
'accounts' => $oauthData['accounts'],
]);
}
public function select(Request $request): RedirectResponse
{
$request->validate([
'account_id' => 'required|string',
]);
$oauthData = session('instagram_oauth');
$workspaceId = session('social_connect_workspace');
if (! $oauthData || ! $workspaceId) {
return redirect()->route('workspaces.index')
->with('error', 'Session expired. Please try again.');
}
$workspace = Workspace::find($workspaceId);
if (! $workspace || ! $request->user()->can('manageAccounts', $workspace)) {
return redirect()->route('workspaces.index')
->with('error', 'Workspace not found.');
}
try {
$selectedAccount = collect($oauthData['accounts'])->firstWhere('id', $request->account_id);
if (! $selectedAccount) {
return redirect()->route('social.instagram.select-account')
->with('error', 'Account not found.');
}
$avatarPath = uploadFromUrl($selectedAccount['profile_picture_url']);
$workspace->socialAccounts()->create([
'platform' => $this->platform->value,
'platform_user_id' => $selectedAccount['id'],
'username' => $selectedAccount['username'],
'display_name' => $selectedAccount['name'] ?? $selectedAccount['username'],
'avatar_url' => $avatarPath,
'access_token' => $selectedAccount['page_access_token'],
'refresh_token' => null,
'token_expires_at' => null,
'scopes' => $this->scopes,
'meta' => [
'instagram_id' => $selectedAccount['id'],
'page_id' => $selectedAccount['page_id'],
'user_id' => $oauthData['user_id'],
'user_token' => $oauthData['user_token'],
],
]);
session()->forget(['instagram_oauth', 'social_connect_workspace']);
return redirect()->route('workspaces.accounts', $workspace)
->with('success', 'Instagram account connected successfully!');
} catch (\Exception $e) {
Log::error('Instagram account selection error', [
'error' => $e->getMessage(),
]);
return redirect()->route('workspaces.accounts', $workspace)
->with('error', 'Error connecting account. Please try again.');
}
}
private function fetchInstagramAccounts(string $userToken): array
{
try {
// First, get all pages with their Instagram business accounts
$response = Http::get('https://graph.facebook.com/v21.0/me/accounts', [
'access_token' => $userToken,
'fields' => 'id,name,access_token,instagram_business_account{id,username,name,profile_picture_url,followers_count}',
]);
if ($response->failed()) {
Log::error('Instagram accounts fetch failed', [
'status' => $response->status(),
'body' => $response->body(),
]);
return [];
}
$data = $response->json();
$accounts = [];
foreach ($data['data'] ?? [] as $page) {
if (isset($page['instagram_business_account'])) {
$ig = $page['instagram_business_account'];
$accounts[] = [
'id' => $ig['id'],
'username' => $ig['username'],
'name' => $ig['name'] ?? $ig['username'],
'profile_picture_url' => $ig['profile_picture_url'] ?? null,
'followers_count' => $ig['followers_count'] ?? 0,
'page_id' => $page['id'],
'page_name' => $page['name'],
'page_access_token' => $page['access_token'],
];
}
}
return $accounts;
} catch (\Exception $e) {
Log::error('Instagram accounts fetch error', [
'error' => $e->getMessage(),
]);
return [];
return $this->popupCallback(false, 'Error connecting account. Please try again.', $this->platform->value);
}
}
}

View file

@ -3,11 +3,13 @@
namespace App\Http\Controllers\Auth;
use App\Enums\SocialPlatform;
use App\Enums\Status;
use App\Models\Workspace;
use Illuminate\Http\RedirectResponse;
use Illuminate\Http\Request;
use Illuminate\Support\Facades\Http;
use Illuminate\Support\Facades\Log;
use Illuminate\View\View;
use Laravel\Socialite\Facades\Socialite;
use Symfony\Component\HttpFoundation\Response;
@ -21,45 +23,56 @@ class LinkedInController extends SocialController
'openid',
'profile',
'email',
'r_basicprofile',
'w_member_social',
];
public function connect(Request $request, Workspace $workspace): Response
public function connect(Request $request): Response|RedirectResponse
{
$this->ensurePlatformEnabled();
$workspace = $request->user()->currentWorkspace;
if (! $workspace) {
return redirect()->route('workspaces.create');
}
$this->authorize('manageAccounts', $workspace);
if ($workspace->hasConnectedPlatform($this->platform->value)) {
$existingAccount = $workspace->socialAccounts()
->where('platform', $this->platform->value)
->first();
if ($existingAccount && ! $existingAccount->isDisconnected()) {
return back()->with('error', 'This platform is already connected.');
}
return $this->redirectToProvider($workspace, $this->driver, $this->scopes);
return $this->redirectToProvider($request, $this->driver, $this->scopes);
}
public function callback(Request $request): RedirectResponse
public function callback(Request $request): View
{
$workspaceId = session('social_connect_workspace');
if (! $workspaceId) {
return redirect()->route('workspaces.index')
->with('error', 'Session expired. Please try again.');
return $this->popupCallback(false, 'Session expired. Please try again.', $this->platform->value);
}
$workspace = Workspace::find($workspaceId);
if (! $workspace || ! $request->user()->can('manageAccounts', $workspace)) {
return redirect()->route('workspaces.index')
->with('error', 'Workspace not found.');
}
if ($workspace->hasConnectedPlatform($this->platform->value)) {
return redirect()->route('workspaces.accounts', $workspace)
->with('error', 'This platform is already connected.');
return $this->popupCallback(false, 'Workspace not found.', $this->platform->value);
}
try {
$socialUser = Socialite::driver($this->driver)->user();
$existingAccount = $workspace->socialAccounts()
->where('platform', $this->platform->value)
->first();
// If account exists and is connected, don't allow duplicate
if ($existingAccount && ! $existingAccount->isDisconnected()) {
return $this->popupCallback(false, 'This platform is already connected.', $this->platform->value);
}
// Fetch vanityName from LinkedIn API (not available via OpenID)
$username = $this->fetchVanityName($socialUser->token);
@ -72,6 +85,24 @@ public function callback(Request $request): RedirectResponse
$avatarPath = uploadFromUrl($socialUser->getAvatar());
if ($existingAccount) {
// Reconnect existing account
$existingAccount->update([
'platform_user_id' => $socialUser->getId(),
'username' => $username,
'display_name' => $socialUser->getName(),
'avatar_url' => $avatarPath,
'access_token' => $socialUser->token,
'refresh_token' => $socialUser->refreshToken,
'token_expires_at' => $socialUser->expiresIn ? now()->addSeconds($socialUser->expiresIn) : null,
'scopes' => $socialUser->approvedScopes ?? null,
]);
$existingAccount->markAsConnected();
return $this->popupCallback(true, 'LinkedIn account reconnected!', $this->platform->value);
}
// Create new account
$workspace->socialAccounts()->create([
'platform' => $this->platform->value,
'platform_user_id' => $socialUser->getId(),
@ -82,19 +113,16 @@ public function callback(Request $request): RedirectResponse
'refresh_token' => $socialUser->refreshToken,
'token_expires_at' => $socialUser->expiresIn ? now()->addSeconds($socialUser->expiresIn) : null,
'scopes' => $socialUser->approvedScopes ?? null,
'status' => Status::Connected,
]);
session()->forget('social_connect_workspace');
return redirect()->route('workspaces.accounts', $workspace)
->with('success', 'Account connected successfully!');
return $this->popupCallback(true, 'LinkedIn account connected!', $this->platform->value);
} catch (\Exception $e) {
Log::error('LinkedIn OAuth Error', [
'error' => $e->getMessage(),
]);
return redirect()->route('workspaces.accounts', $workspace)
->with('error', 'Error connecting account. Please try again.');
return $this->popupCallback(false, 'Error connecting account. Please try again.', $this->platform->value);
}
}

View file

@ -3,11 +3,13 @@
namespace App\Http\Controllers\Auth;
use App\Enums\SocialPlatform;
use App\Enums\Status;
use App\Models\Workspace;
use Illuminate\Http\RedirectResponse;
use Illuminate\Http\Request;
use Illuminate\Support\Facades\Http;
use Illuminate\Support\Facades\Log;
use Illuminate\View\View;
use Inertia\Inertia;
use Inertia\Response;
use Laravel\Socialite\Facades\Socialite;
@ -29,16 +31,31 @@ class LinkedInPageController extends SocialController
'w_member_social',
];
public function connect(Request $request, Workspace $workspace): SymfonyResponse
public function connect(Request $request): SymfonyResponse|RedirectResponse
{
$this->ensurePlatformEnabled();
$workspace = $request->user()->currentWorkspace;
if (! $workspace) {
return redirect()->route('workspaces.create');
}
$this->authorize('manageAccounts', $workspace);
if ($workspace->hasConnectedPlatform($this->platform->value)) {
$existingAccount = $workspace->socialAccounts()
->where('platform', $this->platform->value)
->first();
if ($existingAccount && ! $existingAccount->isDisconnected()) {
return back()->with('error', 'This platform is already connected.');
}
session(['social_connect_workspace' => $workspace->id]);
session([
'social_connect_workspace' => $workspace->id,
'linkedin_page_reconnect_id' => $existingAccount?->id,
'social_connect_onboarding' => $request->boolean('onboarding'),
]);
return Inertia::location(
Socialite::driver($this->driver)
@ -51,20 +68,18 @@ public function connect(Request $request, Workspace $workspace): SymfonyResponse
);
}
public function callback(Request $request): RedirectResponse
public function callback(Request $request): View|RedirectResponse
{
$workspaceId = session('social_connect_workspace');
if (! $workspaceId) {
return redirect()->route('workspaces.index')
->with('error', 'Session expired. Please try again.');
return $this->popupCallback(false, 'Session expired. Please try again.', $this->platform->value);
}
$workspace = Workspace::find($workspaceId);
if (! $workspace || ! $request->user()->can('manageAccounts', $workspace)) {
return redirect()->route('workspaces.index')
->with('error', 'Workspace not found.');
return $this->popupCallback(false, 'Workspace not found.', $this->platform->value);
}
try {
@ -79,10 +94,7 @@ public function callback(Request $request): RedirectResponse
$organizations = $this->fetchOrganizations($socialUser->token);
if (empty($organizations)) {
session()->forget('social_connect_workspace');
return redirect()->route('workspaces.accounts', $workspace)
->with('error', 'You are not an administrator of any LinkedIn page.');
return $this->popupCallback(false, 'You are not an administrator of any LinkedIn page.', $this->platform->value);
}
// Store data in session and redirect to selection page
@ -96,6 +108,7 @@ public function callback(Request $request): RedirectResponse
'refresh_token' => $socialUser->refreshToken,
'expires_in' => $socialUser->expiresIn,
'organizations' => $organizations,
'reconnect_id' => session('linkedin_page_reconnect_id'),
],
]);
@ -105,8 +118,7 @@ public function callback(Request $request): RedirectResponse
'error' => $e->getMessage(),
]);
return redirect()->route('workspaces.accounts', $workspace)
->with('error', 'Error connecting account. Please try again.');
return $this->popupCallback(false, 'Error connecting account. Please try again.', $this->platform->value);
}
}
@ -115,14 +127,14 @@ public function selectPage(Request $request): Response|RedirectResponse
$pendingData = session('linkedin_page_pending');
if (! $pendingData) {
return redirect()->route('workspaces.index')
return redirect()->route('dashboard')
->with('error', 'Session expired. Please try again.');
}
$workspace = Workspace::find($pendingData['workspace_id']);
if (! $workspace || ! $request->user()->can('manageAccounts', $workspace)) {
return redirect()->route('workspaces.index')
return redirect()->route('dashboard')
->with('error', 'Workspace not found.');
}
@ -132,7 +144,7 @@ public function selectPage(Request $request): Response|RedirectResponse
]);
}
public function select(Request $request): RedirectResponse
public function select(Request $request): View
{
$request->validate([
'organization_id' => 'required',
@ -144,20 +156,47 @@ public function select(Request $request): RedirectResponse
$pendingData = session('linkedin_page_pending');
if (! $pendingData) {
return redirect()->route('workspaces.index')
->with('error', 'Session expired. Please try again.');
return $this->popupCallback(false, 'Session expired. Please try again.', $this->platform->value);
}
$workspace = Workspace::find($pendingData['workspace_id']);
if (! $workspace || ! $request->user()->can('manageAccounts', $workspace)) {
return redirect()->route('workspaces.index')
->with('error', 'Workspace not found.');
return $this->popupCallback(false, 'Workspace not found.', $this->platform->value);
}
try {
$avatarPath = uploadFromUrl($request->organization_logo);
$reconnectId = $pendingData['reconnect_id'] ?? null;
if ($reconnectId) {
// Reconnect existing account
$existingAccount = $workspace->socialAccounts()->find($reconnectId);
if ($existingAccount) {
$existingAccount->update([
'platform_user_id' => $request->organization_id,
'username' => $request->organization_vanity_name,
'display_name' => $request->organization_name,
'avatar_url' => $avatarPath,
'access_token' => $pendingData['token'],
'refresh_token' => $pendingData['refresh_token'],
'token_expires_at' => $pendingData['expires_in'] ? now()->addSeconds($pendingData['expires_in']) : null,
'meta' => [
'organization_id' => $request->organization_id,
'admin_user_id' => $pendingData['user_id'],
'admin_name' => $pendingData['name'],
],
]);
$existingAccount->markAsConnected();
session()->forget(['linkedin_page_pending', 'linkedin_page_reconnect_id']);
return $this->popupCallback(true, 'LinkedIn Page reconnected!', $this->platform->value);
}
}
// Create new account
$workspace->socialAccounts()->create([
'platform' => $this->platform->value,
'platform_user_id' => $request->organization_id,
@ -167,6 +206,7 @@ public function select(Request $request): RedirectResponse
'access_token' => $pendingData['token'],
'refresh_token' => $pendingData['refresh_token'],
'token_expires_at' => $pendingData['expires_in'] ? now()->addSeconds($pendingData['expires_in']) : null,
'status' => Status::Connected,
'meta' => [
'organization_id' => $request->organization_id,
'admin_user_id' => $pendingData['user_id'],
@ -174,17 +214,15 @@ public function select(Request $request): RedirectResponse
],
]);
session()->forget(['social_connect_workspace', 'linkedin_page_pending']);
session()->forget(['linkedin_page_pending', 'linkedin_page_reconnect_id']);
return redirect()->route('workspaces.accounts', $workspace)
->with('success', 'LinkedIn Page connected successfully!');
return $this->popupCallback(true, 'LinkedIn Page connected!', $this->platform->value);
} catch (\Exception $e) {
Log::error('LinkedIn Page selection error', [
'error' => $e->getMessage(),
]);
return redirect()->route('workspaces.accounts', $workspace)
->with('error', 'Error connecting page. Please try again.');
return $this->popupCallback(false, 'Error connecting page. Please try again.', $this->platform->value);
}
}

View file

@ -3,12 +3,14 @@
namespace App\Http\Controllers\Auth;
use App\Enums\SocialPlatform;
use App\Enums\Status;
use App\Http\Controllers\Controller;
use App\Models\SocialAccount;
use App\Models\Workspace;
use Illuminate\Http\RedirectResponse;
use Illuminate\Http\Request;
use Illuminate\Support\Facades\Log;
use Illuminate\View\View;
use Inertia\Inertia;
use Inertia\Response;
use Laravel\Socialite\Facades\Socialite;
@ -25,8 +27,14 @@ protected function ensurePlatformEnabled(): void
}
}
public function index(Workspace $workspace): Response
public function index(Request $request): Response|RedirectResponse
{
$workspace = $request->user()->currentWorkspace;
if (! $workspace) {
return redirect()->route('workspaces.create');
}
$this->authorize('view', $workspace);
$connectedAccounts = $workspace->socialAccounts;
@ -49,8 +57,14 @@ public function index(Workspace $workspace): Response
]);
}
public function disconnect(Workspace $workspace, SocialAccount $account): RedirectResponse
public function disconnect(Request $request, SocialAccount $account): RedirectResponse
{
$workspace = $request->user()->currentWorkspace;
if (! $workspace) {
return redirect()->route('workspaces.create');
}
$this->authorize('manageAccounts', $workspace);
if ($account->workspace_id !== $workspace->id) {
@ -65,9 +79,16 @@ public function disconnect(Workspace $workspace, SocialAccount $account): Redire
return back();
}
protected function redirectToProvider(Workspace $workspace, string $driver, array $scopes): SymfonyResponse
protected function redirectToProvider(Request $request, string $driver, array $scopes): SymfonyResponse
{
$workspace = $request->user()->currentWorkspace;
if (! $workspace) {
return redirect()->route('workspaces.create');
}
session(['social_connect_workspace' => $workspace->id]);
session(['social_connect_onboarding' => $request->boolean('onboarding')]);
return Inertia::location(
Socialite::driver($driver)
@ -81,36 +102,50 @@ protected function handleCallback(
Request $request,
SocialPlatform $platform,
string $driver
): RedirectResponse {
): View {
$workspaceId = session('social_connect_workspace');
if (! $workspaceId) {
session()->flash('flash.banner', 'Session expired. Please try again.');
session()->flash('flash.bannerStyle', 'danger');
return redirect()->route('workspaces.index');
return $this->popupCallback(false, 'Session expired. Please try again.', $platform->value);
}
$workspace = Workspace::find($workspaceId);
if (! $workspace || ! $request->user()->can('manageAccounts', $workspace)) {
session()->flash('flash.banner', 'Workspace not found.');
session()->flash('flash.bannerStyle', 'danger');
return redirect()->route('workspaces.index');
}
if ($workspace->hasConnectedPlatform($platform->value)) {
session()->flash('flash.banner', 'This platform is already connected.');
session()->flash('flash.bannerStyle', 'danger');
return redirect()->route('workspaces.accounts', $workspace);
return $this->popupCallback(false, 'Workspace not found.', $platform->value);
}
try {
$socialUser = Socialite::driver($driver)->user();
$existingAccount = $workspace->socialAccounts()
->where('platform', $platform->value)
->first();
// If account exists and is connected, don't allow duplicate
if ($existingAccount && ! $existingAccount->isDisconnected()) {
return $this->popupCallback(false, 'This platform is already connected.', $platform->value);
}
$avatarPath = uploadFromUrl($socialUser->getAvatar());
if ($existingAccount) {
// Reconnect existing account
$existingAccount->update([
'platform_user_id' => $socialUser->getId(),
'username' => $socialUser->getNickname(),
'display_name' => $socialUser->getName(),
'avatar_url' => $avatarPath,
'access_token' => $socialUser->token,
'refresh_token' => $socialUser->refreshToken,
'token_expires_at' => $socialUser->expiresIn ? now()->addSeconds($socialUser->expiresIn) : null,
'scopes' => $socialUser->approvedScopes ?? null,
]);
$existingAccount->markAsConnected();
return $this->popupCallback(true, 'Account reconnected!', $platform->value);
}
// Create new account
$workspace->socialAccounts()->create([
'platform' => $platform->value,
'platform_user_id' => $socialUser->getId(),
@ -121,24 +156,41 @@ protected function handleCallback(
'refresh_token' => $socialUser->refreshToken,
'token_expires_at' => $socialUser->expiresIn ? now()->addSeconds($socialUser->expiresIn) : null,
'scopes' => $socialUser->approvedScopes ?? null,
'status' => Status::Connected,
]);
session()->forget('social_connect_workspace');
session()->flash('flash.banner', 'Account connected successfully!');
session()->flash('flash.bannerStyle', 'success');
return redirect()->route('workspaces.accounts', $workspace);
return $this->popupCallback(true, 'Account connected!', $platform->value);
} catch (\Exception $e) {
Log::error('Social OAuth Error', [
'platform' => $platform->value,
'error' => $e->getMessage(),
]);
session()->flash('flash.banner', 'Error connecting account. Please try again.');
session()->flash('flash.bannerStyle', 'danger');
return redirect()->route('workspaces.accounts', $workspace);
return $this->popupCallback(false, 'Error connecting account. Please try again.', $platform->value);
}
}
protected function forgetSocialConnectSession(): void
{
session()->forget(['social_connect_workspace', 'social_connect_onboarding']);
}
protected function getRedirectRoute(): string
{
return session('social_connect_onboarding', false) ? 'onboarding.step2' : 'accounts';
}
/**
* Return a view that closes the popup and notifies the parent window.
*/
protected function popupCallback(bool $success, string $message, ?string $platform = null): View
{
$this->forgetSocialConnectSession();
return view('auth.social-callback', [
'success' => $success,
'message' => $message,
'platform' => $platform,
]);
}
}

View file

@ -3,11 +3,13 @@
namespace App\Http\Controllers\Auth;
use App\Enums\SocialPlatform;
use App\Enums\Status;
use App\Models\Workspace;
use Illuminate\Http\RedirectResponse;
use Illuminate\Http\Request;
use Illuminate\Support\Facades\Http;
use Illuminate\Support\Facades\Log;
use Illuminate\View\View;
use Inertia\Inertia;
use Symfony\Component\HttpFoundation\Response;
@ -22,16 +24,30 @@ class ThreadsController extends SocialController
'threads_read_replies',
];
public function connect(Request $request, Workspace $workspace): Response
public function connect(Request $request): Response|RedirectResponse
{
$this->ensurePlatformEnabled();
$workspace = $request->user()->currentWorkspace;
if (! $workspace) {
return redirect()->route('workspaces.create');
}
$this->authorize('manageAccounts', $workspace);
if ($workspace->hasConnectedPlatform($this->platform->value)) {
$existingAccount = $workspace->socialAccounts()
->where('platform', $this->platform->value)
->first();
if ($existingAccount && ! $existingAccount->isDisconnected()) {
return back()->with('error', 'This platform is already connected.');
}
session(['social_connect_workspace' => $workspace->id]);
session([
'social_connect_workspace' => $workspace->id,
'social_reconnect_id' => $existingAccount?->id,
]);
$state = bin2hex(random_bytes(16));
session(['threads_oauth_state' => $state]);
@ -47,31 +63,39 @@ public function connect(Request $request, Workspace $workspace): Response
return Inertia::location("https://threads.net/oauth/authorize?{$params}");
}
public function callback(Request $request): RedirectResponse
public function callback(Request $request): View
{
$workspaceId = session('social_connect_workspace');
$savedState = session('threads_oauth_state');
if (! $workspaceId) {
return redirect()->route('workspaces.index')
->with('error', 'Session expired. Please try again.');
session()->forget(['threads_oauth_state', 'social_reconnect_id']);
return $this->popupCallback(false, 'Session expired. Please try again.', $this->platform->value);
}
if ($request->state !== $savedState) {
return redirect()->route('workspaces.index')
->with('error', 'Invalid state. Please try again.');
session()->forget(['threads_oauth_state', 'social_reconnect_id']);
return $this->popupCallback(false, 'Invalid state. Please try again.', $this->platform->value);
}
$workspace = Workspace::find($workspaceId);
if (! $workspace || ! $request->user()->can('manageAccounts', $workspace)) {
return redirect()->route('workspaces.index')
->with('error', 'Workspace not found.');
session()->forget(['threads_oauth_state', 'social_reconnect_id']);
return $this->popupCallback(false, 'Workspace not found.', $this->platform->value);
}
if ($workspace->hasConnectedPlatform($this->platform->value)) {
return redirect()->route('workspaces.accounts', $workspace)
->with('error', 'This platform is already connected.');
$reconnectId = session('social_reconnect_id');
$existingAccount = $reconnectId ? $workspace->socialAccounts()->find($reconnectId) : null;
// If account exists and is connected, don't allow duplicate
if (! $existingAccount && $workspace->hasConnectedPlatform($this->platform->value)) {
session()->forget(['threads_oauth_state', 'social_reconnect_id']);
return $this->popupCallback(false, 'This platform is already connected.', $this->platform->value);
}
try {
@ -128,6 +152,26 @@ public function callback(Request $request): RedirectResponse
$profile = $profileResponse->json();
$avatarPath = uploadFromUrl($profile['threads_profile_picture_url'] ?? null);
if ($existingAccount) {
// Reconnect existing account
$existingAccount->update([
'platform_user_id' => $profile['id'],
'username' => $profile['username'],
'display_name' => $profile['name'] ?? $profile['username'],
'avatar_url' => $avatarPath,
'access_token' => $longLivedToken,
'refresh_token' => null,
'token_expires_at' => $expiresIn ? now()->addSeconds($expiresIn) : null,
'scopes' => $this->scopes,
]);
$existingAccount->markAsConnected();
session()->forget(['threads_oauth_state', 'social_reconnect_id']);
return $this->popupCallback(true, 'Threads account reconnected!', $this->platform->value);
}
// Create new account
$workspace->socialAccounts()->create([
'platform' => $this->platform->value,
'platform_user_id' => $profile['id'],
@ -138,20 +182,21 @@ public function callback(Request $request): RedirectResponse
'refresh_token' => null,
'token_expires_at' => $expiresIn ? now()->addSeconds($expiresIn) : null,
'scopes' => $this->scopes,
'status' => Status::Connected,
]);
session()->forget(['social_connect_workspace', 'threads_oauth_state']);
session()->forget(['threads_oauth_state', 'social_reconnect_id']);
return redirect()->route('workspaces.accounts', $workspace)
->with('success', 'Threads account connected successfully!');
return $this->popupCallback(true, 'Threads account connected!', $this->platform->value);
} catch (\Exception $e) {
Log::error('Threads OAuth Error', [
'error' => $e->getMessage(),
'trace' => $e->getTraceAsString(),
]);
return redirect()->route('workspaces.accounts', $workspace)
->with('error', 'Error connecting account. Please try again.');
session()->forget(['threads_oauth_state', 'social_reconnect_id']);
return $this->popupCallback(false, 'Error connecting account. Please try again.', $this->platform->value);
}
}
}

View file

@ -3,10 +3,12 @@
namespace App\Http\Controllers\Auth;
use App\Enums\SocialPlatform;
use App\Enums\Status;
use App\Models\Workspace;
use Illuminate\Http\RedirectResponse;
use Illuminate\Http\Request;
use Illuminate\Support\Facades\Log;
use Illuminate\View\View;
use Laravel\Socialite\Facades\Socialite;
use Symfony\Component\HttpFoundation\Response;
@ -22,37 +24,51 @@ class TikTokController extends SocialController
'video.publish',
];
public function connect(Request $request, Workspace $workspace): Response
public function connect(Request $request): Response|RedirectResponse
{
$this->ensurePlatformEnabled();
$workspace = $request->user()->currentWorkspace;
if (! $workspace) {
return redirect()->route('workspaces.create');
}
$this->authorize('manageAccounts', $workspace);
if ($workspace->hasConnectedPlatform($this->platform->value)) {
$existingAccount = $workspace->socialAccounts()
->where('platform', $this->platform->value)
->first();
if ($existingAccount && ! $existingAccount->isDisconnected()) {
return back()->with('error', 'This platform is already connected.');
}
return $this->redirectToProvider($workspace, $this->driver, $this->scopes);
session(['social_reconnect_id' => $existingAccount?->id]);
return $this->redirectToProvider($request, $this->driver, $this->scopes);
}
public function callback(Request $request): RedirectResponse
public function callback(Request $request): View
{
$workspaceId = session('social_connect_workspace');
if (! $workspaceId) {
return redirect()->route('workspaces.index')
->with('error', 'Session expired. Please try again.');
return $this->popupCallback(false, 'Session expired. Please try again.', $this->platform->value);
}
$workspace = Workspace::find($workspaceId);
if (! $workspace || ! $request->user()->can('manageAccounts', $workspace)) {
return redirect()->route('workspaces.index')
->with('error', 'Workspace not found.');
return $this->popupCallback(false, 'Workspace not found.', $this->platform->value);
}
if ($workspace->hasConnectedPlatform($this->platform->value)) {
return redirect()->route('workspaces.accounts', $workspace)
->with('error', 'This platform is already connected.');
$reconnectId = session('social_reconnect_id');
$existingAccount = $reconnectId ? $workspace->socialAccounts()->find($reconnectId) : null;
// If account exists and is connected, don't allow duplicate
if (! $existingAccount && $workspace->hasConnectedPlatform($this->platform->value)) {
return $this->popupCallback(false, 'This platform is already connected.', $this->platform->value);
}
try {
@ -70,6 +86,26 @@ public function callback(Request $request): RedirectResponse
$username = $socialUser->getNickname();
$avatarPath = uploadFromUrl($socialUser->getAvatar());
if ($existingAccount) {
// Reconnect existing account
$existingAccount->update([
'platform_user_id' => $socialUser->getId(),
'username' => $username,
'display_name' => $socialUser->getName(),
'avatar_url' => $avatarPath,
'access_token' => $socialUser->token,
'refresh_token' => $socialUser->refreshToken,
'token_expires_at' => $socialUser->expiresIn ? now()->addSeconds($socialUser->expiresIn) : null,
'scopes' => $socialUser->approvedScopes ?? null,
]);
$existingAccount->markAsConnected();
session()->forget('social_reconnect_id');
return $this->popupCallback(true, 'TikTok account reconnected!', $this->platform->value);
}
// Create new account
$workspace->socialAccounts()->create([
'platform' => $this->platform->value,
'platform_user_id' => $socialUser->getId(),
@ -80,19 +116,18 @@ public function callback(Request $request): RedirectResponse
'refresh_token' => $socialUser->refreshToken,
'token_expires_at' => $socialUser->expiresIn ? now()->addSeconds($socialUser->expiresIn) : null,
'scopes' => $socialUser->approvedScopes ?? null,
'status' => Status::Connected,
]);
session()->forget('social_connect_workspace');
session()->forget('social_reconnect_id');
return redirect()->route('workspaces.accounts', $workspace)
->with('success', 'Account connected successfully!');
return $this->popupCallback(true, 'TikTok account connected!', $this->platform->value);
} catch (\Exception $e) {
Log::error('TikTok OAuth Error', [
'error' => $e->getMessage(),
]);
return redirect()->route('workspaces.accounts', $workspace)
->with('error', 'Error connecting account. Please try again.');
return $this->popupCallback(false, 'Error connecting account. Please try again.', $this->platform->value);
}
}
}

View file

@ -3,9 +3,9 @@
namespace App\Http\Controllers\Auth;
use App\Enums\SocialPlatform;
use App\Models\Workspace;
use Illuminate\Http\RedirectResponse;
use Illuminate\Http\Request;
use Illuminate\View\View;
use Symfony\Component\HttpFoundation\Response;
class XController extends SocialController
@ -22,19 +22,30 @@ class XController extends SocialController
'offline.access',
];
public function connect(Request $request, Workspace $workspace): Response
public function connect(Request $request): Response|RedirectResponse
{
$this->ensurePlatformEnabled();
$workspace = $request->user()->currentWorkspace;
if (! $workspace) {
return redirect()->route('workspaces.create');
}
$this->authorize('manageAccounts', $workspace);
if ($workspace->hasConnectedPlatform($this->platform->value)) {
$existingAccount = $workspace->socialAccounts()
->where('platform', $this->platform->value)
->first();
if ($existingAccount && ! $existingAccount->isDisconnected()) {
return back()->with('error', 'This platform is already connected.');
}
return $this->redirectToProvider($workspace, $this->driver, $this->scopes);
return $this->redirectToProvider($request, $this->driver, $this->scopes);
}
public function callback(Request $request): RedirectResponse
public function callback(Request $request): View
{
return $this->handleCallback($request, $this->platform, $this->driver);
}

View file

@ -3,10 +3,12 @@
namespace App\Http\Controllers\Auth;
use App\Enums\SocialPlatform;
use App\Enums\Status;
use App\Models\Workspace;
use Illuminate\Http\RedirectResponse;
use Illuminate\Http\Request;
use Illuminate\Support\Facades\Log;
use Illuminate\View\View;
use Laravel\Socialite\Facades\Socialite;
use Symfony\Component\HttpFoundation\Response;
@ -22,39 +24,55 @@ class YouTubeController extends SocialController
'https://www.googleapis.com/auth/youtube.force-ssl',
];
public function connect(Request $request, Workspace $workspace): Response
public function connect(Request $request): Response|RedirectResponse
{
$this->ensurePlatformEnabled();
$workspace = $request->user()->currentWorkspace;
if (! $workspace) {
return redirect()->route('workspaces.create');
}
$this->authorize('manageAccounts', $workspace);
if ($workspace->hasConnectedPlatform($this->platform->value)) {
$existingAccount = $workspace->socialAccounts()
->where('platform', $this->platform->value)
->first();
if ($existingAccount && ! $existingAccount->isDisconnected()) {
return back()->with('error', 'This platform is already connected.');
}
session(['social_connect_workspace' => $workspace->id]);
session([
'social_connect_workspace' => $workspace->id,
'social_reconnect_id' => $existingAccount?->id,
'social_connect_onboarding' => $request->boolean('onboarding'),
]);
return $this->redirectToGoogle($workspace);
return $this->redirectToGoogle();
}
public function callback(Request $request): RedirectResponse
public function callback(Request $request): View|RedirectResponse
{
$workspaceId = session('social_connect_workspace');
if (! $workspaceId) {
return redirect()->route('workspaces.index')
->with('error', 'Session expired. Please try again.');
return $this->popupCallback(false, 'Session expired. Please try again.', $this->platform->value);
}
$workspace = Workspace::find($workspaceId);
if (! $workspace || ! $request->user()->can('manageAccounts', $workspace)) {
return redirect()->route('workspaces.index')
->with('error', 'Workspace not found.');
return $this->popupCallback(false, 'Workspace not found.', $this->platform->value);
}
if ($workspace->hasConnectedPlatform($this->platform->value)) {
return redirect()->route('workspaces.accounts', $workspace)
->with('error', 'This platform is already connected.');
$reconnectId = session('social_reconnect_id');
$existingAccount = $reconnectId ? $workspace->socialAccounts()->find($reconnectId) : null;
// If account exists and is connected, don't allow duplicate
if (! $existingAccount && $workspace->hasConnectedPlatform($this->platform->value)) {
return $this->popupCallback(false, 'This platform is already connected.', $this->platform->value);
}
try {
@ -64,8 +82,7 @@ public function callback(Request $request): RedirectResponse
$channels = $this->fetchChannels($socialUser->token);
if (empty($channels)) {
return redirect()->route('workspaces.accounts', $workspace)
->with('error', 'No YouTube channels found. Please create a channel first.');
return $this->popupCallback(false, 'No YouTube channels found. Please create a channel first.', $this->platform->value);
}
// If only one channel, connect directly (most common case)
@ -73,26 +90,50 @@ public function callback(Request $request): RedirectResponse
$channel = $channels[0];
$avatarPath = uploadFromUrl($channel['thumbnail']);
if ($existingAccount) {
// Reconnect existing account
$existingAccount->update([
'platform_user_id' => $channel['id'],
'username' => ltrim($channel['custom_url'] ?? $channel['id'], '@'),
'display_name' => $channel['title'],
'avatar_url' => $avatarPath,
'access_token' => $socialUser->token,
'refresh_token' => $socialUser->refreshToken,
'token_expires_at' => $socialUser->expiresIn ? now()->addSeconds($socialUser->expiresIn) : null,
'scopes' => $this->scopes,
'meta' => [
'channel_id' => $channel['id'],
'google_user_id' => $socialUser->getId(),
],
]);
$existingAccount->markAsConnected();
session()->forget('social_reconnect_id');
return $this->popupCallback(true, 'YouTube channel reconnected!', $this->platform->value);
}
// Create new account
$workspace->socialAccounts()->create([
'platform' => $this->platform->value,
'platform_user_id' => $channel['id'],
'username' => $channel['custom_url'] ?? $channel['id'],
'username' => ltrim($channel['custom_url'] ?? $channel['id'], '@'),
'display_name' => $channel['title'],
'avatar_url' => $avatarPath,
'access_token' => $socialUser->token,
'refresh_token' => $socialUser->refreshToken,
'token_expires_at' => $socialUser->expiresIn ? now()->addSeconds($socialUser->expiresIn) : null,
'scopes' => $this->scopes,
'status' => Status::Connected,
'meta' => [
'channel_id' => $channel['id'],
'google_user_id' => $socialUser->getId(),
],
]);
session()->forget('social_connect_workspace');
session()->forget('social_reconnect_id');
return redirect()->route('workspaces.accounts', $workspace)
->with('success', 'YouTube channel connected successfully!');
return $this->popupCallback(true, 'YouTube channel connected!', $this->platform->value);
}
// Multiple channels - store data and show selection screen
@ -102,6 +143,7 @@ public function callback(Request $request): RedirectResponse
'refresh_token' => $socialUser->refreshToken,
'expires_in' => $socialUser->expiresIn,
'user_id' => $socialUser->getId(),
'reconnect_id' => $reconnectId,
],
]);
@ -112,8 +154,7 @@ public function callback(Request $request): RedirectResponse
'trace' => $e->getTraceAsString(),
]);
return redirect()->route('workspaces.accounts', $workspace)
->with('error', 'Error connecting account. Please try again.');
return $this->popupCallback(false, 'Error connecting account. Please try again.', $this->platform->value);
}
}
@ -123,14 +164,14 @@ public function selectChannel(Request $request)
$workspaceId = session('social_connect_workspace');
if (! $oauthData || ! $workspaceId) {
return redirect()->route('workspaces.index')
return redirect()->route('dashboard')
->with('error', 'Session expired. Please try again.');
}
$workspace = Workspace::find($workspaceId);
if (! $workspace) {
return redirect()->route('workspaces.index')
return redirect()->route('dashboard')
->with('error', 'Workspace not found.');
}
@ -138,9 +179,11 @@ public function selectChannel(Request $request)
$channels = $this->fetchChannels($oauthData['access_token']);
if (empty($channels)) {
session()->forget(['youtube_oauth', 'social_connect_workspace']);
$redirectRoute = $this->getRedirectRoute();
$this->forgetSocialConnectSession();
session()->forget('youtube_oauth');
return redirect()->route('workspaces.accounts', $workspace)
return redirect()->route($redirectRoute)
->with('error', 'No YouTube channels found. Please create a channel first.');
}
@ -150,7 +193,7 @@ public function selectChannel(Request $request)
]);
}
public function select(Request $request): RedirectResponse
public function select(Request $request): View
{
$request->validate([
'channel_id' => 'required|string',
@ -160,15 +203,13 @@ public function select(Request $request): RedirectResponse
$workspaceId = session('social_connect_workspace');
if (! $oauthData || ! $workspaceId) {
return redirect()->route('workspaces.index')
->with('error', 'Session expired. Please try again.');
return $this->popupCallback(false, 'Session expired. Please try again.', $this->platform->value);
}
$workspace = Workspace::find($workspaceId);
if (! $workspace || ! $request->user()->can('manageAccounts', $workspace)) {
return redirect()->route('workspaces.index')
->with('error', 'Workspace not found.');
return $this->popupCallback(false, 'Workspace not found.', $this->platform->value);
}
try {
@ -176,43 +217,70 @@ public function select(Request $request): RedirectResponse
$selectedChannel = collect($channels)->firstWhere('id', $request->channel_id);
if (! $selectedChannel) {
return redirect()->route('social.youtube.select-channel')
->with('error', 'Channel not found.');
return $this->popupCallback(false, 'Channel not found.', $this->platform->value);
}
$avatarPath = uploadFromUrl($selectedChannel['thumbnail']);
$reconnectId = $oauthData['reconnect_id'] ?? null;
if ($reconnectId) {
// Reconnect existing account
$existingAccount = $workspace->socialAccounts()->find($reconnectId);
if ($existingAccount) {
$existingAccount->update([
'platform_user_id' => $selectedChannel['id'],
'username' => ltrim($selectedChannel['custom_url'] ?? $selectedChannel['id'], '@'),
'display_name' => $selectedChannel['title'],
'avatar_url' => $avatarPath,
'access_token' => $oauthData['access_token'],
'refresh_token' => $oauthData['refresh_token'],
'token_expires_at' => $oauthData['expires_in'] ? now()->addSeconds($oauthData['expires_in']) : null,
'scopes' => $this->scopes,
'meta' => [
'channel_id' => $selectedChannel['id'],
'google_user_id' => $oauthData['user_id'],
],
]);
$existingAccount->markAsConnected();
session()->forget(['youtube_oauth', 'social_reconnect_id']);
return $this->popupCallback(true, 'YouTube channel reconnected!', $this->platform->value);
}
}
// Create new account
$workspace->socialAccounts()->create([
'platform' => $this->platform->value,
'platform_user_id' => $selectedChannel['id'],
'username' => $selectedChannel['custom_url'] ?? $selectedChannel['id'],
'username' => ltrim($selectedChannel['custom_url'] ?? $selectedChannel['id'], '@'),
'display_name' => $selectedChannel['title'],
'avatar_url' => $avatarPath,
'access_token' => $oauthData['access_token'],
'refresh_token' => $oauthData['refresh_token'],
'token_expires_at' => $oauthData['expires_in'] ? now()->addSeconds($oauthData['expires_in']) : null,
'scopes' => $this->scopes,
'status' => Status::Connected,
'meta' => [
'channel_id' => $selectedChannel['id'],
'google_user_id' => $oauthData['user_id'],
],
]);
session()->forget(['youtube_oauth', 'social_connect_workspace']);
session()->forget(['youtube_oauth', 'social_reconnect_id']);
return redirect()->route('workspaces.accounts', $workspace)
->with('success', 'YouTube channel connected successfully!');
return $this->popupCallback(true, 'YouTube channel connected!', $this->platform->value);
} catch (\Exception $e) {
Log::error('YouTube channel selection error', [
'error' => $e->getMessage(),
]);
return redirect()->route('workspaces.accounts', $workspace)
->with('error', 'Error connecting channel. Please try again.');
return $this->popupCallback(false, 'Error connecting channel. Please try again.', $this->platform->value);
}
}
private function redirectToGoogle(Workspace $workspace): Response
private function redirectToGoogle(): Response
{
return \Inertia\Inertia::location(
Socialite::driver($this->driver)

View file

@ -6,19 +6,40 @@
use Illuminate\Http\Request;
use Inertia\Inertia;
use Inertia\Response;
use Symfony\Component\HttpFoundation\Response as SymfonyResponse;
class BillingController extends Controller
{
/**
* Show the subscription selection page for new users.
*/
public function subscribe(Request $request): Response|RedirectResponse
{
$user = $request->user();
// If already subscribed, redirect to billing
if ($user->subscribed('default')) {
return redirect()->route('billing.index');
}
return Inertia::render('billing/Subscribe', [
'trialDays' => config('cashier.trial_days'),
]);
}
/**
* Show the billing dashboard.
*/
public function index(Request $request): Response
{
$user = $request->user();
$subscription = $user->subscription('default');
return Inertia::render('billing/Index', [
'hasSubscription' => $user->hasActiveSubscription(),
'subscription' => $user->subscription('default')?->only([
'hasSubscription' => $user->subscribed('default'),
'onTrial' => $subscription?->onTrial() ?? false,
'trialEndsAt' => $subscription?->trial_ends_at?->toFormattedDateString(),
'subscription' => $subscription?->only([
'stripe_status',
'quantity',
'ends_at',
@ -41,22 +62,47 @@ public function index(Request $request): Response
}
/**
* Create a Stripe Checkout session for new subscription.
* Create a Stripe Checkout session for new subscription with trial.
*/
public function checkout(Request $request): RedirectResponse
public function checkout(Request $request): SymfonyResponse
{
$user = $request->user();
// Calculate quantity based on workspaces (minimum 1)
$quantity = max(1, $user->ownedWorkspacesCount());
$subscription = $user->newSubscription('default', config('cashier.plans.monthly.price_id'))
->allowPromotionCodes()
->trialDays(config('cashier.trial_days'))
->quantity(1);
return $user->newSubscription('default', config('services.stripe.price_id'))
->quantity($quantity)
->checkout([
'success_url' => route('billing.index') . '?checkout=success',
'cancel_url' => route('billing.index') . '?checkout=cancelled',
])
->redirect();
$checkoutSession = $subscription->checkout([
'success_url' => route('billing.processing').'?status=success',
'cancel_url' => route('billing.processing').'?status=cancelled',
]);
return Inertia::location($checkoutSession->url);
}
/**
* Show the checkout processing page.
*/
public function processing(Request $request): Response|RedirectResponse
{
$user = $request->user();
$status = $request->query('status', 'processing');
// If already subscribed, redirect to dashboard
if ($user->subscribed('default')) {
return redirect()->route('dashboard');
}
// Validate status
if (! in_array($status, ['processing', 'success', 'cancelled'])) {
$status = 'processing';
}
return Inertia::render('billing/Processing', [
'userId' => $user->id,
'status' => $status,
]);
}
/**
@ -76,14 +122,14 @@ public function addWorkspace(Request $request): RedirectResponse
{
$user = $request->user();
if (! $user->hasActiveSubscription()) {
if (! $user->subscribed('default')) {
return redirect()->route('billing.index')
->withErrors(['subscription' => 'Você precisa de uma assinatura ativa.']);
->withErrors(['subscription' => 'You need an active subscription.']);
}
$user->incrementWorkspaceQuantity();
return back()->with('success', 'Workspace adicionado à assinatura.');
return back()->with('success', 'Workspace added to subscription.');
}
/**
@ -93,12 +139,12 @@ public function removeWorkspace(Request $request): RedirectResponse
{
$user = $request->user();
if (! $user->hasActiveSubscription()) {
if (! $user->subscribed('default')) {
return back();
}
$user->decrementWorkspaceQuantity();
return back()->with('success', 'Workspace removido da assinatura.');
return back()->with('success', 'Workspace removed from subscription.');
}
}

View file

@ -0,0 +1,117 @@
<?php
namespace App\Http\Controllers;
use App\Enums\SocialPlatform;
use App\Enums\User\Persona;
use App\Enums\User\Setup;
use Illuminate\Http\RedirectResponse;
use Illuminate\Http\Request;
use Illuminate\Validation\Rule;
use Inertia\Inertia;
use Inertia\Response;
use Symfony\Component\HttpFoundation\Response as SymfonyResponse;
class OnboardingController extends Controller
{
/**
* Step 1: Select persona (user type).
*/
public function step1(): Response
{
return Inertia::render('onboarding/Step1', [
'personas' => Persona::toSelectArray(),
]);
}
/**
* Store step 1 and proceed to step 2.
*/
public function storeStep1(Request $request): RedirectResponse
{
$validated = $request->validate([
'persona' => ['required', Rule::enum(Persona::class)],
]);
$request->user()->update([
'persona' => $validated['persona'],
'setup' => Setup::Connections,
]);
return redirect()->route('onboarding.step2');
}
/**
* Step 2: Connect social accounts.
*/
public function step2(Request $request): Response
{
$user = $request->user();
$workspace = $user->currentWorkspace;
$platforms = collect();
if ($workspace) {
$connectedAccounts = $workspace->socialAccounts;
$platforms = collect(SocialPlatform::enabled())->map(function ($platform) use ($connectedAccounts) {
$connected = $connectedAccounts->firstWhere('platform', $platform);
return [
'value' => $platform->value,
'label' => $platform->label(),
'color' => $platform->color(),
'connected' => $connected !== null,
'account' => $connected,
];
})->values();
}
return Inertia::render('onboarding/Step2', [
'platforms' => $platforms,
'hasWorkspace' => $workspace !== null,
]);
}
/**
* Store step 2 and redirect to Stripe checkout.
*/
public function storeStep2(Request $request): SymfonyResponse
{
$user = $request->user();
$user->update([
'setup' => Setup::Subscription,
]);
// Redirect to Stripe checkout
$subscription = $user->newSubscription('default', config('cashier.plans.monthly.price_id'))
->allowPromotionCodes()
->trialDays(config('cashier.trial_days'))
->quantity(1);
$checkoutSession = $subscription->checkout([
'success_url' => route('onboarding.complete').'?session_id={CHECKOUT_SESSION_ID}',
'cancel_url' => route('onboarding.step2'),
]);
return Inertia::location($checkoutSession->url);
}
/**
* Complete onboarding after successful Stripe checkout.
*/
public function complete(Request $request): RedirectResponse
{
$user = $request->user();
$user->update([
'setup' => Setup::Completed,
]);
session()->flash('flash.banner', 'Welcome to TryPost! Your trial has started.');
session()->flash('flash.bannerStyle', 'success');
return redirect()->route('calendar');
}
}

View file

@ -7,7 +7,6 @@
use App\Http\Requests\UpdatePostRequest;
use App\Jobs\PublishPost;
use App\Models\Post;
use App\Models\Workspace;
use Carbon\Carbon;
use Illuminate\Http\RedirectResponse;
use Illuminate\Http\Request;
@ -16,8 +15,14 @@
class PostController extends Controller
{
public function index(Workspace $workspace): Response
public function index(Request $request): Response|RedirectResponse
{
$workspace = $request->user()->currentWorkspace;
if (! $workspace) {
return redirect()->route('workspaces.create');
}
$this->authorize('view', $workspace);
$posts = $workspace->posts()
@ -31,8 +36,14 @@ public function index(Workspace $workspace): Response
]);
}
public function calendar(Request $request, Workspace $workspace): Response
public function calendar(Request $request): Response|RedirectResponse
{
$workspace = $request->user()->currentWorkspace;
if (! $workspace) {
return redirect()->route('workspaces.create');
}
$this->authorize('view', $workspace);
$tz = $workspace->timezone;
@ -61,8 +72,14 @@ public function calendar(Request $request, Workspace $workspace): Response
]);
}
public function create(Request $request, Workspace $workspace): RedirectResponse
public function create(Request $request): RedirectResponse
{
$workspace = $request->user()->currentWorkspace;
if (! $workspace) {
return redirect()->route('workspaces.create');
}
$this->authorize('view', $workspace);
$socialAccounts = $workspace->socialAccounts;
@ -71,7 +88,7 @@ public function create(Request $request, Workspace $workspace): RedirectResponse
session()->flash('flash.banner', 'Connect at least one social network before creating a post.');
session()->flash('flash.bannerStyle', 'danger');
return redirect()->route('workspaces.accounts', $workspace);
return redirect()->route('accounts');
}
// Create a draft post - default to today if no date provided
@ -96,11 +113,17 @@ public function create(Request $request, Workspace $workspace): RedirectResponse
]);
}
return redirect()->route('workspaces.posts.edit', [$workspace, $post]);
return redirect()->route('posts.edit', $post);
}
public function store(StorePostRequest $request, Workspace $workspace): RedirectResponse
public function store(StorePostRequest $request): RedirectResponse
{
$workspace = $request->user()->currentWorkspace;
if (! $workspace) {
return redirect()->route('workspaces.create');
}
$this->authorize('view', $workspace);
$post = $workspace->posts()->create([
@ -125,17 +148,23 @@ public function store(StorePostRequest $request, Workspace $workspace): Redirect
}
$route = $request->input('status') === PostStatus::Scheduled->value
? 'workspaces.calendar'
: 'workspaces.posts.index';
? 'calendar'
: 'posts.index';
session()->flash('flash.banner', 'Post created successfully!');
session()->flash('flash.bannerStyle', 'success');
return redirect()->route($route, $workspace);
return redirect()->route($route);
}
public function show(Workspace $workspace, Post $post): Response
public function show(Request $request, Post $post): Response|RedirectResponse
{
$workspace = $request->user()->currentWorkspace;
if (! $workspace) {
return redirect()->route('workspaces.create');
}
$this->authorize('view', $workspace);
if ($post->workspace_id !== $workspace->id) {
@ -150,8 +179,14 @@ public function show(Workspace $workspace, Post $post): Response
]);
}
public function edit(Workspace $workspace, Post $post): Response|RedirectResponse
public function edit(Request $request, Post $post): Response|RedirectResponse
{
$workspace = $request->user()->currentWorkspace;
if (! $workspace) {
return redirect()->route('workspaces.create');
}
$this->authorize('view', $workspace);
if ($post->workspace_id !== $workspace->id) {
@ -162,7 +197,7 @@ public function edit(Workspace $workspace, Post $post): Response|RedirectRespons
session()->flash('flash.banner', 'Published posts cannot be edited.');
session()->flash('flash.bannerStyle', 'danger');
return redirect()->route('workspaces.posts.show', [$workspace, $post]);
return redirect()->route('posts.show', $post);
}
$post->load(['postPlatforms.socialAccount', 'postPlatforms.media']);
@ -189,8 +224,14 @@ public function edit(Workspace $workspace, Post $post): Response|RedirectRespons
]);
}
public function update(UpdatePostRequest $request, Workspace $workspace, Post $post): RedirectResponse
public function update(UpdatePostRequest $request, Post $post): RedirectResponse
{
$workspace = $request->user()->currentWorkspace;
if (! $workspace) {
return redirect()->route('workspaces.create');
}
$this->authorize('view', $workspace);
if ($post->workspace_id !== $workspace->id) {
@ -238,17 +279,23 @@ public function update(UpdatePostRequest $request, Workspace $workspace, Post $p
session()->flash('flash.banner', 'Post is being published!');
session()->flash('flash.bannerStyle', 'success');
return redirect()->route('workspaces.posts.show', [$workspace, $post]);
return redirect()->route('posts.show', $post);
}
session()->flash('flash.banner', 'Post updated successfully!');
session()->flash('flash.bannerStyle', 'success');
return redirect()->route('workspaces.posts.show', [$workspace, $post]);
return redirect()->route('posts.show', $post);
}
public function destroy(Workspace $workspace, Post $post): RedirectResponse
public function destroy(Request $request, Post $post): RedirectResponse
{
$workspace = $request->user()->currentWorkspace;
if (! $workspace) {
return redirect()->route('workspaces.create');
}
$this->authorize('view', $workspace);
if ($post->workspace_id !== $workspace->id) {
@ -260,6 +307,6 @@ public function destroy(Workspace $workspace, Post $post): RedirectResponse
session()->flash('flash.banner', 'Post deleted successfully!');
session()->flash('flash.bannerStyle', 'success');
return redirect()->route('workspaces.calendar', $workspace);
return redirect()->route('calendar');
}
}

View file

@ -12,19 +12,27 @@
class WorkspaceController extends Controller
{
/**
* List all workspaces.
*/
public function index(Request $request): Response
{
$workspaces = $request->user()
->workspaces()
$user = $request->user();
$workspaces = $user->workspaces()
->withCount(['socialAccounts', 'posts'])
->latest()
->get();
return Inertia::render('workspaces/Index', [
'workspaces' => $workspaces,
'currentWorkspaceId' => $user->current_workspace_id,
]);
}
/**
* Show create workspace form.
*/
public function create(Request $request): Response|RedirectResponse
{
$user = $request->user();
@ -38,6 +46,9 @@ public function create(Request $request): Response|RedirectResponse
return Inertia::render('workspaces/Create');
}
/**
* Store a new workspace.
*/
public function store(StoreWorkspaceRequest $request): RedirectResponse
{
$user = $request->user();
@ -50,57 +61,46 @@ public function store(StoreWorkspaceRequest $request): RedirectResponse
$workspace = $user->workspaces()->create($request->validated());
// Set as current workspace
$user->switchWorkspace($workspace);
// Increment subscription quantity if user has subscription
if ($user->hasActiveSubscription()) {
$user->incrementWorkspaceQuantity();
}
return redirect()->route('workspaces.show', $workspace)
return redirect()->route('calendar')
->with('success', 'Workspace created successfully!');
}
public function show(Request $request, Workspace $workspace): Response
/**
* Switch to a different workspace.
*/
public function switch(Request $request, Workspace $workspace): RedirectResponse
{
$this->authorize('view', $workspace);
$user = $request->user();
$workspace->load(['socialAccounts', 'posts' => function ($query) {
$query->latest()->take(5);
}]);
if (! $user->belongsToWorkspace($workspace)) {
abort(403);
}
$stats = [
'total_posts' => $workspace->posts()->count(),
'scheduled_posts' => $workspace->posts()->scheduled()->count(),
'published_posts' => $workspace->posts()->published()->count(),
'connected_accounts' => $workspace->socialAccounts()->count(),
];
$user->switchWorkspace($workspace);
return Inertia::render('workspaces/Show', [
'workspace' => $workspace,
'stats' => $stats,
]);
return redirect()->route('calendar');
}
public function edit(Workspace $workspace): Response
/**
* Show workspace settings.
*/
public function settings(Request $request): Response|RedirectResponse
{
$this->authorize('update', $workspace);
$user = $request->user();
$workspace = $user->currentWorkspace;
return Inertia::render('workspaces/Edit', [
'workspace' => $workspace,
]);
}
if (! $workspace) {
return redirect()->route('workspaces.create');
}
public function update(UpdateWorkspaceRequest $request, Workspace $workspace): RedirectResponse
{
$this->authorize('update', $workspace);
$workspace->update($request->validated());
return redirect()->route('workspaces.show', $workspace)
->with('success', 'Workspace updated successfully!');
}
public function settings(Workspace $workspace): Response
{
$this->authorize('update', $workspace);
$timezones = collect(timezone_identifiers_list())
@ -113,8 +113,18 @@ public function settings(Workspace $workspace): Response
]);
}
public function updateSettings(UpdateWorkspaceRequest $request, Workspace $workspace): RedirectResponse
/**
* Update workspace settings.
*/
public function updateSettings(UpdateWorkspaceRequest $request): RedirectResponse
{
$user = $request->user();
$workspace = $user->currentWorkspace;
if (! $workspace) {
return redirect()->route('workspaces.create');
}
$this->authorize('update', $workspace);
$workspace->update($request->validated());
@ -122,15 +132,23 @@ public function updateSettings(UpdateWorkspaceRequest $request, Workspace $works
session()->flash('flash.banner', 'Settings updated successfully!');
session()->flash('flash.bannerStyle', 'success');
return redirect()->route('workspaces.settings', $workspace);
return redirect()->route('settings');
}
/**
* Delete a workspace.
*/
public function destroy(Request $request, Workspace $workspace): RedirectResponse
{
$this->authorize('delete', $workspace);
$user = $request->user();
// If deleting current workspace, clear it
if ($user->current_workspace_id === $workspace->id) {
$user->update(['current_workspace_id' => null]);
}
$workspace->delete();
// Decrement subscription quantity if user has subscription
@ -138,7 +156,7 @@ public function destroy(Request $request, Workspace $workspace): RedirectRespons
$user->decrementWorkspaceQuantity();
}
return redirect()->route('workspaces.index')
return redirect()->route('dashboard')
->with('success', 'Workspace deleted successfully!');
}
}

View file

@ -4,7 +4,6 @@
use App\Enums\WorkspaceRole;
use App\Http\Requests\StoreWorkspaceInviteRequest;
use App\Models\Workspace;
use App\Models\WorkspaceInvite;
use App\Notifications\WorkspaceInviteNotification;
use Illuminate\Http\RedirectResponse;
@ -14,8 +13,14 @@
class WorkspaceInviteController extends Controller
{
public function index(Workspace $workspace): Response
public function index(Request $request): Response|RedirectResponse
{
$workspace = $request->user()->currentWorkspace;
if (! $workspace) {
return redirect()->route('workspaces.create');
}
$this->authorize('manageTeam', $workspace);
return Inertia::render('workspaces/Invites', [
@ -48,8 +53,14 @@ public function index(Workspace $workspace): Response
]);
}
public function store(StoreWorkspaceInviteRequest $request, Workspace $workspace): RedirectResponse
public function store(StoreWorkspaceInviteRequest $request): RedirectResponse
{
$workspace = $request->user()->currentWorkspace;
if (! $workspace) {
return redirect()->route('workspaces.create');
}
$this->authorize('manageTeam', $workspace);
$existingInvite = $workspace->invites()
@ -80,8 +91,14 @@ public function store(StoreWorkspaceInviteRequest $request, Workspace $workspace
return back()->with('success', 'Invite sent successfully!');
}
public function destroy(Workspace $workspace, WorkspaceInvite $invite): RedirectResponse
public function destroy(Request $request, WorkspaceInvite $invite): RedirectResponse
{
$workspace = $request->user()->currentWorkspace;
if (! $workspace) {
return redirect()->route('workspaces.create');
}
$this->authorize('manageTeam', $workspace);
if ($invite->workspace_id !== $workspace->id) {
@ -99,11 +116,11 @@ public function accept(Request $request, string $token): RedirectResponse
if (! $invite->isValid()) {
if ($invite->isExpired()) {
return redirect()->route('workspaces.index')
return redirect()->route('dashboard')
->withErrors(['invite' => 'This invite has expired.']);
}
return redirect()->route('workspaces.index')
return redirect()->route('dashboard')
->withErrors(['invite' => 'This invite is no longer valid.']);
}
@ -117,18 +134,30 @@ public function accept(Request $request, string $token): RedirectResponse
}
if ($invite->workspace->hasMember($user)) {
return redirect()->route('workspaces.show', $invite->workspace)
// Switch to this workspace
$user->switchWorkspace($invite->workspace);
return redirect()->route('calendar')
->with('message', 'You are already a member of this workspace.');
}
$invite->accept($user);
return redirect()->route('workspaces.show', $invite->workspace)
// Switch to the new workspace
$user->switchWorkspace($invite->workspace);
return redirect()->route('calendar')
->with('success', 'You are now a member of the workspace!');
}
public function removeMember(Workspace $workspace, string $userId): RedirectResponse
public function removeMember(Request $request, string $userId): RedirectResponse
{
$workspace = $request->user()->currentWorkspace;
if (! $workspace) {
return redirect()->route('workspaces.create');
}
$this->authorize('manageTeam', $workspace);
if ($workspace->user_id === $userId) {

View file

@ -0,0 +1,32 @@
<?php
namespace App\Http\Middleware;
use Closure;
use Illuminate\Http\Request;
use Symfony\Component\HttpFoundation\Response;
class EnsureSubscribed
{
/**
* Handle an incoming request.
*
* @param \Closure(\Illuminate\Http\Request): (\Symfony\Component\HttpFoundation\Response) $next
*/
public function handle(Request $request, Closure $next): Response
{
$user = $request->user();
if (! $user) {
return redirect()->route('login');
}
// Allow access if user has active subscription or is on trial
if ($user->subscribed('default') || $user->onTrial('default')) {
return $next($request);
}
// Redirect to subscription page
return redirect()->route('subscribe');
}
}

View file

@ -0,0 +1,55 @@
<?php
namespace App\Http\Middleware;
use App\Enums\User\Setup;
use Closure;
use Illuminate\Http\Request;
use Symfony\Component\HttpFoundation\Response;
class EnsureUserSetupIsComplete
{
/**
* Handle an incoming request.
*
* @param \Closure(\Illuminate\Http\Request): (\Symfony\Component\HttpFoundation\Response) $next
*/
public function handle(Request $request, Closure $next): Response
{
$user = $request->user();
if (! $user) {
return $next($request);
}
// If setup is completed, allow through
if ($user->setup === Setup::Completed) {
return $next($request);
}
// 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'],
};
$currentRoute = $request->route()?->getName();
// Check if current route is allowed
foreach ($allowedRoutes as $pattern) {
if ($currentRoute === $pattern || fnmatch($pattern, $currentRoute ?? '')) {
return $next($request);
}
}
// Redirect to appropriate step
return match ($user->setup) {
Setup::Role => redirect()->route('onboarding.step1'),
Setup::Connections => redirect()->route('onboarding.step2'),
Setup::Subscription => redirect()->route('onboarding.step2'),
default => redirect()->route('onboarding.step1'),
};
}
}

View file

@ -35,12 +35,16 @@ public function version(Request $request): ?string
*/
public function share(Request $request): array
{
$user = $request->user();
return [
...parent::share($request),
'name' => config('app.name'),
'auth' => [
'user' => $request->user(),
'user' => $user,
],
'currentWorkspace' => $user?->currentWorkspace,
'workspaces' => $user ? $user->workspaces()->select('workspaces.id', 'workspaces.name')->get() : [],
'sidebarOpen' => ! $request->hasCookie('sidebar_state') || $request->cookie('sidebar_state') === 'true',
'flash' => $request->session()->get('flash', []),
'env' => config('app.env'),

View file

@ -0,0 +1,28 @@
<?php
namespace App\Http\Responses;
use App\Enums\User\Setup;
use Laravel\Fortify\Contracts\LoginResponse as LoginResponseContract;
use Symfony\Component\HttpFoundation\Response;
class LoginResponse implements LoginResponseContract
{
public function toResponse($request): Response
{
$user = $request->user();
// Determine redirect based on setup status
$redirect = match ($user->setup) {
Setup::Completed => route('calendar'),
Setup::Role => route('onboarding.step1'),
Setup::Connections => route('onboarding.step2'),
Setup::Subscription => route('onboarding.step2'),
default => route('onboarding.step1'),
};
return $request->wantsJson()
? response()->json(['two_factor' => false])
: redirect()->intended($redirect);
}
}

View file

@ -0,0 +1,16 @@
<?php
namespace App\Http\Responses;
use Laravel\Fortify\Contracts\RegisterResponse as RegisterResponseContract;
use Symfony\Component\HttpFoundation\Response;
class RegisterResponse implements RegisterResponseContract
{
public function toResponse($request): Response
{
return $request->wantsJson()
? response()->json(['two_factor' => false])
: redirect()->route('onboarding.step1');
}
}

View file

@ -4,6 +4,7 @@
use App\Enums\SocialPlatform;
use App\Events\PostPlatformStatusUpdated;
use App\Exceptions\TokenExpiredException;
use App\Models\PostPlatform;
use App\Services\Social\FacebookPublisher;
use App\Services\Social\InstagramPublisher;
@ -37,6 +38,16 @@ public function handle(): void
$result = $publisher->publish($this->postPlatform);
$this->postPlatform->markAsPublished($result['id'], $result['url'] ?? null);
} catch (TokenExpiredException $e) {
Log::error('Token expired while publishing to social platform', [
'post_platform_id' => $this->postPlatform->id,
'platform' => $this->postPlatform->platform->value,
'error' => $e->getMessage(),
'platform_error_code' => $e->platformErrorCode,
]);
$this->postPlatform->markAsFailed($e->getMessage());
$this->postPlatform->socialAccount->markAsDisconnected($e->getMessage());
} catch (\Exception $e) {
Log::error('Failed to publish to social platform', [
'post_platform_id' => $this->postPlatform->id,

View file

@ -0,0 +1,61 @@
<?php
declare(strict_types=1);
namespace App\Listeners;
use App\Events\SubscriptionCreated;
use App\Models\User;
use Illuminate\Support\Facades\Log;
use Laravel\Cashier\Events\WebhookReceived;
class StripeEventListener
{
/**
* Handle received Stripe webhooks.
*/
public function handle(WebhookReceived $event): void
{
try {
$type = $event->payload['type'] ?? null;
$stripeCustomerId = $event->payload['data']['object']['customer'] ?? null;
if (! $stripeCustomerId) {
return;
}
$user = User::where('stripe_id', $stripeCustomerId)->first();
if (! $user) {
return;
}
match ($type) {
'customer.subscription.created' => $this->handleSubscriptionCreated($user, $event->payload),
'customer.subscription.updated' => $this->handleSubscriptionUpdated($user, $event->payload),
'customer.subscription.deleted' => $this->handleSubscriptionDeleted($user, $event->payload),
default => null,
};
} catch (\Exception $e) {
Log::error('Stripe webhook error: '.$e->getMessage(), [
'exception' => $e,
'payload' => $event->payload,
]);
}
}
protected function handleSubscriptionCreated(User $user, array $payload): void
{
SubscriptionCreated::dispatch($user);
}
protected function handleSubscriptionUpdated(User $user, array $payload): void
{
// Future: dispatch SubscriptionUpdated event if needed
}
protected function handleSubscriptionDeleted(User $user, array $payload): void
{
// Future: dispatch SubscriptionDeleted event if needed
}
}

View file

@ -15,6 +15,8 @@ class PostMedia extends Model
/** @use HasFactory<\Database\Factories\PostMediaFactory> */
use HasFactory, HasUuids;
protected $table = 'post_medias';
protected $appends = ['url'];
protected $fillable = [

View file

@ -3,12 +3,15 @@
namespace App\Models;
use App\Enums\SocialPlatform;
use App\Enums\Status;
use App\Notifications\AccountDisconnectedNotification;
use Illuminate\Database\Eloquent\Casts\Attribute;
use Illuminate\Database\Eloquent\Concerns\HasUuids;
use Illuminate\Database\Eloquent\Factories\HasFactory;
use Illuminate\Database\Eloquent\Model;
use Illuminate\Database\Eloquent\Relations\BelongsTo;
use Illuminate\Database\Eloquent\Relations\HasMany;
use Illuminate\Support\Facades\Cache;
use Illuminate\Support\Facades\Storage;
class SocialAccount extends Model
@ -28,6 +31,9 @@ class SocialAccount extends Model
'token_expires_at',
'scopes',
'meta',
'status',
'error_message',
'disconnected_at',
];
protected $hidden = [
@ -39,9 +45,11 @@ protected function casts(): array
{
return [
'platform' => SocialPlatform::class,
'status' => Status::class,
'access_token' => 'encrypted',
'refresh_token' => 'encrypted',
'token_expires_at' => 'datetime',
'disconnected_at' => 'datetime',
'scopes' => 'array',
'meta' => 'array',
];
@ -77,4 +85,42 @@ protected function avatarUrl(): Attribute
get: fn (?string $value) => $value ? Storage::url($value) : null,
);
}
public function markAsDisconnected(string $errorMessage): void
{
$lock = Cache::lock("social_account_disconnect:{$this->id}", 10);
if ($lock->get()) {
try {
$this->refresh();
$wasConnected = $this->status !== Status::Disconnected;
$this->update([
'status' => Status::Disconnected,
'error_message' => $errorMessage,
'disconnected_at' => now(),
]);
if ($wasConnected) {
$this->workspace->owner->notify(new AccountDisconnectedNotification($this));
}
} finally {
$lock->release();
}
}
}
public function markAsConnected(): void
{
$this->update([
'status' => Status::Connected,
'error_message' => null,
'disconnected_at' => null,
]);
}
public function isDisconnected(): bool
{
return $this->status === Status::Disconnected || $this->status === Status::TokenExpired;
}
}

View file

@ -3,8 +3,11 @@
namespace App\Models;
// use Illuminate\Contracts\Auth\MustVerifyEmail;
use App\Enums\User\Persona;
use App\Enums\User\Setup;
use Illuminate\Database\Eloquent\Concerns\HasUuids;
use Illuminate\Database\Eloquent\Factories\HasFactory;
use Illuminate\Database\Eloquent\Relations\BelongsTo;
use Illuminate\Database\Eloquent\Relations\BelongsToMany;
use Illuminate\Database\Eloquent\Relations\HasMany;
use Illuminate\Foundation\Auth\User as Authenticatable;
@ -26,6 +29,9 @@ class User extends Authenticatable
'name',
'email',
'password',
'setup',
'persona',
'current_workspace_id',
];
/**
@ -51,6 +57,8 @@ protected function casts(): array
'email_verified_at' => 'datetime',
'password' => 'hashed',
'two_factor_confirmed_at' => 'datetime',
'setup' => Setup::class,
'persona' => Persona::class,
];
}
@ -73,6 +81,31 @@ public function memberWorkspaces(): BelongsToMany
->withTimestamps();
}
/**
* Get the user's current workspace.
*/
public function currentWorkspace(): BelongsTo
{
return $this->belongsTo(Workspace::class, 'current_workspace_id');
}
/**
* Switch to a different workspace.
*/
public function switchWorkspace(Workspace $workspace): void
{
$this->update(['current_workspace_id' => $workspace->id]);
}
/**
* Check if user belongs to a workspace (owner or member).
*/
public function belongsToWorkspace(Workspace $workspace): bool
{
return $this->workspaces()->where('id', $workspace->id)->exists()
|| $this->memberWorkspaces()->where('workspaces.id', $workspace->id)->exists();
}
/**
* Get the count of workspaces the user owns.
*/
@ -89,6 +122,14 @@ public function hasActiveSubscription(): bool
return $this->subscribed('default');
}
/**
* Check if user has ever had a subscription (for trial eligibility).
*/
public function hasEverSubscribed(): bool
{
return $this->subscriptions()->exists();
}
/**
* Check if user can create more workspaces based on subscription.
*/

View file

@ -0,0 +1,46 @@
<?php
namespace App\Notifications;
use App\Models\SocialAccount;
use Illuminate\Notifications\Messages\MailMessage;
use Illuminate\Notifications\Notification;
class AccountDisconnectedNotification extends Notification
{
public function __construct(
public SocialAccount $account
) {}
public function via(object $notifiable): array
{
return ['mail'];
}
public function toMail(object $notifiable): MailMessage
{
$reconnectUrl = route('workspaces.accounts', $this->account->workspace_id);
$platformName = $this->account->platform->label();
$accountName = $this->account->display_name ?? $this->account->username;
return (new MailMessage)
->subject("Your {$platformName} account needs to be reconnected")
->greeting('Hello!')
->line("Your **{$platformName}** account **{$accountName}** has been disconnected from TryPost.")
->line('This may have happened because:')
->line('- Your access token expired')
->line('- You revoked access to TryPost')
->line('- There was an authentication error')
->line('Please reconnect your account to continue scheduling and publishing posts.')
->action('Reconnect Account', $reconnectUrl);
}
public function toArray(object $notifiable): array
{
return [
'account_id' => $this->account->id,
'platform' => $this->account->platform->value,
'workspace_id' => $this->account->workspace_id,
];
}
}

View file

@ -2,7 +2,8 @@
namespace App\Providers;
use App\Socialite\InstagramExtendSocialite;
use App\Listeners\StripeEventListener;
use App\Socialite\InstagramProvider;
use App\Socialite\LinkedInPageExtendSocialite;
use Carbon\CarbonImmutable;
use Illuminate\Support\Facades\Date;
@ -10,6 +11,9 @@
use Illuminate\Support\Facades\Event;
use Illuminate\Support\ServiceProvider;
use Illuminate\Validation\Rules\Password;
use Laravel\Cashier\Events\WebhookReceived;
use Laravel\Socialite\Facades\Socialite;
use SocialiteProviders\Facebook\FacebookExtendSocialite;
use SocialiteProviders\LinkedIn\LinkedInExtendSocialite;
use SocialiteProviders\Manager\SocialiteWasCalled;
use SocialiteProviders\TikTok\TikTokExtendSocialite;
@ -31,11 +35,24 @@ public function boot(): void
{
$this->configureDefaults();
$this->configureSocialite();
$this->configureStripeWebhooks();
}
protected function configureStripeWebhooks(): void
{
Event::listen(WebhookReceived::class, StripeEventListener::class);
}
protected function configureSocialite(): void
{
Event::listen(SocialiteWasCalled::class, InstagramExtendSocialite::class);
// Instagram Business Login
Socialite::extend('instagram', function ($app) {
$config = $app['config']['services.instagram'];
return Socialite::buildProvider(InstagramProvider::class, $config);
});
Event::listen(SocialiteWasCalled::class, FacebookExtendSocialite::class);
Event::listen(SocialiteWasCalled::class, LinkedInExtendSocialite::class);
Event::listen(SocialiteWasCalled::class, LinkedInPageExtendSocialite::class);
Event::listen(SocialiteWasCalled::class, TikTokExtendSocialite::class);

View file

@ -4,12 +4,16 @@
use App\Actions\Fortify\CreateNewUser;
use App\Actions\Fortify\ResetUserPassword;
use App\Http\Responses\LoginResponse;
use App\Http\Responses\RegisterResponse;
use Illuminate\Cache\RateLimiting\Limit;
use Illuminate\Http\Request;
use Illuminate\Support\Facades\RateLimiter;
use Illuminate\Support\ServiceProvider;
use Illuminate\Support\Str;
use Inertia\Inertia;
use Laravel\Fortify\Contracts\LoginResponse as LoginResponseContract;
use Laravel\Fortify\Contracts\RegisterResponse as RegisterResponseContract;
use Laravel\Fortify\Features;
use Laravel\Fortify\Fortify;
@ -20,7 +24,8 @@ class FortifyServiceProvider extends ServiceProvider
*/
public function register(): void
{
//
$this->app->singleton(LoginResponseContract::class, LoginResponse::class);
$this->app->singleton(RegisterResponseContract::class, RegisterResponse::class);
}
/**

View file

@ -2,12 +2,32 @@
namespace App\Services\Social;
use App\Exceptions\TokenExpiredException;
use App\Models\PostPlatform;
use Illuminate\Http\Client\Response;
use Illuminate\Support\Facades\Http;
use Illuminate\Support\Facades\Log;
class FacebookPublisher
{
/**
* Meta Graph API error codes that indicate token issues.
*
* @see https://developers.facebook.com/docs/graph-api/guides/error-handling
*/
private const TOKEN_ERROR_CODES = [
190, // Invalid OAuth access token
];
private const TOKEN_ERROR_SUBCODES = [
458, // App not installed
459, // User checkpointed
460, // Password changed
463, // Session expired
464, // Unconfirmed user
467, // Invalid access token
];
private string $baseUrl = 'https://graph.facebook.com/v21.0';
public function publish(PostPlatform $postPlatform): array
@ -57,7 +77,7 @@ private function publishTextPost(string $pageId, string $accessToken, string $co
'status' => $response->status(),
'body' => $response->body(),
]);
throw new \Exception('Facebook API error: '.$response->body());
$this->handleApiError($response, 'Facebook API error');
}
$data = $response->json();
@ -84,7 +104,7 @@ private function publishSingleImagePost(string $pageId, string $accessToken, str
'status' => $response->status(),
'body' => $response->body(),
]);
throw new \Exception('Facebook API error: '.$response->body());
$this->handleApiError($response, 'Facebook API error');
}
$data = $response->json();
@ -150,7 +170,7 @@ private function publishMultiImagePost(string $pageId, string $accessToken, stri
'status' => $response->status(),
'body' => $response->body(),
]);
throw new \Exception('Facebook API error: '.$response->body());
$this->handleApiError($response, 'Facebook API error');
}
$data = $response->json();
@ -178,7 +198,7 @@ private function publishVideoPost(string $pageId, string $accessToken, string $c
'status' => $response->status(),
'body' => $response->body(),
]);
throw new \Exception('Facebook API error: '.$response->body());
$this->handleApiError($response, 'Facebook API error');
}
$data = $response->json();
@ -189,4 +209,27 @@ private function publishVideoPost(string $pageId, string $accessToken, string $c
'url' => "https://www.facebook.com/{$pageId}/videos/{$videoId}",
];
}
private function handleApiError(Response $response, string $context): void
{
$body = $response->json() ?? [];
$error = $body['error'] ?? [];
$errorCode = $error['code'] ?? null;
$errorSubcode = $error['error_subcode'] ?? null;
$errorType = $error['type'] ?? null;
$message = $error['message'] ?? $response->body();
$isTokenError = $errorType === 'OAuthException'
|| in_array($errorCode, self::TOKEN_ERROR_CODES)
|| in_array($errorSubcode, self::TOKEN_ERROR_SUBCODES);
if ($isTokenError) {
throw new TokenExpiredException(
"{$context}: {$message}",
$errorCode ? (string) $errorCode : null
);
}
throw new \Exception("{$context}: {$message}");
}
}

View file

@ -2,12 +2,32 @@
namespace App\Services\Social;
use App\Exceptions\TokenExpiredException;
use App\Models\PostPlatform;
use Illuminate\Http\Client\Response;
use Illuminate\Support\Facades\Http;
use Illuminate\Support\Facades\Log;
class InstagramPublisher
{
/**
* Meta Graph API error codes that indicate token issues.
*
* @see https://developers.facebook.com/docs/graph-api/guides/error-handling
*/
private const TOKEN_ERROR_CODES = [
190, // Invalid OAuth access token
];
private const TOKEN_ERROR_SUBCODES = [
458, // App not installed
459, // User checkpointed
460, // Password changed
463, // Session expired
464, // Unconfirmed user
467, // Invalid access token
];
private string $baseUrl = 'https://graph.facebook.com/v21.0';
public function publish(PostPlatform $postPlatform): array
@ -54,7 +74,7 @@ private function publishSingleImage(string $instagramId, string $accessToken, st
'status' => $containerResponse->status(),
'body' => $containerResponse->body(),
]);
throw new \Exception('Instagram API error: '.$containerResponse->body());
$this->handleApiError($containerResponse, 'Instagram API error');
}
$containerId = $containerResponse->json()['id'];
@ -80,7 +100,7 @@ private function publishReel(string $instagramId, string $accessToken, string $c
'status' => $containerResponse->status(),
'body' => $containerResponse->body(),
]);
throw new \Exception('Instagram API error: '.$containerResponse->body());
$this->handleApiError($containerResponse, 'Instagram API error');
}
$containerId = $containerResponse->json()['id'];
@ -153,7 +173,7 @@ private function publishCarousel(string $instagramId, string $accessToken, strin
Log::error('Instagram carousel container creation failed', [
'body' => $carouselResponse->body(),
]);
throw new \Exception('Instagram API error: '.$carouselResponse->body());
$this->handleApiError($carouselResponse, 'Instagram API error');
}
$carouselId = $carouselResponse->json()['id'];
@ -174,7 +194,7 @@ private function publishContainer(string $instagramId, string $accessToken, stri
'status' => $publishResponse->status(),
'body' => $publishResponse->body(),
]);
throw new \Exception('Instagram publish error: '.$publishResponse->body());
$this->handleApiError($publishResponse, 'Instagram publish error');
}
$mediaId = $publishResponse->json()['id'];
@ -226,4 +246,27 @@ private function waitForMediaProcessing(string $containerId, string $accessToken
Log::warning('Instagram media processing timeout, proceeding anyway');
}
private function handleApiError(Response $response, string $context): void
{
$body = $response->json() ?? [];
$error = $body['error'] ?? [];
$errorCode = $error['code'] ?? null;
$errorSubcode = $error['error_subcode'] ?? null;
$errorType = $error['type'] ?? null;
$message = $error['message'] ?? $response->body();
$isTokenError = $errorType === 'OAuthException'
|| in_array($errorCode, self::TOKEN_ERROR_CODES)
|| in_array($errorSubcode, self::TOKEN_ERROR_SUBCODES);
if ($isTokenError) {
throw new TokenExpiredException(
"{$context}: {$message}",
$errorCode ? (string) $errorCode : null
);
}
throw new \Exception("{$context}: {$message}");
}
}

View file

@ -2,17 +2,30 @@
namespace App\Services\Social;
use App\Exceptions\TokenExpiredException;
use App\Models\PostPlatform;
use App\Models\SocialAccount;
use Illuminate\Http\Client\PendingRequest;
use Illuminate\Http\Client\Response;
use Illuminate\Support\Facades\Http;
use Illuminate\Support\Facades\Log;
class LinkedInPagePublisher
{
/**
* LinkedIn API error codes that indicate token issues.
*
* @see https://learn.microsoft.com/en-us/linkedin/shared/api-guide/concepts/error-handling
*/
private const TOKEN_ERROR_CODES = [
'REVOKED_ACCESS_TOKEN',
'EXPIRED_ACCESS_TOKEN',
'INVALID_ACCESS_TOKEN',
];
private string $baseUrl = 'https://api.linkedin.com';
private string $apiVersion = '202501';
private string $apiVersion = '202601';
private string $accessToken;
@ -72,7 +85,7 @@ public function publish(PostPlatform $postPlatform): array
'status' => $response->status(),
'body' => $response->body(),
]);
throw new \Exception('LinkedIn Page API error: '.$response->body());
$this->handleApiError($response, 'LinkedIn Page API error');
}
$postId = $response->header('x-restli-id');
@ -131,7 +144,7 @@ private function uploadImage($mediaItem, string $ownerUrn): ?string
if ($initResponse->failed()) {
Log::error('LinkedIn Page image init failed', ['body' => $initResponse->body()]);
throw new \Exception('Failed to initialize LinkedIn Page image upload: '.$initResponse->body());
$this->handleApiError($initResponse, 'Failed to initialize LinkedIn Page image upload');
}
$initData = $initResponse->json();
@ -156,7 +169,7 @@ private function uploadImage($mediaItem, string $ownerUrn): ?string
if ($uploadResponse->failed()) {
Log::error('LinkedIn Page image upload failed', ['body' => $uploadResponse->body()]);
throw new \Exception('Failed to upload LinkedIn Page image: '.$uploadResponse->body());
$this->handleApiError($uploadResponse, 'Failed to upload LinkedIn Page image');
}
Log::info('LinkedIn Page image upload success', ['imageUrn' => $imageUrn]);
@ -187,7 +200,7 @@ private function uploadVideo($mediaItem, string $ownerUrn): ?string
if ($initResponse->failed()) {
Log::error('LinkedIn Page video init failed', ['body' => $initResponse->body()]);
throw new \Exception('Failed to initialize LinkedIn Page video upload: '.$initResponse->body());
$this->handleApiError($initResponse, 'Failed to initialize LinkedIn Page video upload');
}
$initData = $initResponse->json();
@ -234,7 +247,7 @@ private function uploadVideo($mediaItem, string $ownerUrn): ?string
'index' => $index,
'body' => $chunkResponse->body(),
]);
throw new \Exception('Failed to upload LinkedIn Page video chunk: '.$chunkResponse->body());
$this->handleApiError($chunkResponse, 'Failed to upload LinkedIn Page video chunk');
}
$etag = $chunkResponse->header('etag');
@ -259,7 +272,7 @@ private function uploadVideo($mediaItem, string $ownerUrn): ?string
if ($finalizeResponse->failed()) {
Log::error('LinkedIn Page video finalize failed', ['body' => $finalizeResponse->body()]);
throw new \Exception('Failed to finalize LinkedIn Page video upload: '.$finalizeResponse->body());
$this->handleApiError($finalizeResponse, 'Failed to finalize LinkedIn Page video upload');
}
Log::info('LinkedIn Page video upload finalized', ['videoUrn' => $videoUrn]);
@ -307,7 +320,7 @@ private function waitForVideoProcessing(string $videoUrn, int $maxAttempts = 30)
private function refreshToken(SocialAccount $account): void
{
if (! $account->refresh_token) {
throw new \Exception('No refresh token available for LinkedIn Page account');
throw new TokenExpiredException('No refresh token available for LinkedIn Page account');
}
$response = Http::asForm()->post('https://www.linkedin.com/oauth/v2/accessToken', [
@ -318,7 +331,7 @@ private function refreshToken(SocialAccount $account): void
]);
if ($response->failed()) {
throw new \Exception('Failed to refresh LinkedIn Page token: '.$response->body());
$this->handleApiError($response, 'Failed to refresh LinkedIn Page token');
}
$data = $response->json();
@ -329,4 +342,20 @@ private function refreshToken(SocialAccount $account): void
'token_expires_at' => isset($data['expires_in']) ? now()->addSeconds($data['expires_in']) : null,
]);
}
private function handleApiError(Response $response, string $context): void
{
$body = $response->json() ?? [];
$errorCode = $body['code'] ?? null;
$message = $body['message'] ?? $response->body();
if ($response->status() === 401 || in_array($errorCode, self::TOKEN_ERROR_CODES)) {
throw new TokenExpiredException(
"{$context}: {$message}",
$errorCode
);
}
throw new \Exception("{$context}: {$message}");
}
}

View file

@ -2,17 +2,30 @@
namespace App\Services\Social;
use App\Exceptions\TokenExpiredException;
use App\Models\PostPlatform;
use App\Models\SocialAccount;
use Illuminate\Http\Client\PendingRequest;
use Illuminate\Http\Client\Response;
use Illuminate\Support\Facades\Http;
use Illuminate\Support\Facades\Log;
class LinkedInPublisher
{
/**
* LinkedIn API error codes that indicate token issues.
*
* @see https://learn.microsoft.com/en-us/linkedin/shared/api-guide/concepts/error-handling
*/
private const TOKEN_ERROR_CODES = [
'REVOKED_ACCESS_TOKEN',
'EXPIRED_ACCESS_TOKEN',
'INVALID_ACCESS_TOKEN',
];
private string $baseUrl = 'https://api.linkedin.com';
private string $apiVersion = '202501';
private string $apiVersion = '202601';
private string $accessToken;
@ -66,7 +79,7 @@ public function publish(PostPlatform $postPlatform): array
'status' => $response->status(),
'body' => $response->body(),
]);
throw new \Exception('LinkedIn API error: '.$response->body());
$this->handleApiError($response, 'LinkedIn API error');
}
$postId = $response->header('x-restli-id');
@ -119,7 +132,7 @@ private function uploadImage($mediaItem, string $ownerUrn): ?string
if ($initResponse->failed()) {
Log::error('LinkedIn image init failed', ['body' => $initResponse->body()]);
throw new \Exception('Failed to initialize LinkedIn image upload: '.$initResponse->body());
$this->handleApiError($initResponse, 'Failed to initialize LinkedIn image upload');
}
$initData = $initResponse->json();
@ -144,7 +157,7 @@ private function uploadImage($mediaItem, string $ownerUrn): ?string
if ($uploadResponse->failed()) {
Log::error('LinkedIn image upload failed', ['body' => $uploadResponse->body()]);
throw new \Exception('Failed to upload LinkedIn image: '.$uploadResponse->body());
$this->handleApiError($uploadResponse, 'Failed to upload LinkedIn image');
}
Log::info('LinkedIn image upload success', ['imageUrn' => $imageUrn]);
@ -175,7 +188,7 @@ private function uploadVideo($mediaItem, string $ownerUrn): ?string
if ($initResponse->failed()) {
Log::error('LinkedIn video init failed', ['body' => $initResponse->body()]);
throw new \Exception('Failed to initialize LinkedIn video upload: '.$initResponse->body());
$this->handleApiError($initResponse, 'Failed to initialize LinkedIn video upload');
}
$initData = $initResponse->json();
@ -222,7 +235,7 @@ private function uploadVideo($mediaItem, string $ownerUrn): ?string
'index' => $index,
'body' => $chunkResponse->body(),
]);
throw new \Exception('Failed to upload LinkedIn video chunk: '.$chunkResponse->body());
$this->handleApiError($chunkResponse, 'Failed to upload LinkedIn video chunk');
}
$etag = $chunkResponse->header('etag');
@ -247,7 +260,7 @@ private function uploadVideo($mediaItem, string $ownerUrn): ?string
if ($finalizeResponse->failed()) {
Log::error('LinkedIn video finalize failed', ['body' => $finalizeResponse->body()]);
throw new \Exception('Failed to finalize LinkedIn video upload: '.$finalizeResponse->body());
$this->handleApiError($finalizeResponse, 'Failed to finalize LinkedIn video upload');
}
Log::info('LinkedIn video upload finalized', ['videoUrn' => $videoUrn]);
@ -295,7 +308,7 @@ private function waitForVideoProcessing(string $videoUrn, int $maxAttempts = 30)
private function refreshToken(SocialAccount $account): void
{
if (! $account->refresh_token) {
throw new \Exception('No refresh token available for LinkedIn account');
throw new TokenExpiredException('No refresh token available for LinkedIn account');
}
$response = Http::asForm()->post('https://www.linkedin.com/oauth/v2/accessToken', [
@ -306,7 +319,7 @@ private function refreshToken(SocialAccount $account): void
]);
if ($response->failed()) {
throw new \Exception('Failed to refresh LinkedIn token: '.$response->body());
$this->handleApiError($response, 'Failed to refresh LinkedIn token');
}
$data = $response->json();
@ -317,4 +330,20 @@ private function refreshToken(SocialAccount $account): void
'token_expires_at' => isset($data['expires_in']) ? now()->addSeconds($data['expires_in']) : null,
]);
}
private function handleApiError(Response $response, string $context): void
{
$body = $response->json() ?? [];
$errorCode = $body['code'] ?? null;
$message = $body['message'] ?? $response->body();
if ($response->status() === 401 || in_array($errorCode, self::TOKEN_ERROR_CODES)) {
throw new TokenExpiredException(
"{$context}: {$message}",
$errorCode
);
}
throw new \Exception("{$context}: {$message}");
}
}

View file

@ -2,13 +2,33 @@
namespace App\Services\Social;
use App\Exceptions\TokenExpiredException;
use App\Models\PostPlatform;
use App\Models\SocialAccount;
use Illuminate\Http\Client\Response;
use Illuminate\Support\Facades\Http;
use Illuminate\Support\Facades\Log;
class ThreadsPublisher
{
/**
* Meta Graph API error codes that indicate token issues.
*
* @see https://developers.facebook.com/docs/threads/error-handling
*/
private const TOKEN_ERROR_CODES = [
190, // Invalid OAuth access token
];
private const TOKEN_ERROR_SUBCODES = [
458, // App not installed
459, // User checkpointed
460, // Password changed
463, // Session expired
464, // Unconfirmed user
467, // Invalid access token
];
private string $baseUrl = 'https://graph.threads.net/v1.0';
public function publish(PostPlatform $postPlatform): array
@ -62,7 +82,7 @@ private function publishTextPost(string $userId, string $accessToken, string $co
'status' => $containerResponse->status(),
'body' => $containerResponse->body(),
]);
throw new \Exception('Threads API error: '.$containerResponse->body());
$this->handleApiError($containerResponse, 'Threads API error');
}
$containerId = $containerResponse->json()['id'];
@ -88,7 +108,7 @@ private function publishImagePost(string $userId, string $accessToken, string $c
'status' => $containerResponse->status(),
'body' => $containerResponse->body(),
]);
throw new \Exception('Threads API error: '.$containerResponse->body());
$this->handleApiError($containerResponse, 'Threads API error');
}
$containerId = $containerResponse->json()['id'];
@ -119,7 +139,7 @@ private function publishVideoPost(string $userId, string $accessToken, string $c
'status' => $containerResponse->status(),
'body' => $containerResponse->body(),
]);
throw new \Exception('Threads API error: '.$containerResponse->body());
$this->handleApiError($containerResponse, 'Threads API error');
}
$containerId = $containerResponse->json()['id'];
@ -191,7 +211,7 @@ private function publishCarousel(string $userId, string $accessToken, string $co
Log::error('Threads carousel container creation failed', [
'body' => $carouselResponse->body(),
]);
throw new \Exception('Threads API error: '.$carouselResponse->body());
$this->handleApiError($carouselResponse, 'Threads API error');
}
$carouselId = $carouselResponse->json()['id'];
@ -212,7 +232,7 @@ private function publishContainer(string $userId, string $accessToken, string $c
'status' => $publishResponse->status(),
'body' => $publishResponse->body(),
]);
throw new \Exception('Threads publish error: '.$publishResponse->body());
$this->handleApiError($publishResponse, 'Threads publish error');
}
$mediaId = $publishResponse->json()['id'];
@ -288,7 +308,7 @@ private function refreshToken(SocialAccount $account): void
if ($response->failed()) {
Log::error('Threads token refresh failed', ['body' => $response->body()]);
throw new \Exception('Failed to refresh Threads token: '.$response->body());
$this->handleApiError($response, 'Failed to refresh Threads token');
}
$data = $response->json();
@ -300,4 +320,27 @@ private function refreshToken(SocialAccount $account): void
Log::info('Threads token refreshed successfully');
}
private function handleApiError(Response $response, string $context): void
{
$body = $response->json() ?? [];
$error = $body['error'] ?? [];
$errorCode = $error['code'] ?? null;
$errorSubcode = $error['error_subcode'] ?? null;
$errorType = $error['type'] ?? null;
$message = $error['message'] ?? $response->body();
$isTokenError = $errorType === 'OAuthException'
|| in_array($errorCode, self::TOKEN_ERROR_CODES)
|| in_array($errorSubcode, self::TOKEN_ERROR_SUBCODES);
if ($isTokenError) {
throw new TokenExpiredException(
"{$context}: {$message}",
$errorCode ? (string) $errorCode : null
);
}
throw new \Exception("{$context}: {$message}");
}
}

View file

@ -2,14 +2,33 @@
namespace App\Services\Social;
use App\Exceptions\TokenExpiredException;
use App\Models\PostPlatform;
use App\Models\SocialAccount;
use Illuminate\Http\Client\PendingRequest;
use Illuminate\Http\Client\Response;
use Illuminate\Support\Facades\Http;
use Illuminate\Support\Facades\Log;
class TikTokPublisher
{
/**
* TikTok API error codes that indicate token issues.
*
* @see https://developers.tiktok.com/doc/tiktok-api-v2-error-handling
*/
private const TOKEN_ERROR_CODES = [
'access_token_invalid',
'access_token_expired',
'token_expired',
];
private const TOKEN_ERROR_NUMERIC_CODES = [
10001, // Invalid Access Token
10002, // Access Token Expired
10003, // Invalid Client Key
];
private string $baseUrl = 'https://open.tiktokapis.com/v2';
private string $accessToken;
@ -59,6 +78,7 @@ private function publishVideo(PostPlatform $postPlatform, $media): array
{
Log::info('TikTok publishing video', [
'video_url' => $media->url,
'media_full_url' => $media->full_url ?? $media->url,
'content' => $postPlatform->content,
]);
@ -66,7 +86,7 @@ private function publishVideo(PostPlatform $postPlatform, $media): array
->post("{$this->baseUrl}/post/publish/video/init/", [
'post_info' => [
'title' => $postPlatform->content,
'privacy_level' => 'PUBLIC_TO_EVERYONE',
'privacy_level' => 'SELF_ONLY',
'disable_duet' => false,
'disable_comment' => false,
'disable_stitch' => false,
@ -82,7 +102,7 @@ private function publishVideo(PostPlatform $postPlatform, $media): array
'status' => $response->status(),
'body' => $response->body(),
]);
throw new \Exception('TikTok API error: '.$response->body());
$this->handleApiError($response, 'TikTok API error');
}
$data = $response->json();
@ -117,6 +137,7 @@ private function publishPhotos(PostPlatform $postPlatform, $mediaCollection): ar
}
Log::info('TikTok publishing photos', [
'photo_urls' => $photoUrls,
'photo_count' => count($photoUrls),
'content' => $postPlatform->content,
]);
@ -125,7 +146,7 @@ private function publishPhotos(PostPlatform $postPlatform, $mediaCollection): ar
->post("{$this->baseUrl}/post/publish/content/init/", [
'post_info' => [
'title' => $postPlatform->content,
'privacy_level' => 'PUBLIC_TO_EVERYONE',
'privacy_level' => 'SELF_ONLY',
'disable_comment' => false,
],
'source_info' => [
@ -142,7 +163,7 @@ private function publishPhotos(PostPlatform $postPlatform, $mediaCollection): ar
'status' => $response->status(),
'body' => $response->body(),
]);
throw new \Exception('TikTok API error: '.$response->body());
$this->handleApiError($response, 'TikTok API error');
}
$data = $response->json();
@ -223,7 +244,7 @@ private function buildTikTokUrl(SocialAccount $account): ?string
private function refreshToken(SocialAccount $account): void
{
if (! $account->refresh_token) {
throw new \Exception('No refresh token available for TikTok account');
throw new TokenExpiredException('No refresh token available for TikTok account');
}
$response = Http::asForm()->post('https://open.tiktokapis.com/v2/oauth/token/', [
@ -235,7 +256,7 @@ private function refreshToken(SocialAccount $account): void
if ($response->failed()) {
Log::error('TikTok token refresh failed', ['body' => $response->body()]);
throw new \Exception('Failed to refresh TikTok token: '.$response->body());
$this->handleApiError($response, 'Failed to refresh TikTok token');
}
$data = $response->json();
@ -248,4 +269,26 @@ private function refreshToken(SocialAccount $account): void
Log::info('TikTok token refreshed successfully');
}
private function handleApiError(Response $response, string $context): void
{
$body = $response->json() ?? [];
$error = $body['error'] ?? [];
$errorCode = $error['code'] ?? $body['error']['code'] ?? null;
$errorMessage = $error['message'] ?? $body['error']['message'] ?? $response->body();
// TikTok can return error codes as strings or numeric codes
$isTokenError = in_array($errorCode, self::TOKEN_ERROR_CODES)
|| in_array((int) $errorCode, self::TOKEN_ERROR_NUMERIC_CODES)
|| $response->status() === 401;
if ($isTokenError) {
throw new TokenExpiredException(
"{$context}: {$errorMessage}",
is_string($errorCode) ? $errorCode : (string) $errorCode
);
}
throw new \Exception("{$context}: {$errorMessage}");
}
}

View file

@ -2,14 +2,25 @@
namespace App\Services\Social;
use App\Exceptions\TokenExpiredException;
use App\Models\PostPlatform;
use App\Models\SocialAccount;
use Illuminate\Http\Client\PendingRequest;
use Illuminate\Http\Client\Response;
use Illuminate\Support\Facades\Http;
use Illuminate\Support\Facades\Log;
class XPublisher
{
/**
* X/Twitter API error titles that indicate token issues.
*
* @see https://developer.twitter.com/en/support/twitter-api/error-troubleshooting
*/
private const TOKEN_ERROR_TITLES = [
'Unauthorized',
];
private string $baseUrl = 'https://api.x.com';
private string $accessToken;
@ -61,11 +72,18 @@ public function publish(PostPlatform $postPlatform): array
Log::info('Posting tweet', ['data' => $data]);
$response = $this->getHttpClient()
->post("{$this->baseUrl}/2/tweets", $data)
->throw()
->json();
->post("{$this->baseUrl}/2/tweets", $data);
$tweetId = $response['data']['id'] ?? null;
if ($response->failed()) {
Log::error('X post creation failed', [
'status' => $response->status(),
'body' => $response->body(),
]);
$this->handleApiError($response, 'X API error');
}
$responseData = $response->json();
$tweetId = $responseData['data']['id'] ?? null;
return [
'id' => $tweetId ?? 'unknown',
@ -131,7 +149,7 @@ private function uploadMedia($mediaItem): ?array
'status' => $response->status(),
'body' => $response->body(),
]);
throw new \Exception('Failed to upload media: '.$response->status().' - '.$response->body());
$this->handleApiError($response, 'Failed to upload media');
}
$responseData = $response->json();
@ -175,7 +193,7 @@ private function chunkedUpload(string $mediaContent, string $mimeType, string $m
'status' => $initResponse->status(),
'body' => $initResponse->body(),
]);
throw new \Exception('Failed to initialize chunked upload: '.$initResponse->body());
$this->handleApiError($initResponse, 'Failed to initialize chunked upload');
}
$initData = $initResponse->json();
@ -212,7 +230,7 @@ private function chunkedUpload(string $mediaContent, string $mimeType, string $m
'body' => $appendResponse->body(),
'segment' => $index,
]);
throw new \Exception('Failed to append chunk: '.$appendResponse->body());
$this->handleApiError($appendResponse, 'Failed to append chunk');
}
}
@ -228,7 +246,7 @@ private function chunkedUpload(string $mediaContent, string $mimeType, string $m
'status' => $finalizeResponse->status(),
'body' => $finalizeResponse->body(),
]);
throw new \Exception('Failed to finalize chunked upload: '.$finalizeResponse->body());
$this->handleApiError($finalizeResponse, 'Failed to finalize chunked upload');
}
$finalizeData = $finalizeResponse->json();
@ -307,7 +325,7 @@ private function waitForProcessing(string $mediaId, int $maxAttempts = 20): bool
private function refreshToken(SocialAccount $account): void
{
if (! $account->refresh_token) {
throw new \Exception('No refresh token available for X account');
throw new TokenExpiredException('No refresh token available for X account');
}
$response = Http::asForm()->post("{$this->baseUrl}/2/oauth2/token", [
@ -317,7 +335,7 @@ private function refreshToken(SocialAccount $account): void
]);
if ($response->failed()) {
throw new \Exception('Failed to refresh X token: '.$response->body());
$this->handleApiError($response, 'Failed to refresh X token');
}
$data = $response->json();
@ -328,4 +346,20 @@ private function refreshToken(SocialAccount $account): void
'token_expires_at' => now()->addSeconds($data['expires_in'] ?? 7200),
]);
}
private function handleApiError(Response $response, string $context): void
{
$body = $response->json() ?? [];
$errorTitle = $body['title'] ?? null;
$message = $body['detail'] ?? $response->body();
if ($response->status() === 401 || in_array($errorTitle, self::TOKEN_ERROR_TITLES)) {
throw new TokenExpiredException(
"{$context}: {$message}",
$errorTitle
);
}
throw new \Exception("{$context}: {$message}");
}
}

View file

@ -2,14 +2,33 @@
namespace App\Services\Social;
use App\Exceptions\TokenExpiredException;
use App\Models\PostPlatform;
use App\Models\SocialAccount;
use Illuminate\Http\Client\PendingRequest;
use Illuminate\Http\Client\Response;
use Illuminate\Support\Facades\Http;
use Illuminate\Support\Facades\Log;
class YouTubePublisher
{
/**
* Google/YouTube API error codes that indicate token issues.
*
* @see https://developers.google.com/youtube/v3/docs/errors
*/
private const TOKEN_ERROR_CODES = [
'invalid_grant',
'invalid_token',
'unauthorized',
];
private const TOKEN_ERROR_REASONS = [
'authError',
'forbidden',
'unauthorized',
];
private string $baseUrl = 'https://www.googleapis.com';
private string $accessToken;
@ -87,7 +106,7 @@ private function publishShort(PostPlatform $postPlatform, $media): array
'status' => $initResponse->status(),
'body' => $initResponse->body(),
]);
throw new \Exception('YouTube API error: '.$initResponse->body());
$this->handleApiError($initResponse, 'YouTube API error');
}
$uploadUrl = $initResponse->header('Location');
@ -113,7 +132,7 @@ private function publishShort(PostPlatform $postPlatform, $media): array
'status' => $uploadResponse->status(),
'body' => $uploadResponse->body(),
]);
throw new \Exception('YouTube upload error: '.$uploadResponse->body());
$this->handleApiError($uploadResponse, 'YouTube upload error');
}
$data = $uploadResponse->json();
@ -154,7 +173,7 @@ private function buildTitle(string $content): string
private function refreshToken(SocialAccount $account): void
{
if (! $account->refresh_token) {
throw new \Exception('No refresh token available for YouTube account');
throw new TokenExpiredException('No refresh token available for YouTube account');
}
$response = Http::asForm()->post('https://oauth2.googleapis.com/token', [
@ -166,7 +185,7 @@ private function refreshToken(SocialAccount $account): void
if ($response->failed()) {
Log::error('YouTube token refresh failed', ['body' => $response->body()]);
throw new \Exception('Failed to refresh YouTube token: '.$response->body());
$this->handleApiError($response, 'Failed to refresh YouTube token');
}
$data = $response->json();
@ -179,4 +198,37 @@ private function refreshToken(SocialAccount $account): void
Log::info('YouTube token refreshed successfully');
}
private function handleApiError(Response $response, string $context): void
{
$body = $response->json() ?? [];
// Google OAuth error format
$errorCode = $body['error'] ?? null;
$errorDescription = $body['error_description'] ?? null;
// YouTube API error format
$error = $body['error'] ?? [];
if (is_array($error)) {
$errors = $error['errors'] ?? [];
$reason = $errors[0]['reason'] ?? null;
$message = $error['message'] ?? $errorDescription ?? $response->body();
} else {
$reason = null;
$message = $errorDescription ?? $response->body();
}
$isTokenError = $response->status() === 401
|| in_array($errorCode, self::TOKEN_ERROR_CODES)
|| in_array($reason, self::TOKEN_ERROR_REASONS);
if ($isTokenError) {
throw new TokenExpiredException(
"{$context}: {$message}",
is_string($errorCode) ? $errorCode : $reason
);
}
throw new \Exception("{$context}: {$message}");
}
}

View file

@ -1,13 +0,0 @@
<?php
namespace App\Socialite;
use SocialiteProviders\Manager\SocialiteWasCalled;
class InstagramExtendSocialite
{
public function handle(SocialiteWasCalled $socialiteWasCalled): void
{
$socialiteWasCalled->extendSocialite('instagram', \Laravel\Socialite\Two\FacebookProvider::class);
}
}

View file

@ -0,0 +1,95 @@
<?php
namespace App\Socialite;
use GuzzleHttp\RequestOptions;
use Laravel\Socialite\Two\AbstractProvider;
use Laravel\Socialite\Two\ProviderInterface;
use Laravel\Socialite\Two\User;
class InstagramProvider extends AbstractProvider implements ProviderInterface
{
protected $scopes = [
'instagram_business_basic',
'instagram_business_content_publish',
];
protected function getAuthUrl($state): string
{
return 'https://www.instagram.com/oauth/authorize?'.http_build_query([
'client_id' => $this->clientId,
'redirect_uri' => $this->redirectUrl,
'response_type' => 'code',
'state' => $state,
'scope' => implode(',', $this->getScopes()),
]);
}
protected function getTokenUrl(): string
{
return 'https://api.instagram.com/oauth/access_token';
}
protected function getUserByToken($token): array
{
$response = $this->getHttpClient()->get('https://graph.instagram.com/v22.0/me', [
RequestOptions::QUERY => [
'access_token' => $token,
'fields' => 'id,username,account_type,name,profile_picture_url',
],
]);
return json_decode((string) $response->getBody(), true);
}
protected function mapUserToObject(array $user): User
{
return (new User)->setRaw($user)->map([
'id' => $user['id'],
'nickname' => $user['username'] ?? null,
'name' => $user['name'] ?? $user['username'] ?? null,
'avatar' => $user['profile_picture_url'] ?? null,
]);
}
public function getAccessTokenResponse($code): array
{
$response = $this->getHttpClient()->post($this->getTokenUrl(), [
RequestOptions::FORM_PARAMS => $this->getTokenFields($code),
]);
$data = json_decode((string) $response->getBody(), true);
// Exchange short-lived token for long-lived token
return $this->exchangeForLongLivedToken($data);
}
protected function exchangeForLongLivedToken(array $data): array
{
$response = $this->getHttpClient()->get('https://graph.instagram.com/access_token', [
RequestOptions::QUERY => [
'grant_type' => 'ig_exchange_token',
'client_secret' => $this->clientSecret,
'access_token' => $data['access_token'],
],
]);
$longLivedData = json_decode((string) $response->getBody(), true);
return array_merge($data, [
'access_token' => $longLivedData['access_token'],
'expires_in' => $longLivedData['expires_in'] ?? null,
]);
}
protected function getTokenFields($code): array
{
return [
'client_id' => $this->clientId,
'client_secret' => $this->clientSecret,
'grant_type' => 'authorization_code',
'redirect_uri' => $this->redirectUrl,
'code' => $code,
];
}
}

View file

@ -1,5 +1,6 @@
<?php
use App\Http\Middleware\EnsureSubscribed;
use App\Http\Middleware\HandleAppearance;
use App\Http\Middleware\HandleInertiaRequests;
use Illuminate\Foundation\Application;
@ -22,6 +23,10 @@
HandleInertiaRequests::class,
AddLinkHeadersForPreloadedAssets::class,
]);
$middleware->alias([
'subscribed' => EnsureSubscribed::class,
]);
})
->withExceptions(function (Exceptions $exceptions): void {
//

View file

@ -21,6 +21,8 @@
"laravel/wayfinder": "^0.1.9",
"league/flysystem-aws-s3-v3": "^3.0",
"predis/predis": "^3.3",
"socialiteproviders/facebook": "^4.1",
"socialiteproviders/instagram": "^5.1",
"socialiteproviders/linkedin": "^5.0",
"socialiteproviders/tiktok": "^5.2",
"socialiteproviders/twitter": "^4.1"

93
composer.lock generated
View file

@ -4,7 +4,7 @@
"Read more about it at https://getcomposer.org/doc/01-basic-usage.md#installing-dependencies",
"This file is @generated automatically"
],
"content-hash": "6993d4c308ced358f1dca8da903c9c22",
"content-hash": "2154f2c0783c4818692dc434812ea32b",
"packages": [
{
"name": "aws/aws-crt-php",
@ -5727,6 +5727,97 @@
],
"time": "2024-06-11T12:45:25+00:00"
},
{
"name": "socialiteproviders/facebook",
"version": "4.1.0",
"source": {
"type": "git",
"url": "https://github.com/SocialiteProviders/Facebook.git",
"reference": "9b94a9334b5d0f61de8f5a20928d63d4d8f4e00d"
},
"dist": {
"type": "zip",
"url": "https://api.github.com/repos/SocialiteProviders/Facebook/zipball/9b94a9334b5d0f61de8f5a20928d63d4d8f4e00d",
"reference": "9b94a9334b5d0f61de8f5a20928d63d4d8f4e00d",
"shasum": ""
},
"require": {
"ext-json": "*",
"php": "^7.2 || ^8.0",
"socialiteproviders/manager": "~4.0"
},
"type": "library",
"autoload": {
"psr-4": {
"SocialiteProviders\\Facebook\\": ""
}
},
"notification-url": "https://packagist.org/downloads/",
"license": [
"MIT"
],
"authors": [
{
"name": "Oleksandr Prypkhan (Alex Wells)",
"email": "autaut03@googlemail.com"
}
],
"description": "Facebook (facebook.com) OAuth2 Provider for Laravel Socialite",
"support": {
"source": "https://github.com/SocialiteProviders/Facebook/tree/4.1.0"
},
"time": "2020-12-01T23:10:59+00:00"
},
{
"name": "socialiteproviders/instagram",
"version": "5.1.0",
"source": {
"type": "git",
"url": "https://github.com/SocialiteProviders/Instagram.git",
"reference": "9b6022f08e328503464cd6480fe65ff0ab5caab9"
},
"dist": {
"type": "zip",
"url": "https://api.github.com/repos/SocialiteProviders/Instagram/zipball/9b6022f08e328503464cd6480fe65ff0ab5caab9",
"reference": "9b6022f08e328503464cd6480fe65ff0ab5caab9",
"shasum": ""
},
"require": {
"ext-json": "*",
"php": "^8.0",
"socialiteproviders/manager": "^4.4"
},
"type": "library",
"autoload": {
"psr-4": {
"SocialiteProviders\\Instagram\\": ""
}
},
"notification-url": "https://packagist.org/downloads/",
"license": [
"MIT"
],
"authors": [
{
"name": "Brian Faust",
"email": "hello@brianfaust.de"
}
],
"description": "Instagram OAuth2 Provider for Laravel Socialite",
"keywords": [
"instagram",
"laravel",
"oauth",
"provider",
"socialite"
],
"support": {
"docs": "https://socialiteproviders.com/instagram",
"issues": "https://github.com/socialiteproviders/providers/issues",
"source": "https://github.com/socialiteproviders/providers"
},
"time": "2025-04-08T06:54:43+00:00"
},
{
"name": "socialiteproviders/linkedin",
"version": "5.0.0",

View file

@ -124,4 +124,39 @@
'logger' => env('CASHIER_LOGGER'),
/*
|--------------------------------------------------------------------------
| Subscription Plans
|--------------------------------------------------------------------------
|
| Define the available subscription plans with their Stripe price IDs.
| The 'monthly' plan bills at $25/workspace/month.
| The 'yearly' plan bills at $20/workspace/month (20% discount).
|
*/
'plans' => [
'monthly' => [
'price_id' => env('STRIPE_PRICE_MONTHLY'),
'price' => 25,
'interval' => 'month',
],
'yearly' => [
'price_id' => env('STRIPE_PRICE_YEARLY'),
'price' => 20,
'interval' => 'year',
],
],
/*
|--------------------------------------------------------------------------
| Trial Period
|--------------------------------------------------------------------------
|
| The number of days for the trial period. Set to 0 to disable trials.
|
*/
'trial_days' => env('CASHIER_TRIAL_DAYS', 8),
];

View file

@ -73,7 +73,7 @@
|
*/
'home' => '/dashboard',
'home' => '/calendar',
/*
|--------------------------------------------------------------------------

View file

@ -14,7 +14,7 @@
|
*/
'default' => env('MAIL_MAILER', 'log'),
'default' => env('MAIL_MAILER', 'smtp'),
/*
|--------------------------------------------------------------------------

View file

@ -88,9 +88,4 @@
'redirect' => env('THREADS_CLIENT_REDIRECT'),
],
'stripe' => [
'price_id' => env('STRIPE_PRICE_ID'),
'workspace_price' => env('STRIPE_WORKSPACE_PRICE', 2000), // $20.00 in cents
],
];

View file

@ -20,7 +20,14 @@ public function up(): void
$table->text('two_factor_secret')->nullable();
$table->text('two_factor_recovery_codes')->nullable();
$table->timestamp('two_factor_confirmed_at')->nullable();
$table->string('stripe_id')->nullable()->index();
$table->string('pm_type')->nullable();
$table->string('pm_last_four', 4)->nullable();
$table->timestamp('trial_ends_at')->nullable();
$table->rememberToken();
$table->string('setup')->nullable();
$table->string('persona')->nullable();
$table->uuid('current_workspace_id')->nullable();
$table->timestamps();
});

View file

@ -15,6 +15,7 @@ public function up(): void
$table->uuid('id')->primary();
$table->uuid('user_id');
$table->string('name');
$table->string('timezone');
$table->timestamps();
$table->foreign('user_id')->references('id')->on('users')->cascadeOnDelete();

View file

@ -24,6 +24,9 @@ public function up(): void
$table->timestamp('token_expires_at')->nullable();
$table->json('scopes')->nullable();
$table->json('meta')->nullable();
$table->string('status');
$table->text('error_message')->nullable();
$table->timestamp('disconnected_at')->nullable();
$table->timestamps();
$table->foreign('workspace_id')->references('id')->on('workspaces')->cascadeOnDelete();

View file

@ -19,6 +19,7 @@ public function up(): void
$table->text('content')->nullable();
$table->string('status')->default('pending');
$table->string('platform_post_id')->nullable();
$table->boolean('enabled');
$table->string('platform_url')->nullable();
$table->text('error_message')->nullable();
$table->timestamp('published_at')->nullable();

View file

@ -11,9 +11,9 @@
*/
public function up(): void
{
Schema::create('post_media', function (Blueprint $table) {
Schema::create('post_medias', function (Blueprint $table) {
$table->uuid('id')->primary();
$table->uuid('post_platform_id');
$table->uuid('post_platform_id')->nullable();
$table->string('type');
$table->string('path');
$table->string('original_filename');
@ -32,6 +32,6 @@ public function up(): void
*/
public function down(): void
{
Schema::dropIfExists('post_media');
Schema::dropIfExists('post_medias');
}
};

View file

@ -1,28 +0,0 @@
<?php
use Illuminate\Database\Migrations\Migration;
use Illuminate\Database\Schema\Blueprint;
use Illuminate\Support\Facades\Schema;
return new class extends Migration
{
/**
* Run the migrations.
*/
public function up(): void
{
Schema::table('post_media', function (Blueprint $table) {
$table->uuid('post_platform_id')->nullable()->change();
});
}
/**
* Reverse the migrations.
*/
public function down(): void
{
Schema::table('post_media', function (Blueprint $table) {
$table->uuid('post_platform_id')->nullable(false)->change();
});
}
};

View file

@ -1,28 +0,0 @@
<?php
use Illuminate\Database\Migrations\Migration;
use Illuminate\Database\Schema\Blueprint;
use Illuminate\Support\Facades\Schema;
return new class extends Migration
{
/**
* Run the migrations.
*/
public function up(): void
{
Schema::table('workspaces', function (Blueprint $table) {
$table->string('timezone')->default('America/New_York')->after('name');
});
}
/**
* Reverse the migrations.
*/
public function down(): void
{
Schema::table('workspaces', function (Blueprint $table) {
$table->dropColumn('timezone');
});
}
};

View file

@ -1,28 +0,0 @@
<?php
use Illuminate\Database\Migrations\Migration;
use Illuminate\Database\Schema\Blueprint;
use Illuminate\Support\Facades\Schema;
return new class extends Migration
{
/**
* Run the migrations.
*/
public function up(): void
{
Schema::table('post_platforms', function (Blueprint $table) {
$table->boolean('enabled')->default(true)->after('social_account_id');
});
}
/**
* Reverse the migrations.
*/
public function down(): void
{
Schema::table('post_platforms', function (Blueprint $table) {
$table->dropColumn('enabled');
});
}
};

View file

@ -13,45 +13,31 @@ @theme inline {
'Apple Color Emoji', 'Segoe UI Emoji', 'Segoe UI Symbol',
'Noto Color Emoji';
--radius-lg: var(--radius);
--radius-md: calc(var(--radius) - 2px);
--radius-sm: calc(var(--radius) - 4px);
--color-background: var(--background);
--color-foreground: var(--foreground);
--color-card: var(--card);
--color-card-foreground: var(--card-foreground);
--color-popover: var(--popover);
--color-popover-foreground: var(--popover-foreground);
--color-primary: var(--primary);
--color-primary-foreground: var(--primary-foreground);
--color-secondary: var(--secondary);
--color-secondary-foreground: var(--secondary-foreground);
--color-muted: var(--muted);
--color-muted-foreground: var(--muted-foreground);
--color-accent: var(--accent);
--color-accent-foreground: var(--accent-foreground);
--color-destructive: var(--destructive);
--color-destructive-foreground: var(--destructive-foreground);
--color-border: var(--border);
--color-input: var(--input);
--color-ring: var(--ring);
--color-chart-1: var(--chart-1);
--color-chart-2: var(--chart-2);
--color-chart-3: var(--chart-3);
--color-chart-4: var(--chart-4);
--color-chart-5: var(--chart-5);
--color-sidebar: var(--sidebar-background);
--color-sidebar: var(--sidebar);
--color-sidebar-foreground: var(--sidebar-foreground);
--color-sidebar-primary: var(--sidebar-primary);
--color-sidebar-primary-foreground: var(--sidebar-primary-foreground);
@ -59,6 +45,24 @@ @theme inline {
--color-sidebar-accent-foreground: var(--sidebar-accent-foreground);
--color-sidebar-border: var(--sidebar-border);
--color-sidebar-ring: var(--sidebar-ring);
--font-sans: var(--font-sans);
--font-mono: var(--font-mono);
--font-serif: var(--font-serif);
--radius-sm: calc(var(--radius) - 4px);
--radius-md: calc(var(--radius) - 2px);
--radius-lg: var(--radius);
--radius-xl: calc(var(--radius) + 4px);
--shadow-2xs: var(--shadow-2xs);
--shadow-xs: var(--shadow-xs);
--shadow-sm: var(--shadow-sm);
--shadow: var(--shadow);
--shadow-md: var(--shadow-md);
--shadow-lg: var(--shadow-lg);
--shadow-xl: var(--shadow-xl);
--shadow-2xl: var(--shadow-2xl);
}
/*
@ -70,6 +74,7 @@ @theme inline {
color utility to any element that depends on these defaults.
*/
@layer base {
*,
::after,
::before,
@ -80,6 +85,7 @@ @layer base {
}
@layer utilities {
body,
html {
--font-sans:
@ -90,83 +96,119 @@ @layer utilities {
}
:root {
--background: hsl(0 0% 100%);
--foreground: hsl(0 0% 3.9%);
--card: hsl(0 0% 100%);
--card-foreground: hsl(0 0% 3.9%);
--popover: hsl(0 0% 100%);
--popover-foreground: hsl(0 0% 3.9%);
--primary: hsl(0 0% 9%);
--primary-foreground: hsl(0 0% 98%);
--secondary: hsl(0 0% 92.1%);
--secondary-foreground: hsl(0 0% 9%);
--muted: hsl(0 0% 96.1%);
--muted-foreground: hsl(0 0% 45.1%);
--accent: hsl(0 0% 96.1%);
--accent-foreground: hsl(0 0% 9%);
--destructive: hsl(0 84.2% 60.2%);
--destructive-foreground: hsl(0 0% 98%);
--border: hsl(0 0% 92.8%);
--input: hsl(0 0% 89.8%);
--ring: hsl(0 0% 3.9%);
--chart-1: hsl(12 76% 61%);
--chart-2: hsl(173 58% 39%);
--chart-3: hsl(197 37% 24%);
--chart-4: hsl(43 74% 66%);
--chart-5: hsl(27 87% 67%);
--background: #f8f9fa;
--foreground: #0c0c1d;
--card: #ffffff;
--card-foreground: #0c0c1d;
--popover: #ffffff;
--popover-foreground: #0c0c1d;
--primary: #ff00c8;
--primary-foreground: #ffffff;
--secondary: #f0f0ff;
--secondary-foreground: #0c0c1d;
--muted: #f0f0ff;
--muted-foreground: #0c0c1d;
--accent: #00ffcc;
--accent-foreground: #0c0c1d;
--destructive: #ff3d00;
--destructive-foreground: #ffffff;
--border: #dfe6e9;
--input: #dfe6e9;
--ring: #ff00c8;
--chart-1: #ff00c8;
--chart-2: #9000ff;
--chart-3: #00e5ff;
--chart-4: #00ffcc;
--chart-5: #ffe600;
--sidebar: #f0f0ff;
--sidebar-foreground: #0c0c1d;
--sidebar-primary: #ff00c8;
--sidebar-primary-foreground: #ffffff;
--sidebar-accent: #00ffcc;
--sidebar-accent-foreground: #0c0c1d;
--sidebar-border: #dfe6e9;
--sidebar-ring: #ff00c8;
--font-sans: Outfit, sans-serif;
--font-serif: ui-serif, Georgia, Cambria, "Times New Roman", Times, serif;
--font-mono: Fira Code, monospace;
--radius: 0.5rem;
--sidebar-background: hsl(0 0% 98%);
--sidebar-foreground: hsl(240 5.3% 26.1%);
--sidebar-primary: hsl(0 0% 10%);
--sidebar-primary-foreground: hsl(0 0% 98%);
--sidebar-accent: hsl(0 0% 94%);
--sidebar-accent-foreground: hsl(0 0% 30%);
--sidebar-border: hsl(0 0% 91%);
--sidebar-ring: hsl(217.2 91.2% 59.8%);
--sidebar: hsl(0 0% 98%);
--shadow-x: 0px;
--shadow-y: 4px;
--shadow-blur: 8px;
--shadow-spread: -2px;
--shadow-opacity: 0.1;
--shadow-color: hsl(0 0% 0%);
--shadow-2xs: 0px 4px 8px -2px hsl(0 0% 0% / 0.05);
--shadow-xs: 0px 4px 8px -2px hsl(0 0% 0% / 0.05);
--shadow-sm: 0px 4px 8px -2px hsl(0 0% 0% / 0.10), 0px 1px 2px -3px hsl(0 0% 0% / 0.10);
--shadow: 0px 4px 8px -2px hsl(0 0% 0% / 0.10), 0px 1px 2px -3px hsl(0 0% 0% / 0.10);
--shadow-md: 0px 4px 8px -2px hsl(0 0% 0% / 0.10), 0px 2px 4px -3px hsl(0 0% 0% / 0.10);
--shadow-lg: 0px 4px 8px -2px hsl(0 0% 0% / 0.10), 0px 4px 6px -3px hsl(0 0% 0% / 0.10);
--shadow-xl: 0px 4px 8px -2px hsl(0 0% 0% / 0.10), 0px 8px 10px -3px hsl(0 0% 0% / 0.10);
--shadow-2xl: 0px 4px 8px -2px hsl(0 0% 0% / 0.25);
--tracking-normal: 0em;
--spacing: 0.25rem;
}
.dark {
--background: hsl(0 0% 3.9%);
--foreground: hsl(0 0% 98%);
--card: hsl(0 0% 3.9%);
--card-foreground: hsl(0 0% 98%);
--popover: hsl(0 0% 3.9%);
--popover-foreground: hsl(0 0% 98%);
--primary: hsl(0 0% 98%);
--primary-foreground: hsl(0 0% 9%);
--secondary: hsl(0 0% 14.9%);
--secondary-foreground: hsl(0 0% 98%);
--muted: hsl(0 0% 16.08%);
--muted-foreground: hsl(0 0% 63.9%);
--accent: hsl(0 0% 14.9%);
--accent-foreground: hsl(0 0% 98%);
--destructive: hsl(0 84% 60%);
--destructive-foreground: hsl(0 0% 98%);
--border: hsl(0 0% 14.9%);
--input: hsl(0 0% 14.9%);
--ring: hsl(0 0% 83.1%);
--chart-1: hsl(220 70% 50%);
--chart-2: hsl(160 60% 45%);
--chart-3: hsl(30 80% 55%);
--chart-4: hsl(280 65% 60%);
--chart-5: hsl(340 75% 55%);
--sidebar-background: hsl(0 0% 7%);
--sidebar-foreground: hsl(0 0% 95.9%);
--sidebar-primary: hsl(360, 100%, 100%);
--sidebar-primary-foreground: hsl(0 0% 100%);
--sidebar-accent: hsl(0 0% 15.9%);
--sidebar-accent-foreground: hsl(240 4.8% 95.9%);
--sidebar-border: hsl(0 0% 15.9%);
--sidebar-ring: hsl(217.2 91.2% 59.8%);
--sidebar: hsl(240 5.9% 10%);
--background: #0c0c1d;
--foreground: #eceff4;
--card: #1e1e3f;
--card-foreground: #eceff4;
--popover: #1e1e3f;
--popover-foreground: #eceff4;
--primary: #ff00c8;
--primary-foreground: #ffffff;
--secondary: #1e1e3f;
--secondary-foreground: #eceff4;
--muted: #151530;
--muted-foreground: #8085a6;
--accent: #00ffcc;
--accent-foreground: #0c0c1d;
--destructive: #ff3d00;
--destructive-foreground: #ffffff;
--border: #2e2e5e;
--input: #2e2e5e;
--ring: #ff00c8;
--chart-1: #ff00c8;
--chart-2: #9000ff;
--chart-3: #00e5ff;
--chart-4: #00ffcc;
--chart-5: #ffe600;
--sidebar: #0c0c1d;
--sidebar-foreground: #eceff4;
--sidebar-primary: #ff00c8;
--sidebar-primary-foreground: #ffffff;
--sidebar-accent: #00ffcc;
--sidebar-accent-foreground: #0c0c1d;
--sidebar-border: #2e2e5e;
--sidebar-ring: #ff00c8;
--font-sans: Outfit, sans-serif;
--font-serif: ui-serif, Georgia, Cambria, "Times New Roman", Times, serif;
--font-mono: Fira Code, monospace;
--radius: 0.5rem;
--shadow-x: 0px;
--shadow-y: 4px;
--shadow-blur: 8px;
--shadow-spread: -2px;
--shadow-opacity: 0.1;
--shadow-color: hsl(0 0% 0%);
--shadow-2xs: 0px 4px 8px -2px hsl(0 0% 0% / 0.05);
--shadow-xs: 0px 4px 8px -2px hsl(0 0% 0% / 0.05);
--shadow-sm: 0px 4px 8px -2px hsl(0 0% 0% / 0.10), 0px 1px 2px -3px hsl(0 0% 0% / 0.10);
--shadow: 0px 4px 8px -2px hsl(0 0% 0% / 0.10), 0px 1px 2px -3px hsl(0 0% 0% / 0.10);
--shadow-md: 0px 4px 8px -2px hsl(0 0% 0% / 0.10), 0px 2px 4px -3px hsl(0 0% 0% / 0.10);
--shadow-lg: 0px 4px 8px -2px hsl(0 0% 0% / 0.10), 0px 4px 6px -3px hsl(0 0% 0% / 0.10);
--shadow-xl: 0px 4px 8px -2px hsl(0 0% 0% / 0.10), 0px 8px 10px -3px hsl(0 0% 0% / 0.10);
--shadow-2xl: 0px 4px 8px -2px hsl(0 0% 0% / 0.25);
}
@layer base {
* {
@apply border-border outline-ring/50;
}
body {
@apply bg-background text-foreground;
}
}
}

View file

@ -1,10 +1,11 @@
<script setup lang="ts">
import type { InertiaLinkProps } from '@inertiajs/vue3';
import { Link, usePage } from '@inertiajs/vue3';
import { Briefcase, Menu } from 'lucide-vue-next';
import { Calendar, Menu, Settings, Share2, Users } from 'lucide-vue-next';
import { computed } from 'vue';
import Breadcrumbs from '@/components/Breadcrumbs.vue';
import WorkspaceSwitcher from '@/components/WorkspaceSwitcher.vue';
import { Avatar, AvatarFallback, AvatarImage } from '@/components/ui/avatar';
import { Button } from '@/components/ui/button';
import {
@ -27,6 +28,7 @@ import {
} from '@/components/ui/sheet';
import UserMenuContent from '@/components/UserMenuContent.vue';
import { useActiveUrl } from '@/composables/useActiveUrl';
import { calendar } from '@/routes';
import { getInitials } from '@/composables/useInitials';
import type { BreadcrumbItem, NavItem } from '@/types';
@ -40,6 +42,7 @@ const props = withDefaults(defineProps<Props>(), {
const page = usePage();
const auth = computed(() => page.props.auth);
const currentWorkspace = computed(() => page.props.currentWorkspace);
const { urlIsActive } = useActiveUrl();
function activeItemStyles(url: NonNullable<InertiaLinkProps['href']>) {
@ -50,9 +53,24 @@ function activeItemStyles(url: NonNullable<InertiaLinkProps['href']>) {
const mainNavItems: NavItem[] = [
{
title: 'Workspaces',
href: '/workspaces',
icon: Briefcase,
title: 'Calendar',
href: '/calendar',
icon: Calendar,
},
{
title: 'Accounts',
href: '/accounts',
icon: Share2,
},
{
title: 'Members',
href: '/members',
icon: Users,
},
{
title: 'Settings',
href: '/settings',
icon: Settings,
},
];
</script>
@ -62,7 +80,7 @@ const mainNavItems: NavItem[] = [
<div class="border-b border-sidebar-border/80">
<div class="mx-auto flex h-16 items-center px-4 md:max-w-7xl">
<!-- Mobile Menu -->
<div class="lg:hidden">
<div v-if="currentWorkspace" class="lg:hidden">
<Sheet>
<SheetTrigger :as-child="true">
<Button variant="ghost" size="icon" class="mr-2 h-9 w-9">
@ -78,27 +96,35 @@ const mainNavItems: NavItem[] = [
class="hidden dark:block h-8 w-auto" />
</SheetHeader>
<div class="flex h-full flex-1 flex-col justify-between space-y-4 py-6">
<nav class="-mx-3 space-y-1">
<Link v-for="item in mainNavItems" :key="item.title" :href="item.href"
class="flex items-center gap-x-3 rounded-lg px-3 py-2 text-sm font-medium hover:bg-accent"
:class="activeItemStyles(item.href)">
<component v-if="item.icon" :is="item.icon" class="h-5 w-5" />
{{ item.title }}
</Link>
</nav>
<div class="space-y-4">
<WorkspaceSwitcher />
<nav class="-mx-3 space-y-1">
<Link v-for="item in mainNavItems" :key="item.title" :href="item.href"
class="flex items-center gap-x-3 rounded-lg px-3 py-2 text-sm font-medium hover:bg-accent"
:class="activeItemStyles(item.href)">
<component v-if="item.icon" :is="item.icon" class="h-5 w-5" />
{{ item.title }}
</Link>
</nav>
</div>
</div>
</SheetContent>
</Sheet>
</div>
<Link href="/workspaces" class="flex items-center gap-x-2">
<Link :href="calendar.url()" class="flex items-center gap-x-2">
<img src="/images/trypost/logo-light.png" alt="TryPost" class="dark:hidden h-8 w-auto" />
<img src="/images/trypost/logo-dark.png" alt="TryPost" class="hidden dark:block h-8 w-auto" />
</Link>
<!-- Workspace Switcher - Desktop -->
<div v-if="currentWorkspace" class="ml-6 hidden lg:block">
<WorkspaceSwitcher />
</div>
<!-- Desktop Menu -->
<div class="hidden h-full lg:flex lg:flex-1">
<NavigationMenu class="ml-10 flex h-full items-stretch">
<div v-if="currentWorkspace" class="hidden h-full lg:flex lg:flex-1">
<NavigationMenu class="ml-6 flex h-full items-stretch">
<NavigationMenuList class="flex h-full items-stretch space-x-2">
<NavigationMenuItem v-for="(item, index) in mainNavItems" :key="index"
class="relative flex h-full items-center">

View file

@ -0,0 +1,244 @@
<script setup lang="ts">
import { Link, router, usePage } from '@inertiajs/vue3';
import { Calendar, Check, ChevronsUpDown, CreditCard, LogOut, Plus, Settings, Share2, Sparkles, Users } from 'lucide-vue-next';
import { computed } from 'vue';
import { Avatar, AvatarFallback, AvatarImage } from '@/components/ui/avatar';
import {
DropdownMenu,
DropdownMenuContent,
DropdownMenuGroup,
DropdownMenuItem,
DropdownMenuLabel,
DropdownMenuSeparator,
DropdownMenuTrigger,
} from '@/components/ui/dropdown-menu';
import {
Sidebar,
SidebarContent,
SidebarFooter,
SidebarGroup,
SidebarGroupContent,
SidebarGroupLabel,
SidebarHeader,
SidebarMenu,
SidebarMenuButton,
SidebarMenuItem,
SidebarRail,
} from '@/components/ui/sidebar';
import { useInitials } from '@/composables/useInitials';
import { accounts, calendar, logout, members, settings } from '@/routes';
import { index as billing } from '@/routes/billing';
import { edit as editProfile } from '@/routes/profile';
import { create as createWorkspaceRoute, switchMethod } from '@/routes/workspaces';
import type { NavItem } from '@/types';
interface Workspace {
id: string;
name: string;
}
const page = usePage();
const auth = computed(() => page.props.auth);
const currentWorkspace = computed<Workspace | null>(() => page.props.currentWorkspace as Workspace | null);
const workspaces = computed<Workspace[]>(() => page.props.workspaces as Workspace[]);
const { getInitials } = useInitials();
const mainNavItems: NavItem[] = [
{
title: 'Calendar',
href: calendar.url(),
icon: Calendar,
},
{
title: 'Accounts',
href: accounts.url(),
icon: Share2,
},
{
title: 'Members',
href: members.url(),
icon: Users,
},
{
title: 'Settings',
href: settings.url(),
icon: Settings,
},
];
function switchWorkspace(workspace: Workspace) {
router.post(switchMethod.url(workspace.id), {}, {
preserveScroll: true,
});
}
function createWorkspace() {
router.visit(createWorkspaceRoute.url());
}
function isActive(href: string): boolean {
return page.url.startsWith(href);
}
function handleLogout() {
router.flushAll();
}
</script>
<template>
<Sidebar collapsible="icon">
<SidebarHeader>
<SidebarMenu>
<SidebarMenuItem>
<DropdownMenu>
<DropdownMenuTrigger as-child>
<SidebarMenuButton
size="lg"
class="data-[state=open]:bg-sidebar-accent data-[state=open]:text-sidebar-accent-foreground"
>
<div class="flex aspect-square size-8 items-center justify-center rounded-lg bg-primary text-primary-foreground">
<Sparkles class="size-4" />
</div>
<div class="grid flex-1 text-left text-sm leading-tight">
<span class="truncate font-semibold">{{ currentWorkspace?.name || 'Select workspace' }}</span>
<span class="truncate text-xs text-muted-foreground">Workspace</span>
</div>
<ChevronsUpDown class="ml-auto size-4" />
</SidebarMenuButton>
</DropdownMenuTrigger>
<DropdownMenuContent
class="w-[--reka-dropdown-menu-trigger-width] min-w-56 rounded-lg"
align="start"
side="bottom"
:side-offset="4"
>
<DropdownMenuLabel class="text-xs text-muted-foreground">
Workspaces
</DropdownMenuLabel>
<DropdownMenuItem
v-for="workspace in workspaces"
:key="workspace.id"
class="cursor-pointer gap-2 p-2"
@click="switchWorkspace(workspace)"
>
<div class="flex size-6 items-center justify-center rounded-sm border bg-background">
<Sparkles class="size-4 shrink-0" />
</div>
<span class="truncate">{{ workspace.name }}</span>
<Check v-if="currentWorkspace?.id === workspace.id" class="ml-auto size-4" />
</DropdownMenuItem>
<DropdownMenuSeparator />
<DropdownMenuItem class="cursor-pointer gap-2 p-2" @click="createWorkspace">
<div class="flex size-6 items-center justify-center rounded-md border bg-background">
<Plus class="size-4" />
</div>
<span class="font-medium">Create workspace</span>
</DropdownMenuItem>
</DropdownMenuContent>
</DropdownMenu>
</SidebarMenuItem>
</SidebarMenu>
</SidebarHeader>
<SidebarContent>
<SidebarGroup v-if="currentWorkspace">
<SidebarGroupLabel>Menu</SidebarGroupLabel>
<SidebarGroupContent>
<SidebarMenu>
<SidebarMenuItem v-for="item in mainNavItems" :key="item.title">
<SidebarMenuButton
as-child
:tooltip="item.title"
:is-active="isActive(item.href as string)"
>
<Link :href="item.href">
<component v-if="item.icon" :is="item.icon" />
<span>{{ item.title }}</span>
</Link>
</SidebarMenuButton>
</SidebarMenuItem>
</SidebarMenu>
</SidebarGroupContent>
</SidebarGroup>
</SidebarContent>
<SidebarFooter>
<SidebarMenu>
<SidebarMenuItem>
<DropdownMenu>
<DropdownMenuTrigger as-child>
<SidebarMenuButton
size="lg"
class="data-[state=open]:bg-sidebar-accent data-[state=open]:text-sidebar-accent-foreground"
>
<Avatar class="h-8 w-8 rounded-lg">
<AvatarImage v-if="auth.user.avatar" :src="auth.user.avatar" :alt="auth.user.name" />
<AvatarFallback class="rounded-lg">
{{ getInitials(auth.user.name) }}
</AvatarFallback>
</Avatar>
<div class="grid flex-1 text-left text-sm leading-tight">
<span class="truncate font-semibold">{{ auth.user.name }}</span>
<span class="truncate text-xs text-muted-foreground">{{ auth.user.email }}</span>
</div>
<ChevronsUpDown class="ml-auto size-4" />
</SidebarMenuButton>
</DropdownMenuTrigger>
<DropdownMenuContent
class="w-[--reka-dropdown-menu-trigger-width] min-w-56 rounded-lg"
side="bottom"
align="end"
:side-offset="4"
>
<DropdownMenuLabel class="p-0 font-normal">
<div class="flex items-center gap-2 px-1 py-1.5 text-left text-sm">
<Avatar class="h-8 w-8 rounded-lg">
<AvatarImage v-if="auth.user.avatar" :src="auth.user.avatar" :alt="auth.user.name" />
<AvatarFallback class="rounded-lg">
{{ getInitials(auth.user.name) }}
</AvatarFallback>
</Avatar>
<div class="grid flex-1 text-left text-sm leading-tight">
<span class="truncate font-semibold">{{ auth.user.name }}</span>
<span class="truncate text-xs text-muted-foreground">{{ auth.user.email }}</span>
</div>
</div>
</DropdownMenuLabel>
<DropdownMenuSeparator />
<DropdownMenuGroup>
<DropdownMenuItem as-child>
<Link class="cursor-pointer" :href="editProfile()">
<Settings class="mr-2 size-4" />
Account Settings
</Link>
</DropdownMenuItem>
<DropdownMenuItem as-child>
<Link class="cursor-pointer" :href="billing.url()">
<CreditCard class="mr-2 size-4" />
Billing
</Link>
</DropdownMenuItem>
</DropdownMenuGroup>
<DropdownMenuSeparator />
<DropdownMenuItem as-child>
<Link
class="cursor-pointer"
:href="logout()"
@click="handleLogout"
as="button"
>
<LogOut class="mr-2 size-4" />
Log out
</Link>
</DropdownMenuItem>
</DropdownMenuContent>
</DropdownMenu>
</SidebarMenuItem>
</SidebarMenu>
</SidebarFooter>
<SidebarRail />
</Sidebar>
</template>

View file

@ -0,0 +1,264 @@
<script setup lang="ts">
import { router } from '@inertiajs/vue3';
import { computed, onMounted, onUnmounted } from 'vue';
import { AlertCircle, Check, ExternalLink, RefreshCw, Trash2 } from 'lucide-vue-next';
import { Avatar, AvatarFallback, AvatarImage } from '@/components/ui/avatar';
import { Button } from '@/components/ui/button';
import { Tooltip, TooltipContent, TooltipProvider, TooltipTrigger } from '@/components/ui/tooltip';
export interface SocialAccount {
id: string;
platform: string;
username: string;
display_name: string;
avatar_url: string;
status: 'connected' | 'disconnected' | 'token_expired' | null;
error_message: string | null;
}
export interface Platform {
value: string;
label: string;
color: string;
connected: boolean;
account: SocialAccount | null;
}
interface Props {
platforms: Platform[];
showDisconnect?: boolean;
showReconnect?: boolean;
showViewProfile?: boolean;
columns?: 2 | 3 | 4;
}
const props = withDefaults(defineProps<Props>(), {
showDisconnect: true,
showReconnect: true,
showViewProfile: true,
columns: 4,
});
const getConnectUrl = (platformValue: string): string => {
return `/connect/${platformValue}`;
};
const openOAuthPopup = (platformValue: string) => {
const url = getConnectUrl(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;
// Reload the page to get fresh data
router.reload();
};
onMounted(() => {
window.addEventListener('message', handleOAuthMessage);
});
onUnmounted(() => {
window.removeEventListener('message', handleOAuthMessage);
});
const gridClass = computed(() => {
switch (props.columns) {
case 2:
return 'sm:grid-cols-2';
case 3:
return 'sm:grid-cols-2 lg:grid-cols-3';
case 4:
default:
return 'sm:grid-cols-2 lg:grid-cols-3 xl:grid-cols-4';
}
});
const emit = defineEmits<{
disconnect: [accountId: string];
}>();
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 getProfileUrl = (platform: string, username: string | null): string | null => {
if (!username) return null;
const urls: Record<string, string> = {
'linkedin': `https://linkedin.com/in/${username}`,
'linkedin-page': `https://linkedin.com/company/${username}`,
'x': `https://x.com/${username}`,
'tiktok': `https://tiktok.com/@${username}`,
'instagram': `https://instagram.com/${username}`,
'facebook': `https://facebook.com/${username}`,
'youtube': `https://youtube.com/@${username}`,
'threads': `https://threads.net/@${username}`,
'bluesky': `https://bsky.app/profile/${username}`,
'pinterest': `https://pinterest.com/${username}`,
};
return urls[platform] || null;
};
const isDisconnected = (account: SocialAccount | null): boolean => {
if (!account) return false;
return account.status === 'disconnected' || account.status === 'token_expired';
};
</script>
<template>
<div class="grid gap-4" :class="gridClass">
<div
v-for="platform in platforms"
:key="platform.value"
class="group relative overflow-hidden rounded-xl border bg-card transition-all hover:shadow-md"
:class="{
'border-green-500/30 bg-green-50/50 dark:bg-green-950/20': platform.connected && !isDisconnected(platform.account),
'border-red-500/30 bg-red-50/50 dark:bg-red-950/20': platform.connected && isDisconnected(platform.account),
}"
>
<!-- Platform Header -->
<div class="flex items-center gap-3 p-4">
<div class="relative">
<img
:src="getPlatformLogo(platform.value)"
:alt="platform.label"
class="h-12 w-12 rounded-lg object-contain"
/>
<div
v-if="platform.connected && !isDisconnected(platform.account)"
class="absolute -bottom-1 -right-1 flex h-5 w-5 items-center justify-center rounded-full bg-green-500 text-white ring-2 ring-white dark:ring-gray-900"
>
<Check class="h-3 w-3" />
</div>
<div
v-else-if="platform.connected && isDisconnected(platform.account)"
class="absolute -bottom-1 -right-1 flex h-5 w-5 items-center justify-center rounded-full bg-red-500 text-white ring-2 ring-white dark:ring-gray-900"
>
<AlertCircle class="h-3 w-3" />
</div>
</div>
<div class="flex-1 min-w-0">
<div class="flex items-center gap-2">
<h3 class="font-semibold truncate">{{ platform.label }}</h3>
</div>
<p v-if="platform.connected && platform.account" class="text-sm text-muted-foreground truncate">
@{{ platform.account.username || platform.account.display_name }}
</p>
<p v-else class="text-sm text-muted-foreground">
Not connected
</p>
</div>
</div>
<!-- Connected State -->
<div v-if="platform.connected && platform.account" class="border-t px-4 py-3">
<!-- Disconnected Warning -->
<div v-if="isDisconnected(platform.account)" class="mb-3 flex items-start gap-2 rounded-lg bg-red-100 p-2 text-sm text-red-700 dark:bg-red-900/30 dark:text-red-400">
<AlertCircle class="h-4 w-4 mt-0.5 shrink-0" />
<div class="flex-1 min-w-0">
<p class="font-medium">Connection lost</p>
<p v-if="platform.account.error_message" class="text-xs truncate opacity-80">
{{ platform.account.error_message }}
</p>
</div>
</div>
<div class="flex items-center justify-between">
<div class="flex items-center gap-2">
<Avatar class="h-8 w-8">
<AvatarImage :src="platform.account.avatar_url" />
<AvatarFallback class="text-xs">
{{ platform.account.display_name?.charAt(0) }}
</AvatarFallback>
</Avatar>
<span class="text-sm font-medium truncate max-w-[120px]">
{{ platform.account.display_name }}
</span>
</div>
<div class="flex items-center gap-1">
<!-- Reconnect button for disconnected accounts -->
<TooltipProvider v-if="showReconnect && isDisconnected(platform.account)">
<Tooltip>
<TooltipTrigger as-child>
<button
@click="openOAuthPopup(platform.value)"
class="p-2 text-amber-600 hover:text-amber-700 transition-colors"
>
<RefreshCw class="h-4 w-4" />
</button>
</TooltipTrigger>
<TooltipContent>
<p>Reconnect account</p>
</TooltipContent>
</Tooltip>
</TooltipProvider>
<TooltipProvider v-if="showViewProfile && getProfileUrl(platform.value, platform.account.username)">
<Tooltip>
<TooltipTrigger as-child>
<a
:href="getProfileUrl(platform.value, platform.account.username)!"
target="_blank"
class="p-2 text-muted-foreground hover:text-foreground transition-colors"
>
<ExternalLink class="h-4 w-4" />
</a>
</TooltipTrigger>
<TooltipContent>
<p>View profile</p>
</TooltipContent>
</Tooltip>
</TooltipProvider>
<TooltipProvider v-if="showDisconnect">
<Tooltip>
<TooltipTrigger as-child>
<button
@click="emit('disconnect', platform.account.id)"
class="p-2 text-muted-foreground hover:text-red-600 transition-colors"
>
<Trash2 class="h-4 w-4" />
</button>
</TooltipTrigger>
<TooltipContent>
<p>Disconnect</p>
</TooltipContent>
</Tooltip>
</TooltipProvider>
</div>
</div>
</div>
<!-- Not Connected State -->
<div v-else class="border-t px-4 py-3">
<Button variant="outline" class="w-full" size="sm" @click="openOAuthPopup(platform.value)">
Connect
</Button>
</div>
</div>
</div>
</template>

View file

@ -0,0 +1,66 @@
<script setup lang="ts">
import { router, usePage } from '@inertiajs/vue3';
import { Check, ChevronsUpDown, Plus } from 'lucide-vue-next';
import { computed } from 'vue';
import { Button } from '@/components/ui/button';
import {
DropdownMenu,
DropdownMenuContent,
DropdownMenuItem,
DropdownMenuLabel,
DropdownMenuSeparator,
DropdownMenuTrigger,
} from '@/components/ui/dropdown-menu';
import { create as createWorkspaceRoute, switchMethod } from '@/routes/workspaces';
interface Workspace {
id: string;
name: string;
}
const page = usePage();
const currentWorkspace = computed<Workspace | null>(() => page.props.currentWorkspace as Workspace | null);
const workspaces = computed<Workspace[]>(() => page.props.workspaces as Workspace[]);
function switchWorkspace(workspace: Workspace) {
router.post(switchMethod.url(workspace.id), {}, {
preserveScroll: true,
});
}
function createWorkspace() {
router.visit(createWorkspaceRoute.url());
}
</script>
<template>
<DropdownMenu>
<DropdownMenuTrigger :as-child="true">
<Button variant="outline" class="w-full justify-between gap-2 px-3" :class="{ 'text-muted-foreground': !currentWorkspace }">
<span class="truncate">{{ currentWorkspace?.name || 'Select workspace' }}</span>
<ChevronsUpDown class="h-4 w-4 shrink-0 opacity-50" />
</Button>
</DropdownMenuTrigger>
<DropdownMenuContent align="start" class="w-56">
<DropdownMenuLabel>Workspaces</DropdownMenuLabel>
<DropdownMenuSeparator />
<DropdownMenuItem
v-for="workspace in workspaces"
:key="workspace.id"
class="cursor-pointer"
@click="switchWorkspace(workspace)"
>
<div class="flex w-full items-center justify-between">
<span class="truncate">{{ workspace.name }}</span>
<Check v-if="currentWorkspace?.id === workspace.id" class="h-4 w-4 shrink-0" />
</div>
</DropdownMenuItem>
<DropdownMenuSeparator />
<DropdownMenuItem class="cursor-pointer" @click="createWorkspace">
<Plus class="mr-2 h-4 w-4" />
Create workspace
</DropdownMenuItem>
</DropdownMenuContent>
</DropdownMenu>
</template>

View file

@ -1,5 +1,5 @@
<script setup lang="ts">
import AppLayout from '@/layouts/app/AppHeaderLayout.vue';
import AppLayout from '@/layouts/app/AppSidebarLayout.vue';
import type { BreadcrumbItemType } from '@/types';
interface Props {

View file

@ -1,6 +1,6 @@
<script setup lang="ts">
import { Link } from '@inertiajs/vue3';
import { home } from '@/routes';
import { home, privacy, terms } from '@/routes';
defineProps<{
title?: string;
@ -42,13 +42,13 @@ defineProps<{
</p>
<nav class="flex items-center gap-6">
<Link
href="/privacy"
:href="privacy.url()"
class="text-sm text-muted-foreground hover:text-foreground transition-colors"
>
Privacy Policy
</Link>
<Link
href="/terms"
:href="terms.url()"
class="text-sm text-muted-foreground hover:text-foreground transition-colors"
>
Terms of Service

View file

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

View file

@ -0,0 +1,17 @@
<script setup lang="ts">
import { Head } from '@inertiajs/vue3';
interface Props {
title?: string;
}
defineProps<Props>();
</script>
<template>
<Head :title="title" />
<div class="min-h-screen bg-background p-6">
<slot />
</div>
</template>

View file

@ -1,49 +0,0 @@
<script setup lang="ts">
import { Head } from '@inertiajs/vue3';
import AppLayout from '@/layouts/AppLayout.vue';
import { dashboard } from '@/routes';
import { type BreadcrumbItem } from '@/types';
import PlaceholderPattern from '../components/PlaceholderPattern.vue';
const breadcrumbs: BreadcrumbItem[] = [
{
title: 'Dashboard',
href: dashboard().url,
},
];
</script>
<template>
<Head title="Dashboard" />
<AppLayout :breadcrumbs="breadcrumbs">
<div
class="flex h-full flex-1 flex-col gap-4 overflow-x-auto rounded-xl p-4"
>
<div class="grid auto-rows-min gap-4 md:grid-cols-3">
<div
class="relative aspect-video overflow-hidden rounded-xl border border-sidebar-border/70 dark:border-sidebar-border"
>
<PlaceholderPattern />
</div>
<div
class="relative aspect-video overflow-hidden rounded-xl border border-sidebar-border/70 dark:border-sidebar-border"
>
<PlaceholderPattern />
</div>
<div
class="relative aspect-video overflow-hidden rounded-xl border border-sidebar-border/70 dark:border-sidebar-border"
>
<PlaceholderPattern />
</div>
</div>
<div
class="relative min-h-[100vh] flex-1 rounded-xl border border-sidebar-border/70 md:min-h-min dark:border-sidebar-border"
>
<PlaceholderPattern />
</div>
</div>
</AppLayout>
</template>

View file

@ -2,7 +2,7 @@
import { Head, Link } from '@inertiajs/vue3';
import { CalendarDays, Clock, Share2, Sparkles, CheckCircle } from 'lucide-vue-next';
import { login, register } from '@/routes';
import { calendar, login, privacy, register, terms } from '@/routes';
import { Button } from '@/components/ui/button';
withDefaults(
@ -64,10 +64,10 @@ const platforms = [
<nav class="flex items-center gap-3">
<Link
v-if="$page.props.auth.user"
href="/workspaces"
:href="calendar.url()"
class="text-sm text-muted-foreground hover:text-foreground transition-colors"
>
Dashboard
Calendar
</Link>
<template v-else>
<Link :href="login()">
@ -207,13 +207,13 @@ const platforms = [
</div>
<nav class="flex items-center gap-6">
<Link
href="/privacy"
:href="privacy.url()"
class="text-sm text-muted-foreground hover:text-foreground transition-colors"
>
Privacy Policy
</Link>
<Link
href="/terms"
:href="terms.url()"
class="text-sm text-muted-foreground hover:text-foreground transition-colors"
>
Terms of Service

View file

@ -1,30 +1,14 @@
<script setup lang="ts">
import { Head, Link } from '@inertiajs/vue3';
import { Head } from '@inertiajs/vue3';
import { ref } from 'vue';
import { Check, ExternalLink, Trash2 } from 'lucide-vue-next';
import AppLayout from '@/layouts/AppLayout.vue';
import { Button } from '@/components/ui/button';
import { Avatar, AvatarFallback, AvatarImage } from '@/components/ui/avatar';
import ConfirmDeleteModal from '@/components/ConfirmDeleteModal.vue';
import SocialAccountsGrid, { type Platform } from '@/components/SocialAccountsGrid.vue';
import { accounts } from '@/routes';
import { disconnect as disconnectAccount } from '@/routes/accounts';
import { type BreadcrumbItemType } from '@/types';
interface SocialAccount {
id: string;
platform: string;
username: string;
display_name: string;
avatar_url: string;
}
interface Platform {
value: string;
label: string;
color: string;
connected: boolean;
account: SocialAccount | null;
}
interface Workspace {
id: string;
name: string;
@ -40,60 +24,14 @@ const props = defineProps<Props>();
const deleteModal = ref<InstanceType<typeof ConfirmDeleteModal> | null>(null);
const breadcrumbs: BreadcrumbItemType[] = [
{
title: 'Workspaces',
href: '/workspaces',
},
{
title: props.workspace.name,
href: `/workspaces/${props.workspace.id}`,
},
{
title: 'Accounts',
href: `/workspaces/${props.workspace.id}/accounts`,
},
{ title: 'Accounts', href: accounts.url() },
];
const disconnect = (accountId: string) => {
const handleDisconnect = (accountId: string) => {
deleteModal.value?.open({
url: `/workspaces/${props.workspace.id}/accounts/${accountId}`,
url: disconnectAccount.url(accountId),
});
};
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 getProfileUrl = (platform: string, username: string | null): string | null => {
if (!username) return null;
const urls: Record<string, string> = {
'linkedin': `https://linkedin.com/in/${username}`,
'linkedin-page': `https://linkedin.com/company/${username}`,
'x': `https://x.com/${username}`,
'tiktok': `https://tiktok.com/@${username}`,
'instagram': `https://instagram.com/${username}`,
'facebook': `https://facebook.com/${username}`,
'youtube': `https://youtube.com/@${username}`,
'threads': `https://threads.net/@${username}`,
'bluesky': `https://bsky.app/profile/${username}`,
'pinterest': `https://pinterest.com/${username}`,
};
return urls[platform] || null;
};
</script>
<template>
@ -108,86 +46,10 @@ const getProfileUrl = (platform: string, username: string | null): string | null
</p>
</div>
<div class="grid gap-4 sm:grid-cols-2 lg:grid-cols-3 xl:grid-cols-4">
<div
v-for="platform in platforms"
:key="platform.value"
class="group relative overflow-hidden rounded-xl border bg-card transition-all hover:shadow-md"
:class="platform.connected ? 'border-green-500/30 bg-green-50/50 dark:bg-green-950/20' : ''"
>
<!-- Platform Header -->
<div class="flex items-center gap-3 p-4">
<div class="relative">
<img
:src="getPlatformLogo(platform.value)"
:alt="platform.label"
class="h-12 w-12 rounded-lg object-contain"
/>
<div
v-if="platform.connected"
class="absolute -bottom-1 -right-1 flex h-5 w-5 items-center justify-center rounded-full bg-green-500 text-white ring-2 ring-white dark:ring-gray-900"
>
<Check class="h-3 w-3" />
</div>
</div>
<div class="flex-1 min-w-0">
<div class="flex items-center gap-2">
<h3 class="font-semibold truncate">{{ platform.label }}</h3>
</div>
<p v-if="platform.connected && platform.account" class="text-sm text-muted-foreground truncate">
@{{ platform.account.username || platform.account.display_name }}
</p>
<p v-else class="text-sm text-muted-foreground">
Not connected
</p>
</div>
</div>
<!-- Connected State -->
<div v-if="platform.connected && platform.account" class="border-t px-4 py-3">
<div class="flex items-center justify-between">
<div class="flex items-center gap-2">
<Avatar class="h-8 w-8">
<AvatarImage :src="platform.account.avatar_url" />
<AvatarFallback class="text-xs">
{{ platform.account.display_name?.charAt(0) }}
</AvatarFallback>
</Avatar>
<span class="text-sm font-medium truncate max-w-[120px]">
{{ platform.account.display_name }}
</span>
</div>
<div class="flex items-center gap-1">
<a
v-if="getProfileUrl(platform.value, platform.account.username)"
:href="getProfileUrl(platform.value, platform.account.username)!"
target="_blank"
class="p-2 text-muted-foreground hover:text-foreground transition-colors"
title="View profile"
>
<ExternalLink class="h-4 w-4" />
</a>
<button
@click="disconnect(platform.account.id)"
class="p-2 text-muted-foreground hover:text-red-600 transition-colors"
title="Disconnect"
>
<Trash2 class="h-4 w-4" />
</button>
</div>
</div>
</div>
<!-- Not Connected State -->
<div v-else class="border-t px-4 py-3">
<Link :href="`/workspaces/${workspace.id}/connect/${platform.value}`">
<Button variant="outline" class="w-full" size="sm">
Connect
</Button>
</Link>
</div>
</div>
</div>
<SocialAccountsGrid
:platforms="platforms"
@disconnect="handleDisconnect"
/>
</div>
</AppLayout>

View file

@ -1,12 +1,11 @@
<script setup lang="ts">
import { Head, router } from '@inertiajs/vue3';
import { Building2, ArrowLeft, Check } from 'lucide-vue-next';
import { Building2, Check } from 'lucide-vue-next';
import AppLayout from '@/layouts/AppLayout.vue';
import { Button } from '@/components/ui/button';
import PopupLayout from '@/layouts/PopupLayout.vue';
import { Avatar, AvatarFallback, AvatarImage } from '@/components/ui/avatar';
import { Alert, AlertDescription } from '@/components/ui/alert';
import { type BreadcrumbItemType } from '@/types';
import { select as selectLinkedInPage } from '@/routes/social/linkedin-page';
interface Organization {
id: string;
@ -26,62 +25,26 @@ interface Props {
error?: string;
}
const props = defineProps<Props>();
defineProps<Props>();
const breadcrumbs: BreadcrumbItemType[] = [
{
title: 'Workspaces',
href: '/workspaces',
},
{
title: props.workspace.name,
href: `/workspaces/${props.workspace.id}`,
},
{
title: 'Contas',
href: `/workspaces/${props.workspace.id}/accounts`,
},
{
title: 'Selecionar Página',
href: '#',
},
];
const selectPage = (org: Organization) => {
router.post('/accounts/linkedin-page/select', {
const handleSelectPage = (org: Organization) => {
router.post(selectLinkedInPage.url(), {
organization_id: org.id,
organization_name: org.name,
organization_vanity_name: org.vanity_name,
organization_logo: org.logo,
});
};
const goBack = () => {
router.visit(`/workspaces/${props.workspace.id}/accounts`);
};
</script>
<template>
<Head title="Selecionar LinkedIn Page" />
<AppLayout :breadcrumbs="breadcrumbs">
<div class="flex flex-col gap-8 p-6">
<div class="flex items-center gap-4">
<Button variant="ghost" size="icon" @click="goBack" class="shrink-0">
<ArrowLeft class="h-4 w-4" />
</Button>
<div class="flex items-center gap-3">
<img
src="/images/accounts/linkedin.png"
alt="LinkedIn"
class="h-10 w-10"
/>
<div>
<h1 class="text-2xl font-bold tracking-tight">Selecionar LinkedIn Page</h1>
<p class="text-muted-foreground">
Escolha qual página você deseja conectar
</p>
</div>
<PopupLayout title="Select LinkedIn Page">
<div class="flex flex-col gap-6">
<div class="flex items-center gap-3">
<img src="/images/accounts/linkedin.png" alt="LinkedIn" class="h-10 w-10" />
<div>
<h1 class="text-xl font-bold tracking-tight">Select LinkedIn Page</h1>
<p class="text-sm text-muted-foreground">Choose which page you want to connect</p>
</div>
</div>
@ -89,31 +52,28 @@ const goBack = () => {
<AlertDescription>{{ error }}</AlertDescription>
</Alert>
<div v-if="organizations.length === 0 && !error" class="text-center py-16">
<div class="mx-auto flex h-16 w-16 items-center justify-center rounded-full bg-muted">
<Building2 class="h-8 w-8 text-muted-foreground" />
<div v-if="organizations.length === 0 && !error" class="text-center py-12">
<div class="mx-auto flex h-14 w-14 items-center justify-center rounded-full bg-muted">
<Building2 class="h-7 w-7 text-muted-foreground" />
</div>
<h3 class="mt-4 text-lg font-semibold">Nenhuma página encontrada</h3>
<p class="mt-1 text-muted-foreground">
Você não é administrador de nenhuma página do LinkedIn.
<h3 class="mt-4 text-lg font-semibold">No pages found</h3>
<p class="mt-1 text-sm text-muted-foreground">
You are not an administrator of any LinkedIn page.
</p>
<Button class="mt-6" @click="goBack">
Voltar
</Button>
</div>
<div v-else class="grid gap-4 sm:grid-cols-2 lg:grid-cols-3">
<div v-else class="grid gap-3">
<button
v-for="org in organizations"
:key="org.id"
@click="selectPage(org)"
class="group relative overflow-hidden rounded-xl border bg-card p-4 text-left transition-all hover:border-primary hover:shadow-md focus:outline-none focus:ring-2 focus:ring-primary focus:ring-offset-2"
@click="handleSelectPage(org)"
class="group relative overflow-hidden rounded-lg border bg-card p-4 text-left transition-all hover:border-primary hover:shadow-md focus:outline-none focus:ring-2 focus:ring-primary focus:ring-offset-2"
>
<div class="flex items-center gap-4">
<Avatar class="h-14 w-14 rounded-lg">
<Avatar class="h-12 w-12 rounded-lg">
<AvatarImage v-if="org.logo" :src="org.logo" class="object-cover" />
<AvatarFallback class="rounded-lg bg-blue-100 dark:bg-blue-900">
<Building2 class="h-7 w-7 text-blue-600 dark:text-blue-400" />
<Building2 class="h-6 w-6 text-blue-600 dark:text-blue-400" />
</AvatarFallback>
</Avatar>
<div class="flex-1 min-w-0">
@ -123,12 +83,12 @@ const goBack = () => {
<p v-if="org.vanity_name" class="text-sm text-muted-foreground truncate">
linkedin.com/company/{{ org.vanity_name }}
</p>
<p v-else class="text-sm text-muted-foreground">
LinkedIn Page
</p>
<p v-else class="text-sm text-muted-foreground">LinkedIn Page</p>
</div>
<div class="shrink-0 opacity-0 group-hover:opacity-100 transition-opacity">
<div class="flex h-8 w-8 items-center justify-center rounded-full bg-primary text-primary-foreground">
<div
class="flex h-8 w-8 items-center justify-center rounded-full bg-primary text-primary-foreground"
>
<Check class="h-4 w-4" />
</div>
</div>
@ -136,5 +96,5 @@ const goBack = () => {
</button>
</div>
</div>
</AppLayout>
</PopupLayout>
</template>

View file

@ -1,149 +0,0 @@
<script setup lang="ts">
import { Head, router } from '@inertiajs/vue3';
import { Youtube, ArrowLeft, Check, Users } from 'lucide-vue-next';
import AppLayout from '@/layouts/AppLayout.vue';
import { Button } from '@/components/ui/button';
import { Avatar, AvatarFallback, AvatarImage } from '@/components/ui/avatar';
import { Alert, AlertDescription } from '@/components/ui/alert';
import { type BreadcrumbItemType } from '@/types';
interface Channel {
id: string;
title: string;
description: string;
thumbnail: string | null;
custom_url: string | null;
subscriber_count: number | string;
}
interface Workspace {
id: string;
name: string;
}
interface Props {
workspace: Workspace;
channels: Channel[];
error?: string;
}
const props = defineProps<Props>();
const breadcrumbs: BreadcrumbItemType[] = [
{
title: 'Workspaces',
href: '/workspaces',
},
{
title: props.workspace.name,
href: `/workspaces/${props.workspace.id}`,
},
{
title: 'Accounts',
href: `/workspaces/${props.workspace.id}/accounts`,
},
{
title: 'Select Channel',
href: '#',
},
];
const selectChannel = (channel: Channel) => {
router.post('/accounts/youtube/select', {
channel_id: channel.id,
});
};
const goBack = () => {
router.visit(`/workspaces/${props.workspace.id}/accounts`);
};
const formatSubscribers = (count: number | string) => {
const num = typeof count === 'string' ? parseInt(count) : count;
if (num >= 1000000) {
return `${(num / 1000000).toFixed(1)}M subscribers`;
}
if (num >= 1000) {
return `${(num / 1000).toFixed(1)}K subscribers`;
}
return `${num} subscribers`;
};
</script>
<template>
<Head title="Select YouTube Channel" />
<AppLayout :breadcrumbs="breadcrumbs">
<div class="flex flex-col gap-8 p-6">
<div class="flex items-center gap-4">
<Button variant="ghost" size="icon" @click="goBack" class="shrink-0">
<ArrowLeft class="h-4 w-4" />
</Button>
<div class="flex items-center gap-3">
<div class="flex h-10 w-10 items-center justify-center rounded-lg bg-red-600">
<Youtube class="h-6 w-6 text-white" />
</div>
<div>
<h1 class="text-2xl font-bold tracking-tight">Select YouTube Channel</h1>
<p class="text-muted-foreground">
Choose which channel you want to connect for Shorts
</p>
</div>
</div>
</div>
<Alert v-if="error" variant="destructive">
<AlertDescription>{{ error }}</AlertDescription>
</Alert>
<div v-if="channels.length === 0 && !error" class="text-center py-16">
<div class="mx-auto flex h-16 w-16 items-center justify-center rounded-full bg-muted">
<Youtube class="h-8 w-8 text-muted-foreground" />
</div>
<h3 class="mt-4 text-lg font-semibold">No channels found</h3>
<p class="mt-1 text-muted-foreground">
You don't have any YouTube channels associated with your account.
</p>
<Button class="mt-6" @click="goBack">
Go Back
</Button>
</div>
<div v-else class="grid gap-4 sm:grid-cols-2 lg:grid-cols-3">
<button
v-for="channel in channels"
:key="channel.id"
@click="selectChannel(channel)"
class="group relative overflow-hidden rounded-xl border bg-card p-4 text-left transition-all hover:border-primary hover:shadow-md focus:outline-none focus:ring-2 focus:ring-primary focus:ring-offset-2"
>
<div class="flex items-center gap-4">
<Avatar class="h-14 w-14 rounded-full">
<AvatarImage v-if="channel.thumbnail" :src="channel.thumbnail" class="object-cover" />
<AvatarFallback class="bg-red-100 dark:bg-red-900">
<Youtube class="h-7 w-7 text-red-600 dark:text-red-400" />
</AvatarFallback>
</Avatar>
<div class="flex-1 min-w-0">
<h3 class="font-semibold truncate group-hover:text-primary transition-colors">
{{ channel.title }}
</h3>
<p v-if="channel.custom_url" class="text-sm text-muted-foreground truncate">
youtube.com/{{ channel.custom_url }}
</p>
<div class="flex items-center gap-1 text-sm text-muted-foreground mt-1">
<Users class="h-3 w-3" />
{{ formatSubscribers(channel.subscriber_count) }}
</div>
</div>
<div class="shrink-0 opacity-0 group-hover:opacity-100 transition-opacity">
<div class="flex h-8 w-8 items-center justify-center rounded-full bg-primary text-primary-foreground">
<Check class="h-4 w-4" />
</div>
</div>
</div>
</button>
</div>
</div>
</AppLayout>
</template>

View file

@ -1,6 +1,6 @@
<script setup lang="ts">
import { Head, useForm, router } from '@inertiajs/vue3';
import { CreditCard, FileText, Building2, Check, ExternalLink } from 'lucide-vue-next';
import { Head } from '@inertiajs/vue3';
import { CreditCard, FileText, Building2, ExternalLink, Sparkles } from 'lucide-vue-next';
import AppLayout from '@/layouts/AppLayout.vue';
import { Button } from '@/components/ui/button';
@ -32,6 +32,8 @@ interface Invoice {
interface Props {
hasSubscription: boolean;
onTrial: boolean;
trialEndsAt: string | null;
subscription: Subscription | null;
workspacesCount: number;
invoices: Invoice[];
@ -42,30 +44,24 @@ const props = defineProps<Props>();
const breadcrumbs: BreadcrumbItemType[] = [
{
title: 'Assinatura',
title: 'Subscription',
href: '/billing',
},
];
const form = useForm({});
function subscribe() {
form.post('/billing/checkout');
}
function openPortal() {
window.location.href = '/billing/portal';
}
function getStatusLabel(status: string): string {
const labels: Record<string, string> = {
active: 'Ativa',
canceled: 'Cancelada',
incomplete: 'Incompleta',
incomplete_expired: 'Expirada',
past_due: 'Atrasada',
trialing: 'Teste',
unpaid: 'Não paga',
active: 'Active',
canceled: 'Canceled',
incomplete: 'Incomplete',
incomplete_expired: 'Expired',
past_due: 'Past due',
trialing: 'Trial',
unpaid: 'Unpaid',
};
return labels[status] || status;
}
@ -75,71 +71,36 @@ function getStatusVariant(status: string): 'default' | 'secondary' | 'destructiv
if (status === 'canceled' || status === 'past_due' || status === 'unpaid') return 'destructive';
return 'secondary';
}
const pricePerWorkspace = 20;
</script>
<template>
<Head title="Assinatura" />
<Head title="Subscription" />
<AppLayout :breadcrumbs="breadcrumbs">
<div class="flex flex-col gap-6 p-6">
<div>
<h1 class="text-2xl font-bold tracking-tight">Assinatura</h1>
<h1 class="text-2xl font-bold tracking-tight">Subscription</h1>
<p class="text-muted-foreground">
Gerencie sua assinatura e método de pagamento
Manage your subscription and payment method
</p>
</div>
<div class="grid gap-6 lg:grid-cols-2">
<Card v-if="!hasSubscription">
<CardHeader>
<CardTitle class="flex items-center gap-2">
<Building2 class="h-5 w-5" />
Plano Pro
</CardTitle>
<CardDescription>
Crie e gerencie múltiplos workspaces
</CardDescription>
</CardHeader>
<CardContent>
<div class="mb-6">
<span class="text-4xl font-bold">${{ pricePerWorkspace }}</span>
<span class="text-muted-foreground">/workspace/mês</span>
</div>
<ul class="space-y-2">
<li class="flex items-center gap-2">
<Check class="h-4 w-4 text-green-500" />
<span>Workspaces ilimitados</span>
</li>
<li class="flex items-center gap-2">
<Check class="h-4 w-4 text-green-500" />
<span>LinkedIn, X e TikTok</span>
</li>
<li class="flex items-center gap-2">
<Check class="h-4 w-4 text-green-500" />
<span>Calendário de agendamento</span>
</li>
<li class="flex items-center gap-2">
<Check class="h-4 w-4 text-green-500" />
<span>Convide colaboradores</span>
</li>
</ul>
</CardContent>
<CardFooter>
<Button @click="subscribe" :disabled="form.processing" class="w-full">
<CreditCard class="mr-2 h-4 w-4" />
Assinar Agora
</Button>
</CardFooter>
</Card>
<Alert v-if="onTrial" class="border-primary/50 bg-primary/5">
<Sparkles class="h-4 w-4 text-primary" />
<AlertTitle>Trial period active</AlertTitle>
<AlertDescription>
Your trial ends on <strong>{{ trialEndsAt }}</strong>.
After that, your subscription will be charged automatically.
</AlertDescription>
</Alert>
<Card v-else>
<div class="grid gap-6 lg:grid-cols-2">
<Card>
<CardHeader>
<div class="flex items-center justify-between">
<CardTitle class="flex items-center gap-2">
<Building2 class="h-5 w-5" />
Sua Assinatura
Your Subscription
</CardTitle>
<Badge :variant="getStatusVariant(subscription?.stripe_status || '')">
{{ getStatusLabel(subscription?.stripe_status || '') }}
@ -153,8 +114,8 @@ const pricePerWorkspace = 20;
<p class="text-2xl font-bold">{{ workspacesCount }}</p>
</div>
<div class="text-right">
<p class="text-sm text-muted-foreground">Total mensal</p>
<p class="text-2xl font-bold">${{ workspacesCount * pricePerWorkspace }}</p>
<p class="text-sm text-muted-foreground">Subscription quantity</p>
<p class="text-2xl font-bold">{{ subscription?.quantity || 0 }}</p>
</div>
</div>
@ -163,21 +124,21 @@ const pricePerWorkspace = 20;
<div>
<p class="font-medium capitalize">{{ defaultPaymentMethod.brand }} **** {{ defaultPaymentMethod.last4 }}</p>
<p class="text-sm text-muted-foreground">
Expira {{ defaultPaymentMethod.exp_month }}/{{ defaultPaymentMethod.exp_year }}
Expires {{ defaultPaymentMethod.exp_month }}/{{ defaultPaymentMethod.exp_year }}
</p>
</div>
</div>
<div v-if="subscription?.ends_at" class="p-3 bg-yellow-50 border border-yellow-200 rounded-lg">
<p class="text-sm text-yellow-800">
Sua assinatura será cancelada em {{ subscription.ends_at }}
<div v-if="subscription?.ends_at" class="p-3 bg-yellow-50 border border-yellow-200 rounded-lg dark:bg-yellow-950 dark:border-yellow-800">
<p class="text-sm text-yellow-800 dark:text-yellow-200">
Your subscription will be canceled on {{ subscription.ends_at }}
</p>
</div>
</CardContent>
<CardFooter>
<Button @click="openPortal" variant="outline" class="w-full">
<ExternalLink class="mr-2 h-4 w-4" />
Gerenciar no Stripe
Manage on Stripe
</Button>
</CardFooter>
</Card>
@ -186,15 +147,15 @@ const pricePerWorkspace = 20;
<CardHeader>
<CardTitle class="flex items-center gap-2">
<FileText class="h-5 w-5" />
Faturas
Invoices
</CardTitle>
<CardDescription>
Histórico de pagamentos
Payment history
</CardDescription>
</CardHeader>
<CardContent>
<div v-if="invoices.length === 0" class="text-center py-6 text-muted-foreground">
Nenhuma fatura encontrada
No invoices found
</div>
<div v-else class="space-y-3">
<a
@ -209,21 +170,13 @@ const pricePerWorkspace = 20;
<p class="text-sm text-muted-foreground">{{ invoice.total }}</p>
</div>
<Badge variant="outline">
{{ invoice.status === 'paid' ? 'Paga' : invoice.status }}
{{ invoice.status === 'paid' ? 'Paid' : invoice.status }}
</Badge>
</a>
</div>
</CardContent>
</Card>
</div>
<Alert v-if="!hasSubscription && workspacesCount > 0">
<Building2 class="h-4 w-4" />
<AlertTitle>Workspace Grátis</AlertTitle>
<AlertDescription>
Você está usando seu workspace gratuito. Assine para criar mais workspaces e desbloquear todos os recursos.
</AlertDescription>
</Alert>
</div>
</AppLayout>
</template>

View file

@ -0,0 +1,99 @@
<script setup lang="ts">
import { Head, router } from '@inertiajs/vue3';
import { useEcho } from '@laravel/echo-vue';
import { Loader2, CheckCircle, XCircle } from 'lucide-vue-next';
import { ref, onMounted } from 'vue';
import { Button } from '@/components/ui/button';
import { subscribe } from '@/routes';
import { index as workspacesIndex } from '@/routes/workspaces';
interface Props {
userId: number;
status: 'processing' | 'success' | 'cancelled';
}
const props = defineProps<Props>();
const currentStatus = ref(props.status);
// Listen for subscription created event
if (props.status === 'processing') {
useEcho(
`users.${props.userId}`,
'SubscriptionCreated',
() => {
currentStatus.value = 'success';
setTimeout(() => {
router.visit(workspacesIndex.url());
}, 1500);
},
);
// Fallback: check subscription status after 10 seconds
onMounted(() => {
setTimeout(() => {
if (currentStatus.value === 'processing') {
router.visit(workspacesIndex.url());
}
}, 10000);
});
}
// If already success, redirect after a moment
if (props.status === 'success') {
onMounted(() => {
setTimeout(() => {
router.visit(workspacesIndex.url());
}, 1500);
});
}
function retry() {
router.visit(subscribe.url());
}
</script>
<template>
<Head title="Processing..." />
<div class="min-h-screen bg-gradient-to-b from-background via-background to-muted/30 flex items-center justify-center">
<div class="text-center max-w-md px-4">
<!-- Processing -->
<template v-if="currentStatus === 'processing'">
<div class="inline-flex items-center justify-center w-20 h-20 rounded-full bg-primary/10 mb-6">
<Loader2 class="w-10 h-10 text-primary animate-spin" />
</div>
<h1 class="text-2xl font-bold tracking-tight mb-3">Processing your subscription</h1>
<p class="text-muted-foreground">
Please wait while we set up your account. This will only take a moment.
</p>
</template>
<!-- Success -->
<template v-else-if="currentStatus === 'success'">
<div class="inline-flex items-center justify-center w-20 h-20 rounded-full bg-green-100 mb-6">
<CheckCircle class="w-10 h-10 text-green-600" />
</div>
<h1 class="text-2xl font-bold tracking-tight mb-3">You're all set!</h1>
<p class="text-muted-foreground">
Your subscription is active. Redirecting you to your workspaces...
</p>
</template>
<!-- Cancelled -->
<template v-else-if="currentStatus === 'cancelled'">
<div class="inline-flex items-center justify-center w-20 h-20 rounded-full bg-red-100 mb-6">
<XCircle class="w-10 h-10 text-red-600" />
</div>
<h1 class="text-2xl font-bold tracking-tight mb-3">Checkout cancelled</h1>
<p class="text-muted-foreground mb-6">
Your checkout was cancelled. No charges were made.
</p>
<Button @click="retry">
Try again
</Button>
</template>
</div>
</div>
</template>

View file

@ -0,0 +1,100 @@
<script setup lang="ts">
import { Head, router } from '@inertiajs/vue3';
import { Sparkles, Calendar, Users, ImageIcon, Video, Clock, BarChart3 } from 'lucide-vue-next';
import { ref } from 'vue';
import { Button } from '@/components/ui/button';
import { checkout } from '@/routes/billing';
interface Props {
trialDays: number;
}
defineProps<Props>();
const processing = ref(false);
function 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: 'Instagram', icon: '/images/accounts/instagram.png' },
{ name: 'Facebook', icon: '/images/accounts/facebook.png' },
{ name: 'Threads', icon: '/images/accounts/threads.png' },
];
const features = [
{ icon: Calendar, title: 'Visual Calendar', description: 'Plan and schedule your content with an intuitive drag-and-drop calendar' },
{ icon: Clock, title: 'Unlimited Scheduling', description: 'Schedule as many posts as you want, whenever you want' },
{ icon: ImageIcon, title: 'Images & Carousels', description: 'Share single images or create engaging carousel posts' },
{ icon: Video, title: 'Video Publishing', description: 'Upload and publish videos across all your social accounts' },
{ icon: Users, title: 'Team Collaboration', description: 'Invite your team members and work together seamlessly' },
{ icon: BarChart3, title: 'Analytics', description: 'Track your post performance and engagement metrics' },
];
</script>
<template>
<Head title="Start your free trial" />
<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">
<Sparkles 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>
<!-- 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
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"
>
<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>
<!-- 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"
>
<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
</Button>
</div>
</div>
</div>
</template>

View file

@ -0,0 +1,88 @@
<script setup lang="ts">
import { Head, useForm } from '@inertiajs/vue3';
import { Building, Building2, Rocket, Sparkles, Store, User } from 'lucide-vue-next';
import { computed } from 'vue';
import OnboardingLayout from '@/layouts/OnboardingLayout.vue';
import { Button } from '@/components/ui/button';
import { storeStep1 } from '@/actions/App/Http/Controllers/OnboardingController';
interface Persona {
value: string;
label: string;
description: string;
icon: string;
}
interface Props {
personas: Persona[];
}
const props = defineProps<Props>();
const form = useForm({
persona: '',
});
const icons: Record<string, typeof Rocket> = {
rocket: Rocket,
sparkles: Sparkles,
building: Building,
'building-2': Building2,
store: Store,
user: User,
};
const submit = () => {
form.post(storeStep1.url());
};
const isSelected = (value: string) => form.persona === value;
</script>
<template>
<Head title="Welcome - Tell us about yourself" />
<OnboardingLayout
title="Tell us about yourself"
description="Help us personalize your experience"
:step="1"
>
<div class="grid grid-cols-2 gap-4">
<button
v-for="persona in personas"
:key="persona.value"
type="button"
class="group flex flex-col items-center gap-3 rounded-xl border-2 p-6 text-center transition-all hover:border-primary hover:bg-accent"
:class="{
'border-primary bg-primary/5': isSelected(persona.value),
'border-border': !isSelected(persona.value),
}"
@click="form.persona = persona.value"
>
<div
class="flex h-12 w-12 items-center justify-center rounded-full transition-colors"
:class="{
'bg-primary text-primary-foreground': isSelected(persona.value),
'bg-muted text-muted-foreground group-hover:bg-primary/10 group-hover:text-primary': !isSelected(persona.value),
}"
>
<component :is="icons[persona.icon]" class="h-6 w-6" />
</div>
<div>
<h3 class="font-semibold">{{ persona.label }}</h3>
<p class="text-sm text-muted-foreground">{{ persona.description }}</p>
</div>
</button>
</div>
<Button
class="w-full"
size="lg"
:disabled="!form.persona || form.processing"
@click="submit"
>
Continue
</Button>
</OnboardingLayout>
</template>

View file

@ -0,0 +1,68 @@
<script setup lang="ts">
import { Head, router } from '@inertiajs/vue3';
import { ref, computed } from 'vue';
import OnboardingLayout from '@/layouts/OnboardingLayout.vue';
import SocialAccountsGrid, { type Platform } from '@/components/SocialAccountsGrid.vue';
import { Button } from '@/components/ui/button';
import { storeStep2 } from '@/actions/App/Http/Controllers/OnboardingController';
interface Props {
platforms: Platform[];
hasWorkspace: boolean;
}
const props = defineProps<Props>();
const isSubmitting = ref(false);
const connectedCount = computed(() => {
return props.platforms.filter((p) => p.connected).length;
});
const submit = () => {
isSubmitting.value = true;
router.post(storeStep2.url());
};
</script>
<template>
<Head title="Connect your accounts" />
<OnboardingLayout
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"
>
Continue
</Button>
</div>
</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')">
Go Back
</Button>
</div>
</OnboardingLayout>
</template>

View file

@ -6,6 +6,8 @@ import dayjs from '@/dayjs';
import AppLayout from '@/layouts/AppLayout.vue';
import { Button } from '@/components/ui/button';
import { calendar } from '@/routes';
import { create as createPost, edit as editPost, show as showPost } from '@/routes/posts';
import { type BreadcrumbItemType } from '@/types';
interface PostPlatform {
@ -42,9 +44,7 @@ interface Props {
const props = defineProps<Props>();
const breadcrumbs: BreadcrumbItemType[] = [
{ title: 'Workspaces', href: '/workspaces' },
{ title: props.workspace.name, href: `/workspaces/${props.workspace.id}` },
{ title: 'Calendar', href: `/workspaces/${props.workspace.id}/calendar` },
{ title: 'Calendar', href: calendar.url() },
];
const weekStart = computed(() => dayjs(props.currentWeekStart));
@ -74,15 +74,13 @@ const getPostsForDay = (day: dayjs.Dayjs): Post[] => {
const navigateWeek = (direction: number) => {
const newStart = weekStart.value.add(direction * 7, 'day');
router.get(`/workspaces/${props.workspace.id}/calendar`, {
week: newStart.format('YYYY-MM-DD'),
}, {
router.get(calendar.url({ query: { week: newStart.format('YYYY-MM-DD') } }), {}, {
preserveState: true,
});
};
const goToToday = () => {
router.get(`/workspaces/${props.workspace.id}/calendar`, {}, {
router.get(calendar.url(), {}, {
preserveState: true,
});
};
@ -118,8 +116,9 @@ const getPlatformLogo = (platform: string): string => {
};
const getPostUrl = (post: Post): string => {
const base = `/workspaces/${props.workspace.id}/posts/${post.id}`;
return post.status === 'draft' || post.status === 'scheduled' ? `${base}/edit` : base;
return post.status === 'draft' || post.status === 'scheduled'
? editPost.url(post.id)
: showPost.url(post.id);
};
const formatTime = (scheduledAt: string): string => {
@ -150,7 +149,7 @@ const formatTime = (scheduledAt: string): string => {
{{ headerTitle }}
</h1>
</div>
<Link :href="`/workspaces/${workspace.id}/posts/create`">
<Link :href="createPost.url()">
<Button>
<Plus class="mr-2 h-4 w-4" />
New Post
@ -186,7 +185,7 @@ const formatTime = (scheduledAt: string): string => {
<div class="flex-1 overflow-y-auto p-2 space-y-2">
<!-- Add Post Button -->
<Link
:href="`/workspaces/${workspace.id}/posts/create?date=${day.format('YYYY-MM-DD')}`"
:href="createPost.url({ query: { date: day.format('YYYY-MM-DD') } })"
class="flex items-center justify-center p-2 rounded border border-dashed border-muted-foreground/30 text-muted-foreground hover:border-primary hover:text-primary hover:bg-primary/5 transition-colors"
>
<Plus class="h-4 w-4" />

View file

@ -13,6 +13,8 @@ import { Switch } from '@/components/ui/switch';
import { Alert, AlertDescription } from '@/components/ui/alert';
import DatePicker from '@/components/DatePicker.vue';
import ConfirmDeleteModal from '@/components/ConfirmDeleteModal.vue';
import { calendar } from '@/routes';
import { destroy as destroyPost, update as updatePost } from '@/routes/posts';
import { type BreadcrumbItemType } from '@/types';
interface SocialAccount {
@ -71,9 +73,7 @@ interface Props {
const props = defineProps<Props>();
const breadcrumbs: BreadcrumbItemType[] = [
{ title: 'Workspaces', href: '/workspaces' },
{ title: props.workspace.name, href: `/workspaces/${props.workspace.id}` },
{ title: 'Calendar', href: `/workspaces/${props.workspace.id}/calendar` },
{ title: 'Calendar', href: calendar.url() },
{ title: 'Edit Post', href: '#' },
];
@ -295,7 +295,7 @@ const submit = (status: string = 'scheduled') => {
isSubmitting.value = true;
router.put(`/workspaces/${props.workspace.id}/posts/${props.post.id}`, {
router.put(updatePost.url(props.post.id), {
status,
scheduled_at,
platforms,
@ -308,7 +308,7 @@ const submit = (status: string = 'scheduled') => {
const deletePost = () => {
deleteModal.value?.open({
url: `/workspaces/${props.workspace.id}/posts/${props.post.id}`,
url: destroyPost.url(props.post.id),
});
};
</script>

View file

@ -9,6 +9,8 @@ import AppLayout from '@/layouts/AppLayout.vue';
import { Button } from '@/components/ui/button';
import { Card, CardContent, CardDescription, CardHeader, CardTitle } from '@/components/ui/card';
import { Badge } from '@/components/ui/badge';
import { calendar } from '@/routes';
import { edit as editPost } from '@/routes/posts';
import { type BreadcrumbItemType } from '@/types';
interface SocialAccount {
@ -91,9 +93,7 @@ useEcho(
);
const breadcrumbs: BreadcrumbItemType[] = [
{ title: 'Workspaces', href: '/workspaces' },
{ title: props.workspace.name, href: `/workspaces/${props.workspace.id}` },
{ title: 'Calendar', href: `/workspaces/${props.workspace.id}/calendar` },
{ title: 'Calendar', href: calendar.url() },
{ title: 'Post', href: '#' },
];
@ -158,7 +158,7 @@ const enabledPlatforms = computed(() => post.value.post_platforms.filter(pp => p
Created by {{ post.user.name }}
</p>
</div>
<Link v-if="canEdit" :href="`/workspaces/${workspace.id}/posts/${post.id}/edit`">
<Link v-if="canEdit" :href="editPost.url(post.id)">
<Button>
<Pencil class="mr-2 h-4 w-4" />
Edit Post

View file

@ -9,14 +9,7 @@ import { Label } from '@/components/ui/label';
import { type BreadcrumbItemType } from '@/types';
const breadcrumbs: BreadcrumbItemType[] = [
{
title: 'Workspaces',
href: '/workspaces',
},
{
title: 'Criar',
href: '/workspaces/create',
},
{ title: 'Create Workspace', href: '/workspaces/create' },
];
const form = useForm({

View file

@ -1,10 +1,12 @@
<script setup lang="ts">
import { Head, Link } from '@inertiajs/vue3';
import { Head, Link, router } from '@inertiajs/vue3';
import { Plus, Calendar, Users, Settings } from 'lucide-vue-next';
import AppLayout from '@/layouts/AppLayout.vue';
import { Button } from '@/components/ui/button';
import { Card, CardContent, CardDescription, CardHeader, CardTitle } from '@/components/ui/card';
import { accounts, calendar, settings } from '@/routes';
import { create as createWorkspace, index as workspacesIndex, switchMethod } from '@/routes/workspaces';
import { type BreadcrumbItemType } from '@/types';
interface Workspace {
@ -22,11 +24,16 @@ interface Props {
defineProps<Props>();
const breadcrumbs: BreadcrumbItemType[] = [
{
title: 'Workspaces',
href: '/workspaces',
},
{ title: 'Workspaces', href: workspacesIndex.url() },
];
function switchAndNavigate(workspaceId: string, destination: string) {
router.post(switchMethod.url(workspaceId), {}, {
onSuccess: () => {
router.visit(destination);
},
});
}
</script>
<template>
@ -41,7 +48,7 @@ const breadcrumbs: BreadcrumbItemType[] = [
Gerencie seus workspaces e redes sociais
</p>
</div>
<Link href="/workspaces/create">
<Link :href="createWorkspace.url()">
<Button>
<Plus class="mr-2 h-4 w-4" />
Novo Workspace
@ -55,7 +62,7 @@ const breadcrumbs: BreadcrumbItemType[] = [
<p class="mb-4 mt-2 text-sm text-muted-foreground">
Crie seu primeiro workspace para começar a agendar posts.
</p>
<Link href="/workspaces/create">
<Link :href="createWorkspace.url()">
<Button>
<Plus class="mr-2 h-4 w-4" />
Criar Workspace
@ -81,21 +88,15 @@ const breadcrumbs: BreadcrumbItemType[] = [
</span>
</div>
<div class="flex gap-2">
<Link :href="`/workspaces/${workspace.id}/calendar`">
<Button variant="outline" size="sm">
<Calendar class="h-4 w-4" />
</Button>
</Link>
<Link :href="`/workspaces/${workspace.id}/accounts`">
<Button variant="outline" size="sm">
<Users class="h-4 w-4" />
</Button>
</Link>
<Link :href="`/workspaces/${workspace.id}`">
<Button variant="outline" size="sm">
<Settings class="h-4 w-4" />
</Button>
</Link>
<Button variant="outline" size="sm" @click="switchAndNavigate(workspace.id, calendar.url())">
<Calendar class="h-4 w-4" />
</Button>
<Button variant="outline" size="sm" @click="switchAndNavigate(workspace.id, accounts.url())">
<Users class="h-4 w-4" />
</Button>
<Button variant="outline" size="sm" @click="switchAndNavigate(workspace.id, settings.url())">
<Settings class="h-4 w-4" />
</Button>
</div>
</div>
</CardContent>

View file

@ -10,6 +10,9 @@ import { Label } from '@/components/ui/label';
import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from '@/components/ui/select';
import { Badge } from '@/components/ui/badge';
import { Alert, AlertDescription } from '@/components/ui/alert';
import { members } from '@/routes';
import { destroy as destroyInvite, store as storeInvite } from '@/routes/invites';
import { remove as removeMember } from '@/routes/members';
import { type BreadcrumbItemType } from '@/types';
interface Workspace {
@ -49,18 +52,7 @@ interface Props {
const props = defineProps<Props>();
const breadcrumbs: BreadcrumbItemType[] = [
{
title: 'Workspaces',
href: '/workspaces',
},
{
title: props.workspace.name,
href: `/workspaces/${props.workspace.id}`,
},
{
title: 'Team',
href: `/workspaces/${props.workspace.id}/members`,
},
{ title: 'Members', href: members.url() },
];
const form = useForm({
@ -69,7 +61,7 @@ const form = useForm({
});
function submitInvite() {
form.post(`/workspaces/${props.workspace.id}/invites`, {
form.post(storeInvite.url(), {
preserveScroll: true,
onSuccess: () => {
form.reset();
@ -79,15 +71,15 @@ function submitInvite() {
function cancelInvite(inviteId: string) {
if (confirm('Are you sure you want to cancel this invite?')) {
router.delete(`/workspaces/${props.workspace.id}/invites/${inviteId}`, {
router.delete(destroyInvite.url(inviteId), {
preserveScroll: true,
});
}
}
function removeMember(memberId: string) {
function handleRemoveMember(memberId: string) {
if (confirm('Are you sure you want to remove this member?')) {
router.delete(`/workspaces/${props.workspace.id}/members/${memberId}`, {
router.delete(removeMember.url(memberId), {
preserveScroll: true,
});
}
@ -276,7 +268,7 @@ function getRoleIcon(role: string) {
<Button
variant="ghost"
size="icon"
@click="removeMember(member.id)"
@click="handleRemoveMember(member.id)"
>
<Trash2 class="h-4 w-4 text-red-500" />
</Button>

View file

@ -24,18 +24,7 @@ interface Props {
const props = defineProps<Props>();
const breadcrumbs: BreadcrumbItemType[] = [
{
title: 'Workspaces',
href: '/workspaces',
},
{
title: props.workspace.name,
href: `/workspaces/${props.workspace.id}`,
},
{
title: 'Settings',
href: `/workspaces/${props.workspace.id}/settings`,
},
{ title: 'Settings', href: '/settings' },
];
const form = useForm({
@ -44,7 +33,7 @@ const form = useForm({
});
const submit = () => {
form.put(`/workspaces/${props.workspace.id}/settings`);
form.put('/settings');
};
</script>

View file

@ -5,6 +5,7 @@ import { Calendar, Users, FileText, CheckCircle, Clock, AlertCircle, UserPlus, S
import AppLayout from '@/layouts/AppLayout.vue';
import { Button } from '@/components/ui/button';
import { Card, CardContent, CardDescription, CardHeader, CardTitle } from '@/components/ui/card';
import { accounts, calendar, members, settings } from '@/routes';
import { type BreadcrumbItemType } from '@/types';
interface SocialAccount {
@ -36,14 +37,7 @@ interface Props {
const props = defineProps<Props>();
const breadcrumbs: BreadcrumbItemType[] = [
{
title: 'Workspaces',
href: '/workspaces',
},
{
title: props.workspace.name,
href: `/workspaces/${props.workspace.id}`,
},
{ title: 'Calendar', href: calendar.url() },
];
</script>
@ -60,24 +54,24 @@ const breadcrumbs: BreadcrumbItemType[] = [
</p>
</div>
<div class="flex gap-2">
<Link :href="`/workspaces/${workspace.id}/settings`">
<Link :href="settings.url()">
<Button variant="ghost" size="icon">
<Settings class="h-4 w-4" />
</Button>
</Link>
<Link :href="`/workspaces/${workspace.id}/members`">
<Link :href="members.url()">
<Button variant="outline">
<UserPlus class="mr-2 h-4 w-4" />
Team
</Button>
</Link>
<Link :href="`/workspaces/${workspace.id}/accounts`">
<Link :href="accounts.url()">
<Button variant="outline">
<Users class="mr-2 h-4 w-4" />
Accounts
</Button>
</Link>
<Link :href="`/workspaces/${workspace.id}/calendar`">
<Link :href="calendar.url()">
<Button>
<Calendar class="mr-2 h-4 w-4" />
Calendar
@ -135,7 +129,7 @@ const breadcrumbs: BreadcrumbItemType[] = [
<p class="mt-2 text-sm text-muted-foreground">
Conecte suas redes sociais para começar a agendar posts.
</p>
<Link :href="`/workspaces/${workspace.id}/accounts`" class="mt-4">
<Link :href="accounts.url()" class="mt-4">
<Button>
<Users class="mr-2 h-4 w-4" />
Conectar Contas

View file

@ -0,0 +1,71 @@
<!DOCTYPE html>
<html>
<head>
<title>{{ $success ? 'Connected' : 'Error' }}</title>
<style>
body {
font-family: system-ui, -apple-system, sans-serif;
display: flex;
align-items: center;
justify-content: center;
min-height: 100vh;
margin: 0;
background: #f9fafb;
}
.container {
text-align: center;
padding: 2rem;
}
.icon {
font-size: 3rem;
margin-bottom: 1rem;
}
.message {
color: #374151;
font-size: 1.125rem;
}
.submessage {
color: #6b7280;
font-size: 0.875rem;
margin-top: 0.5rem;
}
</style>
</head>
<body>
<div class="container">
<div class="icon">{{ $success ? '✓' : '✕' }}</div>
<div class="message">{{ $message }}</div>
<div class="submessage">This window will close automatically...</div>
</div>
<script>
(function() {
const result = {
success: {{ $success ? 'true' : 'false' }},
message: @json($message),
platform: @json($platform ?? null)
};
// Try to notify the parent window
if (window.opener) {
try {
window.opener.postMessage({
type: 'social-oauth-callback',
...result
}, window.location.origin);
} catch (e) {
// If postMessage fails, just reload the opener
try {
window.opener.location.reload();
} catch (e2) {}
}
}
// Close this window after a short delay
setTimeout(function() {
window.close();
}, 1500);
})();
</script>
</body>
</html>

View file

@ -0,0 +1,24 @@
@props([
'url',
'color' => 'primary',
'align' => 'center',
])
<table class="action" align="{{ $align }}" width="100%" cellpadding="0" cellspacing="0" role="presentation">
<tr>
<td align="{{ $align }}">
<table width="100%" border="0" cellpadding="0" cellspacing="0" role="presentation">
<tr>
<td align="{{ $align }}">
<table border="0" cellpadding="0" cellspacing="0" role="presentation">
<tr>
<td>
<a href="{{ $url }}" class="button button-{{ $color }}" target="_blank" rel="noopener">{!! $slot !!}</a>
</td>
</tr>
</table>
</td>
</tr>
</table>
</td>
</tr>
</table>

View file

@ -0,0 +1,11 @@
<tr>
<td>
<table class="footer" align="center" width="570" cellpadding="0" cellspacing="0" role="presentation">
<tr>
<td class="content-cell" align="center">
{{ Illuminate\Mail\Markdown::parse($slot) }}
</td>
</tr>
</table>
</td>
</tr>

View file

@ -0,0 +1,8 @@
@props(['url'])
<tr>
<td class="header">
<a href="{{ $url }}" style="display: inline-block;">
<img src="{{ asset('images/trypost/logo-light.png') }}" class="logo" alt="TryPost Logo" style="height: 40px; width: auto;">
</a>
</td>
</tr>

View file

@ -0,0 +1,58 @@
<!DOCTYPE html PUBLIC "-//W3C//DTD XHTML 1.0 Transitional//EN" "http://www.w3.org/TR/xhtml1/DTD/xhtml1-transitional.dtd">
<html xmlns="http://www.w3.org/1999/xhtml" lang="{{ str_replace('_', '-', app()->getLocale()) }}">
<head>
<title>{{ config('app.name') }}</title>
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
<meta http-equiv="Content-Type" content="text/html; charset=UTF-8" />
<meta name="color-scheme" content="light">
<meta name="supported-color-schemes" content="light">
<style>
@media only screen and (max-width: 600px) {
.inner-body {
width: 100% !important;
}
.footer {
width: 100% !important;
}
}
@media only screen and (max-width: 500px) {
.button {
width: 100% !important;
}
}
</style>
{!! $head ?? '' !!}
</head>
<body>
<table class="wrapper" width="100%" cellpadding="0" cellspacing="0" role="presentation">
<tr>
<td align="center">
<table class="content" width="100%" cellpadding="0" cellspacing="0" role="presentation">
{!! $header ?? '' !!}
<!-- Email Body -->
<tr>
<td class="body" width="100%" cellpadding="0" cellspacing="0" style="border: hidden !important;">
<table class="inner-body" align="center" width="570" cellpadding="0" cellspacing="0" role="presentation">
<!-- Body content -->
<tr>
<td class="content-cell">
{!! Illuminate\Mail\Markdown::parse($slot) !!}
{!! $subcopy ?? '' !!}
</td>
</tr>
</table>
</td>
</tr>
{!! $footer ?? '' !!}
</table>
</td>
</tr>
</table>
</body>
</html>

View file

@ -0,0 +1,27 @@
<x-mail::layout>
{{-- Header --}}
<x-slot:header>
<x-mail::header :url="config('app.url')">
{{ config('app.name') }}
</x-mail::header>
</x-slot:header>
{{-- Body --}}
{!! $slot !!}
{{-- Subcopy --}}
@isset($subcopy)
<x-slot:subcopy>
<x-mail::subcopy>
{!! $subcopy !!}
</x-mail::subcopy>
</x-slot:subcopy>
@endisset
{{-- Footer --}}
<x-slot:footer>
<x-mail::footer>
© {{ date('Y') }} {{ config('app.name') }}. {{ __('All rights reserved.') }}
</x-mail::footer>
</x-slot:footer>
</x-mail::layout>

View file

@ -0,0 +1,14 @@
<table class="panel" width="100%" cellpadding="0" cellspacing="0" role="presentation">
<tr>
<td class="panel-content">
<table width="100%" cellpadding="0" cellspacing="0" role="presentation">
<tr>
<td class="panel-item">
{{ Illuminate\Mail\Markdown::parse($slot) }}
</td>
</tr>
</table>
</td>
</tr>
</table>

View file

@ -0,0 +1,7 @@
<table class="subcopy" width="100%" cellpadding="0" cellspacing="0" role="presentation">
<tr>
<td>
{{ Illuminate\Mail\Markdown::parse($slot) }}
</td>
</tr>
</table>

View file

@ -0,0 +1,3 @@
<div class="table">
{{ Illuminate\Mail\Markdown::parse($slot) }}
</div>

View file

@ -0,0 +1,297 @@
/* Base */
body,
body *:not(html):not(style):not(br):not(tr):not(code) {
box-sizing: border-box;
font-family: -apple-system, BlinkMacSystemFont, 'Segoe UI', Roboto, Helvetica, Arial, sans-serif,
'Apple Color Emoji', 'Segoe UI Emoji', 'Segoe UI Symbol';
position: relative;
}
body {
-webkit-text-size-adjust: none;
background-color: #ffffff;
color: #52525b;
height: 100%;
line-height: 1.4;
margin: 0;
padding: 0;
width: 100% !important;
}
p,
ul,
ol,
blockquote {
line-height: 1.4;
text-align: left;
}
a {
color: #18181b;
}
a img {
border: none;
}
/* Typography */
h1 {
color: #18181b;
font-size: 18px;
font-weight: bold;
margin-top: 0;
text-align: left;
}
h2 {
font-size: 16px;
font-weight: bold;
margin-top: 0;
text-align: left;
}
h3 {
font-size: 14px;
font-weight: bold;
margin-top: 0;
text-align: left;
}
p {
font-size: 16px;
line-height: 1.5em;
margin-top: 0;
text-align: left;
}
p.sub {
font-size: 12px;
}
img {
max-width: 100%;
}
/* Layout */
.wrapper {
-premailer-cellpadding: 0;
-premailer-cellspacing: 0;
-premailer-width: 100%;
background-color: #fafafa;
margin: 0;
padding: 0;
width: 100%;
}
.content {
-premailer-cellpadding: 0;
-premailer-cellspacing: 0;
-premailer-width: 100%;
margin: 0;
padding: 0;
width: 100%;
}
/* Header */
.header {
padding: 25px 0;
text-align: center;
}
.header a {
color: #18181b;
font-size: 19px;
font-weight: bold;
text-decoration: none;
}
/* Logo */
.logo {
height: 75px;
margin-top: 15px;
margin-bottom: 10px;
max-height: 75px;
width: 75px;
}
/* Body */
.body {
-premailer-cellpadding: 0;
-premailer-cellspacing: 0;
-premailer-width: 100%;
background-color: #fafafa;
border-bottom: 1px solid #fafafa;
border-top: 1px solid #fafafa;
margin: 0;
padding: 0;
width: 100%;
}
.inner-body {
-premailer-cellpadding: 0;
-premailer-cellspacing: 0;
-premailer-width: 570px;
background-color: #ffffff;
border-color: #e4e4e7;
border-radius: 4px;
border-width: 1px;
box-shadow: 0 1px 3px 0 rgba(0, 0, 0, 0.1), 0 1px 2px -1px rgba(0, 0, 0, 0.1);
margin: 0 auto;
padding: 0;
width: 570px;
}
.inner-body a {
word-break: break-all;
}
/* Subcopy */
.subcopy {
border-top: 1px solid #e4e4e7;
margin-top: 25px;
padding-top: 25px;
}
.subcopy p {
font-size: 14px;
}
/* Footer */
.footer {
-premailer-cellpadding: 0;
-premailer-cellspacing: 0;
-premailer-width: 570px;
margin: 0 auto;
padding: 0;
text-align: center;
width: 570px;
}
.footer p {
color: #a1a1aa;
font-size: 12px;
text-align: center;
}
.footer a {
color: #a1a1aa;
text-decoration: underline;
}
/* Tables */
.table table {
-premailer-cellpadding: 0;
-premailer-cellspacing: 0;
-premailer-width: 100%;
margin: 30px auto;
width: 100%;
}
.table th {
border-bottom: 1px solid #e4e4e7;
margin: 0;
padding-bottom: 8px;
}
.table td {
color: #52525b;
font-size: 15px;
line-height: 18px;
margin: 0;
padding: 10px 0;
}
.content-cell {
max-width: 100vw;
padding: 32px;
}
/* Buttons */
.action {
-premailer-cellpadding: 0;
-premailer-cellspacing: 0;
-premailer-width: 100%;
margin: 30px auto;
padding: 0;
text-align: center;
width: 100%;
float: unset;
}
.button {
-webkit-text-size-adjust: none;
border-radius: 4px;
color: #fff;
display: inline-block;
overflow: hidden;
text-decoration: none;
}
.button-blue,
.button-primary {
background-color: #18181b;
border-bottom: 8px solid #18181b;
border-left: 18px solid #18181b;
border-right: 18px solid #18181b;
border-top: 8px solid #18181b;
}
.button-green,
.button-success {
background-color: #16a34a;
border-bottom: 8px solid #16a34a;
border-left: 18px solid #16a34a;
border-right: 18px solid #16a34a;
border-top: 8px solid #16a34a;
}
.button-red,
.button-error {
background-color: #dc2626;
border-bottom: 8px solid #dc2626;
border-left: 18px solid #dc2626;
border-right: 18px solid #dc2626;
border-top: 8px solid #dc2626;
}
/* Panels */
.panel {
border-left: #18181b solid 4px;
margin: 21px 0;
}
.panel-content {
background-color: #fafafa;
color: #52525b;
padding: 16px;
}
.panel-content p {
color: #52525b;
}
.panel-item {
padding: 0;
}
.panel-item p:last-of-type {
margin-bottom: 0;
padding-bottom: 0;
}
/* Utilities */
.break-all {
word-break: break-all;
}

View file

@ -0,0 +1 @@
{{ $slot }}: {{ $url }}

View file

@ -0,0 +1 @@
{{ $slot }}

View file

@ -0,0 +1 @@
{{ $slot }}: {{ $url }}

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