diff --git a/backend/app/Http/Controllers/Api/V1/OnboardingController.php b/backend/app/Http/Controllers/Api/V1/OnboardingController.php
new file mode 100644
index 0000000..a1c3b1b
--- /dev/null
+++ b/backend/app/Http/Controllers/Api/V1/OnboardingController.php
@@ -0,0 +1,222 @@
+exists();
+ $hasFeed = Feed::where('is_active', true)->exists();
+ $hasChannel = PlatformChannel::where('is_active', true)->exists();
+
+ $needsOnboarding = !$hasPlatformAccount || !$hasFeed || !$hasChannel;
+
+ // Determine current step
+ $currentStep = null;
+ if ($needsOnboarding) {
+ if (!$hasPlatformAccount) {
+ $currentStep = 'platform';
+ } elseif (!$hasFeed) {
+ $currentStep = 'feed';
+ } elseif (!$hasChannel) {
+ $currentStep = 'channel';
+ }
+ }
+
+ return $this->sendResponse([
+ 'needs_onboarding' => $needsOnboarding,
+ 'current_step' => $currentStep,
+ 'has_platform_account' => $hasPlatformAccount,
+ 'has_feed' => $hasFeed,
+ 'has_channel' => $hasChannel,
+ ], 'Onboarding status retrieved successfully.');
+ }
+
+ /**
+ * Get onboarding options (languages, platform instances)
+ */
+ public function options(): JsonResponse
+ {
+ $languages = Language::where('is_active', true)
+ ->orderBy('name')
+ ->get(['id', 'short_code', 'name', 'native_name', 'is_active']);
+
+ $platformInstances = PlatformInstance::where('is_active', true)
+ ->orderBy('name')
+ ->get(['id', 'platform', 'url', 'name', 'description', 'is_active']);
+
+ return $this->sendResponse([
+ 'languages' => $languages,
+ 'platform_instances' => $platformInstances,
+ ], 'Onboarding options retrieved successfully.');
+ }
+
+ /**
+ * Create platform account for onboarding
+ */
+ public function createPlatform(Request $request): JsonResponse
+ {
+ $validator = Validator::make($request->all(), [
+ 'instance_url' => 'required|url|max:255',
+ 'username' => 'required|string|max:255',
+ 'password' => 'required|string|min:6',
+ 'platform' => 'required|in:lemmy',
+ ]);
+
+ if ($validator->fails()) {
+ throw new ValidationException($validator);
+ }
+
+ $validated = $validator->validated();
+
+ try {
+ // Create or get platform instance
+ $platformInstance = PlatformInstance::firstOrCreate([
+ 'url' => $validated['instance_url'],
+ 'platform' => $validated['platform'],
+ ], [
+ 'name' => parse_url($validated['instance_url'], PHP_URL_HOST) ?? 'Lemmy Instance',
+ 'is_active' => true,
+ ]);
+
+ // Authenticate with Lemmy API
+ $authResponse = $this->lemmyAuthService->authenticate(
+ $validated['instance_url'],
+ $validated['username'],
+ $validated['password']
+ );
+
+ // Create platform account with the current schema
+ $platformAccount = PlatformAccount::create([
+ 'platform' => $validated['platform'],
+ 'instance_url' => $validated['instance_url'],
+ '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,
+ ],
+ 'is_active' => true,
+ 'status' => 'active',
+ ]);
+
+ return $this->sendResponse(
+ new PlatformAccountResource($platformAccount),
+ 'Platform account created successfully.'
+ );
+
+ } catch (\Exception $e) {
+ return $this->sendError('Failed to create platform account: ' . $e->getMessage(), [], 422);
+ }
+ }
+
+ /**
+ * Create feed for onboarding
+ */
+ public function createFeed(Request $request): JsonResponse
+ {
+ $validator = Validator::make($request->all(), [
+ 'name' => 'required|string|max:255',
+ 'url' => 'required|url|max:500',
+ 'type' => 'required|in:website,rss',
+ 'language_id' => 'required|exists:languages,id',
+ 'description' => 'nullable|string|max:1000',
+ ]);
+
+ if ($validator->fails()) {
+ throw new ValidationException($validator);
+ }
+
+ $validated = $validator->validated();
+
+ $feed = Feed::create([
+ 'name' => $validated['name'],
+ 'url' => $validated['url'],
+ 'type' => $validated['type'],
+ 'language_id' => $validated['language_id'],
+ 'description' => $validated['description'] ?? null,
+ 'is_active' => true,
+ ]);
+
+ return $this->sendResponse(
+ new FeedResource($feed->load('language')),
+ 'Feed created successfully.'
+ );
+ }
+
+ /**
+ * Create channel for onboarding
+ */
+ public function createChannel(Request $request): JsonResponse
+ {
+ $validator = Validator::make($request->all(), [
+ 'name' => 'required|string|max:255',
+ 'platform_instance_id' => 'required|exists:platform_instances,id',
+ 'language_id' => 'required|exists:languages,id',
+ 'description' => 'nullable|string|max:1000',
+ ]);
+
+ if ($validator->fails()) {
+ throw new ValidationException($validator);
+ }
+
+ $validated = $validator->validated();
+
+ $channel = PlatformChannel::create([
+ 'platform_instance_id' => $validated['platform_instance_id'],
+ 'channel_id' => $validated['name'], // For Lemmy, this is the community name
+ 'name' => $validated['name'],
+ 'display_name' => ucfirst($validated['name']),
+ 'description' => $validated['description'] ?? null,
+ 'language_id' => $validated['language_id'],
+ 'is_active' => true,
+ ]);
+
+ return $this->sendResponse(
+ new PlatformChannelResource($channel->load(['platformInstance', 'language'])),
+ 'Channel created successfully.'
+ );
+ }
+
+ /**
+ * Mark onboarding as complete
+ */
+ public function complete(): JsonResponse
+ {
+ // In a real implementation, you might want to update a user preference
+ // or create a setting that tracks onboarding completion
+ // For now, we'll just return success since the onboarding status
+ // is determined by the existence of platform accounts, feeds, and channels
+
+ return $this->sendResponse(
+ ['completed' => true],
+ 'Onboarding completed successfully.'
+ );
+ }
+}
\ No newline at end of file
diff --git a/backend/app/Services/Auth/LemmyAuthService.php b/backend/app/Services/Auth/LemmyAuthService.php
index 1ee58b2..6c0afda 100644
--- a/backend/app/Services/Auth/LemmyAuthService.php
+++ b/backend/app/Services/Auth/LemmyAuthService.php
@@ -6,6 +6,7 @@
use App\Exceptions\PlatformAuthException;
use App\Models\PlatformAccount;
use App\Modules\Lemmy\Services\LemmyApiService;
+use Exception;
use Illuminate\Support\Facades\Cache;
class LemmyAuthService
@@ -38,4 +39,36 @@ public static function getToken(PlatformAccount $account): string
return $token;
}
+
+ /**
+ * Authenticate with Lemmy API and return user data with JWT
+ * @throws PlatformAuthException
+ */
+ public function authenticate(string $instanceUrl, string $username, string $password): array
+ {
+ try {
+ $api = new LemmyApiService($instanceUrl);
+ $token = $api->login($username, $password);
+
+ if (!$token) {
+ throw new PlatformAuthException(PlatformEnum::LEMMY, 'Login failed for user: ' . $username);
+ }
+
+ // Get user info with the token
+ // For now, we'll return a basic response structure
+ // In a real implementation, you might want to fetch user details
+ return [
+ 'jwt' => $token,
+ 'person_view' => [
+ 'person' => [
+ 'id' => 0, // Would need API call to get actual user info
+ 'display_name' => null,
+ 'bio' => null,
+ ]
+ ]
+ ];
+ } catch (Exception $e) {
+ throw new PlatformAuthException(PlatformEnum::LEMMY, 'Authentication failed: ' . $e->getMessage());
+ }
+ }
}
diff --git a/backend/routes/api.php b/backend/routes/api.php
index 4b3b990..f89d445 100644
--- a/backend/routes/api.php
+++ b/backend/routes/api.php
@@ -5,11 +5,11 @@
use App\Http\Controllers\Api\V1\DashboardController;
use App\Http\Controllers\Api\V1\FeedsController;
use App\Http\Controllers\Api\V1\LogsController;
+use App\Http\Controllers\Api\V1\OnboardingController;
use App\Http\Controllers\Api\V1\PlatformAccountsController;
use App\Http\Controllers\Api\V1\PlatformChannelsController;
use App\Http\Controllers\Api\V1\RoutingController;
use App\Http\Controllers\Api\V1\SettingsController;
-use Illuminate\Http\Request;
use Illuminate\Support\Facades\Route;
/*
@@ -27,23 +27,29 @@
// Public authentication routes
Route::post('/auth/login', [AuthController::class, 'login'])->name('api.auth.login');
Route::post('/auth/register', [AuthController::class, 'register'])->name('api.auth.register');
-
+
// Protected authentication routes
Route::middleware('auth:sanctum')->group(function () {
Route::post('/auth/logout', [AuthController::class, 'logout'])->name('api.auth.logout');
Route::get('/auth/me', [AuthController::class, 'me'])->name('api.auth.me');
});
- // For demo purposes, making most endpoints public. In production, wrap in auth:sanctum middleware
- // Route::middleware('auth:sanctum')->group(function () {
+ // Onboarding
+ Route::get('/onboarding/status', [OnboardingController::class, 'status'])->name('api.onboarding.status');
+ Route::get('/onboarding/options', [OnboardingController::class, 'options'])->name('api.onboarding.options');
+ 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/complete', [OnboardingController::class, 'complete'])->name('api.onboarding.complete');
+
// Dashboard stats
Route::get('/dashboard/stats', [DashboardController::class, 'stats'])->name('api.dashboard.stats');
-
+
// Articles
Route::get('/articles', [ArticlesController::class, 'index'])->name('api.articles.index');
Route::post('/articles/{article}/approve', [ArticlesController::class, 'approve'])->name('api.articles.approve');
Route::post('/articles/{article}/reject', [ArticlesController::class, 'reject'])->name('api.articles.reject');
-
+
// Platform Accounts
Route::apiResource('platform-accounts', PlatformAccountsController::class)->names([
'index' => 'api.platform-accounts.index',
@@ -54,7 +60,7 @@
]);
Route::post('/platform-accounts/{platformAccount}/set-active', [PlatformAccountsController::class, 'setActive'])
->name('api.platform-accounts.set-active');
-
+
// Platform Channels
Route::apiResource('platform-channels', PlatformChannelsController::class)->names([
'index' => 'api.platform-channels.index',
@@ -65,7 +71,7 @@
]);
Route::post('/platform-channels/{channel}/toggle', [PlatformChannelsController::class, 'toggle'])
->name('api.platform-channels.toggle');
-
+
// Feeds
Route::apiResource('feeds', FeedsController::class)->names([
'index' => 'api.feeds.index',
@@ -75,7 +81,7 @@
'destroy' => 'api.feeds.destroy',
]);
Route::post('/feeds/{feed}/toggle', [FeedsController::class, 'toggle'])->name('api.feeds.toggle');
-
+
// Routing
Route::get('/routing', [RoutingController::class, 'index'])->name('api.routing.index');
Route::post('/routing', [RoutingController::class, 'store'])->name('api.routing.store');
@@ -83,14 +89,11 @@
Route::put('/routing/{feed}/{channel}', [RoutingController::class, 'update'])->name('api.routing.update');
Route::delete('/routing/{feed}/{channel}', [RoutingController::class, 'destroy'])->name('api.routing.destroy');
Route::post('/routing/{feed}/{channel}/toggle', [RoutingController::class, 'toggle'])->name('api.routing.toggle');
-
+
// Settings
Route::get('/settings', [SettingsController::class, 'index'])->name('api.settings.index');
Route::put('/settings', [SettingsController::class, 'update'])->name('api.settings.update');
-
+
// Logs
Route::get('/logs', [LogsController::class, 'index'])->name('api.logs.index');
-
- // Close the auth:sanctum middleware group when ready
- // });
-});
\ No newline at end of file
+});
diff --git a/frontend/src/App.tsx b/frontend/src/App.tsx
index d523794..04a0e4b 100644
--- a/frontend/src/App.tsx
+++ b/frontend/src/App.tsx
@@ -5,19 +5,50 @@ import Dashboard from './pages/Dashboard';
import Articles from './pages/Articles';
import Feeds from './pages/Feeds';
import Settings from './pages/Settings';
+import OnboardingWizard from './pages/onboarding/OnboardingWizard';
+import { OnboardingProvider, useOnboarding } from './contexts/OnboardingContext';
+
+const AppContent: React.FC = () => {
+ const { isLoading } = useOnboarding();
+
+ if (isLoading) {
+ return (
+
+ );
+ }
+
+ return (
+
+ {/* Onboarding routes - outside of main layout */}
+ } />
+
+ {/* Main app routes - with layout */}
+
+
+ } />
+ } />
+ } />
+ } />
+ } />
+ } />
+
+
+ } />
+
+ );
+};
const App: React.FC = () => {
return (
-
-
- } />
- } />
- } />
- } />
- } />
- } />
-
-
+
+
+
);
};
diff --git a/frontend/src/contexts/OnboardingContext.tsx b/frontend/src/contexts/OnboardingContext.tsx
new file mode 100644
index 0000000..47b9287
--- /dev/null
+++ b/frontend/src/contexts/OnboardingContext.tsx
@@ -0,0 +1,68 @@
+import React, { createContext, useContext, type ReactNode } from 'react';
+import { useQuery } from '@tanstack/react-query';
+import { useNavigate, useLocation } from 'react-router-dom';
+import { apiClient, type OnboardingStatus } from '../lib/api';
+
+interface OnboardingContextValue {
+ onboardingStatus: OnboardingStatus | undefined;
+ isLoading: boolean;
+ needsOnboarding: boolean;
+}
+
+const OnboardingContext = createContext(null);
+
+interface OnboardingProviderProps {
+ children: ReactNode;
+}
+
+export const OnboardingProvider: React.FC = ({ children }) => {
+ const navigate = useNavigate();
+ const location = useLocation();
+
+ const { data: onboardingStatus, isLoading } = useQuery({
+ queryKey: ['onboarding-status'],
+ queryFn: () => apiClient.getOnboardingStatus(),
+ retry: 1,
+ });
+
+ const needsOnboarding = onboardingStatus?.needs_onboarding ?? false;
+ const isOnOnboardingPage = location.pathname.startsWith('/onboarding');
+
+ // Redirect logic
+ React.useEffect(() => {
+ if (isLoading) return;
+
+ if (needsOnboarding && !isOnOnboardingPage) {
+ // User needs onboarding but is not on onboarding pages
+ const targetStep = onboardingStatus?.current_step;
+ if (targetStep) {
+ navigate(`/onboarding/${targetStep}`, { replace: true });
+ } else {
+ navigate('/onboarding', { replace: true });
+ }
+ } else if (!needsOnboarding && isOnOnboardingPage) {
+ // User doesn't need onboarding but is on onboarding pages
+ navigate('/dashboard', { replace: true });
+ }
+ }, [onboardingStatus, isLoading, needsOnboarding, isOnOnboardingPage, navigate]);
+
+ const value: OnboardingContextValue = {
+ onboardingStatus,
+ isLoading,
+ needsOnboarding,
+ };
+
+ return (
+
+ {children}
+
+ );
+};
+
+export const useOnboarding = () => {
+ const context = useContext(OnboardingContext);
+ if (!context) {
+ throw new Error('useOnboarding must be used within an OnboardingProvider');
+ }
+ return context;
+};
\ No newline at end of file
diff --git a/frontend/src/lib/api.ts b/frontend/src/lib/api.ts
index 1404909..b2fd143 100644
--- a/frontend/src/lib/api.ts
+++ b/frontend/src/lib/api.ts
@@ -127,6 +127,59 @@ export interface DashboardStats {
current_period: string;
}
+// Onboarding types
+export interface Language {
+ id: number;
+ short_code: string;
+ name: string;
+ native_name: string;
+ is_active: boolean;
+}
+
+export interface PlatformInstance {
+ id: number;
+ platform: 'lemmy';
+ url: string;
+ name: string;
+ description: string | null;
+ is_active: boolean;
+}
+
+export interface OnboardingStatus {
+ needs_onboarding: boolean;
+ current_step: 'platform' | 'feed' | 'channel' | 'complete' | null;
+ has_platform_account: boolean;
+ has_feed: boolean;
+ has_channel: boolean;
+}
+
+export interface OnboardingOptions {
+ languages: Language[];
+ platform_instances: PlatformInstance[];
+}
+
+export interface PlatformAccountRequest {
+ instance_url: string;
+ username: string;
+ password: string;
+ platform: 'lemmy';
+}
+
+export interface FeedRequest {
+ name: string;
+ url: string;
+ type: 'rss' | 'website';
+ language_id: number;
+ description?: string;
+}
+
+export interface ChannelRequest {
+ name: string;
+ platform_instance_id: number;
+ language_id: number;
+ description?: string;
+}
+
// API Client class
class ApiClient {
constructor() {
@@ -205,6 +258,36 @@ class ApiClient {
const response = await axios.put>('/settings', data);
return response.data.data;
}
+
+ // Onboarding endpoints
+ async getOnboardingStatus(): Promise {
+ const response = await axios.get>('/onboarding/status');
+ return response.data.data;
+ }
+
+ async getOnboardingOptions(): Promise {
+ const response = await axios.get>('/onboarding/options');
+ return response.data.data;
+ }
+
+ async createPlatformAccount(data: PlatformAccountRequest): Promise {
+ const response = await axios.post>('/onboarding/platform', data);
+ return response.data.data;
+ }
+
+ async createFeedForOnboarding(data: FeedRequest): Promise {
+ const response = await axios.post>('/onboarding/feed', data);
+ return response.data.data;
+ }
+
+ async createChannelForOnboarding(data: ChannelRequest): Promise {
+ const response = await axios.post>('/onboarding/channel', data);
+ return response.data.data;
+ }
+
+ async completeOnboarding(): Promise {
+ await axios.post('/onboarding/complete');
+ }
}
export const apiClient = new ApiClient();
\ No newline at end of file
diff --git a/frontend/src/pages/onboarding/OnboardingLayout.tsx b/frontend/src/pages/onboarding/OnboardingLayout.tsx
new file mode 100644
index 0000000..4985823
--- /dev/null
+++ b/frontend/src/pages/onboarding/OnboardingLayout.tsx
@@ -0,0 +1,17 @@
+import React from 'react';
+
+interface OnboardingLayoutProps {
+ children: React.ReactNode;
+}
+
+const OnboardingLayout: React.FC = ({ children }) => {
+ return (
+
+ );
+};
+
+export default OnboardingLayout;
\ No newline at end of file
diff --git a/frontend/src/pages/onboarding/OnboardingWizard.tsx b/frontend/src/pages/onboarding/OnboardingWizard.tsx
new file mode 100644
index 0000000..b8379bb
--- /dev/null
+++ b/frontend/src/pages/onboarding/OnboardingWizard.tsx
@@ -0,0 +1,25 @@
+import React from 'react';
+import { Routes, Route, Navigate } from 'react-router-dom';
+import OnboardingLayout from './OnboardingLayout';
+import WelcomeStep from './steps/WelcomeStep';
+import PlatformStep from './steps/PlatformStep';
+import FeedStep from './steps/FeedStep';
+import ChannelStep from './steps/ChannelStep';
+import CompleteStep from './steps/CompleteStep';
+
+const OnboardingWizard: React.FC = () => {
+ return (
+
+
+ } />
+ } />
+ } />
+ } />
+ } />
+ } />
+
+
+ );
+};
+
+export default OnboardingWizard;
\ No newline at end of file
diff --git a/frontend/src/pages/onboarding/steps/ChannelStep.tsx b/frontend/src/pages/onboarding/steps/ChannelStep.tsx
new file mode 100644
index 0000000..b15b244
--- /dev/null
+++ b/frontend/src/pages/onboarding/steps/ChannelStep.tsx
@@ -0,0 +1,178 @@
+import React, { useState } from 'react';
+import { Link, useNavigate } from 'react-router-dom';
+import { useMutation, useQuery } from '@tanstack/react-query';
+import { apiClient, type ChannelRequest, type Language, type PlatformInstance } from '../../../lib/api';
+
+const ChannelStep: React.FC = () => {
+ const navigate = useNavigate();
+ const [formData, setFormData] = useState({
+ name: '',
+ platform_instance_id: 0,
+ language_id: 0,
+ description: ''
+ });
+ const [errors, setErrors] = useState>({});
+
+ // Get onboarding options (languages, platform instances)
+ const { data: options, isLoading: optionsLoading } = useQuery({
+ queryKey: ['onboarding-options'],
+ queryFn: () => apiClient.getOnboardingOptions()
+ });
+
+ const createChannelMutation = useMutation({
+ mutationFn: (data: ChannelRequest) => apiClient.createChannelForOnboarding(data),
+ onSuccess: () => {
+ 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({});
+ createChannelMutation.mutate(formData);
+ };
+
+ const handleChange = (field: keyof ChannelRequest, value: string | number) => {
+ setFormData(prev => ({ ...prev, [field]: value }));
+ // Clear field error when user starts typing
+ if (errors[field]) {
+ setErrors(prev => ({ ...prev, [field]: [] }));
+ }
+ };
+
+ if (optionsLoading) {
+ return Loading...
;
+ }
+
+ return (
+
+
Configure Your Channel
+
+ Set up a Lemmy community where articles will be posted
+
+
+ {/* Progress indicator */}
+
+
+
+
+ );
+};
+
+export default ChannelStep;
\ No newline at end of file
diff --git a/frontend/src/pages/onboarding/steps/CompleteStep.tsx b/frontend/src/pages/onboarding/steps/CompleteStep.tsx
new file mode 100644
index 0000000..9662af5
--- /dev/null
+++ b/frontend/src/pages/onboarding/steps/CompleteStep.tsx
@@ -0,0 +1,86 @@
+import React from 'react';
+import { Link, useNavigate } from 'react-router-dom';
+import { useMutation } from '@tanstack/react-query';
+import { apiClient } from '../../../lib/api';
+
+const CompleteStep: React.FC = () => {
+ const navigate = useNavigate();
+
+ const completeOnboardingMutation = useMutation({
+ mutationFn: () => apiClient.completeOnboarding(),
+ onSuccess: () => {
+ navigate('/dashboard');
+ },
+ onError: (error) => {
+ console.error('Failed to complete onboarding:', error);
+ // Still navigate to dashboard even if completion fails
+ navigate('/dashboard');
+ }
+ });
+
+ const handleFinish = () => {
+ completeOnboardingMutation.mutate();
+ };
+
+ return (
+
+
+
+
Setup Complete!
+
+ Great! You've successfully configured FFR. Your feeds will now be monitored and articles will be automatically posted to your configured channels.
+
+
+
+ {/* Progress indicator */}
+
+
+
+
+
What happens next?
+
+ - • Your feeds will be checked regularly for new articles
+ - • New articles will be automatically posted to your channels
+ - • You can monitor activity in the Articles and other sections
+
+
+
+
+
Want more control?
+
+ You can add more feeds, channels, and configure settings from the dashboard.
+
+
+
+
+
+
+
+
+ View Articles
+ {' • '}
+ Manage Feeds
+ {' • '}
+ Settings
+
+
+
+ );
+};
+
+export default CompleteStep;
\ No newline at end of file
diff --git a/frontend/src/pages/onboarding/steps/FeedStep.tsx b/frontend/src/pages/onboarding/steps/FeedStep.tsx
new file mode 100644
index 0000000..4fcb253
--- /dev/null
+++ b/frontend/src/pages/onboarding/steps/FeedStep.tsx
@@ -0,0 +1,193 @@
+import React, { useState } from 'react';
+import { Link, useNavigate } from 'react-router-dom';
+import { useMutation, useQuery } from '@tanstack/react-query';
+import { apiClient, type FeedRequest, type Language } from '../../../lib/api';
+
+const FeedStep: React.FC = () => {
+ const navigate = useNavigate();
+ const [formData, setFormData] = useState({
+ name: '',
+ url: '',
+ type: 'rss',
+ language_id: 0,
+ description: ''
+ });
+ const [errors, setErrors] = useState>({});
+
+ // Get onboarding options (languages)
+ const { data: options, isLoading: optionsLoading } = useQuery({
+ queryKey: ['onboarding-options'],
+ queryFn: () => apiClient.getOnboardingOptions()
+ });
+
+ const createFeedMutation = useMutation({
+ mutationFn: (data: FeedRequest) => apiClient.createFeedForOnboarding(data),
+ onSuccess: () => {
+ navigate('/onboarding/channel');
+ },
+ 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({});
+ createFeedMutation.mutate(formData);
+ };
+
+ const handleChange = (field: keyof FeedRequest, value: string | number) => {
+ setFormData(prev => ({ ...prev, [field]: value }));
+ // Clear field error when user starts typing
+ if (errors[field]) {
+ setErrors(prev => ({ ...prev, [field]: [] }));
+ }
+ };
+
+ if (optionsLoading) {
+ return Loading...
;
+ }
+
+ return (
+
+
Add Your First Feed
+
+ Add a RSS feed or website to monitor for new articles
+
+
+ {/* Progress indicator */}
+
+
+
+
+ );
+};
+
+export default FeedStep;
\ No newline at end of file
diff --git a/frontend/src/pages/onboarding/steps/PlatformStep.tsx b/frontend/src/pages/onboarding/steps/PlatformStep.tsx
new file mode 100644
index 0000000..9d13b52
--- /dev/null
+++ b/frontend/src/pages/onboarding/steps/PlatformStep.tsx
@@ -0,0 +1,138 @@
+import React, { useState } from 'react';
+import { Link, useNavigate } from 'react-router-dom';
+import { useMutation } from '@tanstack/react-query';
+import { apiClient, type PlatformAccountRequest } from '../../../lib/api';
+
+const PlatformStep: React.FC = () => {
+ const navigate = useNavigate();
+ const [formData, setFormData] = useState({
+ instance_url: '',
+ username: '',
+ password: '',
+ platform: 'lemmy'
+ });
+ const [errors, setErrors] = useState>({});
+
+ const createPlatformMutation = useMutation({
+ mutationFn: (data: PlatformAccountRequest) => apiClient.createPlatformAccount(data),
+ onSuccess: () => {
+ navigate('/onboarding/feed');
+ },
+ 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({});
+ createPlatformMutation.mutate(formData);
+ };
+
+ const handleChange = (field: keyof PlatformAccountRequest, value: string) => {
+ setFormData(prev => ({ ...prev, [field]: value }));
+ // Clear field error when user starts typing
+ if (errors[field]) {
+ setErrors(prev => ({ ...prev, [field]: [] }));
+ }
+ };
+
+ return (
+
+
Connect Your Lemmy Account
+
+ Enter your Lemmy instance details and login credentials
+
+
+ {/* Progress indicator */}
+
+
+
+
+ );
+};
+
+export default PlatformStep;
\ No newline at end of file
diff --git a/frontend/src/pages/onboarding/steps/WelcomeStep.tsx b/frontend/src/pages/onboarding/steps/WelcomeStep.tsx
new file mode 100644
index 0000000..c4a39d1
--- /dev/null
+++ b/frontend/src/pages/onboarding/steps/WelcomeStep.tsx
@@ -0,0 +1,43 @@
+import React from 'react';
+import { Link } from 'react-router-dom';
+
+const WelcomeStep: React.FC = () => {
+ return (
+
+
Welcome to FFR
+
+ Let's get you set up! We'll help you configure your Lemmy account, add your first feed, and create a channel for posting.
+
+
+
+
+
1
+
Connect your Lemmy account
+
+
+
2
+
Add your first feed
+
+
+
3
+
Configure a channel
+
+
+
4
+
You're ready to go!
+
+
+
+
+
+ Get Started
+
+
+
+ );
+};
+
+export default WelcomeStep;
\ No newline at end of file