feat: Implement user onboarding, subscription management, and refactor social integrations with new UI components and mail templates.
This commit is contained in:
parent
7867fcce02
commit
7c00c3387e
108 changed files with 4101 additions and 1418 deletions
|
|
@ -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
15
README.md
Normal 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
|
||||
```
|
||||
|
|
@ -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
28
app/Enums/Status.php
Normal 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',
|
||||
};
|
||||
}
|
||||
}
|
||||
65
app/Enums/User/Persona.php
Normal file
65
app/Enums/User/Persona.php
Normal 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
34
app/Enums/User/Setup.php
Normal 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,
|
||||
};
|
||||
}
|
||||
}
|
||||
32
app/Events/SubscriptionCreated.php
Normal file
32
app/Events/SubscriptionCreated.php
Normal 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',
|
||||
];
|
||||
}
|
||||
}
|
||||
15
app/Exceptions/TokenExpiredException.php
Normal file
15
app/Exceptions/TokenExpiredException.php
Normal 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);
|
||||
}
|
||||
}
|
||||
|
|
@ -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',
|
||||
]);
|
||||
|
|
|
|||
|
|
@ -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);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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);
|
||||
}
|
||||
}
|
||||
|
||||
|
|
|
|||
|
|
@ -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);
|
||||
}
|
||||
}
|
||||
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
]);
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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);
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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)
|
||||
|
|
|
|||
|
|
@ -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.');
|
||||
}
|
||||
}
|
||||
|
|
|
|||
117
app/Http/Controllers/OnboardingController.php
Normal file
117
app/Http/Controllers/OnboardingController.php
Normal 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');
|
||||
}
|
||||
}
|
||||
|
|
@ -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');
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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!');
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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) {
|
||||
|
|
|
|||
32
app/Http/Middleware/EnsureSubscribed.php
Normal file
32
app/Http/Middleware/EnsureSubscribed.php
Normal 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');
|
||||
}
|
||||
}
|
||||
55
app/Http/Middleware/EnsureUserSetupIsComplete.php
Normal file
55
app/Http/Middleware/EnsureUserSetupIsComplete.php
Normal 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'),
|
||||
};
|
||||
}
|
||||
}
|
||||
|
|
@ -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'),
|
||||
|
|
|
|||
28
app/Http/Responses/LoginResponse.php
Normal file
28
app/Http/Responses/LoginResponse.php
Normal 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);
|
||||
}
|
||||
}
|
||||
16
app/Http/Responses/RegisterResponse.php
Normal file
16
app/Http/Responses/RegisterResponse.php
Normal 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');
|
||||
}
|
||||
}
|
||||
|
|
@ -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,
|
||||
|
|
|
|||
61
app/Listeners/StripeEventListener.php
Normal file
61
app/Listeners/StripeEventListener.php
Normal 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
|
||||
}
|
||||
}
|
||||
|
|
@ -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 = [
|
||||
|
|
|
|||
|
|
@ -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;
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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.
|
||||
*/
|
||||
|
|
|
|||
46
app/Notifications/AccountDisconnectedNotification.php
Normal file
46
app/Notifications/AccountDisconnectedNotification.php
Normal 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,
|
||||
];
|
||||
}
|
||||
}
|
||||
|
|
@ -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);
|
||||
|
|
|
|||
|
|
@ -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);
|
||||
}
|
||||
|
||||
/**
|
||||
|
|
|
|||
|
|
@ -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}");
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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}");
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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}");
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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}");
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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}");
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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}");
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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}");
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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}");
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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);
|
||||
}
|
||||
}
|
||||
95
app/Socialite/InstagramProvider.php
Normal file
95
app/Socialite/InstagramProvider.php
Normal 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,
|
||||
];
|
||||
}
|
||||
}
|
||||
|
|
@ -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 {
|
||||
//
|
||||
|
|
|
|||
|
|
@ -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
93
composer.lock
generated
|
|
@ -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",
|
||||
|
|
|
|||
|
|
@ -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),
|
||||
|
||||
];
|
||||
|
|
|
|||
|
|
@ -73,7 +73,7 @@
|
|||
|
|
||||
*/
|
||||
|
||||
'home' => '/dashboard',
|
||||
'home' => '/calendar',
|
||||
|
||||
/*
|
||||
|--------------------------------------------------------------------------
|
||||
|
|
|
|||
|
|
@ -14,7 +14,7 @@
|
|||
|
|
||||
*/
|
||||
|
||||
'default' => env('MAIL_MAILER', 'log'),
|
||||
'default' => env('MAIL_MAILER', 'smtp'),
|
||||
|
||||
/*
|
||||
|--------------------------------------------------------------------------
|
||||
|
|
|
|||
|
|
@ -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
|
||||
],
|
||||
|
||||
];
|
||||
|
|
|
|||
|
|
@ -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();
|
||||
});
|
||||
|
||||
|
|
|
|||
|
|
@ -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();
|
||||
|
|
|
|||
|
|
@ -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();
|
||||
|
|
|
|||
|
|
@ -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();
|
||||
|
|
|
|||
|
|
@ -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');
|
||||
}
|
||||
};
|
||||
|
|
|
|||
|
|
@ -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();
|
||||
});
|
||||
}
|
||||
};
|
||||
|
|
@ -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');
|
||||
});
|
||||
}
|
||||
};
|
||||
|
|
@ -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');
|
||||
});
|
||||
}
|
||||
};
|
||||
|
|
@ -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;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
@ -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">
|
||||
|
|
|
|||
244
resources/js/components/AppSidebar.vue
Normal file
244
resources/js/components/AppSidebar.vue
Normal 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>
|
||||
264
resources/js/components/SocialAccountsGrid.vue
Normal file
264
resources/js/components/SocialAccountsGrid.vue
Normal 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>
|
||||
66
resources/js/components/WorkspaceSwitcher.vue
Normal file
66
resources/js/components/WorkspaceSwitcher.vue
Normal 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>
|
||||
|
|
@ -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 {
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
44
resources/js/layouts/OnboardingLayout.vue
Normal file
44
resources/js/layouts/OnboardingLayout.vue
Normal 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>
|
||||
17
resources/js/layouts/PopupLayout.vue
Normal file
17
resources/js/layouts/PopupLayout.vue
Normal 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>
|
||||
|
|
@ -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>
|
||||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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>
|
||||
|
||||
|
|
|
|||
|
|
@ -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>
|
||||
|
|
|
|||
|
|
@ -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>
|
||||
|
|
@ -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>
|
||||
|
|
|
|||
99
resources/js/pages/billing/Processing.vue
Normal file
99
resources/js/pages/billing/Processing.vue
Normal 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>
|
||||
100
resources/js/pages/billing/Subscribe.vue
Normal file
100
resources/js/pages/billing/Subscribe.vue
Normal 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>
|
||||
88
resources/js/pages/onboarding/Step1.vue
Normal file
88
resources/js/pages/onboarding/Step1.vue
Normal 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>
|
||||
68
resources/js/pages/onboarding/Step2.vue
Normal file
68
resources/js/pages/onboarding/Step2.vue
Normal 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>
|
||||
|
|
@ -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" />
|
||||
|
|
|
|||
|
|
@ -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>
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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({
|
||||
|
|
|
|||
|
|
@ -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>
|
||||
|
|
|
|||
|
|
@ -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>
|
||||
|
|
|
|||
|
|
@ -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>
|
||||
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
71
resources/views/auth/social-callback.blade.php
Normal file
71
resources/views/auth/social-callback.blade.php
Normal 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>
|
||||
24
resources/views/vendor/mail/html/button.blade.php
vendored
Normal file
24
resources/views/vendor/mail/html/button.blade.php
vendored
Normal 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>
|
||||
11
resources/views/vendor/mail/html/footer.blade.php
vendored
Normal file
11
resources/views/vendor/mail/html/footer.blade.php
vendored
Normal 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>
|
||||
8
resources/views/vendor/mail/html/header.blade.php
vendored
Normal file
8
resources/views/vendor/mail/html/header.blade.php
vendored
Normal 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>
|
||||
58
resources/views/vendor/mail/html/layout.blade.php
vendored
Normal file
58
resources/views/vendor/mail/html/layout.blade.php
vendored
Normal 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>
|
||||
27
resources/views/vendor/mail/html/message.blade.php
vendored
Normal file
27
resources/views/vendor/mail/html/message.blade.php
vendored
Normal 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>
|
||||
14
resources/views/vendor/mail/html/panel.blade.php
vendored
Normal file
14
resources/views/vendor/mail/html/panel.blade.php
vendored
Normal 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>
|
||||
|
||||
7
resources/views/vendor/mail/html/subcopy.blade.php
vendored
Normal file
7
resources/views/vendor/mail/html/subcopy.blade.php
vendored
Normal 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>
|
||||
3
resources/views/vendor/mail/html/table.blade.php
vendored
Normal file
3
resources/views/vendor/mail/html/table.blade.php
vendored
Normal file
|
|
@ -0,0 +1,3 @@
|
|||
<div class="table">
|
||||
{{ Illuminate\Mail\Markdown::parse($slot) }}
|
||||
</div>
|
||||
297
resources/views/vendor/mail/html/themes/default.css
vendored
Normal file
297
resources/views/vendor/mail/html/themes/default.css
vendored
Normal 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;
|
||||
}
|
||||
1
resources/views/vendor/mail/text/button.blade.php
vendored
Normal file
1
resources/views/vendor/mail/text/button.blade.php
vendored
Normal file
|
|
@ -0,0 +1 @@
|
|||
{{ $slot }}: {{ $url }}
|
||||
1
resources/views/vendor/mail/text/footer.blade.php
vendored
Normal file
1
resources/views/vendor/mail/text/footer.blade.php
vendored
Normal file
|
|
@ -0,0 +1 @@
|
|||
{{ $slot }}
|
||||
1
resources/views/vendor/mail/text/header.blade.php
vendored
Normal file
1
resources/views/vendor/mail/text/header.blade.php
vendored
Normal file
|
|
@ -0,0 +1 @@
|
|||
{{ $slot }}: {{ $url }}
|
||||
Some files were not shown because too many files have changed in this diff Show more
Loading…
Reference in a new issue