chore: posthog, ui, features and more
This commit is contained in:
parent
91ad58cd01
commit
843b3991ec
69 changed files with 1697 additions and 264 deletions
10
.env.example
10
.env.example
|
|
@ -120,19 +120,27 @@ THREADS_CLIENT_ID=
|
|||
THREADS_CLIENT_SECRET=
|
||||
THREADS_CLIENT_REDIRECT="${APP_URL}/accounts/threads/callback"
|
||||
|
||||
# YouTube / Google (https://console.cloud.google.com)
|
||||
# Google (https://console.cloud.google.com)
|
||||
# Used for YouTube social account connection AND Google login/signup
|
||||
GOOGLE_CLIENT_ID=
|
||||
GOOGLE_CLIENT_SECRET=
|
||||
GOOGLE_CLIENT_REDIRECT="${APP_URL}/accounts/youtube/callback"
|
||||
GOOGLE_AUTH_CALLBACK="${APP_URL}/auth/google/callback"
|
||||
|
||||
# Pinterest (https://developers.pinterest.com)
|
||||
PINTEREST_CLIENT_ID=
|
||||
PINTEREST_CLIENT_SECRET=
|
||||
PINTEREST_CLIENT_REDIRECT="${APP_URL}/accounts/pinterest/callback"
|
||||
|
||||
# PostHog (optional - analytics)
|
||||
POSTHOG_API_KEY=
|
||||
POSTHOG_HOST=https://us.i.posthog.com
|
||||
|
||||
# Vite
|
||||
VITE_APP_NAME="${APP_NAME}"
|
||||
VITE_REVERB_APP_KEY="${REVERB_APP_KEY}"
|
||||
VITE_REVERB_HOST="${REVERB_HOST}"
|
||||
VITE_REVERB_PORT="${REVERB_PORT}"
|
||||
VITE_REVERB_SCHEME="${REVERB_SCHEME}"
|
||||
VITE_POSTHOG_API_KEY="${POSTHOG_API_KEY}"
|
||||
VITE_POSTHOG_HOST="${POSTHOG_HOST}"
|
||||
|
|
|
|||
|
|
@ -44,7 +44,7 @@ ## Skills Activation
|
|||
- `cashier-stripe-development` — Handles Laravel Cashier Stripe integration including subscriptions, webhooks, Stripe Checkout, invoices, charges, refunds, trials, coupons, metered billing, and payment failure handling. Triggered when a user mentions Cashier, Billable, IncompletePayment, stripe_id, newSubscription, Stripe subscriptions, or billing. Also applies when setting up webhooks, handling SCA/3DS payment failures, testing with Stripe test cards, or troubleshooting incomplete subscriptions, CSRF webhook errors, or migration publish issues.
|
||||
- `laravel-best-practices` — Apply this skill whenever writing, reviewing, or refactoring Laravel PHP code. This includes creating or modifying controllers, models, migrations, form requests, policies, jobs, scheduled commands, service classes, and Eloquent queries. Triggers for N+1 and query performance issues, caching strategies, authorization and security patterns, validation, error handling, queue and job configuration, route definitions, and architectural decisions. Also use for Laravel code reviews and refactoring existing Laravel code to follow best practices. Covers any task involving Laravel backend PHP code patterns.
|
||||
- `configuring-horizon` — Use this skill whenever the user mentions Horizon by name in a Laravel context. Covers the full Horizon lifecycle: installing Horizon (horizon:install, Sail setup), configuring config/horizon.php (supervisor blocks, queue assignments, balancing strategies, minProcesses/maxProcesses), fixing the dashboard (authorization via Gate::define viewHorizon, blank metrics, horizon:snapshot scheduling), and troubleshooting production issues (worker crashes, timeout chain ordering, LongWaitDetected notifications, waits config). Also covers job tagging and silencing. Do not use for generic Laravel queues without Horizon, SQS or database drivers, standalone Redis setup, Linux supervisord, Telescope, or job batching.
|
||||
- `mcp-development` — Use this skill for Laravel MCP development only. Trigger when creating or editing MCP tools, resources, prompts, or servers in Laravel projects. Covers: artisan make:mcp-\* generators, mcp:inspector, routes/ai.php, Tool/Resource/Prompt classes, schema validation, shouldRegister(), OAuth setup, URI templates, read-only attributes, and MCP debugging. Do not use for non-Laravel MCP projects or generic AI features without MCP.
|
||||
- `mcp-development` — Use this skill for Laravel MCP development only. Trigger when creating or editing MCP tools, resources, prompts, or servers in Laravel projects. Covers: artisan make:mcp-* generators, mcp:inspector, routes/ai.php, Tool/Resource/Prompt classes, schema validation, shouldRegister(), OAuth setup, URI templates, read-only attributes, and MCP debugging. Do not use for non-Laravel MCP projects or generic AI features without MCP.
|
||||
- `socialite-development` — Manages OAuth social authentication with Laravel Socialite. Activate when adding social login providers; configuring OAuth redirect/callback flows; retrieving authenticated user details; customizing scopes or parameters; setting up community providers; testing with Socialite fakes; or when the user mentions social login, OAuth, Socialite, or third-party authentication.
|
||||
- `wayfinder-development` — Use this skill for Laravel Wayfinder which auto-generates typed functions for Laravel controllers and routes. ALWAYS use this skill when frontend code needs to call backend routes or controller actions. Trigger when: connecting any React/Vue/Svelte/Inertia frontend to Laravel controllers, routes, building end-to-end features with both frontend and backend, wiring up forms or links to backend endpoints, fixing route-related TypeScript errors, importing from @/actions or @/routes, or running wayfinder:generate. Use Wayfinder route functions instead of hardcoded URLs. Covers: wayfinder() vite plugin, .url()/.get()/.post()/.form(), query params, route model binding, tree-shaking. Do not use for backend-only task
|
||||
- `pest-testing` — Use this skill for Pest PHP testing in Laravel projects only. Trigger whenever any test is being written, edited, fixed, or refactored — including fixing tests that broke after a code change, adding assertions, converting PHPUnit to Pest, adding datasets, and TDD workflows. Always activate when the user asks how to write something in Pest, mentions test files or directories (tests/Feature, tests/Unit, tests/Browser), or needs browser testing, smoke testing multiple pages for JS errors, or architecture tests. Covers: it()/expect() syntax, datasets, mocking, browser testing (visit/click/fill), smoke testing, arch(), Livewire component tests, RefreshDatabase, and all Pest 4 features. Do not use for factories, seeders, migrations, controllers, models, or non-test PHP code.
|
||||
|
|
@ -115,7 +115,7 @@ ## Tinker
|
|||
|
||||
- Execute PHP in app context for debugging and testing code. Do not create models without user approval, prefer tests with factories instead. Prefer existing Artisan commands over custom tinker code.
|
||||
- Always use single quotes to prevent shell expansion: `vendor/bin/sail artisan tinker --execute 'Your::code();'`
|
||||
- Double quotes for PHP strings inside: `vendor/bin/sail artisan tinker --execute 'User::where("active", true)->count();'`
|
||||
- Double quotes for PHP strings inside: `vendor/bin/sail artisan tinker --execute 'User::where("active", true)->count();'`
|
||||
|
||||
=== php rules ===
|
||||
|
||||
|
|
@ -228,7 +228,6 @@ ## Pest
|
|||
# Inertia + Vue
|
||||
|
||||
Vue components must have a single root element.
|
||||
|
||||
- IMPORTANT: Activate `inertia-vue-development` when working with Inertia Vue client-side patterns.
|
||||
|
||||
</laravel-boost-guidelines>
|
||||
|
|
|
|||
|
|
@ -28,7 +28,7 @@ public static function execute(Workspace $workspace, User $user, array $data): P
|
|||
'scheduled_at' => $scheduledAt,
|
||||
]);
|
||||
|
||||
$socialAccounts = $workspace->socialAccounts;
|
||||
$socialAccounts = $workspace->socialAccounts()->active()->get();
|
||||
|
||||
foreach ($socialAccounts as $account) {
|
||||
$post->postPlatforms()->create([
|
||||
|
|
|
|||
43
app/Actions/User/CreateUser.php
Normal file
43
app/Actions/User/CreateUser.php
Normal file
|
|
@ -0,0 +1,43 @@
|
|||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace App\Actions\User;
|
||||
|
||||
use App\Enums\User\Setup;
|
||||
use App\Models\User;
|
||||
use App\Models\Workspace;
|
||||
use Illuminate\Support\Facades\DB;
|
||||
|
||||
class CreateUser
|
||||
{
|
||||
/**
|
||||
* @param array{name: string, email: string, password?: string, timezone?: string, setup?: Setup, email_verified_at?: \DateTimeInterface|null} $data
|
||||
*/
|
||||
public static function execute(array $data): User
|
||||
{
|
||||
return DB::transaction(function () use ($data): User {
|
||||
$isInviteRegistration = data_get($data, 'is_invite', false);
|
||||
|
||||
$user = User::create([
|
||||
'name' => data_get($data, 'name'),
|
||||
'email' => data_get($data, 'email'),
|
||||
'password' => data_get($data, 'password'),
|
||||
'setup' => data_get($data, 'setup', $isInviteRegistration ? Setup::Completed : Setup::Role),
|
||||
'email_verified_at' => data_get($data, 'email_verified_at', $isInviteRegistration ? now() : null),
|
||||
]);
|
||||
|
||||
$workspace = Workspace::create([
|
||||
'user_id' => $user->id,
|
||||
'name' => $user->name."'s Workspace",
|
||||
'timezone' => data_get($data, 'timezone', 'UTC'),
|
||||
]);
|
||||
|
||||
$workspace->members()->attach($user->id, ['role' => 'owner']);
|
||||
|
||||
$user->update(['current_workspace_id' => $workspace->id]);
|
||||
|
||||
return $user;
|
||||
});
|
||||
}
|
||||
}
|
||||
|
|
@ -41,10 +41,17 @@ public function index(Request $request, ?string $status = null): Response|Redire
|
|||
};
|
||||
}
|
||||
|
||||
if ($search = $request->input('search')) {
|
||||
$query->whereHas('postPlatforms', fn ($q) => $q->where('content', 'ilike', "%{$search}%"));
|
||||
}
|
||||
|
||||
return Inertia::render('posts/Index', [
|
||||
'workspace' => $workspace,
|
||||
'posts' => Inertia::scroll(fn () => $query->latest('scheduled_at')->paginate(15)),
|
||||
'posts' => Inertia::scroll(fn () => $query->latest('scheduled_at')->paginate(config('app.pagination.default'))),
|
||||
'currentStatus' => $status,
|
||||
'filters' => [
|
||||
'search' => $request->input('search', ''),
|
||||
],
|
||||
]);
|
||||
}
|
||||
|
||||
|
|
@ -114,7 +121,7 @@ public function store(Request $request): RedirectResponse|\Symfony\Component\Htt
|
|||
|
||||
$this->authorize('createPost', $workspace);
|
||||
|
||||
$socialAccounts = $workspace->socialAccounts;
|
||||
$socialAccounts = $workspace->socialAccounts()->active()->get();
|
||||
|
||||
if ($socialAccounts->isEmpty()) {
|
||||
session()->flash('flash.banner', __('posts.flash.connect_first'));
|
||||
|
|
@ -145,7 +152,7 @@ public function edit(Request $request, Post $post): Response|RedirectResponse
|
|||
}
|
||||
|
||||
$post->load(['postPlatforms.socialAccount', 'postPlatforms.media', 'labels']);
|
||||
$socialAccounts = $workspace->socialAccounts;
|
||||
$socialAccounts = $workspace->socialAccounts()->active()->get();
|
||||
$labels = $workspace->labels;
|
||||
$hashtags = $workspace->hashtags;
|
||||
|
||||
|
|
|
|||
|
|
@ -25,9 +25,17 @@ public function index(Request $request): Response|RedirectResponse
|
|||
|
||||
$this->authorize('view', $workspace);
|
||||
|
||||
$hashtags = $workspace->hashtags()
|
||||
->when($request->input('search'), fn ($query, $search) => $query->where('name', 'ilike', "%{$search}%"))
|
||||
->latest()
|
||||
->paginate(config('app.pagination.default'));
|
||||
|
||||
return Inertia::render('hashtags/Index', [
|
||||
'workspace' => $workspace,
|
||||
'hashtags' => $workspace->hashtags()->latest()->get(),
|
||||
'hashtags' => Inertia::scroll(fn () => $hashtags),
|
||||
'filters' => [
|
||||
'search' => $request->input('search', ''),
|
||||
],
|
||||
]);
|
||||
}
|
||||
|
||||
|
|
|
|||
|
|
@ -25,9 +25,17 @@ public function index(Request $request): Response|RedirectResponse
|
|||
|
||||
$this->authorize('view', $workspace);
|
||||
|
||||
$labels = $workspace->labels()
|
||||
->when($request->input('search'), fn ($query, $search) => $query->where('name', 'ilike', "%{$search}%"))
|
||||
->latest()
|
||||
->paginate(config('app.pagination.default'));
|
||||
|
||||
return Inertia::render('labels/Index', [
|
||||
'workspace' => $workspace,
|
||||
'labels' => $workspace->labels()->latest()->get(),
|
||||
'labels' => Inertia::scroll(fn () => $labels),
|
||||
'filters' => [
|
||||
'search' => $request->input('search', ''),
|
||||
],
|
||||
]);
|
||||
}
|
||||
|
||||
|
|
|
|||
|
|
@ -4,25 +4,20 @@
|
|||
|
||||
namespace App\Http\Controllers\Auth;
|
||||
|
||||
use App\Enums\User\Setup;
|
||||
use App\Actions\User\CreateUser;
|
||||
use App\Http\Controllers\Controller;
|
||||
use App\Models\User;
|
||||
use App\Models\Workspace;
|
||||
use App\Rules\Timezone;
|
||||
use Illuminate\Auth\Events\Registered;
|
||||
use Illuminate\Http\RedirectResponse;
|
||||
use Illuminate\Http\Request;
|
||||
use Illuminate\Support\Facades\Auth;
|
||||
use Illuminate\Support\Facades\DB;
|
||||
use Illuminate\Validation\Rules;
|
||||
use Inertia\Inertia;
|
||||
use Inertia\Response;
|
||||
|
||||
class RegisteredUserController extends Controller
|
||||
{
|
||||
/**
|
||||
* Display the registration view.
|
||||
*/
|
||||
public function create(Request $request): Response
|
||||
{
|
||||
return Inertia::render('auth/Register', [
|
||||
|
|
@ -31,9 +26,6 @@ public function create(Request $request): Response
|
|||
]);
|
||||
}
|
||||
|
||||
/**
|
||||
* Handle an incoming registration request.
|
||||
*/
|
||||
public function store(Request $request): RedirectResponse
|
||||
{
|
||||
$request->validate([
|
||||
|
|
@ -43,41 +35,21 @@ public function store(Request $request): RedirectResponse
|
|||
'timezone' => ['nullable', 'string', new Timezone],
|
||||
]);
|
||||
|
||||
// Check if registering via invite link (redirect contains /invites/)
|
||||
$isInviteRegistration = str_contains($request->input('redirect', ''), '/invites/');
|
||||
|
||||
$user = DB::transaction(function () use ($request, $isInviteRegistration) {
|
||||
$user = User::create([
|
||||
'name' => $request->name,
|
||||
'email' => $request->email,
|
||||
'password' => $request->password,
|
||||
'setup' => $isInviteRegistration ? Setup::Completed : Setup::Role,
|
||||
'email_verified_at' => $isInviteRegistration ? now() : null,
|
||||
]);
|
||||
|
||||
// Create default workspace for new user
|
||||
$workspace = Workspace::create([
|
||||
'user_id' => $user->id,
|
||||
'name' => $user->name."'s Workspace",
|
||||
'timezone' => $request->input('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;
|
||||
});
|
||||
$user = CreateUser::execute([
|
||||
'name' => $request->name,
|
||||
'email' => $request->email,
|
||||
'password' => $request->password,
|
||||
'timezone' => $request->input('timezone', 'UTC'),
|
||||
'is_invite' => $isInviteRegistration,
|
||||
]);
|
||||
|
||||
event(new Registered($user));
|
||||
|
||||
Auth::login($user);
|
||||
|
||||
// Check for redirect param
|
||||
if ($redirect = $request->input('redirect')) {
|
||||
// Only allow internal redirects (paths starting with /)
|
||||
if (str_starts_with($redirect, '/') && ! str_starts_with($redirect, '//')) {
|
||||
return redirect($redirect);
|
||||
}
|
||||
|
|
|
|||
|
|
@ -81,6 +81,25 @@ public function disconnect(Request $request, SocialAccount $account): RedirectRe
|
|||
return back();
|
||||
}
|
||||
|
||||
public function toggleActive(Request $request, SocialAccount $account): RedirectResponse
|
||||
{
|
||||
$workspace = $request->user()->currentWorkspace;
|
||||
|
||||
$this->authorize('manageAccounts', $workspace);
|
||||
|
||||
if ($account->workspace_id !== $workspace->id) {
|
||||
abort(403);
|
||||
}
|
||||
|
||||
$account->update(['is_active' => ! $account->is_active]);
|
||||
|
||||
$status = $account->is_active ? 'activated' : 'deactivated';
|
||||
session()->flash('flash.banner', __("accounts.flash.{$status}"));
|
||||
session()->flash('flash.bannerStyle', 'success');
|
||||
|
||||
return back();
|
||||
}
|
||||
|
||||
protected function redirectToProvider(Request $request, string $driver, array $scopes): SymfonyResponse
|
||||
{
|
||||
$workspace = $request->user()->currentWorkspace;
|
||||
|
|
|
|||
60
app/Http/Controllers/Auth/SocialLoginController.php
Normal file
60
app/Http/Controllers/Auth/SocialLoginController.php
Normal file
|
|
@ -0,0 +1,60 @@
|
|||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace App\Http\Controllers\Auth;
|
||||
|
||||
use App\Actions\User\CreateUser;
|
||||
use App\Http\Controllers\Controller;
|
||||
use App\Models\User;
|
||||
use Illuminate\Auth\Events\Registered;
|
||||
use Illuminate\Http\RedirectResponse;
|
||||
use Illuminate\Support\Facades\Auth;
|
||||
use Laravel\Socialite\Facades\Socialite;
|
||||
|
||||
class SocialLoginController extends Controller
|
||||
{
|
||||
public function redirect(): RedirectResponse
|
||||
{
|
||||
return Socialite::driver('google-auth')->redirect();
|
||||
}
|
||||
|
||||
public function callback(): RedirectResponse
|
||||
{
|
||||
$googleUser = Socialite::driver('google-auth')->user();
|
||||
|
||||
$user = User::where('email', $googleUser->getEmail())->first();
|
||||
|
||||
if ($user) {
|
||||
return $this->loginExistingUser($user);
|
||||
}
|
||||
|
||||
return $this->registerNewUser($googleUser);
|
||||
}
|
||||
|
||||
private function loginExistingUser(User $user): RedirectResponse
|
||||
{
|
||||
if (! $user->hasVerifiedEmail()) {
|
||||
$user->markEmailAsVerified();
|
||||
}
|
||||
|
||||
Auth::login($user, remember: true);
|
||||
|
||||
return redirect()->route('app.home');
|
||||
}
|
||||
|
||||
private function registerNewUser(\Laravel\Socialite\Contracts\User $googleUser): RedirectResponse
|
||||
{
|
||||
$user = CreateUser::execute([
|
||||
'name' => $googleUser->getName(),
|
||||
'email' => $googleUser->getEmail(),
|
||||
'email_verified_at' => now(),
|
||||
]);
|
||||
|
||||
event(new Registered($user));
|
||||
|
||||
Auth::login($user, remember: true);
|
||||
|
||||
return redirect()->route('app.onboarding.role');
|
||||
}
|
||||
}
|
||||
|
|
@ -41,6 +41,14 @@ public function __construct(public PostPlatform $postPlatform) {}
|
|||
|
||||
public function handle(): void
|
||||
{
|
||||
if (! $this->postPlatform->socialAccount->is_active) {
|
||||
$this->postPlatform->markAsFailed(__('posts.errors.account_inactive'));
|
||||
$this->updatePostStatus();
|
||||
$this->broadcastStatus();
|
||||
|
||||
return;
|
||||
}
|
||||
|
||||
if ($this->postPlatform->socialAccount->isDisconnected()) {
|
||||
$this->postPlatform->markAsFailed(__('posts.errors.account_disconnected'));
|
||||
$this->updatePostStatus();
|
||||
|
|
|
|||
49
app/Jobs/SendPostHogEvent.php
Normal file
49
app/Jobs/SendPostHogEvent.php
Normal file
|
|
@ -0,0 +1,49 @@
|
|||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace App\Jobs;
|
||||
|
||||
use Illuminate\Bus\Queueable;
|
||||
use Illuminate\Contracts\Queue\ShouldQueue;
|
||||
use Illuminate\Foundation\Bus\Dispatchable;
|
||||
use Illuminate\Queue\InteractsWithQueue;
|
||||
use Illuminate\Queue\SerializesModels;
|
||||
use Illuminate\Support\Facades\Log;
|
||||
use PostHog\PostHog;
|
||||
|
||||
class SendPostHogEvent implements ShouldQueue
|
||||
{
|
||||
use Dispatchable, InteractsWithQueue, Queueable, SerializesModels;
|
||||
|
||||
public int $tries = 3;
|
||||
|
||||
public int $timeout = 15;
|
||||
|
||||
/**
|
||||
* @param array<array{method: string, payload: array<string, mixed>}> $calls
|
||||
*/
|
||||
public function __construct(
|
||||
public array $calls,
|
||||
) {
|
||||
$this->onQueue('posthog');
|
||||
}
|
||||
|
||||
public function handle(): void
|
||||
{
|
||||
if (! config('services.posthog.api_key')) {
|
||||
return;
|
||||
}
|
||||
|
||||
foreach ($this->calls as $call) {
|
||||
match ($call['method']) {
|
||||
'capture' => PostHog::capture($call['payload']),
|
||||
'identify' => PostHog::identify($call['payload']),
|
||||
'groupIdentify' => PostHog::groupIdentify($call['payload']),
|
||||
default => Log::warning('SendPostHogEvent: unknown method', ['method' => $call['method']]),
|
||||
};
|
||||
}
|
||||
|
||||
PostHog::flush();
|
||||
}
|
||||
}
|
||||
|
|
@ -11,6 +11,7 @@
|
|||
use App\Jobs\SendNotification;
|
||||
use App\Mail\AccountDisconnected;
|
||||
use Database\Factories\SocialAccountFactory;
|
||||
use Illuminate\Database\Eloquent\Builder;
|
||||
use Illuminate\Database\Eloquent\Casts\Attribute;
|
||||
use Illuminate\Database\Eloquent\Concerns\HasUuids;
|
||||
use Illuminate\Database\Eloquent\Factories\HasFactory;
|
||||
|
|
@ -38,6 +39,7 @@ class SocialAccount extends Model
|
|||
'scopes',
|
||||
'meta',
|
||||
'status',
|
||||
'is_active',
|
||||
'error_message',
|
||||
'disconnected_at',
|
||||
];
|
||||
|
|
@ -52,6 +54,7 @@ protected function casts(): array
|
|||
return [
|
||||
'platform' => SocialPlatform::class,
|
||||
'status' => Status::class,
|
||||
'is_active' => 'boolean',
|
||||
'access_token' => 'encrypted',
|
||||
'refresh_token' => 'encrypted',
|
||||
'token_expires_at' => 'datetime',
|
||||
|
|
@ -141,4 +144,9 @@ public function isDisconnected(): bool
|
|||
{
|
||||
return $this->status === Status::Disconnected || $this->status === Status::TokenExpired;
|
||||
}
|
||||
|
||||
public function scopeActive(Builder $query): Builder
|
||||
{
|
||||
return $query->where('is_active', true)->orderBy('platform');
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -40,6 +40,8 @@
|
|||
use Laravel\Nightwatch\Facades\Nightwatch;
|
||||
use Laravel\Nightwatch\Records\CacheEvent;
|
||||
use Laravel\Socialite\Facades\Socialite;
|
||||
use Laravel\Socialite\Two\GoogleProvider;
|
||||
use PostHog\PostHog;
|
||||
use SocialiteProviders\Facebook\FacebookExtendSocialite;
|
||||
use SocialiteProviders\LinkedIn\LinkedInExtendSocialite;
|
||||
use SocialiteProviders\Manager\SocialiteWasCalled;
|
||||
|
|
@ -66,6 +68,7 @@ public function boot(): void
|
|||
{
|
||||
$this->configureDefaults();
|
||||
$this->configureMorphMap();
|
||||
$this->configurePostHog();
|
||||
$this->configureRateLimiting();
|
||||
$this->configureSocialite();
|
||||
$this->configureStripeWebhooks();
|
||||
|
|
@ -93,6 +96,17 @@ protected function configureMorphMap(): void
|
|||
]);
|
||||
}
|
||||
|
||||
protected function configurePostHog(): void
|
||||
{
|
||||
$apiKey = config('services.posthog.api_key');
|
||||
|
||||
if ($apiKey) {
|
||||
PostHog::init($apiKey, [
|
||||
'host' => config('services.posthog.host'),
|
||||
]);
|
||||
}
|
||||
}
|
||||
|
||||
protected function configureRateLimiting(): void
|
||||
{
|
||||
RateLimiter::for('api', function (Request $request) {
|
||||
|
|
@ -111,6 +125,13 @@ protected function configureStripeWebhooks(): void
|
|||
|
||||
protected function configureSocialite(): void
|
||||
{
|
||||
// Google Auth (login/signup) - separate from YouTube OAuth
|
||||
Socialite::extend('google-auth', function ($app) {
|
||||
$config = $app['config']['services.google-auth'];
|
||||
|
||||
return Socialite::buildProvider(GoogleProvider::class, $config);
|
||||
});
|
||||
|
||||
// Instagram Business Login
|
||||
Socialite::extend('instagram', function ($app) {
|
||||
$config = $app['config']['services.instagram'];
|
||||
|
|
|
|||
72
app/Services/PostHogService.php
Normal file
72
app/Services/PostHogService.php
Normal file
|
|
@ -0,0 +1,72 @@
|
|||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace App\Services;
|
||||
|
||||
use App\Jobs\SendPostHogEvent;
|
||||
use Illuminate\Support\Facades\Log;
|
||||
|
||||
class PostHogService
|
||||
{
|
||||
/**
|
||||
* @param array<string, mixed> $properties
|
||||
*/
|
||||
public function capture(string $distinctId, string $event, array $properties = []): void
|
||||
{
|
||||
if (! config('services.posthog.api_key')) {
|
||||
return;
|
||||
}
|
||||
|
||||
$this->dispatch('capture', [
|
||||
'distinctId' => $distinctId,
|
||||
'event' => $event,
|
||||
'properties' => $properties,
|
||||
]);
|
||||
}
|
||||
|
||||
/**
|
||||
* @param array<string, mixed> $properties
|
||||
*/
|
||||
public function identify(string $distinctId, array $properties = []): void
|
||||
{
|
||||
if (! config('services.posthog.api_key')) {
|
||||
return;
|
||||
}
|
||||
|
||||
$this->dispatch('identify', [
|
||||
'distinctId' => $distinctId,
|
||||
'properties' => $properties,
|
||||
]);
|
||||
}
|
||||
|
||||
/**
|
||||
* @param array<string, mixed> $properties
|
||||
*/
|
||||
public function groupIdentify(string $groupType, string $groupKey, array $properties = []): void
|
||||
{
|
||||
if (! config('services.posthog.api_key')) {
|
||||
return;
|
||||
}
|
||||
|
||||
$this->dispatch('groupIdentify', [
|
||||
'groupType' => $groupType,
|
||||
'groupKey' => $groupKey,
|
||||
'properties' => $properties,
|
||||
]);
|
||||
}
|
||||
|
||||
/**
|
||||
* @param array<string, mixed> $payload
|
||||
*/
|
||||
private function dispatch(string $method, array $payload): void
|
||||
{
|
||||
try {
|
||||
SendPostHogEvent::dispatch([
|
||||
['method' => $method, 'payload' => $payload],
|
||||
]);
|
||||
} catch (\Throwable $e) {
|
||||
Log::warning('PostHogService: failed to dispatch event', ['method' => $method, 'error' => $e->getMessage()]);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
@ -47,6 +47,7 @@
|
|||
"laravel/tinker": "^3.0",
|
||||
"laravel/wayfinder": "^0.1.9",
|
||||
"league/flysystem-aws-s3-v3": "^3.0",
|
||||
"posthog/posthog-php": "^4.1",
|
||||
"predis/predis": "^3.3",
|
||||
"sendkit/sendkit-laravel": "^1.1",
|
||||
"socialiteproviders/facebook": "^4.1",
|
||||
|
|
|
|||
55
composer.lock
generated
55
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": "5408bdeb64bc5066a115e08ba7f378f3",
|
||||
"content-hash": "f97b0298cdb7a80d1c5d1706165b6fe9",
|
||||
"packages": [
|
||||
{
|
||||
"name": "aws/aws-crt-php",
|
||||
|
|
@ -4534,6 +4534,59 @@
|
|||
},
|
||||
"time": "2026-01-25T14:56:51+00:00"
|
||||
},
|
||||
{
|
||||
"name": "posthog/posthog-php",
|
||||
"version": "4.1.1",
|
||||
"source": {
|
||||
"type": "git",
|
||||
"url": "https://github.com/PostHog/posthog-php.git",
|
||||
"reference": "6fe1346ea8b178a9b2c79777bc980892f0486cbb"
|
||||
},
|
||||
"dist": {
|
||||
"type": "zip",
|
||||
"url": "https://api.github.com/repos/PostHog/posthog-php/zipball/6fe1346ea8b178a9b2c79777bc980892f0486cbb",
|
||||
"reference": "6fe1346ea8b178a9b2c79777bc980892f0486cbb",
|
||||
"shasum": ""
|
||||
},
|
||||
"require": {
|
||||
"ext-json": "*",
|
||||
"php": ">=8.2",
|
||||
"symfony/clock": "^6.2|^7.0|^8.0"
|
||||
},
|
||||
"require-dev": {
|
||||
"phpunit/phpunit": "^11.0",
|
||||
"squizlabs/php_codesniffer": "^3.7"
|
||||
},
|
||||
"bin": [
|
||||
"bin/posthog"
|
||||
],
|
||||
"type": "library",
|
||||
"autoload": {
|
||||
"psr-4": {
|
||||
"PostHog\\": "lib/"
|
||||
}
|
||||
},
|
||||
"notification-url": "https://packagist.org/downloads/",
|
||||
"license": [
|
||||
"MIT"
|
||||
],
|
||||
"authors": [
|
||||
{
|
||||
"name": "PostHog <hey@posthog.com>",
|
||||
"homepage": "https://posthog.com/"
|
||||
}
|
||||
],
|
||||
"description": "PostHog PHP Library",
|
||||
"homepage": "https://github.com/PostHog/posthog-php",
|
||||
"keywords": [
|
||||
"posthog"
|
||||
],
|
||||
"support": {
|
||||
"issues": "https://github.com/PostHog/posthog-php/issues",
|
||||
"source": "https://github.com/PostHog/posthog-php/tree/4.1.1"
|
||||
},
|
||||
"time": "2026-03-30T12:02:24+00:00"
|
||||
},
|
||||
{
|
||||
"name": "predis/predis",
|
||||
"version": "v3.4.2",
|
||||
|
|
|
|||
|
|
@ -88,6 +88,18 @@
|
|||
|
||||
'faker_locale' => env('APP_FAKER_LOCALE', 'en_US'),
|
||||
|
||||
/*
|
||||
|--------------------------------------------------------------------------
|
||||
| Application Pagination
|
||||
|--------------------------------------------------------------------------
|
||||
|
|
||||
| This is default pagination value for the application.
|
||||
|
|
||||
*/
|
||||
'pagination' => [
|
||||
'default' => 25,
|
||||
],
|
||||
|
||||
/*
|
||||
|--------------------------------------------------------------------------
|
||||
| Encryption Key
|
||||
|
|
|
|||
|
|
@ -69,6 +69,13 @@
|
|||
'redirect' => env('GOOGLE_CLIENT_REDIRECT'),
|
||||
],
|
||||
|
||||
// Google OAuth (used for login/signup)
|
||||
'google-auth' => [
|
||||
'client_id' => env('GOOGLE_CLIENT_ID'),
|
||||
'client_secret' => env('GOOGLE_CLIENT_SECRET'),
|
||||
'redirect' => env('GOOGLE_AUTH_CALLBACK'),
|
||||
],
|
||||
|
||||
// Facebook Pages
|
||||
'facebook' => [
|
||||
'client_id' => env('FACEBOOK_CLIENT_ID'),
|
||||
|
|
@ -97,4 +104,9 @@
|
|||
'redirect' => env('PINTEREST_CLIENT_REDIRECT'),
|
||||
],
|
||||
|
||||
'posthog' => [
|
||||
'api_key' => env('POSTHOG_API_KEY'),
|
||||
'host' => env('POSTHOG_HOST', 'https://us.i.posthog.com'),
|
||||
],
|
||||
|
||||
];
|
||||
|
|
|
|||
|
|
@ -0,0 +1,24 @@
|
|||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
use Illuminate\Database\Migrations\Migration;
|
||||
use Illuminate\Database\Schema\Blueprint;
|
||||
use Illuminate\Support\Facades\Schema;
|
||||
|
||||
return new class extends Migration
|
||||
{
|
||||
public function up(): void
|
||||
{
|
||||
Schema::table('social_accounts', function (Blueprint $table) {
|
||||
$table->boolean('is_active')->default(true)->after('status');
|
||||
});
|
||||
}
|
||||
|
||||
public function down(): void
|
||||
{
|
||||
Schema::table('social_accounts', function (Blueprint $table) {
|
||||
$table->dropColumn('is_active');
|
||||
});
|
||||
}
|
||||
};
|
||||
|
|
@ -0,0 +1,24 @@
|
|||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
use Illuminate\Database\Migrations\Migration;
|
||||
use Illuminate\Database\Schema\Blueprint;
|
||||
use Illuminate\Support\Facades\Schema;
|
||||
|
||||
return new class extends Migration
|
||||
{
|
||||
public function up(): void
|
||||
{
|
||||
Schema::table('users', function (Blueprint $table) {
|
||||
$table->string('password')->nullable()->change();
|
||||
});
|
||||
}
|
||||
|
||||
public function down(): void
|
||||
{
|
||||
Schema::table('users', function (Blueprint $table) {
|
||||
$table->string('password')->nullable(false)->change();
|
||||
});
|
||||
}
|
||||
};
|
||||
|
|
@ -62,6 +62,8 @@
|
|||
'connected' => 'Account connected successfully!',
|
||||
'session_expired' => 'Session expired. Please try again.',
|
||||
'workspace_not_found' => 'Workspace not found.',
|
||||
'activated' => 'Account activated!',
|
||||
'deactivated' => 'Account deactivated!',
|
||||
'already_connected' => 'This platform is already connected.',
|
||||
'no_youtube_channels' => 'No YouTube channels found. Please create a channel first.',
|
||||
],
|
||||
|
|
|
|||
|
|
@ -51,6 +51,10 @@
|
|||
],
|
||||
],
|
||||
|
||||
'or_continue_with' => 'Or continue with',
|
||||
'google_login' => 'Log in with Google',
|
||||
'google_signup' => 'Sign up with Google',
|
||||
|
||||
'login' => [
|
||||
'title' => 'Log in to your account',
|
||||
'description' => 'Enter your email and password below to log in',
|
||||
|
|
|
|||
|
|
@ -3,6 +3,7 @@
|
|||
return [
|
||||
'title' => 'Hashtags',
|
||||
'description' => 'Create hashtag groups to quickly add to your posts',
|
||||
'search' => 'Search hashtags...',
|
||||
'new_group' => 'New Group',
|
||||
'no_groups_yet' => 'No hashtag groups yet',
|
||||
'no_groups_description' => 'Create hashtag groups to quickly add popular hashtags to your posts',
|
||||
|
|
|
|||
|
|
@ -3,6 +3,7 @@
|
|||
return [
|
||||
'title' => 'Labels',
|
||||
'description' => 'Create labels to organize and categorize your posts',
|
||||
'search' => 'Search labels...',
|
||||
'new_label' => 'New Label',
|
||||
'no_labels_yet' => 'No labels yet',
|
||||
'create_first_label' => 'Create your first label',
|
||||
|
|
|
|||
|
|
@ -2,6 +2,7 @@
|
|||
|
||||
return [
|
||||
'title' => 'Posts',
|
||||
'search' => 'Search posts...',
|
||||
'all_posts' => 'All Posts',
|
||||
'new_post' => 'New Post',
|
||||
'no_posts' => 'No posts found',
|
||||
|
|
@ -224,5 +225,6 @@
|
|||
|
||||
'errors' => [
|
||||
'account_disconnected' => 'Social account is disconnected',
|
||||
'account_inactive' => 'Social account is deactivated',
|
||||
],
|
||||
];
|
||||
|
|
|
|||
|
|
@ -43,6 +43,7 @@
|
|||
|
||||
'notifications' => 'Notifications',
|
||||
'mark_all_read' => 'Mark all as read',
|
||||
'mark_as_read' => 'Mark as read',
|
||||
'archive_all' => 'Archive all',
|
||||
'no_notifications' => 'No notifications',
|
||||
|
||||
|
|
|
|||
|
|
@ -62,6 +62,8 @@
|
|||
'connected' => '¡Cuenta conectada correctamente!',
|
||||
'session_expired' => 'Sesión expirada. Inténtalo de nuevo.',
|
||||
'workspace_not_found' => 'Workspace no encontrado.',
|
||||
'activated' => '¡Cuenta activada!',
|
||||
'deactivated' => '¡Cuenta desactivada!',
|
||||
'already_connected' => 'Esta plataforma ya está conectada.',
|
||||
'no_youtube_channels' => 'No se encontraron canales de YouTube. Crea un canal primero.',
|
||||
],
|
||||
|
|
|
|||
|
|
@ -39,6 +39,10 @@
|
|||
],
|
||||
],
|
||||
|
||||
'or_continue_with' => 'O continuar con',
|
||||
'google_login' => 'Iniciar sesión con Google',
|
||||
'google_signup' => 'Registrarse con Google',
|
||||
|
||||
'login' => [
|
||||
'title' => 'Inicia sesión en tu cuenta',
|
||||
'description' => 'Introduce tu correo y contraseña para iniciar sesión',
|
||||
|
|
|
|||
|
|
@ -3,6 +3,7 @@
|
|||
return [
|
||||
'title' => 'Hashtags',
|
||||
'description' => 'Crea grupos de hashtags para agregarlos rápidamente a tus posts',
|
||||
'search' => 'Buscar hashtags...',
|
||||
'new_group' => 'Nuevo grupo',
|
||||
'no_groups_yet' => 'Aún no hay grupos de hashtags',
|
||||
'no_groups_description' => 'Crea grupos de hashtags para agregar rápidamente hashtags populares a tus posts',
|
||||
|
|
|
|||
|
|
@ -3,6 +3,7 @@
|
|||
return [
|
||||
'title' => 'Etiquetas',
|
||||
'description' => 'Crea etiquetas para organizar y categorizar tus posts',
|
||||
'search' => 'Buscar etiquetas...',
|
||||
'new_label' => 'Nueva etiqueta',
|
||||
'no_labels_yet' => 'Aún no hay etiquetas',
|
||||
'create_first_label' => 'Crea tu primera etiqueta',
|
||||
|
|
|
|||
|
|
@ -2,6 +2,7 @@
|
|||
|
||||
return [
|
||||
'title' => 'Posts',
|
||||
'search' => 'Buscar posts...',
|
||||
'all_posts' => 'Todos los posts',
|
||||
'new_post' => 'Nuevo post',
|
||||
'no_posts' => 'No se encontraron posts',
|
||||
|
|
@ -224,5 +225,6 @@
|
|||
|
||||
'errors' => [
|
||||
'account_disconnected' => 'Cuenta social desconectada',
|
||||
'account_inactive' => 'Cuenta social desactivada',
|
||||
],
|
||||
];
|
||||
|
|
|
|||
|
|
@ -43,6 +43,7 @@
|
|||
|
||||
'notifications' => 'Notificaciones',
|
||||
'mark_all_read' => 'Marcar todo como leído',
|
||||
'mark_as_read' => 'Marcar como leído',
|
||||
'archive_all' => 'Archivar todo',
|
||||
'no_notifications' => 'Sin notificaciones',
|
||||
|
||||
|
|
|
|||
File diff suppressed because one or more lines are too long
File diff suppressed because one or more lines are too long
File diff suppressed because one or more lines are too long
|
|
@ -62,6 +62,8 @@
|
|||
'connected' => 'Conta conectada com sucesso!',
|
||||
'session_expired' => 'Sessão expirada. Por favor, tente novamente.',
|
||||
'workspace_not_found' => 'Workspace não encontrado.',
|
||||
'activated' => 'Conta ativada!',
|
||||
'deactivated' => 'Conta desativada!',
|
||||
'already_connected' => 'Esta plataforma já está conectada.',
|
||||
'no_youtube_channels' => 'Nenhum canal do YouTube encontrado. Por favor, crie um canal primeiro.',
|
||||
],
|
||||
|
|
|
|||
|
|
@ -51,6 +51,10 @@
|
|||
],
|
||||
],
|
||||
|
||||
'or_continue_with' => 'Ou continue com',
|
||||
'google_login' => 'Entrar com Google',
|
||||
'google_signup' => 'Cadastrar com Google',
|
||||
|
||||
'login' => [
|
||||
'title' => 'Entrar na sua conta',
|
||||
'description' => 'Digite seu email e senha abaixo para entrar',
|
||||
|
|
|
|||
|
|
@ -3,6 +3,7 @@
|
|||
return [
|
||||
'title' => 'Hashtags',
|
||||
'description' => 'Crie grupos de hashtags para adicionar rapidamente aos seus posts',
|
||||
'search' => 'Buscar hashtags...',
|
||||
'new_group' => 'Novo Grupo',
|
||||
'no_groups_yet' => 'Nenhum grupo de hashtags ainda',
|
||||
'no_groups_description' => 'Crie grupos de hashtags para adicionar rapidamente hashtags populares aos seus posts',
|
||||
|
|
|
|||
|
|
@ -3,6 +3,7 @@
|
|||
return [
|
||||
'title' => 'Etiquetas',
|
||||
'description' => 'Crie etiquetas para organizar e categorizar seus posts',
|
||||
'search' => 'Buscar etiquetas...',
|
||||
'new_label' => 'Nova Etiqueta',
|
||||
'no_labels_yet' => 'Nenhuma etiqueta ainda',
|
||||
'create_first_label' => 'Crie sua primeira etiqueta',
|
||||
|
|
|
|||
|
|
@ -2,6 +2,7 @@
|
|||
|
||||
return [
|
||||
'title' => 'Posts',
|
||||
'search' => 'Buscar posts...',
|
||||
'all_posts' => 'Todos os Posts',
|
||||
'new_post' => 'Novo Post',
|
||||
'no_posts' => 'Nenhum post encontrado',
|
||||
|
|
@ -224,5 +225,6 @@
|
|||
|
||||
'errors' => [
|
||||
'account_disconnected' => 'Conta social está desconectada',
|
||||
'account_inactive' => 'Conta social está desativada',
|
||||
],
|
||||
];
|
||||
|
|
|
|||
|
|
@ -43,6 +43,7 @@
|
|||
|
||||
'notifications' => 'Notificações',
|
||||
'mark_all_read' => 'Marcar tudo como lido',
|
||||
'mark_as_read' => 'Marcar como lido',
|
||||
'archive_all' => 'Arquivar tudo',
|
||||
'no_notifications' => 'Sem notificações',
|
||||
|
||||
|
|
|
|||
440
package-lock.json
generated
440
package-lock.json
generated
|
|
@ -17,6 +17,7 @@
|
|||
"laravel-vite-plugin": "^2.0.0",
|
||||
"laravel-vue-i18n": "^2.8.0",
|
||||
"maska": "^3.2.0",
|
||||
"posthog-js": "^1.364.2",
|
||||
"reka-ui": "^2.7.0",
|
||||
"tailwind-merge": "^3.2.0",
|
||||
"tailwindcss": "^4.1.1",
|
||||
|
|
@ -1020,6 +1021,331 @@
|
|||
"node": ">= 8"
|
||||
}
|
||||
},
|
||||
"node_modules/@opentelemetry/api": {
|
||||
"version": "1.9.1",
|
||||
"resolved": "https://registry.npmjs.org/@opentelemetry/api/-/api-1.9.1.tgz",
|
||||
"integrity": "sha512-gLyJlPHPZYdAk1JENA9LeHejZe1Ti77/pTeFm/nMXmQH/HFZlcS/O2XJB+L8fkbrNSqhdtlvjBVjxwUYanNH5Q==",
|
||||
"license": "Apache-2.0",
|
||||
"engines": {
|
||||
"node": ">=8.0.0"
|
||||
}
|
||||
},
|
||||
"node_modules/@opentelemetry/api-logs": {
|
||||
"version": "0.208.0",
|
||||
"resolved": "https://registry.npmjs.org/@opentelemetry/api-logs/-/api-logs-0.208.0.tgz",
|
||||
"integrity": "sha512-CjruKY9V6NMssL/T1kAFgzosF1v9o6oeN+aX5JB/C/xPNtmgIJqcXHG7fA82Ou1zCpWGl4lROQUKwUNE1pMCyg==",
|
||||
"license": "Apache-2.0",
|
||||
"dependencies": {
|
||||
"@opentelemetry/api": "^1.3.0"
|
||||
},
|
||||
"engines": {
|
||||
"node": ">=8.0.0"
|
||||
}
|
||||
},
|
||||
"node_modules/@opentelemetry/core": {
|
||||
"version": "2.2.0",
|
||||
"resolved": "https://registry.npmjs.org/@opentelemetry/core/-/core-2.2.0.tgz",
|
||||
"integrity": "sha512-FuabnnUm8LflnieVxs6eP7Z383hgQU4W1e3KJS6aOG3RxWxcHyBxH8fDMHNgu/gFx/M2jvTOW/4/PHhLz6bjWw==",
|
||||
"license": "Apache-2.0",
|
||||
"dependencies": {
|
||||
"@opentelemetry/semantic-conventions": "^1.29.0"
|
||||
},
|
||||
"engines": {
|
||||
"node": "^18.19.0 || >=20.6.0"
|
||||
},
|
||||
"peerDependencies": {
|
||||
"@opentelemetry/api": ">=1.0.0 <1.10.0"
|
||||
}
|
||||
},
|
||||
"node_modules/@opentelemetry/exporter-logs-otlp-http": {
|
||||
"version": "0.208.0",
|
||||
"resolved": "https://registry.npmjs.org/@opentelemetry/exporter-logs-otlp-http/-/exporter-logs-otlp-http-0.208.0.tgz",
|
||||
"integrity": "sha512-jOv40Bs9jy9bZVLo/i8FwUiuCvbjWDI+ZW13wimJm4LjnlwJxGgB+N/VWOZUTpM+ah/awXeQqKdNlpLf2EjvYg==",
|
||||
"license": "Apache-2.0",
|
||||
"dependencies": {
|
||||
"@opentelemetry/api-logs": "0.208.0",
|
||||
"@opentelemetry/core": "2.2.0",
|
||||
"@opentelemetry/otlp-exporter-base": "0.208.0",
|
||||
"@opentelemetry/otlp-transformer": "0.208.0",
|
||||
"@opentelemetry/sdk-logs": "0.208.0"
|
||||
},
|
||||
"engines": {
|
||||
"node": "^18.19.0 || >=20.6.0"
|
||||
},
|
||||
"peerDependencies": {
|
||||
"@opentelemetry/api": "^1.3.0"
|
||||
}
|
||||
},
|
||||
"node_modules/@opentelemetry/otlp-exporter-base": {
|
||||
"version": "0.208.0",
|
||||
"resolved": "https://registry.npmjs.org/@opentelemetry/otlp-exporter-base/-/otlp-exporter-base-0.208.0.tgz",
|
||||
"integrity": "sha512-gMd39gIfVb2OgxldxUtOwGJYSH8P1kVFFlJLuut32L6KgUC4gl1dMhn+YC2mGn0bDOiQYSk/uHOdSjuKp58vvA==",
|
||||
"license": "Apache-2.0",
|
||||
"dependencies": {
|
||||
"@opentelemetry/core": "2.2.0",
|
||||
"@opentelemetry/otlp-transformer": "0.208.0"
|
||||
},
|
||||
"engines": {
|
||||
"node": "^18.19.0 || >=20.6.0"
|
||||
},
|
||||
"peerDependencies": {
|
||||
"@opentelemetry/api": "^1.3.0"
|
||||
}
|
||||
},
|
||||
"node_modules/@opentelemetry/otlp-transformer": {
|
||||
"version": "0.208.0",
|
||||
"resolved": "https://registry.npmjs.org/@opentelemetry/otlp-transformer/-/otlp-transformer-0.208.0.tgz",
|
||||
"integrity": "sha512-DCFPY8C6lAQHUNkzcNT9R+qYExvsk6C5Bto2pbNxgicpcSWbe2WHShLxkOxIdNcBiYPdVHv/e7vH7K6TI+C+fQ==",
|
||||
"license": "Apache-2.0",
|
||||
"dependencies": {
|
||||
"@opentelemetry/api-logs": "0.208.0",
|
||||
"@opentelemetry/core": "2.2.0",
|
||||
"@opentelemetry/resources": "2.2.0",
|
||||
"@opentelemetry/sdk-logs": "0.208.0",
|
||||
"@opentelemetry/sdk-metrics": "2.2.0",
|
||||
"@opentelemetry/sdk-trace-base": "2.2.0",
|
||||
"protobufjs": "^7.3.0"
|
||||
},
|
||||
"engines": {
|
||||
"node": "^18.19.0 || >=20.6.0"
|
||||
},
|
||||
"peerDependencies": {
|
||||
"@opentelemetry/api": "^1.3.0"
|
||||
}
|
||||
},
|
||||
"node_modules/@opentelemetry/otlp-transformer/node_modules/@opentelemetry/resources": {
|
||||
"version": "2.2.0",
|
||||
"resolved": "https://registry.npmjs.org/@opentelemetry/resources/-/resources-2.2.0.tgz",
|
||||
"integrity": "sha512-1pNQf/JazQTMA0BiO5NINUzH0cbLbbl7mntLa4aJNmCCXSj0q03T5ZXXL0zw4G55TjdL9Tz32cznGClf+8zr5A==",
|
||||
"license": "Apache-2.0",
|
||||
"dependencies": {
|
||||
"@opentelemetry/core": "2.2.0",
|
||||
"@opentelemetry/semantic-conventions": "^1.29.0"
|
||||
},
|
||||
"engines": {
|
||||
"node": "^18.19.0 || >=20.6.0"
|
||||
},
|
||||
"peerDependencies": {
|
||||
"@opentelemetry/api": ">=1.3.0 <1.10.0"
|
||||
}
|
||||
},
|
||||
"node_modules/@opentelemetry/resources": {
|
||||
"version": "2.6.1",
|
||||
"resolved": "https://registry.npmjs.org/@opentelemetry/resources/-/resources-2.6.1.tgz",
|
||||
"integrity": "sha512-lID/vxSuKWXM55XhAKNoYXu9Cutoq5hFdkbTdI/zDKQktXzcWBVhNsOkiZFTMU9UtEWuGRNe0HUgmsFldIdxVA==",
|
||||
"license": "Apache-2.0",
|
||||
"dependencies": {
|
||||
"@opentelemetry/core": "2.6.1",
|
||||
"@opentelemetry/semantic-conventions": "^1.29.0"
|
||||
},
|
||||
"engines": {
|
||||
"node": "^18.19.0 || >=20.6.0"
|
||||
},
|
||||
"peerDependencies": {
|
||||
"@opentelemetry/api": ">=1.3.0 <1.10.0"
|
||||
}
|
||||
},
|
||||
"node_modules/@opentelemetry/resources/node_modules/@opentelemetry/core": {
|
||||
"version": "2.6.1",
|
||||
"resolved": "https://registry.npmjs.org/@opentelemetry/core/-/core-2.6.1.tgz",
|
||||
"integrity": "sha512-8xHSGWpJP9wBxgBpnqGL0R3PbdWQndL1Qp50qrg71+B28zK5OQmUgcDKLJgzyAAV38t4tOyLMGDD60LneR5W8g==",
|
||||
"license": "Apache-2.0",
|
||||
"dependencies": {
|
||||
"@opentelemetry/semantic-conventions": "^1.29.0"
|
||||
},
|
||||
"engines": {
|
||||
"node": "^18.19.0 || >=20.6.0"
|
||||
},
|
||||
"peerDependencies": {
|
||||
"@opentelemetry/api": ">=1.0.0 <1.10.0"
|
||||
}
|
||||
},
|
||||
"node_modules/@opentelemetry/sdk-logs": {
|
||||
"version": "0.208.0",
|
||||
"resolved": "https://registry.npmjs.org/@opentelemetry/sdk-logs/-/sdk-logs-0.208.0.tgz",
|
||||
"integrity": "sha512-QlAyL1jRpOeaqx7/leG1vJMp84g0xKP6gJmfELBpnI4O/9xPX+Hu5m1POk9Kl+veNkyth5t19hRlN6tNY1sjbA==",
|
||||
"license": "Apache-2.0",
|
||||
"dependencies": {
|
||||
"@opentelemetry/api-logs": "0.208.0",
|
||||
"@opentelemetry/core": "2.2.0",
|
||||
"@opentelemetry/resources": "2.2.0"
|
||||
},
|
||||
"engines": {
|
||||
"node": "^18.19.0 || >=20.6.0"
|
||||
},
|
||||
"peerDependencies": {
|
||||
"@opentelemetry/api": ">=1.4.0 <1.10.0"
|
||||
}
|
||||
},
|
||||
"node_modules/@opentelemetry/sdk-logs/node_modules/@opentelemetry/resources": {
|
||||
"version": "2.2.0",
|
||||
"resolved": "https://registry.npmjs.org/@opentelemetry/resources/-/resources-2.2.0.tgz",
|
||||
"integrity": "sha512-1pNQf/JazQTMA0BiO5NINUzH0cbLbbl7mntLa4aJNmCCXSj0q03T5ZXXL0zw4G55TjdL9Tz32cznGClf+8zr5A==",
|
||||
"license": "Apache-2.0",
|
||||
"dependencies": {
|
||||
"@opentelemetry/core": "2.2.0",
|
||||
"@opentelemetry/semantic-conventions": "^1.29.0"
|
||||
},
|
||||
"engines": {
|
||||
"node": "^18.19.0 || >=20.6.0"
|
||||
},
|
||||
"peerDependencies": {
|
||||
"@opentelemetry/api": ">=1.3.0 <1.10.0"
|
||||
}
|
||||
},
|
||||
"node_modules/@opentelemetry/sdk-metrics": {
|
||||
"version": "2.2.0",
|
||||
"resolved": "https://registry.npmjs.org/@opentelemetry/sdk-metrics/-/sdk-metrics-2.2.0.tgz",
|
||||
"integrity": "sha512-G5KYP6+VJMZzpGipQw7Giif48h6SGQ2PFKEYCybeXJsOCB4fp8azqMAAzE5lnnHK3ZVwYQrgmFbsUJO/zOnwGw==",
|
||||
"license": "Apache-2.0",
|
||||
"dependencies": {
|
||||
"@opentelemetry/core": "2.2.0",
|
||||
"@opentelemetry/resources": "2.2.0"
|
||||
},
|
||||
"engines": {
|
||||
"node": "^18.19.0 || >=20.6.0"
|
||||
},
|
||||
"peerDependencies": {
|
||||
"@opentelemetry/api": ">=1.9.0 <1.10.0"
|
||||
}
|
||||
},
|
||||
"node_modules/@opentelemetry/sdk-metrics/node_modules/@opentelemetry/resources": {
|
||||
"version": "2.2.0",
|
||||
"resolved": "https://registry.npmjs.org/@opentelemetry/resources/-/resources-2.2.0.tgz",
|
||||
"integrity": "sha512-1pNQf/JazQTMA0BiO5NINUzH0cbLbbl7mntLa4aJNmCCXSj0q03T5ZXXL0zw4G55TjdL9Tz32cznGClf+8zr5A==",
|
||||
"license": "Apache-2.0",
|
||||
"dependencies": {
|
||||
"@opentelemetry/core": "2.2.0",
|
||||
"@opentelemetry/semantic-conventions": "^1.29.0"
|
||||
},
|
||||
"engines": {
|
||||
"node": "^18.19.0 || >=20.6.0"
|
||||
},
|
||||
"peerDependencies": {
|
||||
"@opentelemetry/api": ">=1.3.0 <1.10.0"
|
||||
}
|
||||
},
|
||||
"node_modules/@opentelemetry/sdk-trace-base": {
|
||||
"version": "2.2.0",
|
||||
"resolved": "https://registry.npmjs.org/@opentelemetry/sdk-trace-base/-/sdk-trace-base-2.2.0.tgz",
|
||||
"integrity": "sha512-xWQgL0Bmctsalg6PaXExmzdedSp3gyKV8mQBwK/j9VGdCDu2fmXIb2gAehBKbkXCpJ4HPkgv3QfoJWRT4dHWbw==",
|
||||
"license": "Apache-2.0",
|
||||
"dependencies": {
|
||||
"@opentelemetry/core": "2.2.0",
|
||||
"@opentelemetry/resources": "2.2.0",
|
||||
"@opentelemetry/semantic-conventions": "^1.29.0"
|
||||
},
|
||||
"engines": {
|
||||
"node": "^18.19.0 || >=20.6.0"
|
||||
},
|
||||
"peerDependencies": {
|
||||
"@opentelemetry/api": ">=1.3.0 <1.10.0"
|
||||
}
|
||||
},
|
||||
"node_modules/@opentelemetry/sdk-trace-base/node_modules/@opentelemetry/resources": {
|
||||
"version": "2.2.0",
|
||||
"resolved": "https://registry.npmjs.org/@opentelemetry/resources/-/resources-2.2.0.tgz",
|
||||
"integrity": "sha512-1pNQf/JazQTMA0BiO5NINUzH0cbLbbl7mntLa4aJNmCCXSj0q03T5ZXXL0zw4G55TjdL9Tz32cznGClf+8zr5A==",
|
||||
"license": "Apache-2.0",
|
||||
"dependencies": {
|
||||
"@opentelemetry/core": "2.2.0",
|
||||
"@opentelemetry/semantic-conventions": "^1.29.0"
|
||||
},
|
||||
"engines": {
|
||||
"node": "^18.19.0 || >=20.6.0"
|
||||
},
|
||||
"peerDependencies": {
|
||||
"@opentelemetry/api": ">=1.3.0 <1.10.0"
|
||||
}
|
||||
},
|
||||
"node_modules/@opentelemetry/semantic-conventions": {
|
||||
"version": "1.40.0",
|
||||
"resolved": "https://registry.npmjs.org/@opentelemetry/semantic-conventions/-/semantic-conventions-1.40.0.tgz",
|
||||
"integrity": "sha512-cifvXDhcqMwwTlTK04GBNeIe7yyo28Mfby85QXFe1Yk8nmi36Ab/5UQwptOx84SsoGNRg+EVSjwzfSZMy6pmlw==",
|
||||
"license": "Apache-2.0",
|
||||
"engines": {
|
||||
"node": ">=14"
|
||||
}
|
||||
},
|
||||
"node_modules/@posthog/core": {
|
||||
"version": "1.24.4",
|
||||
"resolved": "https://registry.npmjs.org/@posthog/core/-/core-1.24.4.tgz",
|
||||
"integrity": "sha512-S+TolwBHSSJz7WWtgaELQWQqXviSm3uf1e+qorWUts0bZcgPwWzhnmhCUZAhvn0NVpTQHDJ3epv+hHbPLl5dHg==",
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"cross-spawn": "^7.0.6"
|
||||
}
|
||||
},
|
||||
"node_modules/@posthog/types": {
|
||||
"version": "1.364.2",
|
||||
"resolved": "https://registry.npmjs.org/@posthog/types/-/types-1.364.2.tgz",
|
||||
"integrity": "sha512-SMTdaYanvRmatgheXtu2XkewhuhdXe8C3JCi7m/Hd2n+sa2DaJphcwg3nAkPtfV69JHMxJLe/gyOt7yFtbQSjQ==",
|
||||
"license": "MIT"
|
||||
},
|
||||
"node_modules/@protobufjs/aspromise": {
|
||||
"version": "1.1.2",
|
||||
"resolved": "https://registry.npmjs.org/@protobufjs/aspromise/-/aspromise-1.1.2.tgz",
|
||||
"integrity": "sha512-j+gKExEuLmKwvz3OgROXtrJ2UG2x8Ch2YZUxahh+s1F2HZ+wAceUNLkvy6zKCPVRkU++ZWQrdxsUeQXmcg4uoQ==",
|
||||
"license": "BSD-3-Clause"
|
||||
},
|
||||
"node_modules/@protobufjs/base64": {
|
||||
"version": "1.1.2",
|
||||
"resolved": "https://registry.npmjs.org/@protobufjs/base64/-/base64-1.1.2.tgz",
|
||||
"integrity": "sha512-AZkcAA5vnN/v4PDqKyMR5lx7hZttPDgClv83E//FMNhR2TMcLUhfRUBHCmSl0oi9zMgDDqRUJkSxO3wm85+XLg==",
|
||||
"license": "BSD-3-Clause"
|
||||
},
|
||||
"node_modules/@protobufjs/codegen": {
|
||||
"version": "2.0.4",
|
||||
"resolved": "https://registry.npmjs.org/@protobufjs/codegen/-/codegen-2.0.4.tgz",
|
||||
"integrity": "sha512-YyFaikqM5sH0ziFZCN3xDC7zeGaB/d0IUb9CATugHWbd1FRFwWwt4ld4OYMPWu5a3Xe01mGAULCdqhMlPl29Jg==",
|
||||
"license": "BSD-3-Clause"
|
||||
},
|
||||
"node_modules/@protobufjs/eventemitter": {
|
||||
"version": "1.1.0",
|
||||
"resolved": "https://registry.npmjs.org/@protobufjs/eventemitter/-/eventemitter-1.1.0.tgz",
|
||||
"integrity": "sha512-j9ednRT81vYJ9OfVuXG6ERSTdEL1xVsNgqpkxMsbIabzSo3goCjDIveeGv5d03om39ML71RdmrGNjG5SReBP/Q==",
|
||||
"license": "BSD-3-Clause"
|
||||
},
|
||||
"node_modules/@protobufjs/fetch": {
|
||||
"version": "1.1.0",
|
||||
"resolved": "https://registry.npmjs.org/@protobufjs/fetch/-/fetch-1.1.0.tgz",
|
||||
"integrity": "sha512-lljVXpqXebpsijW71PZaCYeIcE5on1w5DlQy5WH6GLbFryLUrBD4932W/E2BSpfRJWseIL4v/KPgBFxDOIdKpQ==",
|
||||
"license": "BSD-3-Clause",
|
||||
"dependencies": {
|
||||
"@protobufjs/aspromise": "^1.1.1",
|
||||
"@protobufjs/inquire": "^1.1.0"
|
||||
}
|
||||
},
|
||||
"node_modules/@protobufjs/float": {
|
||||
"version": "1.0.2",
|
||||
"resolved": "https://registry.npmjs.org/@protobufjs/float/-/float-1.0.2.tgz",
|
||||
"integrity": "sha512-Ddb+kVXlXst9d+R9PfTIxh1EdNkgoRe5tOX6t01f1lYWOvJnSPDBlG241QLzcyPdoNTsblLUdujGSE4RzrTZGQ==",
|
||||
"license": "BSD-3-Clause"
|
||||
},
|
||||
"node_modules/@protobufjs/inquire": {
|
||||
"version": "1.1.0",
|
||||
"resolved": "https://registry.npmjs.org/@protobufjs/inquire/-/inquire-1.1.0.tgz",
|
||||
"integrity": "sha512-kdSefcPdruJiFMVSbn801t4vFK7KB/5gd2fYvrxhuJYg8ILrmn9SKSX2tZdV6V+ksulWqS7aXjBcRXl3wHoD9Q==",
|
||||
"license": "BSD-3-Clause"
|
||||
},
|
||||
"node_modules/@protobufjs/path": {
|
||||
"version": "1.1.2",
|
||||
"resolved": "https://registry.npmjs.org/@protobufjs/path/-/path-1.1.2.tgz",
|
||||
"integrity": "sha512-6JOcJ5Tm08dOHAbdR3GrvP+yUUfkjG5ePsHYczMFLq3ZmMkAD98cDgcT2iA1lJ9NVwFd4tH/iSSoe44YWkltEA==",
|
||||
"license": "BSD-3-Clause"
|
||||
},
|
||||
"node_modules/@protobufjs/pool": {
|
||||
"version": "1.1.0",
|
||||
"resolved": "https://registry.npmjs.org/@protobufjs/pool/-/pool-1.1.0.tgz",
|
||||
"integrity": "sha512-0kELaGSIDBKvcgS4zkjz1PeddatrjYcmMWOlAuAPwAeccUrPHdUqo/J6LiymHHEiJT5NrF1UVwxY14f+fy4WQw==",
|
||||
"license": "BSD-3-Clause"
|
||||
},
|
||||
"node_modules/@protobufjs/utf8": {
|
||||
"version": "1.1.0",
|
||||
"resolved": "https://registry.npmjs.org/@protobufjs/utf8/-/utf8-1.1.0.tgz",
|
||||
"integrity": "sha512-Vvn3zZrhQZkkBE8LSuW3em98c0FwgO4nxzv6OdSxPKJIEKY2bGbHn+mhGIPerzI4twdxaP8/0+06HBpwf345Lw==",
|
||||
"license": "BSD-3-Clause"
|
||||
},
|
||||
"node_modules/@rolldown/pluginutils": {
|
||||
"version": "1.0.0-beta.53",
|
||||
"resolved": "https://registry.npmjs.org/@rolldown/pluginutils/-/pluginutils-1.0.0-beta.53.tgz",
|
||||
|
|
@ -1758,12 +2084,18 @@
|
|||
"version": "22.19.6",
|
||||
"resolved": "https://registry.npmjs.org/@types/node/-/node-22.19.6.tgz",
|
||||
"integrity": "sha512-qm+G8HuG6hOHQigsi7VGuLjUVu6TtBo/F05zvX04Mw2uCg9Dv0Qxy3Qw7j41SidlTcl5D/5yg0SEZqOB+EqZnQ==",
|
||||
"devOptional": true,
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"undici-types": "~6.21.0"
|
||||
}
|
||||
},
|
||||
"node_modules/@types/trusted-types": {
|
||||
"version": "2.0.7",
|
||||
"resolved": "https://registry.npmjs.org/@types/trusted-types/-/trusted-types-2.0.7.tgz",
|
||||
"integrity": "sha512-ScaPdn1dQczgbl0QFTeTOmVHFULt394XJgOQNoyVhZ6r2vLnMLJfBPd53SB52T/3G36VI1/g2MZaX0cwDuXsfw==",
|
||||
"license": "MIT",
|
||||
"optional": true
|
||||
},
|
||||
"node_modules/@types/web-bluetooth": {
|
||||
"version": "0.0.21",
|
||||
"resolved": "https://registry.npmjs.org/@types/web-bluetooth/-/web-bluetooth-0.0.21.tgz",
|
||||
|
|
@ -3025,11 +3357,21 @@
|
|||
"url": "https://github.com/open-cli-tools/concurrently?sponsor=1"
|
||||
}
|
||||
},
|
||||
"node_modules/core-js": {
|
||||
"version": "3.49.0",
|
||||
"resolved": "https://registry.npmjs.org/core-js/-/core-js-3.49.0.tgz",
|
||||
"integrity": "sha512-es1U2+YTtzpwkxVLwAFdSpaIMyQaq0PBgm3YD1W3Qpsn1NAmO3KSgZfu+oGSWVu6NvLHoHCV/aYcsE5wiB7ALg==",
|
||||
"hasInstallScript": true,
|
||||
"license": "MIT",
|
||||
"funding": {
|
||||
"type": "opencollective",
|
||||
"url": "https://opencollective.com/core-js"
|
||||
}
|
||||
},
|
||||
"node_modules/cross-spawn": {
|
||||
"version": "7.0.6",
|
||||
"resolved": "https://registry.npmjs.org/cross-spawn/-/cross-spawn-7.0.6.tgz",
|
||||
"integrity": "sha512-uV2QOWP2nWzsy2aMp8aRibhi9dlzF5Hgh5SHaB9OiTGEyDTiJJyx0uy51QXdyWbtAHNua4XJzUKca3OzKUd3vA==",
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"path-key": "^3.1.0",
|
||||
|
|
@ -3224,6 +3566,15 @@
|
|||
"node": ">=0.10.0"
|
||||
}
|
||||
},
|
||||
"node_modules/dompurify": {
|
||||
"version": "3.3.3",
|
||||
"resolved": "https://registry.npmjs.org/dompurify/-/dompurify-3.3.3.tgz",
|
||||
"integrity": "sha512-Oj6pzI2+RqBfFG+qOaOLbFXLQ90ARpcGG6UePL82bJLtdsa6CYJD7nmiU8MW9nQNOtCHV3lZ/Bzq1X0QYbBZCA==",
|
||||
"license": "(MPL-2.0 OR Apache-2.0)",
|
||||
"optionalDependencies": {
|
||||
"@types/trusted-types": "^2.0.7"
|
||||
}
|
||||
},
|
||||
"node_modules/dunder-proto": {
|
||||
"version": "1.0.1",
|
||||
"resolved": "https://registry.npmjs.org/dunder-proto/-/dunder-proto-1.0.1.tgz",
|
||||
|
|
@ -4118,6 +4469,12 @@
|
|||
"reusify": "^1.0.4"
|
||||
}
|
||||
},
|
||||
"node_modules/fflate": {
|
||||
"version": "0.4.8",
|
||||
"resolved": "https://registry.npmjs.org/fflate/-/fflate-0.4.8.tgz",
|
||||
"integrity": "sha512-FJqqoDBR00Mdj9ppamLa/Y7vxm+PRmNWA67N846RvsoYVMKB4q3y/de5PA7gUmRMYK/8CMz2GDZQmCRN1wBcWA==",
|
||||
"license": "MIT"
|
||||
},
|
||||
"node_modules/file-entry-cache": {
|
||||
"version": "8.0.0",
|
||||
"resolved": "https://registry.npmjs.org/file-entry-cache/-/file-entry-cache-8.0.0.tgz",
|
||||
|
|
@ -5014,7 +5371,6 @@
|
|||
"version": "2.0.0",
|
||||
"resolved": "https://registry.npmjs.org/isexe/-/isexe-2.0.0.tgz",
|
||||
"integrity": "sha512-RHxMLp9lnKHGHRng9QFhRCMbYAcVpn69smSGcq3f36xjgVVWThj4qqLbTLlq7Ssj8B+fIQ1EuCEGI2lKsyQeIw==",
|
||||
"dev": true,
|
||||
"license": "ISC"
|
||||
},
|
||||
"node_modules/jiti": {
|
||||
|
|
@ -5438,6 +5794,12 @@
|
|||
"dev": true,
|
||||
"license": "MIT"
|
||||
},
|
||||
"node_modules/long": {
|
||||
"version": "5.3.2",
|
||||
"resolved": "https://registry.npmjs.org/long/-/long-5.3.2.tgz",
|
||||
"integrity": "sha512-mNAgZ1GmyNhD7AuqnTG3/VQ26o760+ZYBPKjPvugO8+nLbYfX6TVpJPseBvopbdY+qpZ/lKUnmEc1LeZYS3QAA==",
|
||||
"license": "Apache-2.0"
|
||||
},
|
||||
"node_modules/magic-string": {
|
||||
"version": "0.30.21",
|
||||
"resolved": "https://registry.npmjs.org/magic-string/-/magic-string-0.30.21.tgz",
|
||||
|
|
@ -5806,7 +6168,6 @@
|
|||
"version": "3.1.1",
|
||||
"resolved": "https://registry.npmjs.org/path-key/-/path-key-3.1.1.tgz",
|
||||
"integrity": "sha512-ojmeN0qd+y0jszEtoY48r0Peq5dwMEkIlCOu6Q5f41lfkswXuKtYrhgoTpLnyIcHm24Uhqx+5Tqm2InSwLhE6Q==",
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"engines": {
|
||||
"node": ">=8"
|
||||
|
|
@ -5895,6 +6256,37 @@
|
|||
"node": ">=4"
|
||||
}
|
||||
},
|
||||
"node_modules/posthog-js": {
|
||||
"version": "1.364.2",
|
||||
"resolved": "https://registry.npmjs.org/posthog-js/-/posthog-js-1.364.2.tgz",
|
||||
"integrity": "sha512-ryeCFcaORLouVI5wsKxnraIDvKFM6RAxbbKlKuqo+A8VFZ9JvvRpwzfiMQR2trGsJUYcn6B3R4Rn0Xht9NrhAQ==",
|
||||
"license": "SEE LICENSE IN LICENSE",
|
||||
"dependencies": {
|
||||
"@opentelemetry/api": "^1.9.0",
|
||||
"@opentelemetry/api-logs": "^0.208.0",
|
||||
"@opentelemetry/exporter-logs-otlp-http": "^0.208.0",
|
||||
"@opentelemetry/resources": "^2.2.0",
|
||||
"@opentelemetry/sdk-logs": "^0.208.0",
|
||||
"@posthog/core": "1.24.4",
|
||||
"@posthog/types": "1.364.2",
|
||||
"core-js": "^3.38.1",
|
||||
"dompurify": "^3.3.2",
|
||||
"fflate": "^0.4.8",
|
||||
"preact": "^10.28.2",
|
||||
"query-selector-shadow-dom": "^1.0.1",
|
||||
"web-vitals": "^5.1.0"
|
||||
}
|
||||
},
|
||||
"node_modules/preact": {
|
||||
"version": "10.29.0",
|
||||
"resolved": "https://registry.npmjs.org/preact/-/preact-10.29.0.tgz",
|
||||
"integrity": "sha512-wSAGyk2bYR1c7t3SZ3jHcM6xy0lcBcDel6lODcs9ME6Th++Dx2KU+6D3HD8wMMKGA8Wpw7OMd3/4RGzYRpzwRg==",
|
||||
"license": "MIT",
|
||||
"funding": {
|
||||
"type": "opencollective",
|
||||
"url": "https://opencollective.com/preact"
|
||||
}
|
||||
},
|
||||
"node_modules/prelude-ls": {
|
||||
"version": "1.2.1",
|
||||
"resolved": "https://registry.npmjs.org/prelude-ls/-/prelude-ls-1.2.1.tgz",
|
||||
|
|
@ -6025,6 +6417,30 @@
|
|||
}
|
||||
}
|
||||
},
|
||||
"node_modules/protobufjs": {
|
||||
"version": "7.5.4",
|
||||
"resolved": "https://registry.npmjs.org/protobufjs/-/protobufjs-7.5.4.tgz",
|
||||
"integrity": "sha512-CvexbZtbov6jW2eXAvLukXjXUW1TzFaivC46BpWc/3BpcCysb5Vffu+B3XHMm8lVEuy2Mm4XGex8hBSg1yapPg==",
|
||||
"hasInstallScript": true,
|
||||
"license": "BSD-3-Clause",
|
||||
"dependencies": {
|
||||
"@protobufjs/aspromise": "^1.1.2",
|
||||
"@protobufjs/base64": "^1.1.2",
|
||||
"@protobufjs/codegen": "^2.0.4",
|
||||
"@protobufjs/eventemitter": "^1.1.0",
|
||||
"@protobufjs/fetch": "^1.1.0",
|
||||
"@protobufjs/float": "^1.0.2",
|
||||
"@protobufjs/inquire": "^1.1.0",
|
||||
"@protobufjs/path": "^1.1.2",
|
||||
"@protobufjs/pool": "^1.1.0",
|
||||
"@protobufjs/utf8": "^1.1.0",
|
||||
"@types/node": ">=13.7.0",
|
||||
"long": "^5.0.0"
|
||||
},
|
||||
"engines": {
|
||||
"node": ">=12.0.0"
|
||||
}
|
||||
},
|
||||
"node_modules/proxy-from-env": {
|
||||
"version": "1.1.0",
|
||||
"resolved": "https://registry.npmjs.org/proxy-from-env/-/proxy-from-env-1.1.0.tgz",
|
||||
|
|
@ -6051,6 +6467,12 @@
|
|||
"tweetnacl": "^1.0.3"
|
||||
}
|
||||
},
|
||||
"node_modules/query-selector-shadow-dom": {
|
||||
"version": "1.0.1",
|
||||
"resolved": "https://registry.npmjs.org/query-selector-shadow-dom/-/query-selector-shadow-dom-1.0.1.tgz",
|
||||
"integrity": "sha512-lT5yCqEBgfoMYpf3F2xQRK7zEr1rhIIZuceDK6+xRkJQ4NMbHTwXqk4NkwDwQMNqXgG9r9fyHnzwNVs6zV5KRw==",
|
||||
"license": "MIT"
|
||||
},
|
||||
"node_modules/queue-microtask": {
|
||||
"version": "1.2.3",
|
||||
"resolved": "https://registry.npmjs.org/queue-microtask/-/queue-microtask-1.2.3.tgz",
|
||||
|
|
@ -6438,7 +6860,6 @@
|
|||
"version": "2.0.0",
|
||||
"resolved": "https://registry.npmjs.org/shebang-command/-/shebang-command-2.0.0.tgz",
|
||||
"integrity": "sha512-kHxr2zZpYtdmrN1qDjrrX/Z1rR1kG8Dx+gkpK1G4eXmvXswmcE1hTWBWYUzlraYw1/yZp6YuDY77YtvbN0dmDA==",
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"shebang-regex": "^3.0.0"
|
||||
|
|
@ -6451,7 +6872,6 @@
|
|||
"version": "3.0.0",
|
||||
"resolved": "https://registry.npmjs.org/shebang-regex/-/shebang-regex-3.0.0.tgz",
|
||||
"integrity": "sha512-7++dFhtcx3353uBaq8DDR4NuxBetBzC7ZQOhmTQInHEd6bSrXdiEyzCvG07Z44UYdLShWUyXt5M/yhz8ekcb1A==",
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"engines": {
|
||||
"node": ">=8"
|
||||
|
|
@ -7061,7 +7481,6 @@
|
|||
"version": "6.21.0",
|
||||
"resolved": "https://registry.npmjs.org/undici-types/-/undici-types-6.21.0.tgz",
|
||||
"integrity": "sha512-iwDZqg0QAGrg9Rav5H4n0M64c3mkR59cJ6wQp+7C4nI0gsmExaedaYLNO44eT4AtBBwjbTiGPMlt2Md0T9H9JQ==",
|
||||
"devOptional": true,
|
||||
"license": "MIT"
|
||||
},
|
||||
"node_modules/unrs-resolver": {
|
||||
|
|
@ -7453,11 +7872,16 @@
|
|||
"typescript": ">=5.0.0"
|
||||
}
|
||||
},
|
||||
"node_modules/web-vitals": {
|
||||
"version": "5.2.0",
|
||||
"resolved": "https://registry.npmjs.org/web-vitals/-/web-vitals-5.2.0.tgz",
|
||||
"integrity": "sha512-i2z98bEmaCqSDiHEDu+gHl/dmR4Q+TxFmG3/13KkMO+o8UxQzCqWaDRCiLgEa41nlO4VpXSI0ASa1xWmO9sBlA==",
|
||||
"license": "Apache-2.0"
|
||||
},
|
||||
"node_modules/which": {
|
||||
"version": "2.0.2",
|
||||
"resolved": "https://registry.npmjs.org/which/-/which-2.0.2.tgz",
|
||||
"integrity": "sha512-BLI3Tl1TW3Pvl70l3yq3Y64i+awpwXqsGBYWkkqMtnbXgrMD+yj7rhW0kuEDxzJaYXGjEW5ogapKNMEKNMjibA==",
|
||||
"dev": true,
|
||||
"license": "ISC",
|
||||
"dependencies": {
|
||||
"isexe": "^2.0.0"
|
||||
|
|
|
|||
|
|
@ -48,6 +48,7 @@
|
|||
"laravel-vite-plugin": "^2.0.0",
|
||||
"laravel-vue-i18n": "^2.8.0",
|
||||
"maska": "^3.2.0",
|
||||
"posthog-js": "^1.364.2",
|
||||
"reka-ui": "^2.7.0",
|
||||
"tailwind-merge": "^3.2.0",
|
||||
"tailwindcss": "^4.1.1",
|
||||
|
|
|
|||
22
public/images/social/google.svg
Normal file
22
public/images/social/google.svg
Normal file
|
|
@ -0,0 +1,22 @@
|
|||
<svg
|
||||
viewBox="0 0 25 25"
|
||||
fill="none"
|
||||
xmlns="http://www.w3.org/2000/svg"
|
||||
>
|
||||
<path
|
||||
d="M5.54053 15.1078L4.67031 18.3565L1.4897 18.4237C0.53916 16.6607 0 14.6436 0 12.5C0 10.4272 0.504102 8.47252 1.39766 6.75137H1.39834L4.22998 7.27051L5.47041 10.0852C5.21079 10.842 5.06929 11.6545 5.06929 12.5C5.06938 13.4176 5.2356 14.2967 5.54053 15.1078Z"
|
||||
fill="#FBBB00"
|
||||
></path>
|
||||
<path
|
||||
d="M24.7816 10.1648C24.9251 10.921 25 11.7019 25 12.5C25 13.3949 24.9059 14.2679 24.7266 15.1099C24.1181 17.9753 22.5282 20.4773 20.3256 22.2479L20.3249 22.2473L16.7583 22.0653L16.2535 18.9142C17.715 18.057 18.8572 16.7157 19.4589 15.1099H12.7748V10.1648H19.5564H24.7816Z"
|
||||
fill="#518EF8"
|
||||
></path>
|
||||
<path
|
||||
d="M20.3249 22.2473L20.3256 22.248C18.1834 23.9698 15.4622 25 12.5 25C7.73972 25 3.601 22.3393 1.48972 18.4238L5.54055 15.1079C6.59616 17.9251 9.31389 19.9307 12.5 19.9307C13.8695 19.9307 15.1525 19.5605 16.2534 18.9142L20.3249 22.2473Z"
|
||||
fill="#28B446"
|
||||
></path>
|
||||
<path
|
||||
d="M20.4787 2.87773L16.4293 6.19297C15.2899 5.48076 13.943 5.06934 12.5 5.06934C9.24177 5.06934 6.47321 7.16685 5.47048 10.0852L1.39836 6.75137H1.39767C3.47805 2.74038 7.66896 0 12.5 0C15.533 0 18.3139 1.08037 20.4787 2.87773Z"
|
||||
fill="#F14336"
|
||||
></path>
|
||||
</svg>
|
||||
|
After Width: | Height: | Size: 1.3 KiB |
|
|
@ -1,6 +1,6 @@
|
|||
import '../css/app.css';
|
||||
|
||||
import { createInertiaApp } from '@inertiajs/vue3';
|
||||
import { createInertiaApp, router } from '@inertiajs/vue3';
|
||||
import { configureEcho } from '@laravel/echo-vue';
|
||||
import { resolvePageComponent } from 'laravel-vite-plugin/inertia-helpers';
|
||||
import { i18nVue } from 'laravel-vue-i18n';
|
||||
|
|
@ -9,6 +9,7 @@ import { createApp, h } from 'vue';
|
|||
|
||||
import { initializeTheme } from './composables/useAppearance';
|
||||
import dayjs from './dayjs';
|
||||
import posthog from './posthog';
|
||||
|
||||
configureEcho({
|
||||
broadcaster: 'reverb',
|
||||
|
|
@ -30,6 +31,27 @@ createInertiaApp({
|
|||
// Set dayjs locale based on user's language
|
||||
dayjs.locale(locale.toLowerCase());
|
||||
|
||||
const auth = props.initialPage.props.auth as { user?: { id: string; email: string; name: string }; currentWorkspace?: { id: string; name: string } } | undefined;
|
||||
|
||||
if (auth?.user) {
|
||||
posthog.identify(auth.user.id, {
|
||||
$email: auth.user.email,
|
||||
$name: auth.user.name,
|
||||
});
|
||||
|
||||
if (auth.currentWorkspace) {
|
||||
posthog.group('workspace', auth.currentWorkspace.id, {
|
||||
name: auth.currentWorkspace.name,
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
router.on('navigate', () => {
|
||||
posthog.capture('$pageview', {
|
||||
$current_url: window.location.href,
|
||||
});
|
||||
});
|
||||
|
||||
createApp({ render: () => h(App, props) })
|
||||
.use(i18nVue, {
|
||||
lang: locale,
|
||||
|
|
|
|||
|
|
@ -22,7 +22,6 @@ import { store as storePost } from '@/actions/App/Http/Controllers/App/PostContr
|
|||
import { index as postsIndex } from '@/actions/App/Http/Controllers/App/PostController';
|
||||
import NavMain from '@/components/NavMain.vue';
|
||||
import NavUser from '@/components/NavUser.vue';
|
||||
import NotificationBell from '@/components/NotificationBell.vue';
|
||||
import { Avatar } from '@/components/ui/avatar';
|
||||
import { Button } from '@/components/ui/button';
|
||||
import {
|
||||
|
|
@ -215,12 +214,7 @@ const switchWorkspace = (workspaceId: string) => {
|
|||
</SidebarMenuButton>
|
||||
</SidebarMenuItem>
|
||||
</SidebarMenu>
|
||||
<div class="flex items-center gap-1">
|
||||
<div class="flex-1">
|
||||
<NavUser />
|
||||
</div>
|
||||
<NotificationBell v-if="currentWorkspace" />
|
||||
</div>
|
||||
<NavUser />
|
||||
</SidebarFooter>
|
||||
</Sidebar>
|
||||
</template>
|
||||
|
|
@ -8,6 +8,7 @@ import {
|
|||
DropdownMenuContent,
|
||||
DropdownMenuTrigger,
|
||||
} from '@/components/ui/dropdown-menu';
|
||||
import NotificationBell from '@/components/NotificationBell.vue';
|
||||
import {
|
||||
SidebarMenu,
|
||||
SidebarMenuButton,
|
||||
|
|
@ -20,12 +21,15 @@ import UserMenuContent from './UserMenuContent.vue';
|
|||
|
||||
const page = usePage();
|
||||
const user = computed(() => page.props.auth.user);
|
||||
const currentWorkspace = computed(() => page.props.auth.currentWorkspace);
|
||||
const { isMobile, state } = useSidebar();
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<SidebarMenu>
|
||||
<SidebarMenuItem>
|
||||
<div class="flex items-center gap-1">
|
||||
<NotificationBell v-if="currentWorkspace && state === 'expanded'" />
|
||||
<DropdownMenu>
|
||||
<DropdownMenuTrigger as-child>
|
||||
<SidebarMenuButton
|
||||
|
|
@ -46,6 +50,7 @@ const { isMobile, state } = useSidebar();
|
|||
<UserMenuContent :user="user" />
|
||||
</DropdownMenuContent>
|
||||
</DropdownMenu>
|
||||
</div>
|
||||
</SidebarMenuItem>
|
||||
</SidebarMenu>
|
||||
</template>
|
||||
|
|
|
|||
|
|
@ -1,22 +1,15 @@
|
|||
<script setup lang="ts">
|
||||
import { router } from '@inertiajs/vue3';
|
||||
import { IconArchive, IconBell, IconCheck, IconChecks } from '@tabler/icons-vue';
|
||||
import { trans } from 'laravel-vue-i18n';
|
||||
import { onMounted, ref } from 'vue';
|
||||
import { IconArchive, IconBell, IconCheck, IconChecks, IconInbox, IconX } from '@tabler/icons-vue';
|
||||
import { onBeforeUnmount, onMounted, ref, watch } from 'vue';
|
||||
|
||||
import { Button } from '@/components/ui/button';
|
||||
import {
|
||||
Dialog,
|
||||
DialogContent,
|
||||
DialogHeader,
|
||||
DialogTitle,
|
||||
} from '@/components/ui/dialog';
|
||||
import {
|
||||
Tooltip,
|
||||
TooltipContent,
|
||||
TooltipProvider,
|
||||
TooltipTrigger,
|
||||
} from '@/components/ui/tooltip';
|
||||
import dayjs from '@/dayjs';
|
||||
import { index, read, readAll, archiveAll } from '@/routes/app/notifications';
|
||||
|
||||
interface Notification {
|
||||
|
|
@ -33,7 +26,11 @@ interface Notification {
|
|||
const notifications = ref<Notification[]>([]);
|
||||
const unreadCount = ref(0);
|
||||
const loading = ref(false);
|
||||
const dialogOpen = ref(false);
|
||||
const show = ref(false);
|
||||
const panel = ref<HTMLElement | null>(null);
|
||||
|
||||
const csrfToken = () =>
|
||||
document.querySelector<HTMLMetaElement>('meta[name="csrf-token"]')?.content ?? '';
|
||||
|
||||
const fetchNotifications = async () => {
|
||||
loading.value = true;
|
||||
|
|
@ -50,12 +47,9 @@ const fetchNotifications = async () => {
|
|||
}
|
||||
};
|
||||
|
||||
const csrfToken = () =>
|
||||
document.querySelector<HTMLMetaElement>('meta[name="csrf-token"]')?.content ?? '';
|
||||
|
||||
const handleMarkAsRead = async (notification: Notification) => {
|
||||
await fetch(read.url(notification.id), {
|
||||
method: 'PATCH',
|
||||
method: 'PUT',
|
||||
headers: { 'Accept': 'application/json', 'X-Requested-With': 'XMLHttpRequest', 'X-CSRF-TOKEN': csrfToken() },
|
||||
credentials: 'same-origin',
|
||||
});
|
||||
|
|
@ -94,7 +88,7 @@ const handleNotificationClick = (notification: Notification) => {
|
|||
handleMarkAsRead(notification);
|
||||
}
|
||||
|
||||
dialogOpen.value = false;
|
||||
close();
|
||||
|
||||
if (notification.data?.post_id) {
|
||||
router.visit(`/posts/${notification.data.post_id}/edit`);
|
||||
|
|
@ -103,18 +97,63 @@ const handleNotificationClick = (notification: Notification) => {
|
|||
}
|
||||
};
|
||||
|
||||
const openDialog = () => {
|
||||
dialogOpen.value = true;
|
||||
const open = () => {
|
||||
show.value = true;
|
||||
fetchNotifications();
|
||||
};
|
||||
|
||||
const close = () => {
|
||||
show.value = false;
|
||||
};
|
||||
|
||||
const toggle = () => {
|
||||
if (show.value) {
|
||||
close();
|
||||
} else {
|
||||
open();
|
||||
}
|
||||
};
|
||||
|
||||
const onClickOutside = (event: MouseEvent) => {
|
||||
if (panel.value && !panel.value.contains(event.target as Node)) {
|
||||
close();
|
||||
}
|
||||
};
|
||||
|
||||
const onEscape = (event: KeyboardEvent) => {
|
||||
if (event.key === 'Escape') {
|
||||
close();
|
||||
}
|
||||
};
|
||||
|
||||
const formatTime = (date: string) => {
|
||||
return dayjs.utc(date).fromNow();
|
||||
};
|
||||
|
||||
watch(show, (value) => {
|
||||
if (value) {
|
||||
setTimeout(() => {
|
||||
document.addEventListener('click', onClickOutside);
|
||||
document.addEventListener('keydown', onEscape);
|
||||
}, 0);
|
||||
} else {
|
||||
document.removeEventListener('click', onClickOutside);
|
||||
document.removeEventListener('keydown', onEscape);
|
||||
}
|
||||
});
|
||||
|
||||
onMounted(() => {
|
||||
fetchNotifications();
|
||||
});
|
||||
|
||||
onBeforeUnmount(() => {
|
||||
document.removeEventListener('click', onClickOutside);
|
||||
document.removeEventListener('keydown', onEscape);
|
||||
});
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<Button variant="ghost" size="icon" class="relative size-8 shrink-0" :title="$t('sidebar.notifications')" @click="openDialog">
|
||||
<Button variant="ghost" size="icon" class="relative size-8 shrink-0" @click.stop="toggle">
|
||||
<IconBell class="size-4" />
|
||||
<span
|
||||
v-if="unreadCount > 0"
|
||||
|
|
@ -124,68 +163,104 @@ onMounted(() => {
|
|||
</span>
|
||||
</Button>
|
||||
|
||||
<Dialog v-model:open="dialogOpen">
|
||||
<DialogContent class="sm:max-w-lg">
|
||||
<DialogHeader>
|
||||
<div class="flex items-center justify-between">
|
||||
<DialogTitle>{{ $t('sidebar.notifications') }}</DialogTitle>
|
||||
<div v-if="notifications.length > 0" class="flex items-center gap-1">
|
||||
<TooltipProvider>
|
||||
<Tooltip>
|
||||
<TooltipTrigger as-child>
|
||||
<Button variant="ghost" size="icon" class="size-7" @click="handleMarkAllAsRead">
|
||||
<IconChecks class="size-4" />
|
||||
</Button>
|
||||
</TooltipTrigger>
|
||||
<TooltipContent>{{ $t('sidebar.mark_all_read') }}</TooltipContent>
|
||||
</Tooltip>
|
||||
</TooltipProvider>
|
||||
<TooltipProvider>
|
||||
<Tooltip>
|
||||
<TooltipTrigger as-child>
|
||||
<Button variant="ghost" size="icon" class="size-7" @click="handleArchiveAll">
|
||||
<IconArchive class="size-4" />
|
||||
</Button>
|
||||
</TooltipTrigger>
|
||||
<TooltipContent>{{ $t('sidebar.archive_all') }}</TooltipContent>
|
||||
</Tooltip>
|
||||
</TooltipProvider>
|
||||
<Teleport to="body">
|
||||
<Transition
|
||||
enter-active-class="transition duration-200 ease-out"
|
||||
enter-from-class="-translate-x-2 opacity-0"
|
||||
enter-to-class="translate-x-0 opacity-100"
|
||||
leave-active-class="transition duration-150 ease-in"
|
||||
leave-from-class="translate-x-0 opacity-100"
|
||||
leave-to-class="-translate-x-2 opacity-0"
|
||||
>
|
||||
<div
|
||||
v-if="show"
|
||||
ref="panel"
|
||||
class="fixed left-[17rem] bottom-2 z-50 w-[22rem] h-[32rem] flex flex-col rounded-xl border border-border bg-card shadow-lg"
|
||||
>
|
||||
<!-- Header -->
|
||||
<div class="flex items-center justify-between px-4 pt-3 pb-2">
|
||||
<h3 class="text-sm font-semibold">{{ $t('sidebar.notifications') }}</h3>
|
||||
<div class="flex items-center gap-0.5">
|
||||
<Tooltip v-if="notifications.length > 0">
|
||||
<TooltipTrigger as-child>
|
||||
<button
|
||||
type="button"
|
||||
class="p-1 text-muted-foreground hover:text-foreground transition-colors rounded"
|
||||
@click="handleMarkAllAsRead"
|
||||
>
|
||||
<IconChecks class="size-4" />
|
||||
</button>
|
||||
</TooltipTrigger>
|
||||
<TooltipContent>{{ $t('sidebar.mark_all_read') }}</TooltipContent>
|
||||
</Tooltip>
|
||||
<Tooltip v-if="notifications.length > 0">
|
||||
<TooltipTrigger as-child>
|
||||
<button
|
||||
type="button"
|
||||
class="p-1 text-muted-foreground hover:text-foreground transition-colors rounded"
|
||||
@click="handleArchiveAll"
|
||||
>
|
||||
<IconArchive class="size-4" />
|
||||
</button>
|
||||
</TooltipTrigger>
|
||||
<TooltipContent>{{ $t('sidebar.archive_all') }}</TooltipContent>
|
||||
</Tooltip>
|
||||
<button
|
||||
type="button"
|
||||
class="p-1 text-muted-foreground hover:text-foreground transition-colors rounded"
|
||||
@click="close"
|
||||
>
|
||||
<IconX class="size-4" />
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</DialogHeader>
|
||||
|
||||
<div v-if="notifications.length === 0" class="py-8 text-center text-sm text-muted-foreground">
|
||||
{{ $t('sidebar.no_notifications') }}
|
||||
</div>
|
||||
|
||||
<div v-else class="-mx-6 max-h-96 overflow-y-auto">
|
||||
<div
|
||||
v-for="notification in notifications"
|
||||
:key="notification.id"
|
||||
class="flex cursor-pointer items-start gap-3 border-b px-6 py-3 transition-colors last:border-0 hover:bg-accent/50"
|
||||
:class="{ 'opacity-60': notification.read_at }"
|
||||
@click="handleNotificationClick(notification)"
|
||||
>
|
||||
<span
|
||||
v-if="!notification.read_at"
|
||||
class="mt-1.5 size-2 shrink-0 rounded-full bg-primary"
|
||||
/>
|
||||
<span v-else class="mt-1.5 size-2 shrink-0" />
|
||||
<div class="min-w-0 flex-1">
|
||||
<p class="text-sm font-medium leading-tight">{{ notification.title }}</p>
|
||||
<p class="mt-0.5 line-clamp-2 text-xs text-muted-foreground">{{ notification.body }}</p>
|
||||
<!-- Notification list -->
|
||||
<div class="flex-1 overflow-y-auto">
|
||||
<div v-if="notifications.length > 0" class="divide-y divide-border">
|
||||
<div
|
||||
v-for="notification in notifications"
|
||||
:key="notification.id"
|
||||
class="px-3 py-2.5 flex items-start gap-2.5 hover:bg-muted/50 transition-colors cursor-pointer"
|
||||
@click="handleNotificationClick(notification)"
|
||||
>
|
||||
<div class="flex items-center mt-1.5 shrink-0">
|
||||
<div
|
||||
:class="[
|
||||
'size-1.5 rounded-full',
|
||||
!notification.read_at ? 'bg-primary' : 'bg-transparent',
|
||||
]"
|
||||
/>
|
||||
</div>
|
||||
<div class="min-w-0 flex-1">
|
||||
<p class="text-xs font-medium truncate">{{ notification.title }}</p>
|
||||
<p class="text-xs text-muted-foreground truncate">{{ notification.body }}</p>
|
||||
<p class="text-[11px] text-muted-foreground/70 mt-0.5">{{ formatTime(notification.created_at) }}</p>
|
||||
</div>
|
||||
<div class="shrink-0" @click.stop>
|
||||
<Tooltip v-if="!notification.read_at">
|
||||
<TooltipTrigger as-child>
|
||||
<button
|
||||
type="button"
|
||||
class="p-1 text-muted-foreground hover:text-foreground transition-colors rounded"
|
||||
@click="handleMarkAsRead(notification)"
|
||||
>
|
||||
<IconCheck class="size-3.5" />
|
||||
</button>
|
||||
</TooltipTrigger>
|
||||
<TooltipContent>{{ $t('sidebar.mark_as_read') }}</TooltipContent>
|
||||
</Tooltip>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Empty state -->
|
||||
<div v-else-if="!loading" class="flex flex-col items-center justify-center py-12 px-6 text-center">
|
||||
<IconInbox class="size-8 text-muted-foreground/50 mb-3" />
|
||||
<p class="text-sm font-medium">{{ $t('sidebar.no_notifications') }}</p>
|
||||
</div>
|
||||
<Button
|
||||
v-if="!notification.read_at"
|
||||
variant="ghost"
|
||||
size="icon"
|
||||
class="size-7 shrink-0"
|
||||
@click.stop="handleMarkAsRead(notification)"
|
||||
>
|
||||
<IconCheck class="size-3.5" />
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
</DialogContent>
|
||||
</Dialog>
|
||||
</Transition>
|
||||
</Teleport>
|
||||
</template>
|
||||
|
|
|
|||
|
|
@ -6,7 +6,9 @@ import { computed, onMounted, onUnmounted } from 'vue';
|
|||
|
||||
import { Avatar, AvatarFallback, AvatarImage } from '@/components/ui/avatar';
|
||||
import { Button } from '@/components/ui/button';
|
||||
import { Switch } from '@/components/ui/switch';
|
||||
import { Tooltip, TooltipContent, TooltipProvider, TooltipTrigger } from '@/components/ui/tooltip';
|
||||
import { toggle as toggleAccount } from '@/routes/app/accounts';
|
||||
|
||||
export interface SocialAccount {
|
||||
id: string;
|
||||
|
|
@ -16,6 +18,7 @@ export interface SocialAccount {
|
|||
display_name: string;
|
||||
avatar_url: string;
|
||||
status: 'connected' | 'disconnected' | 'token_expired' | null;
|
||||
is_active: boolean;
|
||||
error_message: string | null;
|
||||
}
|
||||
|
||||
|
|
@ -42,6 +45,12 @@ const props = withDefaults(defineProps<Props>(), {
|
|||
columns: 4,
|
||||
});
|
||||
|
||||
const handleToggle = (accountId: string) => {
|
||||
router.put(toggleAccount.url(accountId), {}, {
|
||||
preserveScroll: true,
|
||||
});
|
||||
};
|
||||
|
||||
const getConnectUrl = (platformValue: string): string => {
|
||||
return `/connect/${platformValue}`;
|
||||
};
|
||||
|
|
@ -141,14 +150,14 @@ const isDisconnected = (account: SocialAccount | null): boolean => {
|
|||
<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),
|
||||
'border-green-500/30 bg-green-50/50 dark:bg-green-950/20': platform.connected && !isDisconnected(platform.account) && platform.account?.is_active,
|
||||
'border-red-500/30 bg-red-50/50 dark:bg-red-950/20': platform.connected && (isDisconnected(platform.account) || !platform.account?.is_active),
|
||||
}">
|
||||
<!-- 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" />
|
||||
class="h-12 w-12 rounded-lg object-contain" :class="{ 'opacity-40': platform.connected && platform.account && !platform.account.is_active }" />
|
||||
<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-neutral-900">
|
||||
<IconCheck class="h-3 w-3" />
|
||||
|
|
@ -159,8 +168,13 @@ const isDisconnected = (account: SocialAccount | null): boolean => {
|
|||
</div>
|
||||
</div>
|
||||
<div class="flex-1 min-w-0">
|
||||
<div class="flex items-center gap-2">
|
||||
<div class="flex items-center justify-between gap-2">
|
||||
<h3 class="font-semibold truncate">{{ platform.label }}</h3>
|
||||
<Switch
|
||||
v-if="platform.connected && platform.account"
|
||||
:model-value="platform.account.is_active"
|
||||
@update:model-value="handleToggle(platform.account.id)"
|
||||
/>
|
||||
</div>
|
||||
<p v-if="platform.connected && platform.account" class="text-sm text-muted-foreground truncate">
|
||||
@{{ platform.account.username || platform.account.display_name }}
|
||||
|
|
|
|||
|
|
@ -52,7 +52,7 @@ const switchLanguage = (code: string) => {
|
|||
loadLanguageAsync(code);
|
||||
dayjs.locale(code.toLowerCase());
|
||||
|
||||
router.patch(updateLanguage.url(), { locale: code }, {
|
||||
router.put(updateLanguage.url(), { locale: code }, {
|
||||
preserveScroll: true,
|
||||
preserveState: false,
|
||||
onError: () => {
|
||||
|
|
|
|||
|
|
@ -55,14 +55,14 @@ const handleOpenChange = (value: boolean) => {
|
|||
|
||||
<template>
|
||||
<Dialog :open="open" @update:open="handleOpenChange">
|
||||
<DialogContent class="sm:max-w-md">
|
||||
<DialogContent class="sm:max-w-lg">
|
||||
<DialogHeader>
|
||||
<DialogTitle>{{ $t('labels.create.title') }}</DialogTitle>
|
||||
<DialogDescription>
|
||||
{{ $t('labels.create.description') }}
|
||||
</DialogDescription>
|
||||
</DialogHeader>
|
||||
<form @submit.prevent="submit" class="space-y-4">
|
||||
<form @submit.prevent="submit" class="space-y-6">
|
||||
<div class="space-y-2">
|
||||
<Label for="create-name">{{ $t('labels.create.name') }}</Label>
|
||||
<Input
|
||||
|
|
@ -78,15 +78,15 @@ const handleOpenChange = (value: boolean) => {
|
|||
|
||||
<div class="space-y-2">
|
||||
<Label>{{ $t('labels.create.color') }}</Label>
|
||||
<div class="flex flex-wrap gap-2">
|
||||
<div class="flex flex-wrap gap-3">
|
||||
<button
|
||||
v-for="color in colors"
|
||||
:key="color"
|
||||
type="button"
|
||||
class="h-10 w-10 rounded-lg transition-all"
|
||||
class="size-8 rounded-full transition-all"
|
||||
:class="[
|
||||
form.color === color
|
||||
? 'ring-2 ring-primary ring-offset-2'
|
||||
? 'ring-2 ring-primary ring-offset-2 ring-offset-background'
|
||||
: 'hover:scale-110'
|
||||
]"
|
||||
:style="{ backgroundColor: color }"
|
||||
|
|
|
|||
|
|
@ -65,14 +65,14 @@ const submit = () => {
|
|||
|
||||
<template>
|
||||
<Dialog v-model:open="open">
|
||||
<DialogContent class="sm:max-w-md">
|
||||
<DialogContent class="sm:max-w-lg">
|
||||
<DialogHeader>
|
||||
<DialogTitle>{{ $t('labels.edit.title') }}</DialogTitle>
|
||||
<DialogDescription>
|
||||
{{ $t('labels.edit.description') }}
|
||||
</DialogDescription>
|
||||
</DialogHeader>
|
||||
<form @submit.prevent="submit" class="space-y-4">
|
||||
<form @submit.prevent="submit" class="space-y-6">
|
||||
<div class="space-y-2">
|
||||
<Label for="edit-name">{{ $t('labels.edit.name') }}</Label>
|
||||
<Input
|
||||
|
|
@ -88,15 +88,15 @@ const submit = () => {
|
|||
|
||||
<div class="space-y-2">
|
||||
<Label>{{ $t('labels.edit.color') }}</Label>
|
||||
<div class="flex flex-wrap gap-2">
|
||||
<div class="flex flex-wrap gap-3">
|
||||
<button
|
||||
v-for="color in colors"
|
||||
:key="color"
|
||||
type="button"
|
||||
class="h-10 w-10 rounded-lg transition-all"
|
||||
class="size-8 rounded-full transition-all"
|
||||
:class="[
|
||||
form.color === color
|
||||
? 'ring-2 ring-primary ring-offset-2'
|
||||
? 'ring-2 ring-primary ring-offset-2 ring-offset-background'
|
||||
: 'hover:scale-110'
|
||||
]"
|
||||
:style="{ backgroundColor: color }"
|
||||
|
|
|
|||
|
|
@ -10,7 +10,7 @@ const props = defineProps<{
|
|||
<template>
|
||||
<main data-slot="sidebar-inset" :class="cn(
|
||||
'bg-card text-card-foreground relative flex w-full flex-1 flex-col overflow-y-auto',
|
||||
'md:peer-data-[variant=inset]:m-2 md:peer-data-[variant=inset]:ml-0 md:peer-data-[variant=inset]:rounded-lg md:peer-data-[variant=inset]:border md:peer-data-[variant=inset]:border-border md:peer-data-[variant=inset]:shadow-xs md:peer-data-[variant=inset]:peer-data-[state=collapsed]:ml-2',
|
||||
'md:peer-data-[variant=inset]:m-2 md:peer-data-[variant=inset]:ml-0 md:peer-data-[variant=inset]:rounded-lg md:peer-data-[variant=inset]:border md:peer-data-[variant=inset]:border-border md:peer-data-[variant=inset]:shadow-xs',
|
||||
props.class,
|
||||
)">
|
||||
<slot />
|
||||
|
|
|
|||
|
|
@ -10,6 +10,7 @@ import { Label } from '@/components/ui/label';
|
|||
import { Spinner } from '@/components/ui/spinner';
|
||||
import AuthBase from '@/layouts/AuthLayout.vue';
|
||||
import { register } from '@/routes';
|
||||
import { redirect as googleRedirect } from '@/routes/auth/google';
|
||||
import { store } from '@/routes/login';
|
||||
import { request } from '@/routes/password';
|
||||
|
||||
|
|
@ -65,6 +66,17 @@ defineProps<{
|
|||
</Button>
|
||||
</div>
|
||||
|
||||
<div
|
||||
class="relative text-center text-sm after:absolute after:inset-0 after:top-1/2 after:z-0 after:flex after:items-center after:border-t after:border-border"
|
||||
>
|
||||
<span class="relative z-10 bg-background px-2 text-muted-foreground">{{ $t('auth.or_continue_with') }}</span>
|
||||
</div>
|
||||
|
||||
<Button variant="outline" class="w-full" as="a" :href="googleRedirect.url()">
|
||||
<img src="/images/social/google.svg" alt="Google" class="size-4" />
|
||||
{{ $t('auth.google_login') }}
|
||||
</Button>
|
||||
|
||||
<div class="text-center text-sm text-muted-foreground">
|
||||
{{ $t('auth.login.no_account') }}
|
||||
<TextLink :href="register()" :tabindex="5">{{ $t('auth.login.sign_up') }}</TextLink>
|
||||
|
|
|
|||
|
|
@ -17,6 +17,7 @@ import {
|
|||
} from '@/components/ui/tooltip';
|
||||
import AuthBase from '@/layouts/AuthLayout.vue';
|
||||
import { login } from '@/routes';
|
||||
import { redirect as googleRedirect } from '@/routes/auth/google';
|
||||
import { store } from '@/routes/register';
|
||||
|
||||
defineProps<{
|
||||
|
|
@ -124,6 +125,17 @@ const timezone = Intl.DateTimeFormat().resolvedOptions().timeZone;
|
|||
</Button>
|
||||
</div>
|
||||
|
||||
<div
|
||||
class="relative text-center text-sm after:absolute after:inset-0 after:top-1/2 after:z-0 after:flex after:items-center after:border-t after:border-border"
|
||||
>
|
||||
<span class="relative z-10 bg-background px-2 text-muted-foreground">{{ $t('auth.or_continue_with') }}</span>
|
||||
</div>
|
||||
|
||||
<Button variant="outline" class="w-full" as="a" :href="googleRedirect.url()">
|
||||
<img src="/images/social/google.svg" alt="Google" class="size-4" />
|
||||
{{ $t('auth.google_signup') }}
|
||||
</Button>
|
||||
|
||||
<div class="text-center text-sm text-muted-foreground">
|
||||
{{ $t('auth.register.has_account') }}
|
||||
<TextLink
|
||||
|
|
|
|||
|
|
@ -1,14 +1,19 @@
|
|||
<script setup lang="ts">
|
||||
import { Head } from '@inertiajs/vue3';
|
||||
import { IconPlus, IconHash, IconPencil, IconTrash } from '@tabler/icons-vue';
|
||||
import { Head, InfiniteScroll, router } from '@inertiajs/vue3';
|
||||
import { IconHash, IconPencil, IconSearch, IconTrash } from '@tabler/icons-vue';
|
||||
|
||||
import { trans } from 'laravel-vue-i18n';
|
||||
import { computed, ref } from 'vue';
|
||||
import { computed, ref, watch } from 'vue';
|
||||
|
||||
import ConfirmDeleteModal from '@/components/ConfirmDeleteModal.vue';
|
||||
import EmptyState from '@/components/EmptyState.vue';
|
||||
import CreateDialog from '@/components/hashtags/CreateDialog.vue';
|
||||
import EditDialog from '@/components/hashtags/EditDialog.vue';
|
||||
import { Button } from '@/components/ui/button';
|
||||
import { Card, CardContent, CardDescription, CardHeader, CardTitle } from '@/components/ui/card';
|
||||
import { Input } from '@/components/ui/input';
|
||||
import { Skeleton } from '@/components/ui/skeleton';
|
||||
import debounce from '@/debounce';
|
||||
import AppLayout from '@/layouts/AppLayout.vue';
|
||||
import { index as hashtagsIndex, destroy as hashtagsDestroy } from '@/routes/app/hashtags';
|
||||
import { type BreadcrumbItemType } from '@/types';
|
||||
|
|
@ -25,12 +30,38 @@ interface Hashtag {
|
|||
created_at: string;
|
||||
}
|
||||
|
||||
interface Props {
|
||||
workspace: Workspace;
|
||||
hashtags: Hashtag[];
|
||||
interface ScrollHashtags {
|
||||
data: Hashtag[];
|
||||
meta: {
|
||||
hasNextPage: boolean;
|
||||
};
|
||||
}
|
||||
|
||||
defineProps<Props>();
|
||||
interface Props {
|
||||
workspace: Workspace;
|
||||
hashtags: ScrollHashtags;
|
||||
filters: {
|
||||
search: string;
|
||||
};
|
||||
}
|
||||
|
||||
const props = defineProps<Props>();
|
||||
|
||||
const searchQuery = ref(props.filters.search);
|
||||
|
||||
const search = debounce(() => {
|
||||
router.get(
|
||||
hashtagsIndex.url(),
|
||||
{ search: searchQuery.value || undefined },
|
||||
{
|
||||
preserveState: true,
|
||||
preserveScroll: true,
|
||||
reset: ['hashtags'],
|
||||
},
|
||||
);
|
||||
}, 300);
|
||||
|
||||
watch(searchQuery, () => search());
|
||||
|
||||
const deleteModal = ref<InstanceType<typeof ConfirmDeleteModal> | null>(null);
|
||||
const isCreateDialogOpen = ref(false);
|
||||
|
|
@ -63,6 +94,14 @@ const getHashtagCount = (hashtags: string): number => {
|
|||
|
||||
<AppLayout :breadcrumbs="breadcrumbs">
|
||||
<template #header-right>
|
||||
<div class="relative">
|
||||
<IconSearch class="pointer-events-none absolute top-1/2 left-3 size-4 -translate-y-1/2 text-muted-foreground" />
|
||||
<Input
|
||||
v-model="searchQuery"
|
||||
:placeholder="trans('hashtags.search')"
|
||||
class="w-64 pl-9"
|
||||
/>
|
||||
</div>
|
||||
<Button @click="isCreateDialogOpen = true">
|
||||
{{ $t('hashtags.new_group') }}
|
||||
</Button>
|
||||
|
|
@ -70,46 +109,62 @@ const getHashtagCount = (hashtags: string): number => {
|
|||
|
||||
<div class="flex flex-col gap-6 p-6">
|
||||
|
||||
<div v-if="hashtags.length === 0" class="flex flex-col items-center justify-center py-16">
|
||||
<div class="h-16 w-16 rounded-full bg-muted flex items-center justify-center mb-4">
|
||||
<IconHash class="h-8 w-8 text-muted-foreground" />
|
||||
</div>
|
||||
<h3 class="text-lg font-semibold mb-2">{{ $t('hashtags.no_groups_yet') }}</h3>
|
||||
<p class="text-muted-foreground mb-4 text-center max-w-sm">
|
||||
{{ $t('hashtags.no_groups_description') }}
|
||||
</p>
|
||||
<Button @click="isCreateDialogOpen = true">
|
||||
<IconPlus class="mr-2 h-4 w-4" />
|
||||
{{ $t('hashtags.create_first_group') }}
|
||||
</Button>
|
||||
</div>
|
||||
<EmptyState
|
||||
v-if="hashtags.data.length === 0"
|
||||
:icon="IconHash"
|
||||
:title="$t('hashtags.no_groups_yet')"
|
||||
:description="$t('hashtags.no_groups_description')"
|
||||
/>
|
||||
|
||||
<div v-else class="grid gap-4 md:grid-cols-2 lg:grid-cols-3">
|
||||
<Card v-for="hashtag in hashtags" :key="hashtag.id">
|
||||
<CardHeader class="pb-3">
|
||||
<div class="flex items-center justify-between">
|
||||
<CardTitle class="text-lg">{{ hashtag.name }}</CardTitle>
|
||||
<div class="flex items-center gap-1">
|
||||
<Button variant="ghost" size="icon" class="h-8 w-8" @click="openEditDialog(hashtag)">
|
||||
<IconPencil class="h-4 w-4" />
|
||||
</Button>
|
||||
<Button variant="ghost" size="icon"
|
||||
class="h-8 w-8 text-destructive hover:text-destructive"
|
||||
@click="handleDelete(hashtag.id)">
|
||||
<IconTrash class="h-4 w-4" />
|
||||
</Button>
|
||||
<div v-else>
|
||||
<div class="grid gap-4 md:grid-cols-2 lg:grid-cols-3">
|
||||
<Card v-for="hashtag in hashtags.data" :key="hashtag.id">
|
||||
<CardHeader class="pb-3">
|
||||
<div class="flex items-center justify-between">
|
||||
<CardTitle class="text-lg">{{ hashtag.name }}</CardTitle>
|
||||
<div class="flex items-center gap-1">
|
||||
<Button variant="ghost" size="icon" class="h-8 w-8" @click="openEditDialog(hashtag)">
|
||||
<IconPencil class="h-4 w-4" />
|
||||
</Button>
|
||||
<Button variant="ghost" size="icon"
|
||||
class="h-8 w-8 text-destructive hover:text-destructive"
|
||||
@click="handleDelete(hashtag.id)">
|
||||
<IconTrash class="h-4 w-4" />
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<CardDescription>
|
||||
{{ $t('hashtags.hashtags_count', { count: getHashtagCount(hashtag.hashtags) }) }}
|
||||
</CardDescription>
|
||||
</CardHeader>
|
||||
<CardContent>
|
||||
<p class="text-sm text-muted-foreground line-clamp-3">
|
||||
{{ hashtag.hashtags }}
|
||||
</p>
|
||||
</CardContent>
|
||||
</Card>
|
||||
<CardDescription>
|
||||
{{ $t('hashtags.hashtags_count', { count: getHashtagCount(hashtag.hashtags) }) }}
|
||||
</CardDescription>
|
||||
</CardHeader>
|
||||
<CardContent>
|
||||
<p class="text-sm text-muted-foreground line-clamp-3">
|
||||
{{ hashtag.hashtags }}
|
||||
</p>
|
||||
</CardContent>
|
||||
</Card>
|
||||
</div>
|
||||
|
||||
<InfiniteScroll data="hashtags" #default="{ loading }">
|
||||
<div v-if="loading" class="grid gap-4 md:grid-cols-2 lg:grid-cols-3 mt-4">
|
||||
<Card v-for="i in 3" :key="i">
|
||||
<CardHeader class="pb-3">
|
||||
<div class="flex items-center justify-between">
|
||||
<Skeleton class="h-6 w-32" />
|
||||
<div class="flex gap-1">
|
||||
<Skeleton class="h-8 w-8" />
|
||||
<Skeleton class="h-8 w-8" />
|
||||
</div>
|
||||
</div>
|
||||
<Skeleton class="h-4 w-20" />
|
||||
</CardHeader>
|
||||
<CardContent>
|
||||
<Skeleton class="h-4 w-full" />
|
||||
<Skeleton class="h-4 w-3/4 mt-2" />
|
||||
</CardContent>
|
||||
</Card>
|
||||
</div>
|
||||
</InfiniteScroll>
|
||||
</div>
|
||||
</div>
|
||||
</AppLayout>
|
||||
|
|
|
|||
|
|
@ -1,23 +1,22 @@
|
|||
<script setup lang="ts">
|
||||
import { Head } from '@inertiajs/vue3';
|
||||
import { IconPlus, IconTag, IconPencil, IconTrash } from '@tabler/icons-vue';
|
||||
import { Head, InfiniteScroll, router } from '@inertiajs/vue3';
|
||||
import { IconPencil, IconSearch, IconTag, IconTrash } from '@tabler/icons-vue';
|
||||
import { trans } from 'laravel-vue-i18n';
|
||||
import { computed, ref } from 'vue';
|
||||
import { computed, ref, watch } from 'vue';
|
||||
|
||||
import ConfirmDeleteModal from '@/components/ConfirmDeleteModal.vue';
|
||||
import EmptyState from '@/components/EmptyState.vue';
|
||||
import CreateDialog from '@/components/labels/CreateDialog.vue';
|
||||
import EditDialog from '@/components/labels/EditDialog.vue';
|
||||
import { Button } from '@/components/ui/button';
|
||||
import { Card, CardHeader, CardTitle } from '@/components/ui/card';
|
||||
import { Input } from '@/components/ui/input';
|
||||
import { Skeleton } from '@/components/ui/skeleton';
|
||||
import debounce from '@/debounce';
|
||||
import AppLayout from '@/layouts/AppLayout.vue';
|
||||
import { index as labelsIndex, destroy as labelsDestroy } from '@/routes/app/labels';
|
||||
import { type BreadcrumbItemType } from '@/types';
|
||||
|
||||
interface Workspace {
|
||||
id: string;
|
||||
name: string;
|
||||
}
|
||||
|
||||
interface Label {
|
||||
id: string;
|
||||
name: string;
|
||||
|
|
@ -25,12 +24,21 @@ interface Label {
|
|||
created_at: string;
|
||||
}
|
||||
|
||||
interface Props {
|
||||
workspace: Workspace;
|
||||
labels: Label[];
|
||||
interface ScrollLabels {
|
||||
data: Label[];
|
||||
meta: {
|
||||
hasNextPage: boolean;
|
||||
};
|
||||
}
|
||||
|
||||
defineProps<Props>();
|
||||
interface Props {
|
||||
labels: ScrollLabels;
|
||||
filters: {
|
||||
search: string;
|
||||
};
|
||||
}
|
||||
|
||||
const props = defineProps<Props>();
|
||||
|
||||
const deleteModal = ref<InstanceType<typeof ConfirmDeleteModal> | null>(null);
|
||||
const isCreateDialogOpen = ref(false);
|
||||
|
|
@ -41,6 +49,22 @@ const breadcrumbs = computed<BreadcrumbItemType[]>(() => [
|
|||
{ title: trans('labels.title'), href: labelsIndex.url() },
|
||||
]);
|
||||
|
||||
const searchQuery = ref(props.filters.search);
|
||||
|
||||
const search = debounce(() => {
|
||||
router.get(
|
||||
labelsIndex.url(),
|
||||
{ search: searchQuery.value || undefined },
|
||||
{
|
||||
preserveState: true,
|
||||
preserveScroll: true,
|
||||
reset: ['labels'],
|
||||
},
|
||||
);
|
||||
}, 300);
|
||||
|
||||
watch(searchQuery, () => search());
|
||||
|
||||
const openEditDialog = (label: Label) => {
|
||||
editingLabel.value = label;
|
||||
isEditDialogOpen.value = true;
|
||||
|
|
@ -59,48 +83,69 @@ const handleDelete = (labelId: string) => {
|
|||
|
||||
<AppLayout :breadcrumbs="breadcrumbs">
|
||||
<template #header-right>
|
||||
<div class="relative">
|
||||
<IconSearch class="pointer-events-none absolute top-1/2 left-3 size-4 -translate-y-1/2 text-muted-foreground" />
|
||||
<Input
|
||||
v-model="searchQuery"
|
||||
:placeholder="trans('labels.search')"
|
||||
class="w-64 pl-9"
|
||||
/>
|
||||
</div>
|
||||
<Button @click="isCreateDialogOpen = true">
|
||||
{{ $t('labels.new_label') }}
|
||||
</Button>
|
||||
</template>
|
||||
|
||||
<div class="flex flex-col gap-6 p-6">
|
||||
<EmptyState
|
||||
v-if="labels.data.length === 0"
|
||||
:icon="IconTag"
|
||||
:title="$t('labels.no_labels_yet')"
|
||||
:description="$t('labels.description')"
|
||||
/>
|
||||
|
||||
<div v-if="labels.length === 0" class="flex flex-col items-center justify-center py-16">
|
||||
<div class="h-16 w-16 rounded-full bg-muted flex items-center justify-center mb-4">
|
||||
<IconTag class="h-8 w-8 text-muted-foreground" />
|
||||
<div v-else>
|
||||
<div class="grid gap-4 md:grid-cols-2 lg:grid-cols-3 xl:grid-cols-4">
|
||||
<Card v-for="label in labels.data" :key="label.id">
|
||||
<CardHeader class="pb-3">
|
||||
<div class="flex items-center justify-between">
|
||||
<div class="flex items-center gap-3">
|
||||
<div class="h-6 w-6 rounded-md" :style="{ backgroundColor: label.color }" />
|
||||
<CardTitle class="text-lg">{{ label.name }}</CardTitle>
|
||||
</div>
|
||||
<div class="flex items-center gap-1">
|
||||
<Button variant="ghost" size="icon" class="h-8 w-8" @click="openEditDialog(label)">
|
||||
<IconPencil class="h-4 w-4" />
|
||||
</Button>
|
||||
<Button variant="ghost" size="icon"
|
||||
class="h-8 w-8 text-destructive hover:text-destructive"
|
||||
@click="handleDelete(label.id)">
|
||||
<IconTrash class="h-4 w-4" />
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
</CardHeader>
|
||||
</Card>
|
||||
</div>
|
||||
<h3 class="text-lg font-semibold mb-2">{{ $t('labels.no_labels_yet') }}</h3>
|
||||
<p class="text-muted-foreground mb-4 text-center max-w-sm">
|
||||
{{ $t('labels.description') }}
|
||||
</p>
|
||||
<Button @click="isCreateDialogOpen = true">
|
||||
<IconPlus class="mr-2 h-4 w-4" />
|
||||
{{ $t('labels.create_first_label') }}
|
||||
</Button>
|
||||
</div>
|
||||
|
||||
<div v-else class="grid gap-4 md:grid-cols-2 lg:grid-cols-3 xl:grid-cols-4">
|
||||
<Card v-for="label in labels" :key="label.id">
|
||||
<CardHeader class="pb-3">
|
||||
<div class="flex items-center justify-between">
|
||||
<div class="flex items-center gap-3">
|
||||
<div class="h-6 w-6 rounded-md" :style="{ backgroundColor: label.color }" />
|
||||
<CardTitle class="text-lg">{{ label.name }}</CardTitle>
|
||||
</div>
|
||||
<div class="flex items-center gap-1">
|
||||
<Button variant="ghost" size="icon" class="h-8 w-8" @click="openEditDialog(label)">
|
||||
<IconPencil class="h-4 w-4" />
|
||||
</Button>
|
||||
<Button variant="ghost" size="icon"
|
||||
class="h-8 w-8 text-destructive hover:text-destructive"
|
||||
@click="handleDelete(label.id)">
|
||||
<IconTrash class="h-4 w-4" />
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
</CardHeader>
|
||||
</Card>
|
||||
<InfiniteScroll data="labels" #default="{ loading }">
|
||||
<div v-if="loading" class="grid gap-4 md:grid-cols-2 lg:grid-cols-3 xl:grid-cols-4 mt-4">
|
||||
<Card v-for="i in 4" :key="i">
|
||||
<CardHeader class="pb-3">
|
||||
<div class="flex items-center justify-between">
|
||||
<div class="flex items-center gap-3">
|
||||
<Skeleton class="h-6 w-6 rounded-md" />
|
||||
<Skeleton class="h-6 w-24" />
|
||||
</div>
|
||||
<div class="flex gap-1">
|
||||
<Skeleton class="h-8 w-8" />
|
||||
<Skeleton class="h-8 w-8" />
|
||||
</div>
|
||||
</div>
|
||||
</CardHeader>
|
||||
</Card>
|
||||
</div>
|
||||
</InfiniteScroll>
|
||||
</div>
|
||||
</div>
|
||||
</AppLayout>
|
||||
|
|
@ -111,4 +156,4 @@ const handleDelete = (labelId: string) => {
|
|||
<ConfirmDeleteModal ref="deleteModal" :title="$t('labels.delete.title')"
|
||||
:description="$t('labels.delete.description')" :action="$t('labels.delete.confirm')"
|
||||
:cancel="$t('labels.delete.cancel')" />
|
||||
</template>
|
||||
</template>
|
||||
|
|
|
|||
|
|
@ -1,8 +1,8 @@
|
|||
<script setup lang="ts">
|
||||
import { Head, Link, InfiniteScroll } from '@inertiajs/vue3';
|
||||
import { IconClock, IconCircleCheck, IconAlertCircle, IconLoader2, IconFileText, IconEye, IconTrash } from '@tabler/icons-vue';
|
||||
import { Head, Link, InfiniteScroll, router } from '@inertiajs/vue3';
|
||||
import { IconClock, IconCircleCheck, IconAlertCircle, IconLoader2, IconFileText, IconEye, IconSearch, IconTrash } from '@tabler/icons-vue';
|
||||
import { trans } from 'laravel-vue-i18n';
|
||||
import { computed, ref } from 'vue';
|
||||
import { computed, ref, watch } from 'vue';
|
||||
|
||||
import { index as postsIndex, store as storePost, edit as editPost, destroy as destroyPost } from '@/actions/App/Http/Controllers/App/PostController';
|
||||
import ConfirmDeleteModal from '@/components/ConfirmDeleteModal.vue';
|
||||
|
|
@ -10,9 +10,11 @@ import EmptyState from '@/components/EmptyState.vue';
|
|||
import { Badge } from '@/components/ui/badge';
|
||||
import { Button } from '@/components/ui/button';
|
||||
import { Card, CardContent } from '@/components/ui/card';
|
||||
import { Input } from '@/components/ui/input';
|
||||
import { Skeleton } from '@/components/ui/skeleton';
|
||||
import { Tooltip, TooltipContent, TooltipProvider, TooltipTrigger } from '@/components/ui/tooltip';
|
||||
import dayjs from '@/dayjs';
|
||||
import debounce from '@/debounce';
|
||||
import AppLayout from '@/layouts/AppLayout.vue';
|
||||
import { type BreadcrumbItemType } from '@/types';
|
||||
|
||||
|
|
@ -72,10 +74,30 @@ interface Props {
|
|||
workspace: Workspace;
|
||||
posts: ScrollPosts;
|
||||
currentStatus: string | null;
|
||||
filters: {
|
||||
search: string;
|
||||
};
|
||||
}
|
||||
|
||||
const props = defineProps<Props>();
|
||||
|
||||
const searchQuery = ref(props.filters.search);
|
||||
|
||||
const search = debounce(() => {
|
||||
const url = props.currentStatus ? postsIndex.url(props.currentStatus) : postsIndex.url();
|
||||
router.get(
|
||||
url,
|
||||
{ search: searchQuery.value || undefined },
|
||||
{
|
||||
preserveState: true,
|
||||
preserveScroll: true,
|
||||
reset: ['posts'],
|
||||
},
|
||||
);
|
||||
}, 300);
|
||||
|
||||
watch(searchQuery, () => search());
|
||||
|
||||
const pageTitle = computed(() => {
|
||||
if (props.currentStatus) {
|
||||
const statusLabel = trans(`posts.status.${props.currentStatus}`);
|
||||
|
|
@ -178,6 +200,14 @@ const handleDelete = (post: Post) => {
|
|||
|
||||
<AppLayout :breadcrumbs="breadcrumbs">
|
||||
<template #header-right>
|
||||
<div class="relative">
|
||||
<IconSearch class="pointer-events-none absolute top-1/2 left-3 size-4 -translate-y-1/2 text-muted-foreground" />
|
||||
<Input
|
||||
v-model="searchQuery"
|
||||
:placeholder="trans('posts.search')"
|
||||
class="w-64 pl-9"
|
||||
/>
|
||||
</div>
|
||||
<Link :href="storePost.url()" method="post">
|
||||
<Button>
|
||||
{{ $t('posts.new_post') }}
|
||||
|
|
|
|||
21
resources/js/posthog.ts
Normal file
21
resources/js/posthog.ts
Normal file
|
|
@ -0,0 +1,21 @@
|
|||
import posthog from 'posthog-js';
|
||||
|
||||
const apiKey = import.meta.env.VITE_POSTHOG_API_KEY as string | undefined;
|
||||
const host = import.meta.env.VITE_POSTHOG_HOST as string | undefined;
|
||||
|
||||
if (apiKey) {
|
||||
posthog.init(apiKey, {
|
||||
api_host: host || 'https://us.i.posthog.com',
|
||||
ui_host: 'https://us.posthog.com',
|
||||
capture_pageview: false,
|
||||
capture_pageleave: true,
|
||||
cross_subdomain_cookie: true,
|
||||
enable_recording_console_log: true,
|
||||
session_recording: {
|
||||
maskAllInputs: true,
|
||||
maskTextSelector: '.ph-no-capture',
|
||||
},
|
||||
});
|
||||
}
|
||||
|
||||
export default posthog;
|
||||
|
|
@ -120,6 +120,7 @@ function () {
|
|||
// Social Accounts
|
||||
Route::get('accounts', [SocialController::class, 'index'])->name('app.accounts');
|
||||
Route::delete('accounts/{account}', [SocialController::class, 'disconnect'])->name('app.accounts.disconnect');
|
||||
Route::put('accounts/{account}/toggle', [SocialController::class, 'toggleActive'])->name('app.accounts.toggle');
|
||||
|
||||
// Calendar
|
||||
Route::get('calendar', [PostController::class, 'calendar'])->name('app.calendar');
|
||||
|
|
@ -168,7 +169,7 @@ function () {
|
|||
|
||||
// Notifications
|
||||
Route::get('notifications', [NotificationController::class, 'index'])->name('app.notifications.index');
|
||||
Route::patch('notifications/{notification}/read', [NotificationController::class, 'markAsRead'])->name('app.notifications.read');
|
||||
Route::put('notifications/{notification}/read', [NotificationController::class, 'markAsRead'])->name('app.notifications.read');
|
||||
Route::post('notifications/read-all', [NotificationController::class, 'markAllAsRead'])->name('app.notifications.read-all');
|
||||
Route::post('notifications/archive-all', [NotificationController::class, 'archiveAll'])->name('app.notifications.archive-all');
|
||||
});
|
||||
|
|
@ -176,10 +177,10 @@ function () {
|
|||
// Settings (auth required)
|
||||
Route::middleware(['auth'])->group(function () {
|
||||
Route::get('settings/profile', [ProfileController::class, 'edit'])->name('app.profile.edit');
|
||||
Route::patch('settings/profile', [ProfileController::class, 'update'])->name('app.profile.update');
|
||||
Route::put('settings/profile', [ProfileController::class, 'update'])->name('app.profile.update');
|
||||
Route::post('settings/profile/photo', [ProfileController::class, 'uploadPhoto'])->name('app.profile.upload-photo');
|
||||
Route::delete('settings/profile/photo', [ProfileController::class, 'deletePhoto'])->name('app.profile.delete-photo');
|
||||
Route::patch('settings/language', [ProfileController::class, 'updateLanguage'])->name('app.profile.language');
|
||||
Route::put('settings/language', [ProfileController::class, 'updateLanguage'])->name('app.profile.language');
|
||||
});
|
||||
|
||||
Route::middleware(['auth', 'verified'])->group(function () {
|
||||
|
|
|
|||
|
|
@ -9,6 +9,7 @@
|
|||
use App\Http\Controllers\Auth\NewPasswordController;
|
||||
use App\Http\Controllers\Auth\PasswordResetLinkController;
|
||||
use App\Http\Controllers\Auth\RegisteredUserController;
|
||||
use App\Http\Controllers\Auth\SocialLoginController;
|
||||
use App\Http\Controllers\Auth\VerifyEmailController;
|
||||
use Illuminate\Support\Facades\Route;
|
||||
|
||||
|
|
@ -38,6 +39,9 @@ function () {
|
|||
|
||||
Route::get('/reset-password/{token}', [NewPasswordController::class, 'create'])->name('password.reset');
|
||||
Route::post('/reset-password', [NewPasswordController::class, 'store'])->name('password.store');
|
||||
|
||||
Route::get('/auth/google/redirect', [SocialLoginController::class, 'redirect'])->name('auth.google.redirect');
|
||||
Route::get('/auth/google/callback', [SocialLoginController::class, 'callback'])->name('auth.google.callback');
|
||||
}
|
||||
);
|
||||
|
||||
|
|
|
|||
|
|
@ -79,7 +79,7 @@
|
|||
'workspace_id' => $this->workspace->id,
|
||||
]);
|
||||
|
||||
$response = $this->actingAs($this->user)->patchJson(route('app.notifications.read', $notification));
|
||||
$response = $this->actingAs($this->user)->putJson(route('app.notifications.read', $notification));
|
||||
|
||||
$response->assertOk();
|
||||
expect($notification->fresh()->read_at)->not->toBeNull();
|
||||
|
|
@ -92,7 +92,7 @@
|
|||
'workspace_id' => $this->workspace->id,
|
||||
]);
|
||||
|
||||
$response = $this->actingAs($this->user)->patchJson(route('app.notifications.read', $notification));
|
||||
$response = $this->actingAs($this->user)->putJson(route('app.notifications.read', $notification));
|
||||
|
||||
$response->assertForbidden();
|
||||
});
|
||||
|
|
|
|||
33
tests/Feature/SendPostHogEventTest.php
Normal file
33
tests/Feature/SendPostHogEventTest.php
Normal file
|
|
@ -0,0 +1,33 @@
|
|||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
use App\Jobs\SendPostHogEvent;
|
||||
|
||||
test('job is queued on posthog queue', function () {
|
||||
$job = new SendPostHogEvent([
|
||||
['method' => 'capture', 'payload' => ['distinctId' => 'user-1', 'event' => 'test']],
|
||||
]);
|
||||
|
||||
expect($job->queue)->toBe('posthog');
|
||||
});
|
||||
|
||||
test('job skips execution when api key is missing', function () {
|
||||
config(['services.posthog.api_key' => null]);
|
||||
|
||||
$job = new SendPostHogEvent([
|
||||
['method' => 'capture', 'payload' => ['distinctId' => 'user-1', 'event' => 'test']],
|
||||
]);
|
||||
|
||||
// Should not throw - silently skips
|
||||
$job->handle();
|
||||
|
||||
expect(true)->toBeTrue();
|
||||
});
|
||||
|
||||
test('job has correct retry and timeout settings', function () {
|
||||
$job = new SendPostHogEvent([]);
|
||||
|
||||
expect($job->tries)->toBe(3);
|
||||
expect($job->timeout)->toBe(15);
|
||||
});
|
||||
|
|
@ -23,7 +23,7 @@
|
|||
|
||||
$response = $this
|
||||
->actingAs($user)
|
||||
->patch(route('app.profile.update'), [
|
||||
->put(route('app.profile.update'), [
|
||||
'name' => 'Test User',
|
||||
'email' => 'test@example.com',
|
||||
]);
|
||||
|
|
@ -44,7 +44,7 @@
|
|||
|
||||
$response = $this
|
||||
->actingAs($user)
|
||||
->patch(route('app.profile.update'), [
|
||||
->put(route('app.profile.update'), [
|
||||
'name' => 'Test User',
|
||||
'email' => $user->email,
|
||||
]);
|
||||
|
|
@ -62,7 +62,7 @@
|
|||
$response = $this
|
||||
->actingAs($user)
|
||||
->from(route('app.posts.index'))
|
||||
->patch(route('app.profile.language'), [
|
||||
->put(route('app.profile.language'), [
|
||||
'locale' => 'es',
|
||||
]);
|
||||
|
||||
|
|
@ -78,7 +78,7 @@
|
|||
|
||||
$response = $this
|
||||
->actingAs($user)
|
||||
->patch(route('app.profile.language'), [
|
||||
->put(route('app.profile.language'), [
|
||||
'locale' => 'invalid',
|
||||
]);
|
||||
|
||||
|
|
|
|||
93
tests/Feature/SocialLoginControllerTest.php
Normal file
93
tests/Feature/SocialLoginControllerTest.php
Normal file
|
|
@ -0,0 +1,93 @@
|
|||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
use App\Models\User;
|
||||
use Laravel\Socialite\Facades\Socialite;
|
||||
use Laravel\Socialite\Two\User as SocialiteUser;
|
||||
|
||||
beforeEach(function () {
|
||||
config([
|
||||
'services.google-auth.client_id' => 'test-client-id',
|
||||
'services.google-auth.client_secret' => 'test-client-secret',
|
||||
'services.google-auth.redirect' => 'https://app.trypost.test/auth/google/callback',
|
||||
]);
|
||||
});
|
||||
|
||||
test('google redirect returns redirect response', function () {
|
||||
$response = $this->get(route('auth.google.redirect'));
|
||||
|
||||
$response->assertRedirect();
|
||||
expect($response->headers->get('Location'))->toContain('accounts.google.com');
|
||||
});
|
||||
|
||||
test('google callback logs in existing user by email', function () {
|
||||
$user = User::factory()->create([
|
||||
'email' => 'existing@example.com',
|
||||
]);
|
||||
|
||||
$socialiteUser = new SocialiteUser;
|
||||
$socialiteUser->map([
|
||||
'id' => '123456',
|
||||
'name' => 'Existing User',
|
||||
'email' => 'existing@example.com',
|
||||
]);
|
||||
|
||||
Socialite::shouldReceive('driver')
|
||||
->with('google-auth')
|
||||
->andReturn($driver = Mockery::mock());
|
||||
$driver->shouldReceive('user')->andReturn($socialiteUser);
|
||||
|
||||
$response = $this->get(route('auth.google.callback'));
|
||||
|
||||
$response->assertRedirect(route('app.home'));
|
||||
$this->assertAuthenticatedAs($user);
|
||||
});
|
||||
|
||||
test('google callback creates new user when email does not exist', function () {
|
||||
$socialiteUser = new SocialiteUser;
|
||||
$socialiteUser->map([
|
||||
'id' => '789',
|
||||
'name' => 'New User',
|
||||
'email' => 'new@example.com',
|
||||
]);
|
||||
|
||||
Socialite::shouldReceive('driver')
|
||||
->with('google-auth')
|
||||
->andReturn($driver = Mockery::mock());
|
||||
$driver->shouldReceive('user')->andReturn($socialiteUser);
|
||||
|
||||
$response = $this->get(route('auth.google.callback'));
|
||||
|
||||
$response->assertRedirect(route('app.onboarding.role'));
|
||||
|
||||
$user = User::where('email', 'new@example.com')->first();
|
||||
expect($user)->not->toBeNull();
|
||||
expect($user->name)->toBe('New User');
|
||||
expect($user->email_verified_at)->not->toBeNull();
|
||||
expect($user->currentWorkspace)->not->toBeNull();
|
||||
$this->assertAuthenticatedAs($user);
|
||||
});
|
||||
|
||||
test('google callback marks unverified existing user as verified', function () {
|
||||
$user = User::factory()->create([
|
||||
'email' => 'unverified@example.com',
|
||||
'email_verified_at' => null,
|
||||
]);
|
||||
|
||||
$socialiteUser = new SocialiteUser;
|
||||
$socialiteUser->map([
|
||||
'id' => '456',
|
||||
'name' => 'Unverified User',
|
||||
'email' => 'unverified@example.com',
|
||||
]);
|
||||
|
||||
Socialite::shouldReceive('driver')
|
||||
->with('google-auth')
|
||||
->andReturn($driver = Mockery::mock());
|
||||
$driver->shouldReceive('user')->andReturn($socialiteUser);
|
||||
|
||||
$this->get(route('auth.google.callback'));
|
||||
|
||||
expect($user->fresh()->email_verified_at)->not->toBeNull();
|
||||
});
|
||||
|
|
@ -29,7 +29,7 @@
|
|||
$response->assertInertia(fn ($page) => $page
|
||||
->component('hashtags/Index', false)
|
||||
->has('workspace')
|
||||
->has('hashtags', 3)
|
||||
->has('hashtags.data', 3)
|
||||
);
|
||||
});
|
||||
|
||||
|
|
@ -140,3 +140,30 @@
|
|||
|
||||
$response->assertNotFound();
|
||||
});
|
||||
|
||||
test('hashtags index filters by search query', function () {
|
||||
WorkspaceHashtag::factory()->create(['workspace_id' => $this->workspace->id, 'name' => 'Marketing']);
|
||||
WorkspaceHashtag::factory()->create(['workspace_id' => $this->workspace->id, 'name' => 'Travel']);
|
||||
WorkspaceHashtag::factory()->create(['workspace_id' => $this->workspace->id, 'name' => 'Food']);
|
||||
|
||||
$response = $this->actingAs($this->user)->get(route('app.hashtags.index', ['search' => 'market']));
|
||||
|
||||
$response->assertOk();
|
||||
$response->assertInertia(fn ($page) => $page
|
||||
->has('hashtags.data', 1)
|
||||
->has('filters')
|
||||
->where('filters.search', 'market')
|
||||
);
|
||||
});
|
||||
|
||||
test('hashtags index returns all when no search query', function () {
|
||||
WorkspaceHashtag::factory()->count(3)->create(['workspace_id' => $this->workspace->id]);
|
||||
|
||||
$response = $this->actingAs($this->user)->get(route('app.hashtags.index'));
|
||||
|
||||
$response->assertOk();
|
||||
$response->assertInertia(fn ($page) => $page
|
||||
->has('hashtags.data', 3)
|
||||
->where('filters.search', '')
|
||||
);
|
||||
});
|
||||
|
|
|
|||
|
|
@ -29,7 +29,7 @@
|
|||
$response->assertInertia(fn ($page) => $page
|
||||
->component('labels/Index', false)
|
||||
->has('workspace')
|
||||
->has('labels', 3)
|
||||
->has('labels.data', 3)
|
||||
);
|
||||
});
|
||||
|
||||
|
|
@ -149,3 +149,30 @@
|
|||
|
||||
$response->assertNotFound();
|
||||
});
|
||||
|
||||
test('labels index filters by search query', function () {
|
||||
WorkspaceLabel::factory()->create(['workspace_id' => $this->workspace->id, 'name' => 'Important']);
|
||||
WorkspaceLabel::factory()->create(['workspace_id' => $this->workspace->id, 'name' => 'Urgent']);
|
||||
WorkspaceLabel::factory()->create(['workspace_id' => $this->workspace->id, 'name' => 'Review']);
|
||||
|
||||
$response = $this->actingAs($this->user)->get(route('app.labels.index', ['search' => 'import']));
|
||||
|
||||
$response->assertOk();
|
||||
$response->assertInertia(fn ($page) => $page
|
||||
->has('labels.data', 1)
|
||||
->has('filters')
|
||||
->where('filters.search', 'import')
|
||||
);
|
||||
});
|
||||
|
||||
test('labels index returns all when no search query', function () {
|
||||
WorkspaceLabel::factory()->count(3)->create(['workspace_id' => $this->workspace->id]);
|
||||
|
||||
$response = $this->actingAs($this->user)->get(route('app.labels.index'));
|
||||
|
||||
$response->assertOk();
|
||||
$response->assertInertia(fn ($page) => $page
|
||||
->has('labels.data', 3)
|
||||
->where('filters.search', '')
|
||||
);
|
||||
});
|
||||
|
|
|
|||
78
tests/Unit/PostHogServiceTest.php
Normal file
78
tests/Unit/PostHogServiceTest.php
Normal file
|
|
@ -0,0 +1,78 @@
|
|||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
use App\Jobs\SendPostHogEvent;
|
||||
use App\Services\PostHogService;
|
||||
use Illuminate\Support\Facades\Queue;
|
||||
|
||||
test('capture dispatches job when api key is configured', function () {
|
||||
Queue::fake();
|
||||
config(['services.posthog.api_key' => 'phc_test_key']);
|
||||
|
||||
$service = new PostHogService;
|
||||
$service->capture('user-123', 'test_event', ['foo' => 'bar']);
|
||||
|
||||
Queue::assertPushed(SendPostHogEvent::class, function ($job) {
|
||||
return $job->calls[0]['method'] === 'capture'
|
||||
&& $job->calls[0]['payload']['event'] === 'test_event'
|
||||
&& $job->calls[0]['payload']['distinctId'] === 'user-123';
|
||||
});
|
||||
});
|
||||
|
||||
test('capture does not dispatch job when api key is missing', function () {
|
||||
Queue::fake();
|
||||
config(['services.posthog.api_key' => null]);
|
||||
|
||||
$service = new PostHogService;
|
||||
$service->capture('user-123', 'test_event');
|
||||
|
||||
Queue::assertNothingPushed();
|
||||
});
|
||||
|
||||
test('identify dispatches job when api key is configured', function () {
|
||||
Queue::fake();
|
||||
config(['services.posthog.api_key' => 'phc_test_key']);
|
||||
|
||||
$service = new PostHogService;
|
||||
$service->identify('user-123', ['$email' => 'test@example.com']);
|
||||
|
||||
Queue::assertPushed(SendPostHogEvent::class, function ($job) {
|
||||
return $job->calls[0]['method'] === 'identify'
|
||||
&& $job->calls[0]['payload']['distinctId'] === 'user-123';
|
||||
});
|
||||
});
|
||||
|
||||
test('identify does not dispatch job when api key is missing', function () {
|
||||
Queue::fake();
|
||||
config(['services.posthog.api_key' => null]);
|
||||
|
||||
$service = new PostHogService;
|
||||
$service->identify('user-123', ['$email' => 'test@example.com']);
|
||||
|
||||
Queue::assertNothingPushed();
|
||||
});
|
||||
|
||||
test('group identify dispatches job when api key is configured', function () {
|
||||
Queue::fake();
|
||||
config(['services.posthog.api_key' => 'phc_test_key']);
|
||||
|
||||
$service = new PostHogService;
|
||||
$service->groupIdentify('workspace', 'ws-123', ['name' => 'Test Workspace']);
|
||||
|
||||
Queue::assertPushed(SendPostHogEvent::class, function ($job) {
|
||||
return $job->calls[0]['method'] === 'groupIdentify'
|
||||
&& $job->calls[0]['payload']['groupType'] === 'workspace'
|
||||
&& $job->calls[0]['payload']['groupKey'] === 'ws-123';
|
||||
});
|
||||
});
|
||||
|
||||
test('group identify does not dispatch job when api key is missing', function () {
|
||||
Queue::fake();
|
||||
config(['services.posthog.api_key' => null]);
|
||||
|
||||
$service = new PostHogService;
|
||||
$service->groupIdentify('workspace', 'ws-123', ['name' => 'Test']);
|
||||
|
||||
Queue::assertNothingPushed();
|
||||
});
|
||||
Loading…
Reference in a new issue