Minor fixes
This commit is contained in:
parent
387920e82b
commit
2a68895ba9
21 changed files with 650 additions and 123 deletions
62
backend/.env.broken
Normal file
62
backend/.env.broken
Normal file
|
|
@ -0,0 +1,62 @@
|
|||
APP_NAME="FFR Development"
|
||||
APP_ENV=local
|
||||
APP_KEY=
|
||||
APP_DEBUG=true
|
||||
APP_TIMEZONE=UTC
|
||||
APP_URL=http://localhost:8000
|
||||
|
||||
APP_LOCALE=en
|
||||
APP_FALLBACK_LOCALE=en
|
||||
APP_FAKER_LOCALE=en_US
|
||||
|
||||
APP_MAINTENANCE_DRIVER=file
|
||||
APP_MAINTENANCE_STORE=database
|
||||
|
||||
BCRYPT_ROUNDS=12
|
||||
|
||||
LOG_CHANNEL=stack
|
||||
LOG_STACK=single
|
||||
LOG_DEPRECATIONS_CHANNEL=null
|
||||
LOG_LEVEL=debug
|
||||
|
||||
DB_CONNECTION=mysql
|
||||
DB_HOST=db
|
||||
DB_PORT=3306
|
||||
DB_DATABASE=ffr_dev
|
||||
DB_USERNAME=ffr_user
|
||||
DB_PASSWORD=ffr_password
|
||||
|
||||
SESSION_DRIVER=redis
|
||||
SESSION_LIFETIME=120
|
||||
SESSION_ENCRYPT=false
|
||||
SESSION_PATH=/
|
||||
SESSION_DOMAIN=null
|
||||
|
||||
BROADCAST_CONNECTION=log
|
||||
FILESYSTEM_DISK=local
|
||||
QUEUE_CONNECTION=redis
|
||||
|
||||
CACHE_STORE=redis
|
||||
CACHE_PREFIX=
|
||||
|
||||
REDIS_CLIENT=phpredis
|
||||
REDIS_HOST=redis
|
||||
REDIS_PASSWORD=null
|
||||
REDIS_PORT=6379
|
||||
|
||||
MAIL_MAILER=log
|
||||
MAIL_HOST=127.0.0.1
|
||||
MAIL_PORT=2525
|
||||
MAIL_USERNAME=null
|
||||
MAIL_PASSWORD=null
|
||||
MAIL_ENCRYPTION=null
|
||||
MAIL_FROM_ADDRESS="hello@example.com"
|
||||
MAIL_FROM_NAME="${APP_NAME}"
|
||||
|
||||
AWS_ACCESS_KEY_ID=
|
||||
AWS_SECRET_ACCESS_KEY=
|
||||
AWS_DEFAULT_REGION=us-east-1
|
||||
AWS_BUCKET=
|
||||
AWS_USE_PATH_STYLE_ENDPOINT=false
|
||||
|
||||
VITE_APP_NAME="${APP_NAME}"
|
||||
|
|
@ -6,11 +6,13 @@
|
|||
use App\Http\Resources\FeedResource;
|
||||
use App\Http\Resources\PlatformAccountResource;
|
||||
use App\Http\Resources\PlatformChannelResource;
|
||||
use App\Http\Resources\RouteResource;
|
||||
use App\Models\Feed;
|
||||
use App\Models\Language;
|
||||
use App\Models\PlatformAccount;
|
||||
use App\Models\PlatformChannel;
|
||||
use App\Models\PlatformInstance;
|
||||
use App\Models\Route;
|
||||
use App\Models\Setting;
|
||||
use App\Services\Auth\LemmyAuthService;
|
||||
use Illuminate\Http\JsonResponse;
|
||||
|
|
@ -32,12 +34,13 @@ public function status(): JsonResponse
|
|||
$hasPlatformAccount = PlatformAccount::where('is_active', true)->exists();
|
||||
$hasFeed = Feed::where('is_active', true)->exists();
|
||||
$hasChannel = PlatformChannel::where('is_active', true)->exists();
|
||||
$hasRoute = Route::where('is_active', true)->exists();
|
||||
|
||||
// Check if onboarding was explicitly skipped
|
||||
$onboardingSkipped = Setting::where('key', 'onboarding_skipped')->value('value') === 'true';
|
||||
|
||||
// User needs onboarding if they don't have the required components AND haven't skipped it
|
||||
$needsOnboarding = (!$hasPlatformAccount || !$hasFeed || !$hasChannel) && !$onboardingSkipped;
|
||||
$needsOnboarding = (!$hasPlatformAccount || !$hasFeed || !$hasChannel || !$hasRoute) && !$onboardingSkipped;
|
||||
|
||||
// Determine current step
|
||||
$currentStep = null;
|
||||
|
|
@ -48,6 +51,8 @@ public function status(): JsonResponse
|
|||
$currentStep = 'feed';
|
||||
} elseif (!$hasChannel) {
|
||||
$currentStep = 'channel';
|
||||
} elseif (!$hasRoute) {
|
||||
$currentStep = 'route';
|
||||
}
|
||||
}
|
||||
|
||||
|
|
@ -57,6 +62,7 @@ public function status(): JsonResponse
|
|||
'has_platform_account' => $hasPlatformAccount,
|
||||
'has_feed' => $hasFeed,
|
||||
'has_channel' => $hasChannel,
|
||||
'has_route' => $hasRoute,
|
||||
'onboarding_skipped' => $onboardingSkipped,
|
||||
], 'Onboarding status retrieved successfully.');
|
||||
}
|
||||
|
|
@ -74,9 +80,21 @@ public function options(): JsonResponse
|
|||
->orderBy('name')
|
||||
->get(['id', 'platform', 'url', 'name', 'description', 'is_active']);
|
||||
|
||||
// Get existing feeds and channels for route creation
|
||||
$feeds = Feed::where('is_active', true)
|
||||
->orderBy('name')
|
||||
->get(['id', 'name', 'url', 'type']);
|
||||
|
||||
$platformChannels = PlatformChannel::where('is_active', true)
|
||||
->with(['platformInstance:id,name,url'])
|
||||
->orderBy('name')
|
||||
->get(['id', 'platform_instance_id', 'name', 'display_name', 'description']);
|
||||
|
||||
return $this->sendResponse([
|
||||
'languages' => $languages,
|
||||
'platform_instances' => $platformInstances,
|
||||
'feeds' => $feeds,
|
||||
'platform_channels' => $platformChannels,
|
||||
], 'Onboarding options retrieved successfully.');
|
||||
}
|
||||
|
||||
|
|
@ -127,12 +145,12 @@ public function createPlatform(Request $request): JsonResponse
|
|||
'instance_url' => $fullInstanceUrl,
|
||||
'username' => $validated['username'],
|
||||
'password' => $validated['password'],
|
||||
'api_token' => $authResponse['jwt'] ?? null,
|
||||
'settings' => [
|
||||
'display_name' => $authResponse['person_view']['person']['display_name'] ?? null,
|
||||
'description' => $authResponse['person_view']['person']['bio'] ?? null,
|
||||
'person_id' => $authResponse['person_view']['person']['id'] ?? null,
|
||||
'platform_instance_id' => $platformInstance->id,
|
||||
'api_token' => $authResponse['jwt'] ?? null, // Store JWT in settings for now
|
||||
],
|
||||
'is_active' => true,
|
||||
'status' => 'active',
|
||||
|
|
@ -144,10 +162,14 @@ public function createPlatform(Request $request): JsonResponse
|
|||
);
|
||||
|
||||
} catch (\App\Exceptions\PlatformAuthException $e) {
|
||||
// Handle authentication-specific errors with cleaner messages
|
||||
// Check if it's a rate limit error
|
||||
if (str_contains($e->getMessage(), 'Rate limited by')) {
|
||||
return $this->sendError($e->getMessage(), [], 429);
|
||||
}
|
||||
|
||||
return $this->sendError('Invalid username or password. Please check your credentials and try again.', [], 422);
|
||||
} catch (\Exception $e) {
|
||||
// Handle other errors (network, instance not found, etc.)
|
||||
|
||||
$message = 'Unable to connect to the Lemmy instance. Please check the URL and try again.';
|
||||
|
||||
// If it's a network/connection issue, provide a more specific message
|
||||
|
|
@ -229,6 +251,38 @@ public function createChannel(Request $request): JsonResponse
|
|||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* Create route for onboarding
|
||||
*/
|
||||
public function createRoute(Request $request): JsonResponse
|
||||
{
|
||||
$validator = Validator::make($request->all(), [
|
||||
'feed_id' => 'required|exists:feeds,id',
|
||||
'platform_channel_id' => 'required|exists:platform_channels,id',
|
||||
'priority' => 'nullable|integer|min:1|max:100',
|
||||
'filters' => 'nullable|array',
|
||||
]);
|
||||
|
||||
if ($validator->fails()) {
|
||||
throw new ValidationException($validator);
|
||||
}
|
||||
|
||||
$validated = $validator->validated();
|
||||
|
||||
$route = Route::create([
|
||||
'feed_id' => $validated['feed_id'],
|
||||
'platform_channel_id' => $validated['platform_channel_id'],
|
||||
'priority' => $validated['priority'] ?? 50,
|
||||
'filters' => $validated['filters'] ?? [],
|
||||
'is_active' => true,
|
||||
]);
|
||||
|
||||
return $this->sendResponse(
|
||||
new RouteResource($route->load(['feed', 'platformChannel'])),
|
||||
'Route created successfully.'
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* Mark onboarding as complete
|
||||
*/
|
||||
|
|
|
|||
|
|
@ -7,28 +7,37 @@
|
|||
|
||||
class LemmyRequest
|
||||
{
|
||||
private string $instance;
|
||||
private string $host;
|
||||
private string $scheme;
|
||||
private ?string $token;
|
||||
|
||||
public function __construct(string $instance, ?string $token = null)
|
||||
{
|
||||
// Handle both full URLs and just domain names
|
||||
$this->instance = $this->normalizeInstance($instance);
|
||||
[$this->scheme, $this->host] = $this->parseInstance($instance);
|
||||
$this->token = $token;
|
||||
}
|
||||
|
||||
/**
|
||||
* Normalize instance URL to just the domain name
|
||||
* Parse instance into scheme and host. Defaults to https when scheme missing.
|
||||
*
|
||||
* @return array{0:string,1:string} [scheme, host]
|
||||
*/
|
||||
private function normalizeInstance(string $instance): string
|
||||
private function parseInstance(string $instance): array
|
||||
{
|
||||
$scheme = 'https';
|
||||
|
||||
// If instance includes a scheme, honor it
|
||||
if (preg_match('/^(https?):\/\//i', $instance, $m)) {
|
||||
$scheme = strtolower($m[1]);
|
||||
}
|
||||
|
||||
// Remove protocol if present
|
||||
$instance = preg_replace('/^https?:\/\//', '', $instance);
|
||||
|
||||
$host = preg_replace('/^https?:\/\//i', '', $instance);
|
||||
// Remove trailing slash if present
|
||||
$instance = rtrim($instance, '/');
|
||||
$host = rtrim($host ?? '', '/');
|
||||
|
||||
return $instance;
|
||||
return [$scheme, $host];
|
||||
}
|
||||
|
||||
/**
|
||||
|
|
@ -36,7 +45,7 @@ private function normalizeInstance(string $instance): string
|
|||
*/
|
||||
public function get(string $endpoint, array $params = []): Response
|
||||
{
|
||||
$url = "https://{$this->instance}/api/v3/{$endpoint}";
|
||||
$url = sprintf('%s://%s/api/v3/%s', $this->scheme, $this->host, ltrim($endpoint, '/'));
|
||||
|
||||
$request = Http::timeout(30);
|
||||
|
||||
|
|
@ -52,7 +61,7 @@ public function get(string $endpoint, array $params = []): Response
|
|||
*/
|
||||
public function post(string $endpoint, array $data = []): Response
|
||||
{
|
||||
$url = "https://{$this->instance}/api/v3/{$endpoint}";
|
||||
$url = sprintf('%s://%s/api/v3/%s', $this->scheme, $this->host, ltrim($endpoint, '/'));
|
||||
|
||||
$request = Http::timeout(30);
|
||||
|
||||
|
|
@ -68,4 +77,14 @@ public function withToken(string $token): self
|
|||
$this->token = $token;
|
||||
return $this;
|
||||
}
|
||||
|
||||
/**
|
||||
* Return a cloned request with a different scheme (http or https)
|
||||
*/
|
||||
public function withScheme(string $scheme): self
|
||||
{
|
||||
$clone = clone $this;
|
||||
$clone->scheme = strtolower($scheme) === 'http' ? 'http' : 'https';
|
||||
return $clone;
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -18,27 +18,61 @@ public function __construct(string $instance)
|
|||
|
||||
public function login(string $username, string $password): ?string
|
||||
{
|
||||
try {
|
||||
$request = new LemmyRequest($this->instance);
|
||||
$response = $request->post('user/login', [
|
||||
'username_or_email' => $username,
|
||||
'password' => $password,
|
||||
]);
|
||||
// Try HTTPS first; on failure, optionally retry with HTTP to support dev instances
|
||||
$schemesToTry = [];
|
||||
if (preg_match('/^https?:\/\//i', $this->instance)) {
|
||||
// Preserve user-provided scheme as first try
|
||||
$schemesToTry[] = strtolower(str_starts_with($this->instance, 'http://') ? 'http' : 'https');
|
||||
} else {
|
||||
// Default order: https then http
|
||||
$schemesToTry = ['https', 'http'];
|
||||
}
|
||||
|
||||
if (!$response->successful()) {
|
||||
logger()->error('Lemmy login failed', [
|
||||
'status' => $response->status(),
|
||||
'body' => $response->body()
|
||||
foreach ($schemesToTry as $idx => $scheme) {
|
||||
try {
|
||||
$request = new LemmyRequest($this->instance);
|
||||
// ensure scheme used matches current attempt
|
||||
$request = $request->withScheme($scheme);
|
||||
|
||||
$response = $request->post('user/login', [
|
||||
'username_or_email' => $username,
|
||||
'password' => $password,
|
||||
]);
|
||||
|
||||
if (!$response->successful()) {
|
||||
$responseBody = $response->body();
|
||||
logger()->error('Lemmy login failed', [
|
||||
'status' => $response->status(),
|
||||
'body' => $responseBody,
|
||||
'scheme' => $scheme,
|
||||
]);
|
||||
|
||||
// Check if it's a rate limit error
|
||||
if (str_contains($responseBody, 'rate_limit_error')) {
|
||||
throw new Exception('Rate limited by Lemmy instance. Please wait a moment and try again.');
|
||||
}
|
||||
|
||||
// If first attempt failed and there is another scheme to try, continue loop
|
||||
if ($idx === 0 && count($schemesToTry) > 1) {
|
||||
continue;
|
||||
}
|
||||
|
||||
return null;
|
||||
}
|
||||
|
||||
$data = $response->json();
|
||||
return $data['jwt'] ?? null;
|
||||
} catch (Exception $e) {
|
||||
logger()->error('Lemmy login exception', ['error' => $e->getMessage(), 'scheme' => $scheme]);
|
||||
// If this was the first attempt and HTTPS, try HTTP next
|
||||
if ($idx === 0 && in_array('http', $schemesToTry, true)) {
|
||||
continue;
|
||||
}
|
||||
return null;
|
||||
}
|
||||
|
||||
$data = $response->json();
|
||||
return $data['jwt'] ?? null;
|
||||
} catch (Exception $e) {
|
||||
logger()->error('Lemmy login exception', ['error' => $e->getMessage()]);
|
||||
return null;
|
||||
}
|
||||
|
||||
return null;
|
||||
}
|
||||
|
||||
public function getCommunityId(string $communityName, string $token): int
|
||||
|
|
|
|||
|
|
@ -72,6 +72,10 @@ public function authenticate(string $instanceUrl, string $username, string $pass
|
|||
// Re-throw PlatformAuthExceptions as-is to avoid nesting
|
||||
throw $e;
|
||||
} catch (Exception $e) {
|
||||
// Check if it's a rate limit error
|
||||
if (str_contains($e->getMessage(), 'Rate limited by')) {
|
||||
throw new PlatformAuthException(PlatformEnum::LEMMY, $e->getMessage());
|
||||
}
|
||||
// For other exceptions, throw a clean PlatformAuthException
|
||||
throw new PlatformAuthException(PlatformEnum::LEMMY, 'Connection failed');
|
||||
}
|
||||
|
|
|
|||
|
|
@ -85,10 +85,17 @@ public function getSystemStats(): array
|
|||
')
|
||||
->first();
|
||||
|
||||
$accountStats = DB::table('platform_accounts')
|
||||
->selectRaw('
|
||||
COUNT(*) as total_platform_accounts,
|
||||
SUM(CASE WHEN is_active = 1 THEN 1 ELSE 0 END) as active_platform_accounts
|
||||
')
|
||||
->first();
|
||||
|
||||
$channelStats = DB::table('platform_channels')
|
||||
->selectRaw('
|
||||
COUNT(*) as total_channels,
|
||||
SUM(CASE WHEN is_active = 1 THEN 1 ELSE 0 END) as active_channels
|
||||
COUNT(*) as total_platform_channels,
|
||||
SUM(CASE WHEN is_active = 1 THEN 1 ELSE 0 END) as active_platform_channels
|
||||
')
|
||||
->first();
|
||||
|
||||
|
|
@ -102,8 +109,10 @@ public function getSystemStats(): array
|
|||
return [
|
||||
'total_feeds' => $feedStats->total_feeds,
|
||||
'active_feeds' => $feedStats->active_feeds,
|
||||
'total_channels' => $channelStats->total_channels,
|
||||
'active_channels' => $channelStats->active_channels,
|
||||
'total_platform_accounts' => $accountStats->total_platform_accounts,
|
||||
'active_platform_accounts' => $accountStats->active_platform_accounts,
|
||||
'total_platform_channels' => $channelStats->total_platform_channels,
|
||||
'active_platform_channels' => $channelStats->active_platform_channels,
|
||||
'total_routes' => $routeStats->total_routes,
|
||||
'active_routes' => $routeStats->active_routes,
|
||||
];
|
||||
|
|
|
|||
|
|
@ -8,6 +8,9 @@ class DatabaseSeeder extends Seeder
|
|||
{
|
||||
public function run(): void
|
||||
{
|
||||
$this->call(SettingsSeeder::class);
|
||||
$this->call([
|
||||
SettingsSeeder::class,
|
||||
LanguageSeeder::class,
|
||||
]);
|
||||
}
|
||||
}
|
||||
|
|
|
|||
29
backend/database/seeders/LanguageSeeder.php
Normal file
29
backend/database/seeders/LanguageSeeder.php
Normal file
|
|
@ -0,0 +1,29 @@
|
|||
<?php
|
||||
|
||||
namespace Database\Seeders;
|
||||
|
||||
use App\Models\Language;
|
||||
use Illuminate\Database\Seeder;
|
||||
|
||||
class LanguageSeeder extends Seeder
|
||||
{
|
||||
public function run(): void
|
||||
{
|
||||
$languages = [
|
||||
// id is auto-increment; we set codes and names
|
||||
['short_code' => 'en', 'name' => 'English', 'native_name' => 'English', 'is_active' => true],
|
||||
['short_code' => 'nl', 'name' => 'Dutch', 'native_name' => 'Nederlands', 'is_active' => true],
|
||||
['short_code' => 'fr', 'name' => 'French', 'native_name' => 'Français', 'is_active' => true],
|
||||
['short_code' => 'de', 'name' => 'German', 'native_name' => 'Deutsch', 'is_active' => true],
|
||||
['short_code' => 'es', 'name' => 'Spanish', 'native_name' => 'Español', 'is_active' => true],
|
||||
['short_code' => 'it', 'name' => 'Italian', 'native_name' => 'Italiano', 'is_active' => true],
|
||||
];
|
||||
|
||||
foreach ($languages as $lang) {
|
||||
Language::updateOrCreate(
|
||||
['short_code' => $lang['short_code']],
|
||||
$lang
|
||||
);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
@ -40,6 +40,7 @@
|
|||
Route::post('/onboarding/platform', [OnboardingController::class, 'createPlatform'])->name('api.onboarding.platform');
|
||||
Route::post('/onboarding/feed', [OnboardingController::class, 'createFeed'])->name('api.onboarding.feed');
|
||||
Route::post('/onboarding/channel', [OnboardingController::class, 'createChannel'])->name('api.onboarding.channel');
|
||||
Route::post('/onboarding/route', [OnboardingController::class, 'createRoute'])->name('api.onboarding.route');
|
||||
Route::post('/onboarding/complete', [OnboardingController::class, 'complete'])->name('api.onboarding.complete');
|
||||
Route::post('/onboarding/skip', [OnboardingController::class, 'skip'])->name('api.onboarding.skip');
|
||||
Route::post('/onboarding/reset-skip', [OnboardingController::class, 'resetSkip'])->name('api.onboarding.reset-skip');
|
||||
|
|
|
|||
|
|
@ -7,6 +7,7 @@
|
|||
use App\Models\PlatformAccount;
|
||||
use App\Models\PlatformChannel;
|
||||
use App\Models\PlatformInstance;
|
||||
use App\Models\Route;
|
||||
use App\Models\Setting;
|
||||
use App\Services\Auth\LemmyAuthService;
|
||||
use Illuminate\Foundation\Testing\RefreshDatabase;
|
||||
|
|
@ -43,6 +44,7 @@ public function test_status_shows_needs_onboarding_when_no_components_exist()
|
|||
'has_platform_account' => false,
|
||||
'has_feed' => false,
|
||||
'has_channel' => false,
|
||||
'has_route' => false,
|
||||
'onboarding_skipped' => false,
|
||||
],
|
||||
]);
|
||||
|
|
@ -63,6 +65,7 @@ public function test_status_shows_feed_step_when_platform_account_exists()
|
|||
'has_platform_account' => true,
|
||||
'has_feed' => false,
|
||||
'has_channel' => false,
|
||||
'has_route' => false,
|
||||
],
|
||||
]);
|
||||
}
|
||||
|
|
@ -83,6 +86,29 @@ public function test_status_shows_channel_step_when_platform_account_and_feed_ex
|
|||
'has_platform_account' => true,
|
||||
'has_feed' => true,
|
||||
'has_channel' => false,
|
||||
'has_route' => false,
|
||||
],
|
||||
]);
|
||||
}
|
||||
|
||||
public function test_status_shows_route_step_when_platform_account_feed_and_channel_exist()
|
||||
{
|
||||
PlatformAccount::factory()->create(['is_active' => true]);
|
||||
Feed::factory()->create(['is_active' => true]);
|
||||
PlatformChannel::factory()->create(['is_active' => true]);
|
||||
|
||||
$response = $this->getJson('/api/v1/onboarding/status');
|
||||
|
||||
$response->assertStatus(200)
|
||||
->assertJson([
|
||||
'success' => true,
|
||||
'data' => [
|
||||
'needs_onboarding' => true,
|
||||
'current_step' => 'route',
|
||||
'has_platform_account' => true,
|
||||
'has_feed' => true,
|
||||
'has_channel' => true,
|
||||
'has_route' => false,
|
||||
],
|
||||
]);
|
||||
}
|
||||
|
|
@ -92,6 +118,7 @@ public function test_status_shows_no_onboarding_needed_when_all_components_exist
|
|||
PlatformAccount::factory()->create(['is_active' => true]);
|
||||
Feed::factory()->create(['is_active' => true]);
|
||||
PlatformChannel::factory()->create(['is_active' => true]);
|
||||
Route::factory()->create(['is_active' => true]);
|
||||
|
||||
$response = $this->getJson('/api/v1/onboarding/status');
|
||||
|
||||
|
|
@ -104,6 +131,7 @@ public function test_status_shows_no_onboarding_needed_when_all_components_exist
|
|||
'has_platform_account' => true,
|
||||
'has_feed' => true,
|
||||
'has_channel' => true,
|
||||
'has_route' => true,
|
||||
],
|
||||
]);
|
||||
}
|
||||
|
|
@ -127,6 +155,7 @@ public function test_status_shows_no_onboarding_needed_when_skipped()
|
|||
'has_platform_account' => false,
|
||||
'has_feed' => false,
|
||||
'has_channel' => false,
|
||||
'has_route' => false,
|
||||
'onboarding_skipped' => true,
|
||||
],
|
||||
]);
|
||||
|
|
@ -238,6 +267,47 @@ public function test_create_channel_creates_channel_successfully()
|
|||
]);
|
||||
}
|
||||
|
||||
public function test_create_route_validates_required_fields()
|
||||
{
|
||||
$response = $this->postJson('/api/v1/onboarding/route', []);
|
||||
|
||||
$response->assertStatus(422)
|
||||
->assertJsonValidationErrors(['feed_id', 'platform_channel_id']);
|
||||
}
|
||||
|
||||
public function test_create_route_creates_route_successfully()
|
||||
{
|
||||
$feed = Feed::factory()->create();
|
||||
$platformChannel = PlatformChannel::factory()->create();
|
||||
|
||||
$routeData = [
|
||||
'feed_id' => $feed->id,
|
||||
'platform_channel_id' => $platformChannel->id,
|
||||
'priority' => 75,
|
||||
'filters' => ['keyword' => 'test'],
|
||||
];
|
||||
|
||||
$response = $this->postJson('/api/v1/onboarding/route', $routeData);
|
||||
|
||||
$response->assertStatus(200)
|
||||
->assertJson([
|
||||
'success' => true,
|
||||
'data' => [
|
||||
'feed_id' => $feed->id,
|
||||
'platform_channel_id' => $platformChannel->id,
|
||||
'priority' => 75,
|
||||
'is_active' => true,
|
||||
]
|
||||
]);
|
||||
|
||||
$this->assertDatabaseHas('routes', [
|
||||
'feed_id' => $feed->id,
|
||||
'platform_channel_id' => $platformChannel->id,
|
||||
'priority' => 75,
|
||||
'is_active' => true,
|
||||
]);
|
||||
}
|
||||
|
||||
public function test_complete_onboarding_returns_success()
|
||||
{
|
||||
$response = $this->postJson('/api/v1/onboarding/complete');
|
||||
|
|
|
|||
|
|
@ -11,9 +11,27 @@ echo "Installing PHP dependencies..."
|
|||
cd /var/www/html/backend
|
||||
composer install --no-interaction
|
||||
|
||||
# Generate application key
|
||||
echo "Generating application key..."
|
||||
php artisan key:generate --force
|
||||
# Ensure APP_KEY is set in backend/.env
|
||||
ENV_APP_KEY="${APP_KEY}"
|
||||
if [ -n "$ENV_APP_KEY" ]; then
|
||||
echo "Using APP_KEY from environment"
|
||||
sed -i "s|^APP_KEY=.*|APP_KEY=${ENV_APP_KEY}|" /var/www/html/backend/.env || true
|
||||
fi
|
||||
|
||||
# Generate application key if still missing
|
||||
CURRENT_APP_KEY=$(grep "^APP_KEY=" /var/www/html/backend/.env | cut -d'=' -f2)
|
||||
if [ -z "$CURRENT_APP_KEY" ]; then
|
||||
echo "Generating application key..."
|
||||
php artisan key:generate --force
|
||||
fi
|
||||
|
||||
# Verify APP_KEY
|
||||
APP_KEY=$(grep "^APP_KEY=" /var/www/html/backend/.env | cut -d'=' -f2)
|
||||
if [ -n "$APP_KEY" ]; then
|
||||
echo "✅ APP_KEY successfully set."
|
||||
else
|
||||
echo "❌ ERROR: APP_KEY not set!"
|
||||
fi
|
||||
|
||||
# Wait for database to be ready
|
||||
echo "Waiting for database..."
|
||||
|
|
@ -45,6 +63,9 @@ npm run dev -- --host 0.0.0.0 --port 5173 &
|
|||
cd /var/www/html/backend
|
||||
php artisan serve --host=127.0.0.1 --port=8000 &
|
||||
|
||||
# Start Horizon (manages queue workers in dev)
|
||||
php artisan horizon &
|
||||
|
||||
# Start nginx
|
||||
nginx -g "daemon off;" &
|
||||
|
||||
|
|
|
|||
|
|
@ -28,17 +28,19 @@ export const OnboardingProvider: React.FC<OnboardingProviderProps> = ({ children
|
|||
const needsOnboarding = onboardingStatus?.needs_onboarding ?? false;
|
||||
const isOnOnboardingPage = location.pathname.startsWith('/onboarding');
|
||||
|
||||
// Redirect logic - only redirect if user explicitly navigates to a protected route
|
||||
// Redirect logic
|
||||
React.useEffect(() => {
|
||||
if (isLoading) return;
|
||||
|
||||
// Only redirect if user doesn't need onboarding but is on onboarding pages
|
||||
// If user doesn't need onboarding but is on onboarding pages, redirect to dashboard
|
||||
if (!needsOnboarding && isOnOnboardingPage) {
|
||||
navigate('/dashboard', { replace: true });
|
||||
}
|
||||
|
||||
// Don't auto-redirect to onboarding - let user navigate manually or via links
|
||||
// This prevents the app from being "stuck" in onboarding mode
|
||||
// If user needs onboarding but is not on onboarding pages, redirect to onboarding
|
||||
if (needsOnboarding && !isOnOnboardingPage) {
|
||||
navigate('/onboarding', { replace: true });
|
||||
}
|
||||
}, [onboardingStatus, isLoading, needsOnboarding, isOnOnboardingPage, navigate]);
|
||||
|
||||
const value: OnboardingContextValue = {
|
||||
|
|
|
|||
|
|
@ -147,16 +147,19 @@ export interface PlatformInstance {
|
|||
|
||||
export interface OnboardingStatus {
|
||||
needs_onboarding: boolean;
|
||||
current_step: 'platform' | 'feed' | 'channel' | 'complete' | null;
|
||||
current_step: 'platform' | 'feed' | 'channel' | 'route' | 'complete' | null;
|
||||
has_platform_account: boolean;
|
||||
has_feed: boolean;
|
||||
has_channel: boolean;
|
||||
has_route: boolean;
|
||||
onboarding_skipped: boolean;
|
||||
}
|
||||
|
||||
export interface OnboardingOptions {
|
||||
languages: Language[];
|
||||
platform_instances: PlatformInstance[];
|
||||
feeds: Feed[];
|
||||
platform_channels: PlatformChannel[];
|
||||
}
|
||||
|
||||
export interface PlatformAccountRequest {
|
||||
|
|
@ -181,6 +184,25 @@ export interface ChannelRequest {
|
|||
description?: string;
|
||||
}
|
||||
|
||||
export interface Route {
|
||||
feed_id: number;
|
||||
platform_channel_id: number;
|
||||
is_active: boolean;
|
||||
priority: number;
|
||||
filters: Record<string, any>;
|
||||
created_at: string;
|
||||
updated_at: string;
|
||||
feed?: Feed;
|
||||
platform_channel?: PlatformChannel;
|
||||
}
|
||||
|
||||
export interface RouteRequest {
|
||||
feed_id: number;
|
||||
platform_channel_id: number;
|
||||
priority?: number;
|
||||
filters?: Record<string, any>;
|
||||
}
|
||||
|
||||
// API Client class
|
||||
class ApiClient {
|
||||
constructor() {
|
||||
|
|
@ -226,8 +248,8 @@ class ApiClient {
|
|||
|
||||
// Feeds endpoints
|
||||
async getFeeds(): Promise<Feed[]> {
|
||||
const response = await axios.get<ApiResponse<Feed[]>>('/feeds');
|
||||
return response.data.data;
|
||||
const response = await axios.get<ApiResponse<{feeds: Feed[], pagination: any}>>('/feeds');
|
||||
return response.data.data.feeds;
|
||||
}
|
||||
|
||||
async createFeed(data: Partial<Feed>): Promise<Feed> {
|
||||
|
|
@ -286,6 +308,11 @@ class ApiClient {
|
|||
return response.data.data;
|
||||
}
|
||||
|
||||
async createRouteForOnboarding(data: RouteRequest): Promise<Route> {
|
||||
const response = await axios.post<ApiResponse<Route>>('/onboarding/route', data);
|
||||
return response.data.data;
|
||||
}
|
||||
|
||||
async completeOnboarding(): Promise<void> {
|
||||
await axios.post('/onboarding/complete');
|
||||
}
|
||||
|
|
|
|||
|
|
@ -4,6 +4,7 @@ import { BrowserRouter } from 'react-router-dom';
|
|||
import { QueryClient, QueryClientProvider } from '@tanstack/react-query';
|
||||
import './index.css';
|
||||
import App from './App';
|
||||
import { OnboardingProvider } from './contexts/OnboardingContext';
|
||||
|
||||
// Create React Query client
|
||||
const queryClient = new QueryClient({
|
||||
|
|
@ -19,7 +20,9 @@ createRoot(document.getElementById('root')!).render(
|
|||
<React.StrictMode>
|
||||
<QueryClientProvider client={queryClient}>
|
||||
<BrowserRouter>
|
||||
<App />
|
||||
<OnboardingProvider>
|
||||
<App />
|
||||
</OnboardingProvider>
|
||||
</BrowserRouter>
|
||||
</QueryClientProvider>
|
||||
</React.StrictMode>,
|
||||
|
|
|
|||
|
|
@ -49,70 +49,8 @@ const Dashboard: React.FC = () => {
|
|||
</p>
|
||||
</div>
|
||||
|
||||
{/* Article Statistics */}
|
||||
<div className="mb-8">
|
||||
<h2 className="text-lg font-semibold text-gray-900 mb-4">Article Statistics</h2>
|
||||
<div className="grid grid-cols-1 md:grid-cols-2 lg:grid-cols-4 gap-6">
|
||||
<div className="bg-white p-6 rounded-lg shadow">
|
||||
<div className="flex items-center">
|
||||
<div className="flex-shrink-0">
|
||||
<FileText className="h-8 w-8 text-blue-500" />
|
||||
</div>
|
||||
<div className="ml-4">
|
||||
<p className="text-sm font-medium text-gray-500">Articles Today</p>
|
||||
<p className="text-2xl font-semibold text-gray-900">
|
||||
{articleStats?.total_today || 0}
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="bg-white p-6 rounded-lg shadow">
|
||||
<div className="flex items-center">
|
||||
<div className="flex-shrink-0">
|
||||
<Clock className="h-8 w-8 text-yellow-500" />
|
||||
</div>
|
||||
<div className="ml-4">
|
||||
<p className="text-sm font-medium text-gray-500">Articles This Week</p>
|
||||
<p className="text-2xl font-semibold text-gray-900">
|
||||
{articleStats?.total_week || 0}
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="bg-white p-6 rounded-lg shadow">
|
||||
<div className="flex items-center">
|
||||
<div className="flex-shrink-0">
|
||||
<CheckCircle className="h-8 w-8 text-green-500" />
|
||||
</div>
|
||||
<div className="ml-4">
|
||||
<p className="text-sm font-medium text-gray-500">Approved Today</p>
|
||||
<p className="text-2xl font-semibold text-gray-900">
|
||||
{articleStats?.approved_today || 0}
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="bg-white p-6 rounded-lg shadow">
|
||||
<div className="flex items-center">
|
||||
<div className="flex-shrink-0">
|
||||
<TrendingUp className="h-8 w-8 text-purple-500" />
|
||||
</div>
|
||||
<div className="ml-4">
|
||||
<p className="text-sm font-medium text-gray-500">Approval Rate</p>
|
||||
<p className="text-2xl font-semibold text-gray-900">
|
||||
{articleStats?.approval_percentage_today?.toFixed(1) || 0}%
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* System Statistics */}
|
||||
<div>
|
||||
<div className="mb-8">
|
||||
<h2 className="text-lg font-semibold text-gray-900 mb-4">System Overview</h2>
|
||||
<div className="grid grid-cols-1 md:grid-cols-2 lg:grid-cols-4 gap-6">
|
||||
<div className="bg-white p-6 rounded-lg shadow">
|
||||
|
|
@ -184,6 +122,68 @@ const Dashboard: React.FC = () => {
|
|||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Article Statistics */}
|
||||
<div>
|
||||
<h2 className="text-lg font-semibold text-gray-900 mb-4">Article Statistics</h2>
|
||||
<div className="grid grid-cols-1 md:grid-cols-2 lg:grid-cols-4 gap-6">
|
||||
<div className="bg-white p-6 rounded-lg shadow">
|
||||
<div className="flex items-center">
|
||||
<div className="flex-shrink-0">
|
||||
<FileText className="h-8 w-8 text-blue-500" />
|
||||
</div>
|
||||
<div className="ml-4">
|
||||
<p className="text-sm font-medium text-gray-500">Articles Today</p>
|
||||
<p className="text-2xl font-semibold text-gray-900">
|
||||
{articleStats?.total_today || 0}
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="bg-white p-6 rounded-lg shadow">
|
||||
<div className="flex items-center">
|
||||
<div className="flex-shrink-0">
|
||||
<Clock className="h-8 w-8 text-yellow-500" />
|
||||
</div>
|
||||
<div className="ml-4">
|
||||
<p className="text-sm font-medium text-gray-500">Articles This Week</p>
|
||||
<p className="text-2xl font-semibold text-gray-900">
|
||||
{articleStats?.total_week || 0}
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="bg-white p-6 rounded-lg shadow">
|
||||
<div className="flex items-center">
|
||||
<div className="flex-shrink-0">
|
||||
<CheckCircle className="h-8 w-8 text-green-500" />
|
||||
</div>
|
||||
<div className="ml-4">
|
||||
<p className="text-sm font-medium text-gray-500">Approved Today</p>
|
||||
<p className="text-2xl font-semibold text-gray-900">
|
||||
{articleStats?.approved_today || 0}
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="bg-white p-6 rounded-lg shadow">
|
||||
<div className="flex items-center">
|
||||
<div className="flex-shrink-0">
|
||||
<TrendingUp className="h-8 w-8 text-purple-500" />
|
||||
</div>
|
||||
<div className="ml-4">
|
||||
<p className="text-sm font-medium text-gray-500">Approval Rate</p>
|
||||
<p className="text-2xl font-semibold text-gray-900">
|
||||
{articleStats?.approval_percentage_today?.toFixed(1) || 0}%
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
|
|
|||
|
|
@ -5,6 +5,7 @@ import WelcomeStep from './steps/WelcomeStep';
|
|||
import PlatformStep from './steps/PlatformStep';
|
||||
import FeedStep from './steps/FeedStep';
|
||||
import ChannelStep from './steps/ChannelStep';
|
||||
import RouteStep from './steps/RouteStep';
|
||||
import CompleteStep from './steps/CompleteStep';
|
||||
|
||||
const OnboardingWizard: React.FC = () => {
|
||||
|
|
@ -15,6 +16,7 @@ const OnboardingWizard: React.FC = () => {
|
|||
<Route path="platform" element={<PlatformStep />} />
|
||||
<Route path="feed" element={<FeedStep />} />
|
||||
<Route path="channel" element={<ChannelStep />} />
|
||||
<Route path="route" element={<RouteStep />} />
|
||||
<Route path="complete" element={<CompleteStep />} />
|
||||
<Route path="*" element={<Navigate to="/onboarding" replace />} />
|
||||
</Routes>
|
||||
|
|
|
|||
|
|
@ -1,10 +1,11 @@
|
|||
import React, { useState } from 'react';
|
||||
import { Link, useNavigate } from 'react-router-dom';
|
||||
import { useMutation, useQuery } from '@tanstack/react-query';
|
||||
import { useMutation, useQuery, useQueryClient } from '@tanstack/react-query';
|
||||
import { apiClient, type ChannelRequest, type Language, type PlatformInstance } from '../../../lib/api';
|
||||
|
||||
const ChannelStep: React.FC = () => {
|
||||
const navigate = useNavigate();
|
||||
const queryClient = useQueryClient();
|
||||
const [formData, setFormData] = useState<ChannelRequest>({
|
||||
name: '',
|
||||
platform_instance_id: 0,
|
||||
|
|
@ -22,7 +23,9 @@ const ChannelStep: React.FC = () => {
|
|||
const createChannelMutation = useMutation({
|
||||
mutationFn: (data: ChannelRequest) => apiClient.createChannelForOnboarding(data),
|
||||
onSuccess: () => {
|
||||
navigate('/onboarding/complete');
|
||||
// Invalidate onboarding status cache
|
||||
queryClient.invalidateQueries({ queryKey: ['onboarding-status'] });
|
||||
navigate('/onboarding/route');
|
||||
},
|
||||
onError: (error: any) => {
|
||||
if (error.response?.data?.errors) {
|
||||
|
|
|
|||
|
|
@ -1,19 +1,25 @@
|
|||
import React from 'react';
|
||||
import { Link, useNavigate } from 'react-router-dom';
|
||||
import { useMutation } from '@tanstack/react-query';
|
||||
import { useMutation, useQueryClient } from '@tanstack/react-query';
|
||||
import { apiClient } from '../../../lib/api';
|
||||
|
||||
const CompleteStep: React.FC = () => {
|
||||
const navigate = useNavigate();
|
||||
const queryClient = useQueryClient();
|
||||
|
||||
const completeOnboardingMutation = useMutation({
|
||||
mutationFn: () => apiClient.completeOnboarding(),
|
||||
onSuccess: () => {
|
||||
// Invalidate onboarding status cache to ensure proper redirect logic
|
||||
queryClient.invalidateQueries({ queryKey: ['onboarding-status'] });
|
||||
queryClient.invalidateQueries({ queryKey: ['dashboard-stats'] });
|
||||
navigate('/dashboard');
|
||||
},
|
||||
onError: (error) => {
|
||||
console.error('Failed to complete onboarding:', error);
|
||||
// Still navigate to dashboard even if completion fails
|
||||
// Still invalidate cache and navigate to dashboard even if completion fails
|
||||
queryClient.invalidateQueries({ queryKey: ['onboarding-status'] });
|
||||
queryClient.invalidateQueries({ queryKey: ['dashboard-stats'] });
|
||||
navigate('/dashboard');
|
||||
}
|
||||
});
|
||||
|
|
|
|||
|
|
@ -136,8 +136,8 @@ const FeedStep: React.FC = () => {
|
|||
</label>
|
||||
<select
|
||||
id="language_id"
|
||||
value={formData.language_id}
|
||||
onChange={(e) => handleChange('language_id', parseInt(e.target.value))}
|
||||
value={formData.language_id || ''}
|
||||
onChange={(e) => handleChange('language_id', e.target.value ? parseInt(e.target.value) : 0)}
|
||||
className="w-full px-3 py-2 border border-gray-300 rounded-md focus:outline-none focus:ring-2 focus:ring-blue-500 focus:border-transparent"
|
||||
required
|
||||
>
|
||||
|
|
|
|||
174
frontend/src/pages/onboarding/steps/RouteStep.tsx
Normal file
174
frontend/src/pages/onboarding/steps/RouteStep.tsx
Normal file
|
|
@ -0,0 +1,174 @@
|
|||
import React, { useState } from 'react';
|
||||
import { Link, useNavigate } from 'react-router-dom';
|
||||
import { useMutation, useQuery, useQueryClient } from '@tanstack/react-query';
|
||||
import { apiClient, type RouteRequest, type Feed, type PlatformChannel } from '../../../lib/api';
|
||||
|
||||
const RouteStep: React.FC = () => {
|
||||
const navigate = useNavigate();
|
||||
const queryClient = useQueryClient();
|
||||
const [formData, setFormData] = useState<RouteRequest>({
|
||||
feed_id: 0,
|
||||
platform_channel_id: 0,
|
||||
priority: 50,
|
||||
filters: {}
|
||||
});
|
||||
const [errors, setErrors] = useState<Record<string, string[]>>({});
|
||||
|
||||
// Get onboarding options (feeds and channels)
|
||||
const { data: options, isLoading: optionsLoading } = useQuery({
|
||||
queryKey: ['onboarding-options'],
|
||||
queryFn: () => apiClient.getOnboardingOptions()
|
||||
});
|
||||
|
||||
const createRouteMutation = useMutation({
|
||||
mutationFn: (data: RouteRequest) => apiClient.createRouteForOnboarding(data),
|
||||
onSuccess: () => {
|
||||
// Invalidate onboarding status cache to refresh the status
|
||||
queryClient.invalidateQueries({ queryKey: ['onboarding-status'] });
|
||||
queryClient.invalidateQueries({ queryKey: ['dashboard-stats'] });
|
||||
navigate('/onboarding/complete');
|
||||
},
|
||||
onError: (error: any) => {
|
||||
if (error.response?.data?.errors) {
|
||||
setErrors(error.response.data.errors);
|
||||
} else {
|
||||
setErrors({ general: [error.response?.data?.message || 'An error occurred'] });
|
||||
}
|
||||
}
|
||||
});
|
||||
|
||||
const handleSubmit = (e: React.FormEvent) => {
|
||||
e.preventDefault();
|
||||
setErrors({});
|
||||
createRouteMutation.mutate(formData);
|
||||
};
|
||||
|
||||
const handleChange = (field: keyof RouteRequest, value: string | number | Record<string, any>) => {
|
||||
setFormData(prev => ({ ...prev, [field]: value }));
|
||||
// Clear field error when user starts typing
|
||||
if (errors[field]) {
|
||||
setErrors(prev => ({ ...prev, [field]: [] }));
|
||||
}
|
||||
};
|
||||
|
||||
if (optionsLoading) {
|
||||
return <div className="text-center">Loading...</div>;
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="text-center mb-8">
|
||||
<h1 className="text-2xl font-bold text-gray-900 mb-2">Create Your First Route</h1>
|
||||
<p className="text-gray-600">
|
||||
Connect your feed to a channel by creating a route. This tells FFR which articles to post where.
|
||||
</p>
|
||||
|
||||
{/* Progress indicator */}
|
||||
<div className="flex justify-center mt-6 space-x-2">
|
||||
<div className="w-6 h-6 bg-green-500 text-white rounded-full flex items-center justify-center text-xs font-semibold">✓</div>
|
||||
<div className="w-6 h-6 bg-green-500 text-white rounded-full flex items-center justify-center text-xs font-semibold">✓</div>
|
||||
<div className="w-6 h-6 bg-green-500 text-white rounded-full flex items-center justify-center text-xs font-semibold">✓</div>
|
||||
<div className="w-6 h-6 bg-blue-500 text-white rounded-full flex items-center justify-center text-xs font-semibold">4</div>
|
||||
<div className="w-6 h-6 bg-gray-300 text-gray-600 rounded-full flex items-center justify-center text-xs font-semibold">5</div>
|
||||
</div>
|
||||
|
||||
<form onSubmit={handleSubmit} className="space-y-6 mt-8 text-left">
|
||||
{errors.general && (
|
||||
<div className="p-3 bg-red-50 border border-red-200 rounded-md">
|
||||
<p className="text-red-600 text-sm">{errors.general[0]}</p>
|
||||
</div>
|
||||
)}
|
||||
|
||||
<div>
|
||||
<label htmlFor="feed_id" className="block text-sm font-medium text-gray-700 mb-2">
|
||||
Select Feed
|
||||
</label>
|
||||
<select
|
||||
id="feed_id"
|
||||
value={formData.feed_id || ''}
|
||||
onChange={(e) => handleChange('feed_id', e.target.value ? parseInt(e.target.value) : 0)}
|
||||
className="w-full px-3 py-2 border border-gray-300 rounded-md focus:outline-none focus:ring-2 focus:ring-blue-500 focus:border-transparent"
|
||||
required
|
||||
>
|
||||
<option value="">Select a feed</option>
|
||||
{options?.feeds?.map((feed: Feed) => (
|
||||
<option key={feed.id} value={feed.id}>
|
||||
{feed.name}
|
||||
</option>
|
||||
))}
|
||||
</select>
|
||||
{errors.feed_id && (
|
||||
<p className="text-red-600 text-sm mt-1">{errors.feed_id[0]}</p>
|
||||
)}
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<label htmlFor="platform_channel_id" className="block text-sm font-medium text-gray-700 mb-2">
|
||||
Select Channel
|
||||
</label>
|
||||
<select
|
||||
id="platform_channel_id"
|
||||
value={formData.platform_channel_id || ''}
|
||||
onChange={(e) => handleChange('platform_channel_id', e.target.value ? parseInt(e.target.value) : 0)}
|
||||
className="w-full px-3 py-2 border border-gray-300 rounded-md focus:outline-none focus:ring-2 focus:ring-blue-500 focus:border-transparent"
|
||||
required
|
||||
>
|
||||
<option value="">Select a channel</option>
|
||||
{options?.platform_channels?.map((channel: PlatformChannel) => (
|
||||
<option key={channel.id} value={channel.id}>
|
||||
{channel.display_name || channel.name}
|
||||
</option>
|
||||
))}
|
||||
</select>
|
||||
{(!options?.platform_channels || options.platform_channels.length === 0) && (
|
||||
<p className="text-sm text-gray-500 mt-1">
|
||||
No channels available. Please create a channel first.
|
||||
</p>
|
||||
)}
|
||||
{errors.platform_channel_id && (
|
||||
<p className="text-red-600 text-sm mt-1">{errors.platform_channel_id[0]}</p>
|
||||
)}
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<label htmlFor="priority" className="block text-sm font-medium text-gray-700 mb-2">
|
||||
Priority (1-100)
|
||||
</label>
|
||||
<input
|
||||
type="number"
|
||||
id="priority"
|
||||
min="1"
|
||||
max="100"
|
||||
value={formData.priority || 50}
|
||||
onChange={(e) => handleChange('priority', parseInt(e.target.value))}
|
||||
placeholder="50"
|
||||
className="w-full px-3 py-2 border border-gray-300 rounded-md focus:outline-none focus:ring-2 focus:ring-blue-500 focus:border-transparent"
|
||||
/>
|
||||
<p className="text-sm text-gray-500 mt-1">
|
||||
Higher priority routes are processed first (default: 50)
|
||||
</p>
|
||||
{errors.priority && (
|
||||
<p className="text-red-600 text-sm mt-1">{errors.priority[0]}</p>
|
||||
)}
|
||||
</div>
|
||||
|
||||
<div className="flex justify-between">
|
||||
<Link
|
||||
to="/onboarding/channel"
|
||||
className="px-4 py-2 text-gray-600 hover:text-gray-800 transition duration-200"
|
||||
>
|
||||
← Back
|
||||
</Link>
|
||||
<button
|
||||
type="submit"
|
||||
disabled={createRouteMutation.isPending || (!options?.platform_channels || options.platform_channels.length === 0)}
|
||||
className="bg-blue-600 text-white py-2 px-6 rounded-md hover:bg-blue-700 transition duration-200 disabled:opacity-50"
|
||||
>
|
||||
{createRouteMutation.isPending ? 'Creating...' : 'Continue'}
|
||||
</button>
|
||||
</div>
|
||||
</form>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
export default RouteStep;
|
||||
|
|
@ -41,6 +41,10 @@ const WelcomeStep: React.FC = () => {
|
|||
</div>
|
||||
<div className="flex items-center text-sm text-gray-600">
|
||||
<div className="w-6 h-6 bg-gray-300 text-gray-600 rounded-full flex items-center justify-center mr-3 text-xs font-semibold">4</div>
|
||||
<span>Create a route</span>
|
||||
</div>
|
||||
<div className="flex items-center text-sm text-gray-600">
|
||||
<div className="w-6 h-6 bg-gray-300 text-gray-600 rounded-full flex items-center justify-center mr-3 text-xs font-semibold">5</div>
|
||||
<span>You're ready to go!</span>
|
||||
</div>
|
||||
</div>
|
||||
|
|
|
|||
Loading…
Reference in a new issue