-
Notifications
You must be signed in to change notification settings - Fork 139
Feat: Added Creator to brand and brand to creator matching...plus added some functional elements to the logged user homepage and made google auth functional in Signup/Login. #83
New issue
Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.
By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.
Already on GitHub? Sign in to your account
Conversation
WalkthroughThis update introduces a comprehensive multi-role onboarding flow for brands and creators, implements dynamic creator-brand matching via new backend endpoints and services, and enhances both frontend and backend authentication and routing logic. It also adds trending niche analytics via Gemini API, improves environment/configuration handling, and refines the homepage, navigation, and dashboard experiences. Changes
Sequence Diagram(s)sequenceDiagram
participant User
participant Frontend
participant Backend
participant Supabase
participant GeminiAPI
User->>Frontend: Access onboarding or dashboard
Frontend->>Backend: /api/match/creators-for-brand or /api/match/brands-for-creator
Backend->>Supabase: Fetch sponsorships/creators and insights
Backend->>Backend: Compute match scores
Backend->>Frontend: Return matches
Frontend->>User: Render matched creators/brands
User->>Frontend: Visit trending niches section
Frontend->>Backend: /api/trending-niches
Backend->>Supabase: Check for today's trending niches
alt Not found
Backend->>GeminiAPI: Request trending niches
GeminiAPI-->>Backend: Return JSON data
Backend->>Supabase: Store new trending niches
Backend->>Frontend: Return trending niches
else Found
Backend->>Frontend: Return cached trending niches
end
Frontend->>User: Display trending niches
Possibly related PRs
Suggested labels
Poem
✨ Finishing Touches
Thanks for using CodeRabbit! It's free for OSS, and your support helps us grow. If you like it, consider giving us a shout-out. 🪧 TipsChatThere are 3 ways to chat with CodeRabbit:
SupportNeed help? Create a ticket on our support page for assistance with any issues or questions. Note: Be mindful of the bot's finite context window. It's strongly recommended to break down tasks such as reading entire modules into smaller chunks. For a focused discussion, use review comments to chat about specific files and their changes, instead of using the PR comments. CodeRabbit Commands (Invoked using PR comments)
Other keywords and placeholders
CodeRabbit Configuration File (
|
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
Actionable comments posted: 22
🔭 Outside diff range comments (1)
Backend/app/db/seed.py (1)
43-51: Critical: Missing required password_hash field.The User model in
Backend/app/models/models.pylines 25-53 showspassword_hashas a required field (nullable=False), but it's not being provided when creating users. This will cause database constraint violations.user = User( id=user_data["id"], username=user_data["username"], email=user_data["email"], + password_hash=user_data["password"], # Add this line role=user_data["role"], profile_image=user_data["profile_image"], bio=user_data["bio"], created_at=user_data["created_at"] )Alternatively, if password handling has changed, ensure the User model is updated to make
password_hashnullable or has a default value.
♻️ Duplicate comments (1)
Frontend/src/components/dashboard/sponsorship-matches.tsx (1)
71-74: Add functionality to buttons or indicate they're placeholdersSimilar to
CreatorMatches, these buttons need onClick handlers or should be disabled.
🧹 Nitpick comments (18)
Backend/app/routes/auth.py (1)
1-7: Fix formatting issue and consider expanding auth functionality.The basic auth router implementation is functional, but there's a formatting issue that should be addressed.
Apply this diff to fix the formatting issue:
from fastapi import APIRouter router = APIRouter() + @router.get("/auth/ping") def ping(): return {"message": "Auth route is working!"}Consider expanding this module to include actual authentication endpoints like login, signup, and token validation as the application grows.
Backend/app/main.py (1)
10-11: Remove unused importsStatic analysis indicates that
loggingandosare imported but never used in this file.-import logging -import osFrontend/src/components/main-nav.tsx (1)
1-1: Remove unused importThe
Linkimport fromreact-router-domis no longer used after removing all navigation links.-import { Link } from "react-router-dom"Frontend/src/utils/demoInsert.ts (1)
3-40: Add error handling and duplicate prevention for demo data insertion.The function works but could be more robust for production use.
Consider these improvements:
export async function demoInsert() { + try { + // Check if demo user already exists + const { data: existingUser } = await supabase + .from('users') + .select('id') + .eq('id', 'demo-user-123') + .single(); + + if (existingUser) { + console.log('Demo user already exists, skipping insertion'); + return; + } + // Insert user const { data: user, error: userError } = await supabase .from('users') .insert({ // ... existing fields }); - console.log('User:', user, userError); + + if (userError) { + console.error('Failed to insert demo user:', userError); + return; + } + console.log('Demo user inserted successfully'); // Insert social profile const { data: profile, error: profileError } = await supabase .from('social_profiles') .insert({ // ... existing fields }); - console.log('Profile:', profile, profileError); + + if (profileError) { + console.error('Failed to insert demo profile:', profileError); + return; + } + console.log('Demo profile inserted successfully'); + } catch (error) { + console.error('Demo insertion failed:', error); + } }README.md (1)
164-166: Minor grammar improvement needed.Consider adding "to" for better readability:
-6. Get the Groq API key: +6. Get the Groq API key: - Visit [Groq Console](https://console.groq.com/) - - Create an API key and paste it into the `.env` file + - Create an API key and paste it into the `.env` fileFrontend/src/pages/Brand/Dashboard.tsx (1)
40-46: Mock data approach is appropriate for current development stage.The hardcoded sponsorships provide a good foundation for testing the dynamic matching functionality.
Consider replacing with an API call when backend sponsorship endpoints are ready:
const [sponsorships, setSponsorships] = useState([]); useEffect(() => { // Fetch real sponsorships from API fetch('/api/sponsorships') .then(res => res.json()) .then(data => setSponsorships(data)) .catch(err => console.error('Failed to fetch sponsorships:', err)); }, []);Frontend/src/App.tsx (1)
24-42: Reconsider the arbitrary loading delay.The 2-second loading delay may not provide the best user experience, especially for users with fast connections. Consider implementing a more dynamic loading approach that actually waits for necessary resources or user authentication status.
- useEffect(() => { - // Set a timeout to ensure the app loads - const timer = setTimeout(() => { - setIsLoading(false); - }, 2000); - - return () => clearTimeout(timer); - }, []); + useEffect(() => { + // Check if essential resources are loaded or perform actual initialization + const initializeApp = async () => { + // Add any actual initialization logic here + // e.g., checking auth state, loading user preferences, etc. + await new Promise(resolve => setTimeout(resolve, 300)); // Minimal delay for smooth UX + setIsLoading(false); + }; + + initializeApp(); + }, []);Backend/app/routes/match.py (1)
13-28: Fix formatting and import issues.The router logic is sound, but there are formatting issues flagged by static analysis tools.
+ router = APIRouter(prefix="/match", tags=["Matching"]) + @router.get("/creators-for-brand/{sponsorship_id}") def get_creators_for_brand(sponsorship_id: str): matches = match_creators_for_brand(sponsorship_id) if not matches: raise HTTPException(status_code=404, detail="No matching creators found.") return {"matches": matches} + @router.get("/brands-for-creator/{creator_id}") def get_brands_for_creator(creator_id: str): matches = match_brands_for_creator(creator_id) if not matches: raise HTTPException(status_code=404, detail="No matching brand campaigns found.") return {"matches": matches}Also, consider using absolute imports instead of relative imports for better maintainability:
-from ..services.db_service import match_creators_for_brand, match_brands_for_creator +from app.services.db_service import match_creators_for_brand, match_brands_for_creatorBackend/app/routes/ai.py (1)
39-66: Add input validation and improve error handling.The endpoint needs better validation of API responses and more specific error handling.
+ @router.get("/api/trending-niches") def trending_niches(): """ API endpoint to get trending niches for the current day. - If today's data exists in Supabase, return it. - Otherwise, fetch from Gemini, store in Supabase, and return the new data. - If Gemini fails, fallback to the most recent data available. """ today = str(date.today()) # Check if today's data exists in Supabase result = supabase.table("trending_niches").select("*").eq("fetched_at", today).execute() if not result.data: # Fetch from Gemini and store try: niches = fetch_from_gemini() + # Validate response structure + if not isinstance(niches, list) or len(niches) != 6: + raise ValueError("Invalid response format from Gemini API") + for niche in niches: + # Validate each niche has required fields + if not all(key in niche for key in ["name", "insight", "global_activity"]): + raise ValueError(f"Missing required fields in niche: {niche}") + supabase.table("trending_niches").insert({ "name": niche["name"], "insight": niche["insight"], "global_activity": int(niche["global_activity"]), "fetched_at": today }).execute() result = supabase.table("trending_niches").select("*").eq("fetched_at", today).execute() except Exception as e: - print("Gemini fetch failed:", e) + print(f"Gemini fetch failed: {type(e).__name__}: {str(e)}") # fallback: serve most recent data result = supabase.table("trending_niches").select("*").order("fetched_at", desc=True).limit(6).execute() return result.dataFrontend/src/components/dashboard/creator-matches.tsx (1)
58-58: Consider making the placeholder image URL configurableThe hardcoded placeholder URL should be moved to a configuration file or environment variable for easier maintenance.
Frontend/src/pages/Signup.tsx (2)
7-7: Remove unused importThe
demoInsertimport is not used in this component.-import { demoInsert } from '../utils/demoInsert';
118-118: Display password strength indicator to usersThe password strength is calculated but never shown to users. This is a missed opportunity to encourage stronger passwords.
Add the password strength indicator after the password input:
<input id="password" name="password" type={showPassword ? "text" : "password"} value={formData.password} onChange={handleChange} required className="w-full px-4 py-3 rounded-lg border border-gray-300 dark:border-gray-600 focus:outline-none focus:ring-2 focus:ring-purple-500 dark:focus:ring-purple-400 focus:border-transparent bg-white dark:bg-gray-700 text-gray-900 dark:text-white transition-all duration-200" placeholder="Password" /> +{formData.password && ( + <div className="mt-2"> + <div className="flex items-center gap-2"> + <div className="flex-1 bg-gray-200 dark:bg-gray-700 rounded-full h-2"> + <div + className={`h-2 rounded-full transition-all duration-300 ${color}`} + style={{ width: `${(strength / 4) * 100}%` }} + /> + </div> + <span className="text-xs text-gray-600 dark:text-gray-400">{text}</span> + </div> + </div> +)}Also applies to: 171-172
Frontend/src/components/PublicRoute.tsx (1)
40-42: Consider improving cache expiration mechanismThe current setTimeout approach for cache expiration might not be cleaned up if the component unmounts, potentially causing memory leaks.
Consider storing the expiration timestamp with the cached data instead:
-sessionStorage.setItem(cacheKey, JSON.stringify({ hasOnboarding, role })); -setTimeout(() => sessionStorage.removeItem(cacheKey), 2 * 60 * 1000); +sessionStorage.setItem(cacheKey, JSON.stringify({ + hasOnboarding, + role, + expiry: Date.now() + 2 * 60 * 1000 +}));Then check expiration when reading from cache:
if (cachedResult) { - const { hasOnboarding, role } = JSON.parse(cachedResult); + const cached = JSON.parse(cachedResult); + if (cached.expiry < Date.now()) { + sessionStorage.removeItem(cacheKey); + } else { + const { hasOnboarding, role } = cached;Frontend/src/context/AuthContext.tsx (2)
25-62: Consider using a more robust caching mechanism.While sessionStorage works, it's cleared when the tab is closed. Consider using localStorage with expiration for better persistence across sessions.
- // Add a simple cache to prevent repeated requests for the same user - const cacheKey = `user_${user.id}`; - if (sessionStorage.getItem(cacheKey)) { - console.log("User already checked, skipping..."); - return; - } + // Add a more robust cache with expiration + const cacheKey = `user_check_${user.id}`; + const cachedData = localStorage.getItem(cacheKey); + if (cachedData) { + const { timestamp } = JSON.parse(cachedData); + // Check if cache is still valid (5 minutes) + if (Date.now() - timestamp < 5 * 60 * 1000) { + console.log("User already checked (cached), skipping..."); + return; + } + } // ... existing code ... - // Cache the result for 5 minutes to prevent repeated requests - sessionStorage.setItem(cacheKey, "true"); - setTimeout(() => sessionStorage.removeItem(cacheKey), 5 * 60 * 1000); + // Cache the result with timestamp + localStorage.setItem(cacheKey, JSON.stringify({ + checked: true, + timestamp: Date.now() + }));
71-107: Optimize database queries and improve rate limiting.The function makes three separate database queries which could be optimized. Also, the rate limiting mechanism could be more sophisticated.
Consider these improvements:
- Combine the queries to reduce round trips
- Use a more robust rate limiting mechanism
const checkUserOnboarding = async (userToCheck?: User | null) => { const userToUse = userToCheck || user; if (!userToUse) return { hasOnboarding: false, role: null }; - // Add rate limiting - only allow one request per 2 seconds - const now = Date.now(); - if (now - lastRequest < 2000) { - console.log("Rate limiting: skipping request"); - return { hasOnboarding: false, role: null }; - } - setLastRequest(now); + // Improved rate limiting with request queue + const cacheKey = `onboarding_check_${userToUse.id}`; + const cached = sessionStorage.getItem(cacheKey); + if (cached) { + const data = JSON.parse(cached); + if (Date.now() - data.timestamp < 2000) { + return data.result; + } + } - // Check if user has completed onboarding by looking for social profiles or brand data - const { data: socialProfiles } = await supabase - .from("social_profiles") - .select("id") - .eq("user_id", userToUse.id) - .limit(1); - - const { data: brandData } = await supabase - .from("brands") - .select("id") - .eq("user_id", userToUse.id) - .limit(1); - - const hasOnboarding = (socialProfiles && socialProfiles.length > 0) || (brandData && brandData.length > 0); - - // Get user role - const { data: userData } = await supabase - .from("users") - .select("role") - .eq("id", userToUse.id) - .single(); + try { + // Combine queries using Promise.all for better performance + const [socialProfilesResp, brandDataResp, userDataResp] = await Promise.all([ + supabase.from("social_profiles").select("id").eq("user_id", userToUse.id).limit(1), + supabase.from("brands").select("id").eq("user_id", userToUse.id).limit(1), + supabase.from("users").select("role").eq("id", userToUse.id).single() + ]); + + const hasOnboarding = + (socialProfilesResp.data && socialProfilesResp.data.length > 0) || + (brandDataResp.data && brandDataResp.data.length > 0); + + const result = { hasOnboarding, role: userDataResp.data?.role || null }; + + // Cache the result + sessionStorage.setItem(cacheKey, JSON.stringify({ + result, + timestamp: Date.now() + })); + + return result; + } catch (error) { + console.error("Error checking user onboarding:", error); + return { hasOnboarding: false, role: null }; + } - - return { hasOnboarding, role: userData?.role || null }; };Frontend/src/components/Onboarding.tsx (2)
901-901: Improve email validation regex.The current email regex is basic and might not catch all invalid formats.
Consider using a more comprehensive email validation:
-if (!brandData.contact_email || !/^[^@\s]+@[^@\s]+\.[^@\s]+$/.test(brandData.contact_email)) return "Valid contact email is required."; +// More comprehensive email validation +const emailRegex = /^[a-zA-Z0-9.!#$%&'*+/=?^_`{|}~-]+@[a-zA-Z0-9](?:[a-zA-Z0-9-]{0,61}[a-zA-Z0-9])?(?:\.[a-zA-Z0-9](?:[a-zA-Z0-9-]{0,61}[a-zA-Z0-9])?)*$/; +if (!brandData.contact_email || !emailRegex.test(brandData.contact_email)) return "Valid contact email is required.";Or better yet, use a library like
validator:+import validator from 'validator'; + -if (!brandData.contact_email || !/^[^@\s]+@[^@\s]+\.[^@\s]+$/.test(brandData.contact_email)) return "Valid contact email is required."; +if (!brandData.contact_email || !validator.isEmail(brandData.contact_email)) return "Valid contact email is required.";
1177-1180: Improve YouTube channel ID extraction.The current regex might not handle all YouTube URL formats correctly.
Consider a more comprehensive URL parsing approach:
- // Extract channel ID from URL if needed - if (input.includes("youtube.com")) { - const match = input.match(/(?:channel\/|user\/|c\/)?([\w-]{21,})/); - if (match) channelId = match[1]; - } + // Extract channel ID from URL if needed + if (input.includes("youtube.com") || input.includes("youtu.be")) { + // Handle various YouTube URL formats + const patterns = [ + /youtube\.com\/channel\/(UC[\w-]{21})/, + /youtube\.com\/c\/([\w-]+)/, + /youtube\.com\/user\/([\w-]+)/, + /youtube\.com\/@([\w-]+)/, + ]; + + for (const pattern of patterns) { + const match = input.match(pattern); + if (match) { + channelId = match[1]; + break; + } + } + + // If it's a custom URL (c/ or @), you might need to resolve it + // This would require an additional API call + }Frontend/src/pages/HomePage.tsx (1)
73-262: Move static data outside component to prevent recreation on each render.These large data arrays are recreated on every render, which is inefficient.
Move all static data arrays outside the component:
+// Move these outside the component +const DASHBOARD_FEATURES = [ + { + icon: TrendingUp, + title: "Analytics Dashboard", + desc: "Track your performance metrics, engagement rates, and growth trends.", + }, + // ... rest of the features +]; + +const SUCCESS_STORIES = [ + // ... success stories data +]; + +const TRENDING_NICHES = [ + // ... trending niches data +]; + +const CREATOR_RESOURCES = [ + // ... creator resources data +]; + +const BRAND_SHOWCASE = [ + // ... brand showcase data +]; export default function HomePage() { - const dashboardFeatures = [ - // ... remove from here - ]; - - const successStories = [ - // ... remove from here - ]; // Use the constants instead + // dashboardFeatures -> DASHBOARD_FEATURES + // successStories -> SUCCESS_STORIES + // etc.This prevents unnecessary memory allocation and improves performance.
📜 Review details
Configuration used: CodeRabbit UI
Review profile: CHILL
Plan: Pro
⛔ Files ignored due to path filters (6)
Frontend/public/brand.pngis excluded by!**/*.pngFrontend/public/contnetcreator.pngis excluded by!**/*.pngFrontend/public/facebook.pngis excluded by!**/*.pngFrontend/public/instagram.pngis excluded by!**/*.pngFrontend/public/tiktok.pngis excluded by!**/*.pngFrontend/public/youtube.pngis excluded by!**/*.png
📒 Files selected for processing (35)
Backend/.env-example(1 hunks)Backend/app/db/seed.py(4 hunks)Backend/app/main.py(2 hunks)Backend/app/models/chat.py(1 hunks)Backend/app/models/models.py(2 hunks)Backend/app/routes/ai.py(1 hunks)Backend/app/routes/auth.py(1 hunks)Backend/app/routes/chat.py(1 hunks)Backend/app/routes/match.py(1 hunks)Backend/app/routes/post.py(1 hunks)Backend/app/schemas/schema.py(0 hunks)Backend/app/services/chat_services.py(1 hunks)Backend/app/services/db_service.py(1 hunks)Backend/sql.txt(1 hunks)Frontend/env-example(1 hunks)Frontend/src/App.tsx(2 hunks)Frontend/src/components/Onboarding.tsx(1 hunks)Frontend/src/components/ProtectedRoute.tsx(1 hunks)Frontend/src/components/PublicRoute.tsx(1 hunks)Frontend/src/components/dashboard/creator-matches.tsx(1 hunks)Frontend/src/components/dashboard/sponsorship-matches.tsx(1 hunks)Frontend/src/components/main-nav.tsx(1 hunks)Frontend/src/components/user-nav.tsx(1 hunks)Frontend/src/context/AuthContext.tsx(4 hunks)Frontend/src/index.css(1 hunks)Frontend/src/pages/Brand/Dashboard.tsx(2 hunks)Frontend/src/pages/DashboardPage.tsx(2 hunks)Frontend/src/pages/HomePage.tsx(2 hunks)Frontend/src/pages/Login.tsx(2 hunks)Frontend/src/pages/RoleSelection.tsx(1 hunks)Frontend/src/pages/Signup.tsx(3 hunks)Frontend/src/utils/demoInsert.ts(1 hunks)Frontend/src/utils/supabase.tsx(1 hunks)Frontend/vite.config.ts(1 hunks)README.md(5 hunks)
💤 Files with no reviewable changes (1)
- Backend/app/schemas/schema.py
🧰 Additional context used
🧬 Code Graph Analysis (13)
Backend/app/routes/chat.py (3)
Backend/app/db/db.py (1)
get_db(38-40)Backend/app/services/redis_client.py (1)
get_redis(6-7)Backend/app/services/chat_pubsub.py (1)
listen_to_channel(6-16)
Frontend/src/components/ProtectedRoute.tsx (1)
Frontend/src/context/AuthContext.tsx (1)
useAuth(216-222)
Backend/app/routes/post.py (1)
Backend/app/models/models.py (7)
User(25-53)AudienceInsights(57-72)Sponsorship(76-92)UserPost(96-110)SponsorshipApplication(114-128)SponsorshipPayment(146-162)Collaboration(132-142)
Frontend/src/pages/DashboardPage.tsx (2)
Frontend/src/context/AuthContext.tsx (1)
useAuth(216-222)Frontend/src/components/dashboard/sponsorship-matches.tsx (1)
SponsorshipMatches(20-83)
Backend/app/db/seed.py (1)
Backend/app/models/models.py (1)
User(25-53)
Backend/app/routes/match.py (1)
Backend/app/services/db_service.py (2)
match_creators_for_brand(13-48)match_brands_for_creator(51-85)
Frontend/src/utils/demoInsert.ts (1)
Frontend/src/utils/supabase.tsx (1)
supabase(11-11)
Frontend/src/pages/Brand/Dashboard.tsx (1)
Frontend/src/components/dashboard/creator-matches.tsx (1)
CreatorMatches(22-85)
Frontend/src/pages/Signup.tsx (2)
Frontend/src/context/AuthContext.tsx (1)
useAuth(216-222)Frontend/src/utils/supabase.tsx (1)
supabase(11-11)
Frontend/src/components/dashboard/sponsorship-matches.tsx (4)
Frontend/src/components/ui/card.tsx (2)
Card(80-80)CardContent(85-85)Frontend/src/components/ui/avatar.tsx (3)
Avatar(50-50)AvatarImage(50-50)AvatarFallback(50-50)Frontend/src/components/ui/badge.tsx (1)
Badge(36-36)Frontend/src/components/ui/button.tsx (1)
Button(54-54)
Frontend/src/components/dashboard/creator-matches.tsx (4)
Frontend/src/components/ui/card.tsx (2)
Card(80-80)CardContent(85-85)Frontend/src/components/ui/avatar.tsx (3)
Avatar(50-50)AvatarImage(50-50)AvatarFallback(50-50)Frontend/src/components/ui/badge.tsx (1)
Badge(36-36)Frontend/src/components/ui/button.tsx (1)
Button(54-54)
Frontend/src/components/PublicRoute.tsx (1)
Frontend/src/context/AuthContext.tsx (1)
useAuth(216-222)
Frontend/src/context/AuthContext.tsx (2)
Backend/app/models/models.py (1)
User(25-53)Frontend/src/utils/supabase.tsx (1)
supabase(11-11)
🪛 Pylint (3.3.7)
Backend/app/routes/chat.py
[error] 10-10: Attempted relative import beyond top-level package
(E0402)
[error] 11-11: Attempted relative import beyond top-level package
(E0402)
[error] 13-13: Attempted relative import beyond top-level package
(E0402)
[error] 15-15: Attempted relative import beyond top-level package
(E0402)
Backend/app/routes/post.py
[error] 4-4: Attempted relative import beyond top-level package
(E0402)
[error] 5-8: Attempted relative import beyond top-level package
(E0402)
Backend/app/routes/match.py
[error] 5-5: Attempted relative import beyond top-level package
(E0402)
Backend/app/services/db_service.py
[refactor] 30-30: Consider using a generator instead 'sum(creator_ages.get(age, 0) for age in required_ages)'
(R1728)
[refactor] 37-37: Consider using a generator instead 'sum(creator_locs.get(loc, 0) for loc in required_locs)'
(R1728)
[refactor] 67-67: Consider using a generator instead 'sum(creator_ages.get(age, 0) for age in required_ages)'
(R1728)
[refactor] 74-74: Consider using a generator instead 'sum(creator_locs.get(loc, 0) for loc in required_locs)'
(R1728)
🪛 Flake8 (7.2.0)
Backend/app/routes/auth.py
[error] 5-5: expected 2 blank lines, found 1
(E302)
Backend/app/routes/post.py
[error] 4-4: '..db.db.AsyncSessionLocal' imported but unused
(F401)
[error] 5-5: '..models.models.User' imported but unused
(F401)
[error] 5-5: '..models.models.AudienceInsights' imported but unused
(F401)
[error] 5-5: '..models.models.Sponsorship' imported but unused
(F401)
[error] 5-5: '..models.models.UserPost' imported but unused
(F401)
[error] 5-5: '..models.models.SponsorshipApplication' imported but unused
(F401)
[error] 5-5: '..models.models.SponsorshipPayment' imported but unused
(F401)
[error] 5-5: '..models.models.Collaboration' imported but unused
(F401)
Backend/app/main.py
[error] 10-10: 'logging' imported but unused
(F401)
[error] 11-11: 'os' imported but unused
(F401)
Backend/app/routes/match.py
[error] 15-15: expected 2 blank lines, found 1
(E302)
[error] 22-22: expected 2 blank lines, found 1
(E302)
Backend/app/routes/ai.py
[error] 18-18: expected 2 blank lines, found 1
(E302)
[error] 21-21: too many blank lines (2)
(E303)
[error] 39-39: expected 2 blank lines, found 1
(E302)
🪛 Ruff (0.11.9)
Backend/app/routes/post.py
4-4: ..db.db.AsyncSessionLocal imported but unused
Remove unused import: ..db.db.AsyncSessionLocal
(F401)
6-6: ..models.models.User imported but unused
Remove unused import
(F401)
6-6: ..models.models.AudienceInsights imported but unused
Remove unused import
(F401)
6-6: ..models.models.Sponsorship imported but unused
Remove unused import
(F401)
6-6: ..models.models.UserPost imported but unused
Remove unused import
(F401)
7-7: ..models.models.SponsorshipApplication imported but unused
Remove unused import
(F401)
7-7: ..models.models.SponsorshipPayment imported but unused
Remove unused import
(F401)
7-7: ..models.models.Collaboration imported but unused
Remove unused import
(F401)
Backend/app/main.py
10-10: logging imported but unused
Remove unused import: logging
(F401)
11-11: os imported but unused
Remove unused import: os
(F401)
🪛 LanguageTool
README.md
[uncategorized] ~166-~166: Possible missing preposition found.
Context: ...onsole](https://console.groq.com/) - Create an API key and paste it into the .env...
(AI_HYDRA_LEO_MISSING_TO)
🪛 Biome (1.9.4)
Frontend/src/components/Onboarding.tsx
[error] 392-392: Change to an optional chain.
Unsafe fix: Change to an optional chain.
(lint/complexity/useOptionalChain)
[error] 645-645: Change to an optional chain.
Unsafe fix: Change to an optional chain.
(lint/complexity/useOptionalChain)
🔇 Additional comments (30)
Backend/.env-example (1)
9-9: LGTM! Environment variable addition is properly formatted.The addition of
GEMINI_API_KEYfollows the consistent pattern of other API keys in the file and supports the new AI-powered trending niches functionality.Frontend/env-example (1)
3-3: LGTM! Frontend environment variable follows proper conventions.The addition of
VITE_YOUTUBE_API_KEYcorrectly uses the VITE_ prefix and provides a clear placeholder value for the YouTube API integration in the onboarding flow.Backend/app/models/chat.py (1)
4-4: LGTM! Import standardization improves maintainability.The change from relative to absolute import path enhances code clarity and consistency across the codebase. This aligns with the broader refactoring effort to standardize imports.
Backend/app/services/chat_services.py (1)
5-6: LGTM! Import path standardization looks good.The change from relative to absolute imports using the
app.prefix aligns with the broader import standardization across the codebase. This improves module resolution consistency without affecting functionality.Frontend/vite.config.ts (1)
14-18: LGTM! Proxy configuration for API development is well-implemented.The proxy setup correctly forwards
/apirequests to the backend server running on port 8000. This is a standard and necessary configuration for seamless frontend-backend integration during development.Frontend/src/utils/supabase.tsx (1)
6-10: Excellent defensive programming with clear error messaging.The runtime validation for Supabase environment variables is a great improvement. It prevents silent failures and provides clear, actionable error messages when configuration is missing. This will help developers quickly identify and fix configuration issues.
Frontend/src/components/ProtectedRoute.tsx (1)
5-7: LGTM! Cleaner authentication logic with explicit boolean check.The change from checking a
userobject to using an explicitisAuthenticatedboolean is more semantically clear and aligns with enhanced authentication state management. This approach is less prone to edge cases and more explicit about the authentication state.Backend/app/main.py (2)
3-8: LGTM: Import changes improve consistencyThe conversion to relative imports for internal modules is a good practice that improves code organization and maintainability.
8-8: LGTM: New router modules enhance API functionalityThe addition of
routes.matchandroutes.aimodules extends the API with matching functionality and AI-powered trending niches, which aligns well with the PR objectives.Also applies to: 14-14, 56-57
Backend/sql.txt (1)
2-5: LGTM: Security improvement by removing password hashesRemoving
password_hashfrom the seed data is a good security practice that prevents storing potentially sensitive authentication data in version control. This aligns well with the Google authentication integration mentioned in the PR objectives.Frontend/src/pages/DashboardPage.tsx (2)
27-27: LGTM: Proper authentication integrationGood implementation of extracting the user object from the auth context to enable personalized sponsorship matching.
183-183: LGTM: Safe user ID handlingThe use of optional chaining (
user?.id || "") properly handles cases where the user might be null or undefined, providing a safe fallback.Frontend/src/index.css (5)
122-133: LGTM: Well-implemented gradient animationThe gradient keyframe animation is properly structured with smooth transitions for background position changes.
135-142: LGTM: Smooth float animationThe float animation provides a subtle vertical movement effect that's pleasant for UI elements.
144-151: LGTM: Effective glow animationThe glow animation creates a nice pulsating effect using box-shadow variations with appropriate opacity levels.
153-164: LGTM: Consistent utility class namingThe utility classes follow a clear naming pattern and have appropriate animation durations and easing functions.
166-181: LGTM: Impressive 3D text effectThe layered text-shadow approach creates a convincing 3D depth effect. The progressive shadows and blur values are well-balanced.
Frontend/src/pages/Login.tsx (1)
33-33: Good architectural improvement with clear documentation.Moving navigation logic to AuthContext improves separation of concerns and centralizes auth flow management. The comments clearly explain the delegation of responsibility.
Also applies to: 52-52
Backend/app/db/seed.py (2)
1-3: Good improvement to use absolute imports and add datetime support.The changes to use absolute imports and add datetime functionality align well with the new fields being added.
15-16: Added new user fields appropriately.The addition of
profile_imageandcreated_atfields with sensible defaults enhances the user data structure.Also applies to: 25-26
README.md (2)
104-104: Excellent addition of explicit .env setup instructions.Adding clear instructions to create
.envfiles from examples improves the developer onboarding experience.Also applies to: 133-133
270-270: Nice touch with the friendly closing message.The "Happy Coding!" addition creates a welcoming tone for contributors.
Frontend/src/pages/Brand/Dashboard.tsx (2)
36-37: Good imports for the dynamic matching functionality.The addition of
useStateandCreatorMatchescomponent supports the transition from static to dynamic creator recommendations.
172-189: Excellent transition to dynamic, campaign-based creator matching.The replacement of static creator cards with a dropdown selection and the
CreatorMatchescomponent creates a much more functional and user-friendly interface. The integration with the backend matching API (referenced in the relevant code snippet) provides real value to brands.Backend/app/models/models.py (1)
12-16: LGTM on the import additions.The new imports for
TIMESTAMPanddatetimeare appropriate for the timestamp handling changes, and the absolute import path forBaseis a good practice.Frontend/src/App.tsx (1)
50-76: Excellent route protection implementation.The use of
PublicRouteandProtectedRoutecomponents provides a clean and secure way to manage access control. The new onboarding routes are well-structured and integrate properly with the authentication flow.Frontend/src/pages/RoleSelection.tsx (1)
1-59: Excellent implementation of role selection component.The component demonstrates good practices with proper state management, error handling, and user experience design. The validation ensures users must make a selection before proceeding, and the responsive UI with dark mode support is well-implemented.
Frontend/src/components/dashboard/creator-matches.tsx (1)
64-64: Verify the match score calculation logicThe match score is divided by 4 before converting to percentage. Please verify this calculation aligns with the backend scoring system.
Is the backend match score on a scale of 0-4? If not, this calculation may show incorrect percentages to users.
Frontend/src/components/dashboard/sponsorship-matches.tsx (1)
62-62: Ensure consistent match score calculation across componentsThe match score calculation (dividing by 4) should be consistent with
CreatorMatchescomponent and the backend logic.Please verify this matches the backend scoring scale and consider extracting this calculation to a shared utility function for consistency.
Frontend/src/components/user-nav.tsx (1)
34-48: Well-implemented avatar error handlingThe avatar error handling with fallback logic is well implemented, providing a good user experience when avatar images fail to load.
| from ..db.db import get_db | ||
| from ..services.chat_services import chat_service | ||
| from redis.asyncio import Redis | ||
| from services.redis_client import get_redis | ||
| from ..services.redis_client import get_redis | ||
| import asyncio | ||
| from services.chat_pubsub import listen_to_channel | ||
| from ..services.chat_pubsub import listen_to_channel |
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
💡 Verification agent
🧩 Analysis chain
Verify the relative import structure to avoid import resolution issues.
The change to relative imports may cause issues with the package structure. Static analysis tools are flagging these as "relative imports beyond top-level package."
Please verify the current package structure and ensure these relative imports work correctly in your deployment environment:
Consider reverting to absolute imports if the relative imports cause issues in your deployment environment:
-from ..db.db import get_db
-from ..services.chat_services import chat_service
-from ..services.redis_client import get_redis
-from ..services.chat_pubsub import listen_to_channel
+from app.db.db import get_db
+from app.services.chat_services import chat_service
+from app.services.redis_client import get_redis
+from app.services.chat_pubsub import listen_to_channel🏁 Script executed:
#!/bin/bash
# Description: Check the package structure and verify import paths
# Check the directory structure
echo "=== Backend directory structure ==="
find Backend -type f -name "*.py" | head -20
echo -e "\n=== Checking if the imported modules exist ==="
# Check if the imported modules exist at the expected paths
ls -la Backend/app/db/db.py
ls -la Backend/app/services/chat_services.py
ls -la Backend/app/services/redis_client.py
ls -la Backend/app/services/chat_pubsub.py
echo -e "\n=== Checking for __init__.py files ==="
# Check for __init__.py files that make directories Python packages
find Backend -name "__init__.py" -type fLength of output: 1409
Ensure package initialization or switch to absolute imports to resolve E0402
Verified that all referenced modules exist under Backend/app, but no __init__.py files are present—so static analysis flags “relative imports beyond top-level package.” Choose one of the following fixes:
• Add empty __init__.py in each folder to turn them into proper packages:
- Backend/app/
- Backend/app/db/
- Backend/app/routes/
- Backend/app/services/
• Or update imports in Backend/app/routes/chat.py to use the app package root:
-from ..db.db import get_db
-from ..services.chat_services import chat_service
-from ..services.redis_client import get_redis
-from ..services.chat_pubsub import listen_to_channel
+from app.db.db import get_db
+from app.services.chat_services import chat_service
+from app.services.redis_client import get_redis
+from app.services.chat_pubsub import listen_to_channel📝 Committable suggestion
‼️ IMPORTANT
Carefully review the code before committing. Ensure that it accurately replaces the highlighted code, contains no missing lines, and has no issues with indentation. Thoroughly test & benchmark the code to ensure it meets the requirements.
| from ..db.db import get_db | |
| from ..services.chat_services import chat_service | |
| from redis.asyncio import Redis | |
| from services.redis_client import get_redis | |
| from ..services.redis_client import get_redis | |
| import asyncio | |
| from services.chat_pubsub import listen_to_channel | |
| from ..services.chat_pubsub import listen_to_channel | |
| from app.db.db import get_db | |
| from app.services.chat_services import chat_service | |
| from redis.asyncio import Redis | |
| from app.services.redis_client import get_redis | |
| import asyncio | |
| from app.services.chat_pubsub import listen_to_channel |
🧰 Tools
🪛 Pylint (3.3.7)
[error] 10-10: Attempted relative import beyond top-level package
(E0402)
[error] 11-11: Attempted relative import beyond top-level package
(E0402)
[error] 13-13: Attempted relative import beyond top-level package
(E0402)
[error] 15-15: Attempted relative import beyond top-level package
(E0402)
🤖 Prompt for AI Agents
In Backend/app/routes/chat.py around lines 10 to 15, the relative imports cause
E0402 errors because the directories lack __init__.py files. Fix this by adding
empty __init__.py files in Backend/app/, Backend/app/db/, Backend/app/routes/,
and Backend/app/services/ to make them proper packages, or alternatively, change
the imports to use absolute imports starting from the app package root.
| from ..db.db import AsyncSessionLocal | ||
| from ..models.models import ( | ||
| User, AudienceInsights, Sponsorship, UserPost, | ||
| SponsorshipApplication, SponsorshipPayment, Collaboration | ||
| ) | ||
| from schemas.schema import ( | ||
| from ..schemas.schema import ( | ||
| UserCreate, AudienceInsightsCreate, SponsorshipCreate, UserPostCreate, | ||
| SponsorshipApplicationCreate, SponsorshipPaymentCreate, CollaborationCreate | ||
| ) |
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
Remove unused imports to clean up the codebase.
The static analysis tools correctly identified that these imports are unused. This file uses Supabase client directly for database operations, making the SQLAlchemy imports (AsyncSessionLocal, model classes, and schema classes) unnecessary.
Remove the unused imports:
-from ..db.db import AsyncSessionLocal
-from ..models.models import (
- User, AudienceInsights, Sponsorship, UserPost,
- SponsorshipApplication, SponsorshipPayment, Collaboration
-)
-from ..schemas.schema import (
- UserCreate, AudienceInsightsCreate, SponsorshipCreate, UserPostCreate,
- SponsorshipApplicationCreate, SponsorshipPaymentCreate, CollaborationCreate
-)📝 Committable suggestion
‼️ IMPORTANT
Carefully review the code before committing. Ensure that it accurately replaces the highlighted code, contains no missing lines, and has no issues with indentation. Thoroughly test & benchmark the code to ensure it meets the requirements.
| from ..db.db import AsyncSessionLocal | |
| from ..models.models import ( | |
| User, AudienceInsights, Sponsorship, UserPost, | |
| SponsorshipApplication, SponsorshipPayment, Collaboration | |
| ) | |
| from schemas.schema import ( | |
| from ..schemas.schema import ( | |
| UserCreate, AudienceInsightsCreate, SponsorshipCreate, UserPostCreate, | |
| SponsorshipApplicationCreate, SponsorshipPaymentCreate, CollaborationCreate | |
| ) |
🧰 Tools
🪛 Ruff (0.11.9)
4-4: ..db.db.AsyncSessionLocal imported but unused
Remove unused import: ..db.db.AsyncSessionLocal
(F401)
6-6: ..models.models.User imported but unused
Remove unused import
(F401)
6-6: ..models.models.AudienceInsights imported but unused
Remove unused import
(F401)
6-6: ..models.models.Sponsorship imported but unused
Remove unused import
(F401)
6-6: ..models.models.UserPost imported but unused
Remove unused import
(F401)
7-7: ..models.models.SponsorshipApplication imported but unused
Remove unused import
(F401)
7-7: ..models.models.SponsorshipPayment imported but unused
Remove unused import
(F401)
7-7: ..models.models.Collaboration imported but unused
Remove unused import
(F401)
🪛 Flake8 (7.2.0)
[error] 4-4: '..db.db.AsyncSessionLocal' imported but unused
(F401)
[error] 5-5: '..models.models.User' imported but unused
(F401)
[error] 5-5: '..models.models.AudienceInsights' imported but unused
(F401)
[error] 5-5: '..models.models.Sponsorship' imported but unused
(F401)
[error] 5-5: '..models.models.UserPost' imported but unused
(F401)
[error] 5-5: '..models.models.SponsorshipApplication' imported but unused
(F401)
[error] 5-5: '..models.models.SponsorshipPayment' imported but unused
(F401)
[error] 5-5: '..models.models.Collaboration' imported but unused
(F401)
🪛 Pylint (3.3.7)
[error] 4-4: Attempted relative import beyond top-level package
(E0402)
[error] 5-8: Attempted relative import beyond top-level package
(E0402)
[error] 9-12: Attempted relative import beyond top-level package
(E0402)
🤖 Prompt for AI Agents
In Backend/app/routes/post.py around lines 4 to 12, remove all imports related
to AsyncSessionLocal, model classes (User, AudienceInsights, Sponsorship,
UserPost, SponsorshipApplication, SponsorshipPayment, Collaboration), and schema
classes (UserCreate, AudienceInsightsCreate, SponsorshipCreate, UserPostCreate,
SponsorshipApplicationCreate, SponsorshipPaymentCreate, CollaborationCreate)
since they are not used in this file. This cleanup will keep the codebase tidy
and avoid unnecessary dependencies.
| export function MainNav() { | ||
| return ( | ||
| <nav className="hidden md:flex gap-6"> | ||
| <Link to="/features" className="text-sm font-medium transition-colors hover:text-primary"> | ||
| Features | ||
| </Link> | ||
| <Link to="/pricing" className="text-sm font-medium transition-colors hover:text-primary"> | ||
| Pricing | ||
| </Link> | ||
| <Link to="/about" className="text-sm font-medium transition-colors hover:text-primary"> | ||
| About | ||
| </Link> | ||
| <Link to="/contact" className="text-sm font-medium transition-colors hover:text-primary"> | ||
| Contact | ||
| </Link> | ||
| {/* Navigation items removed - keeping component for future use */} | ||
| </nav> | ||
| ) | ||
| } |
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
💡 Verification agent
🧩 Analysis chain
Consider component purpose and usage
The component now renders an empty navigation element. While the comment suggests it's kept for future use, consider whether:
- This component is still being imported and used elsewhere
- An empty nav element provides any value to the UI
- It might be better to temporarily remove the component entirely or implement a placeholder
🏁 Script executed:
#!/bin/bash
# Description: Check where MainNav is imported and used
# Expected: Find imports and usages of MainNav component
echo "=== Searching for MainNav imports ==="
rg "import.*MainNav" --type tsx --type ts
echo -e "\n=== Searching for MainNav usage ==="
rg "MainNav" --type tsx --type ts -A 2 -B 2Length of output: 315
Let’s retry searching without the --type flags, using globs instead:
🏁 Script executed:
#!/bin/bash
# Search for MainNav imports and usages using file globs
echo "=== Searching for MainNav imports ==="
rg "import.*MainNav" -g '*.tsx' -g '*.ts'
echo -e "\n=== Searching for JSX usage (<MainNav />) ==="
rg "<MainNav" -g '*.tsx' -g '*.ts'Length of output: 554
Empty MainNav is still imported and rendered
After verifying, MainNav is imported in two places but only rendered in one—and that render produces an empty <nav>:
• Frontend/src/pages/BasicDetails.tsx
– Imported but never used
• Frontend/src/pages/HomePage.tsx
– Rendered twice (<MainNav />) but shows no navigation items
Recommendations:
- Remove the unused import from BasicDetails.tsx
- Decide how you want to handle the empty nav in HomePage.tsx:
- Remove the
<MainNav />calls until actual menu items are added - Or update MainNav (Frontend/src/components/main-nav.tsx) with a placeholder (e.g. “Menu coming soon”) or real links
- Remove the
- If you intend to re-enable navigation soon, consider gating MainNav behind a feature flag or clearly marking it as “under construction”
Paths to update:
- Frontend/src/pages/BasicDetails.tsx
- Frontend/src/pages/HomePage.tsx
- Frontend/src/components/main-nav.tsx
🤖 Prompt for AI Agents
In Frontend/src/components/main-nav.tsx lines 3 to 9, the MainNav component
returns an empty nav element which is rendered twice in HomePage.tsx and
imported but unused in BasicDetails.tsx. To fix this, remove the unused MainNav
import from BasicDetails.tsx, then either remove the <MainNav /> calls in
HomePage.tsx until menu items are added or update MainNav to include a
placeholder message like "Menu coming soon" or actual navigation links.
Optionally, add a feature flag or a clear "under construction" comment in
MainNav to indicate its incomplete status.
| created_at = Column(TIMESTAMP, default=datetime.utcnow) | ||
|
|
||
| is_online = Column(Boolean, default=False) # ✅ Track if user is online | ||
| last_seen = Column( | ||
| DateTime(timezone=True), default=lambda: datetime.now(timezone.utc) | ||
| ) | ||
| last_seen = Column(TIMESTAMP, default=datetime.utcnow) |
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
Fix timezone inconsistency in timestamp handling.
The User model now uses timezone-naive datetime.utcnow() with TIMESTAMP columns, while other models in the same file still use timezone-aware datetime.now(timezone.utc) with DateTime(timezone=True). This inconsistency can cause issues when comparing timestamps across different models or when the database expects consistent timezone handling.
Consider one of these solutions:
Option 1 (Recommended): Use timezone-aware timestamps consistently
-from datetime import datetime
+from datetime import datetime, timezone
- created_at = Column(TIMESTAMP, default=datetime.utcnow)
+ created_at = Column(DateTime(timezone=True), default=lambda: datetime.now(timezone.utc))
- last_seen = Column(TIMESTAMP, default=datetime.utcnow)
+ last_seen = Column(DateTime(timezone=True), default=lambda: datetime.now(timezone.utc))Option 2: Update all models to use TIMESTAMP consistently
# Update all other DateTime(timezone=True) columns to use TIMESTAMP with datetime.utcnow📝 Committable suggestion
‼️ IMPORTANT
Carefully review the code before committing. Ensure that it accurately replaces the highlighted code, contains no missing lines, and has no issues with indentation. Thoroughly test & benchmark the code to ensure it meets the requirements.
| created_at = Column(TIMESTAMP, default=datetime.utcnow) | |
| is_online = Column(Boolean, default=False) # ✅ Track if user is online | |
| last_seen = Column( | |
| DateTime(timezone=True), default=lambda: datetime.now(timezone.utc) | |
| ) | |
| last_seen = Column(TIMESTAMP, default=datetime.utcnow) | |
| # at the top of the file, update the import | |
| -from datetime import datetime | |
| +from datetime import datetime, timezone | |
| # in your User model, replace the naive TIMESTAMP fields | |
| created_at = Column(DateTime(timezone=True), default=lambda: datetime.now(timezone.utc)) | |
| is_online = Column(Boolean, default=False) # ✅ Track if user is online | |
| - last_seen = Column(TIMESTAMP, default=datetime.utcnow) | |
| + last_seen = Column(DateTime(timezone=True), default=lambda: datetime.now(timezone.utc)) |
🤖 Prompt for AI Agents
In Backend/app/models/models.py around lines 35 to 38, the timestamp columns use
timezone-naive datetime.utcnow() with TIMESTAMP, causing inconsistency with
other models that use timezone-aware datetime.now(timezone.utc) and
DateTime(timezone=True). To fix this, update the created_at and last_seen
columns to use DateTime(timezone=True) and set their default to a timezone-aware
function like datetime.now(timezone.utc) to ensure consistent timezone handling
across all models.
| import { useState, useEffect } from "react"; | ||
| import { useNavigate } from "react-router-dom"; | ||
| import { ChevronRight } from "lucide-react"; | ||
| import { useAuth } from "../context/AuthContext"; | ||
| import { Info } from "lucide-react"; | ||
| import { supabase } from "../utils/supabase"; | ||
|
|
||
| const platforms = [ | ||
| { name: "YouTube", icon: "/youtube.png" }, | ||
| { name: "Instagram", icon: "/instagram.png" }, | ||
| { name: "Facebook", icon: "/facebook.png" }, | ||
| { name: "TikTok", icon: "/tiktok.png" }, | ||
| ]; | ||
|
|
||
| const steps = [ | ||
| "Role Selection", | ||
| "Personal Details", | ||
| "Platform Selection", | ||
| "Platform Details", | ||
| "Pricing", | ||
| "Profile Picture", | ||
| "Review & Submit", | ||
| ]; | ||
|
|
||
| const YOUTUBE_API_KEY = import.meta.env.VITE_YOUTUBE_API_KEY; | ||
|
|
||
| type BrandData = { | ||
| brand_name: string; | ||
| logo: File | null; | ||
| website_url: string; | ||
| industry: string; | ||
| company_size: string; | ||
| location: string; | ||
| description: string; | ||
| contact_person: string; | ||
| contact_email: string; | ||
| contact_phone: string; | ||
| role: string; | ||
| platforms: string[]; | ||
| social_links: Record<string, string>; | ||
| collaboration_types: string[]; | ||
| preferred_creator_categories: string[]; | ||
| brand_values: string[]; | ||
| preferred_tone: string[]; | ||
| }; | ||
|
|
||
| const brandInitialState: BrandData = { | ||
| brand_name: "", | ||
| logo: null, | ||
| website_url: "", | ||
| industry: "", | ||
| company_size: "", | ||
| location: "", | ||
| description: "", | ||
| contact_person: "", | ||
| contact_email: "", | ||
| contact_phone: "", | ||
| role: "", | ||
| platforms: [], | ||
| social_links: {}, | ||
| collaboration_types: [], | ||
| preferred_creator_categories: [], | ||
| brand_values: [], | ||
| preferred_tone: [], | ||
| }; | ||
|
|
||
| export default function Onboarding() { | ||
| const navigate = useNavigate(); | ||
| const { user } = useAuth(); | ||
| const [step, setStep] = useState(0); | ||
| const [role, setRole] = useState(""); | ||
| const [personal, setPersonal] = useState({ name: "", email: "", age: "", gender: "", country: "", category: "", otherCategory: "" }); | ||
| const [selectedPlatforms, setSelectedPlatforms] = useState<string[]>([]); | ||
| const [platformDetails, setPlatformDetails] = useState<any>({}); | ||
| const [pricing, setPricing] = useState<any>({}); | ||
| const [personalError, setPersonalError] = useState(""); | ||
| const [platformDetailsError, setPlatformDetailsError] = useState(""); | ||
| const [pricingError, setPricingError] = useState(""); | ||
| const [profilePic, setProfilePic] = useState<File | null>(null); | ||
| const [profilePicError, setProfilePicError] = useState(""); | ||
| const [submitError, setSubmitError] = useState(""); | ||
| const [submitSuccess, setSubmitSuccess] = useState(""); | ||
| const [submitting, setSubmitting] = useState(false); | ||
| const [progress, setProgress] = useState(0); | ||
| const [brandStep, setBrandStep] = useState(0); | ||
| const [brandData, setBrandData] = useState<BrandData>(brandInitialState); | ||
| const [brandLogoPreview, setBrandLogoPreview] = useState<string | null>(null); | ||
| const [brandError, setBrandError] = useState(""); | ||
|
|
||
| return ( | ||
| <div className="bg-gray-200 flex flex-col items-center justify-center px-4 py-16"> | ||
| <div className="max-w-md w-full text-center"> | ||
| <h1 className="text-3xl font-bold text-gray-900">Welcome to Inpact</h1> | ||
| <p className="mt-2 text-gray-600"> | ||
| Let's get you started with your profile setup | ||
| </p> | ||
| // Prefill name and email from Google user if available | ||
| useEffect(() => { | ||
| if (user) { | ||
| setPersonal((prev) => ({ | ||
| ...prev, | ||
| name: user.user_metadata?.name || prev.name, | ||
| email: user.email || prev.email, | ||
| })); | ||
| } | ||
| }, [user]); | ||
|
|
||
| // Validation for personal details | ||
| const validatePersonal = () => { | ||
| if (!personal.name || personal.name.length < 2) return "Please enter a valid name."; | ||
| if (!personal.email) return "Email is required."; | ||
| if (!personal.age || isNaN(Number(personal.age)) || Number(personal.age) < 10 || Number(personal.age) > 99) return "Please enter a valid age (10-99)."; | ||
| if (!personal.gender) return "Please select a gender."; | ||
| if (!personal.category) return "Please select a content category."; | ||
| if (personal.category === "Other" && !personal.otherCategory) return "Please enter your content category."; | ||
| if (!personal.country) return "Please enter a valid country."; | ||
| return ""; | ||
| }; | ||
|
|
||
| // Validation for platform details | ||
| const validatePlatformDetails = () => { | ||
| for (const platform of selectedPlatforms) { | ||
| const details = platformDetails[platform]; | ||
| if (!details) return `Please fill in all details for ${platform}.`; | ||
| if (platform === "YouTube") { | ||
| if (!details.channelUrl || !details.channelId || !details.channelName) return `Please provide a valid YouTube channel for ${platform}.`; | ||
| } else { | ||
| if (!details.profileUrl || !details.followers || !details.posts) return `Please fill in all details for ${platform}.`; | ||
| if (isNaN(Number(details.followers)) || isNaN(Number(details.posts))) return `Followers and posts must be numbers for ${platform}.`; | ||
| } | ||
| } | ||
| return ""; | ||
| }; | ||
|
|
||
| // Validation for pricing | ||
| const validatePricing = () => { | ||
| for (const platform of selectedPlatforms) { | ||
| const p = pricing[platform]; | ||
| if (!p) return `Please fill in pricing for ${platform}.`; | ||
| if (platform === "YouTube") { | ||
| if (!p.per_video_cost || !p.per_short_cost || !p.per_community_post_cost || !p.currency) return `Please fill all YouTube pricing fields.`; | ||
| if ([p.per_video_cost, p.per_short_cost, p.per_community_post_cost].some(v => isNaN(Number(v)))) return `YouTube pricing must be numbers.`; | ||
| } else if (platform === "Instagram") { | ||
| if (!p.per_post_cost || !p.per_story_cost || !p.per_reel_cost || !p.currency) return `Please fill all Instagram pricing fields.`; | ||
| if ([p.per_post_cost, p.per_story_cost, p.per_reel_cost].some(v => isNaN(Number(v)))) return `Instagram pricing must be numbers.`; | ||
| } else if (platform === "Facebook") { | ||
| if (!p.per_post_cost || !p.currency) return `Please fill all Facebook pricing fields.`; | ||
| if (isNaN(Number(p.per_post_cost))) return `Facebook pricing must be a number.`; | ||
| } else if (platform === "TikTok") { | ||
| if (!p.per_video_cost || !p.currency) return `Please fill all TikTok pricing fields.`; | ||
| if (isNaN(Number(p.per_video_cost))) return `TikTok pricing must be a number.`; | ||
| } | ||
| } | ||
| return ""; | ||
| }; | ||
|
|
||
| // Step 1: Role Selection | ||
| const renderRoleStep = () => ( | ||
| <div> | ||
| <h2 className="text-2xl font-bold mb-4">Are you a Brand or a Creator?</h2> | ||
| <div className="flex gap-8 justify-center"> | ||
| <button | ||
| className={`flex flex-col items-center px-8 py-4 rounded-xl border-2 text-lg font-semibold transition-all duration-200 shadow-sm w-48 h-48 ${role === "brand" ? "border-purple-600 bg-purple-50" : "border-gray-300 bg-white"}`} | ||
| onClick={() => setRole("brand")} | ||
| > | ||
| <img src="/brand.png" alt="Brand" className="h-20 w-20 mb-2" /> | ||
| <span className="font-bold text-xl">Brand</span> | ||
| </button> | ||
| <button | ||
| className={`flex flex-col items-center px-8 py-4 rounded-xl border-2 text-lg font-semibold transition-all duration-200 shadow-sm w-48 h-48 ${role === "creator" ? "border-purple-600 bg-purple-50" : "border-gray-300 bg-white"}`} | ||
| onClick={() => setRole("creator")} | ||
| > | ||
| <img src="/contnetcreator.png" alt="Content Creator" className="h-20 w-20 mb-2" /> | ||
| <span className="font-bold text-xl">Content Creator</span> | ||
| </button> | ||
| </div> | ||
| </div> | ||
| ); | ||
|
|
||
| // Step 2: Personal Details | ||
| const genderOptions = ["Male", "Female", "Non-binary", "Prefer not to say"]; | ||
| const categoryOptions = [ | ||
| "Tech", | ||
| "Fashion", | ||
| "Travel", | ||
| "Food", | ||
| "Fitness", | ||
| "Beauty", | ||
| "Gaming", | ||
| "Education", | ||
| "Music", | ||
| "Finance", | ||
| "Other", | ||
| ]; | ||
| const renderPersonalStep = () => ( | ||
| <div> | ||
| <h2 className="text-2xl font-bold mb-4">Personal Details</h2> | ||
| <form className="grid grid-cols-1 gap-4 max-w-lg mx-auto"> | ||
| <input | ||
| type="text" | ||
| placeholder="Name" | ||
| value={personal.name} | ||
| onChange={e => { | ||
| // Only allow letters, spaces, and basic punctuation | ||
| const value = e.target.value.replace(/[^a-zA-Z\s.'-]/g, ""); | ||
| setPersonal({ ...personal, name: value }); | ||
| }} | ||
| className="px-4 py-3 rounded-lg border border-gray-300 focus:outline-none focus:ring-2 focus:ring-purple-500" | ||
| required | ||
| /> | ||
| <input | ||
| type="email" | ||
| placeholder="Email" | ||
| value={personal.email} | ||
| onChange={e => setPersonal({ ...personal, email: e.target.value })} | ||
| className="px-4 py-3 rounded-lg border border-gray-300 focus:outline-none focus:ring-2 focus:ring-purple-500" | ||
| disabled | ||
| /> | ||
| <input | ||
| type="number" | ||
| placeholder="Age" | ||
| value={personal.age} | ||
| onChange={e => { | ||
| // Only allow numbers and limit to 2 digits | ||
| let value = e.target.value.replace(/[^0-9]/g, ""); | ||
| if (value.length > 2) value = value.slice(0, 2); | ||
| setPersonal({ ...personal, age: value }); | ||
| }} | ||
| className="px-4 py-3 rounded-lg border border-gray-300 focus:outline-none focus:ring-2 focus:ring-purple-500" | ||
| min={10} | ||
| max={99} | ||
| required | ||
| /> | ||
| <select | ||
| value={personal.gender} | ||
| onChange={e => setPersonal({ ...personal, gender: e.target.value })} | ||
| className="px-4 py-3 rounded-lg border border-gray-300 focus:outline-none focus:ring-2 focus:ring-purple-500" | ||
| required | ||
| > | ||
| <option value="" disabled> | ||
| Select Gender | ||
| </option> | ||
| {genderOptions.map(opt => ( | ||
| <option key={opt} value={opt}>{opt}</option> | ||
| ))} | ||
| </select> | ||
| <select | ||
| value={personal.category} | ||
| onChange={e => setPersonal({ ...personal, category: e.target.value })} | ||
| className="px-4 py-3 rounded-lg border border-gray-300 focus:outline-none focus:ring-2 focus:ring-purple-500" | ||
| required | ||
| > | ||
| <option value="" disabled> | ||
| Select Content Category | ||
| </option> | ||
| {categoryOptions.map(opt => ( | ||
| <option key={opt} value={opt}>{opt}</option> | ||
| ))} | ||
| </select> | ||
| {personal.category === "Other" && ( | ||
| <input | ||
| type="text" | ||
| placeholder="Enter your content category" | ||
| value={personal.otherCategory || ""} | ||
| onChange={e => setPersonal({ ...personal, otherCategory: e.target.value })} | ||
| className="px-4 py-3 rounded-lg border border-gray-300 focus:outline-none focus:ring-2 focus:ring-purple-500" | ||
| required | ||
| /> | ||
| )} | ||
| <input | ||
| type="text" | ||
| placeholder="Country" | ||
| value={personal.country} | ||
| onChange={e => { | ||
| // Only allow letters and spaces | ||
| const value = e.target.value.replace(/[^a-zA-Z\s]/g, ""); | ||
| setPersonal({ ...personal, country: value }); | ||
| }} | ||
| className="px-4 py-3 rounded-lg border border-gray-300 focus:outline-none focus:ring-2 focus:ring-purple-500" | ||
| required | ||
| /> | ||
| </form> | ||
| {personalError && <div className="text-red-500 text-sm mt-2">{personalError}</div>} | ||
| </div> | ||
| ); | ||
|
|
||
| <div className="mt-6 space-y-4"> | ||
| {/* Influencer Button */} | ||
| // Step 3: Platform Selection | ||
| const renderPlatformStep = () => ( | ||
| <div> | ||
| <h2 className="text-2xl font-bold mb-4">Which platforms do you use?</h2> | ||
| <div className="flex flex-wrap gap-6 justify-center"> | ||
| {platforms.map((platform) => ( | ||
| <button | ||
| onClick={() => navigate("/signup")} | ||
| className="w-full flex items-center justify-between bg-purple-600 text-white font-medium px-6 py-3 rounded-lg shadow-md hover:bg-purple-700 transition" | ||
| key={platform.name} | ||
| type="button" | ||
| onClick={() => { | ||
| setSelectedPlatforms((prev) => | ||
| prev.includes(platform.name) | ||
| ? prev.filter((p) => p !== platform.name) | ||
| : [...prev, platform.name] | ||
| ); | ||
| }} | ||
| className={`flex flex-col items-center px-6 py-4 rounded-xl border-2 transition-all duration-200 shadow-sm w-32 h-36 ${selectedPlatforms.includes(platform.name) ? "border-purple-600 bg-purple-50" : "border-gray-300 bg-white"}`} | ||
| > | ||
| I'm an Influencer | ||
| <ChevronRight className="h-5 w-5" /> | ||
| <img src={platform.icon} alt={platform.name} className="h-12 w-12 mb-2" /> | ||
| <span className="font-semibold text-lg">{platform.name}</span> | ||
| </button> | ||
| ))} | ||
| </div> | ||
| </div> | ||
| ); | ||
|
|
||
| // Step 4: Platform Details | ||
| const renderPlatformDetailsStep = () => ( | ||
| <div> | ||
| <h2 className="text-2xl font-bold mb-4">Platform Details</h2> | ||
| <div className="space-y-8"> | ||
| {selectedPlatforms.map((platform) => ( | ||
| <div key={platform} className="bg-gray-50 dark:bg-gray-700 rounded-xl p-6 shadow"> | ||
| <div className="flex items-center gap-4 mb-4"> | ||
| <img src={platforms.find(p => p.name === platform)?.icon} alt={platform} className="h-8 w-8" /> | ||
| <span className="font-semibold text-lg">{platform}</span> | ||
| </div> | ||
| {platform === "YouTube" && ( | ||
| <YouTubeDetails | ||
| details={platformDetails[platform] || {}} | ||
| setDetails={d => setPlatformDetails((prev: any) => ({ ...prev, [platform]: d }))} | ||
| /> | ||
| )} | ||
| {platform === "Instagram" && ( | ||
| <InstagramDetails | ||
| details={platformDetails[platform] || {}} | ||
| setDetails={d => setPlatformDetails((prev: any) => ({ ...prev, [platform]: d }))} | ||
| /> | ||
| )} | ||
| {platform === "Facebook" && ( | ||
| <FacebookDetails | ||
| details={platformDetails[platform] || {}} | ||
| setDetails={d => setPlatformDetails((prev: any) => ({ ...prev, [platform]: d }))} | ||
| /> | ||
| )} | ||
| {platform === "TikTok" && ( | ||
| <TikTokDetails | ||
| details={platformDetails[platform] || {}} | ||
| setDetails={d => setPlatformDetails((prev: any) => ({ ...prev, [platform]: d }))} | ||
| /> | ||
| )} | ||
| </div> | ||
| ))} | ||
| </div> | ||
| {platformDetailsError && <div className="text-red-500 text-sm mt-2">{platformDetailsError}</div>} | ||
| </div> | ||
| ); | ||
|
|
||
| // Step 5: Pricing | ||
| const renderPricingStep = () => ( | ||
| <div> | ||
| <h2 className="text-2xl font-bold mb-4">Set Your Pricing</h2> | ||
| <div className="space-y-8"> | ||
| {selectedPlatforms.map((platform) => ( | ||
| <div key={platform} className="bg-gray-50 dark:bg-gray-700 rounded-xl p-6 shadow"> | ||
| <div className="flex items-center gap-4 mb-4"> | ||
| <img src={platforms.find(p => p.name === platform)?.icon} alt={platform} className="h-8 w-8" /> | ||
| <span className="font-semibold text-lg">{platform}</span> | ||
| </div> | ||
| {platform === "YouTube" && ( | ||
| <YouTubePricing | ||
| pricing={pricing[platform] || {}} | ||
| setPricing={d => setPricing((prev: any) => ({ ...prev, [platform]: d }))} | ||
| /> | ||
| )} | ||
| {platform === "Instagram" && ( | ||
| <InstagramPricing | ||
| pricing={pricing[platform] || {}} | ||
| setPricing={d => setPricing((prev: any) => ({ ...prev, [platform]: d }))} | ||
| /> | ||
| )} | ||
| {platform === "Facebook" && ( | ||
| <FacebookPricing | ||
| pricing={pricing[platform] || {}} | ||
| setPricing={d => setPricing((prev: any) => ({ ...prev, [platform]: d }))} | ||
| /> | ||
| )} | ||
| {platform === "TikTok" && ( | ||
| <TikTokPricing | ||
| pricing={pricing[platform] || {}} | ||
| setPricing={d => setPricing((prev: any) => ({ ...prev, [platform]: d }))} | ||
| /> | ||
| )} | ||
| </div> | ||
| ))} | ||
| </div> | ||
| {pricingError && <div className="text-red-500 text-sm mt-2">{pricingError}</div>} | ||
| </div> | ||
| ); | ||
|
|
||
| // Step 5: Profile Picture Upload (new step) | ||
| const handleProfilePicChange = (e: React.ChangeEvent<HTMLInputElement>) => { | ||
| setProfilePicError(""); | ||
| if (e.target.files && e.target.files[0]) { | ||
| const file = e.target.files[0]; | ||
| if (file.size > 3 * 1024 * 1024) { | ||
| setProfilePicError("File size must be less than 3MB."); | ||
| setProfilePic(null); | ||
| return; | ||
| } | ||
| setProfilePic(file); | ||
| } | ||
| }; | ||
|
|
||
| {/* Brand Button */} | ||
| const renderProfilePicStep = () => ( | ||
| <div> | ||
| <h2 className="text-2xl font-bold mb-4">Upload Profile Picture</h2> | ||
| <div className="flex flex-col items-start gap-2"> | ||
| <label htmlFor="profile-pic-upload" className="inline-block px-6 py-2 bg-purple-600 text-white rounded-lg font-semibold cursor-pointer hover:bg-purple-700 transition-colors"> | ||
| Choose File | ||
| </label> | ||
| <input | ||
| id="profile-pic-upload" | ||
| type="file" | ||
| accept="image/*" | ||
| onChange={handleProfilePicChange} | ||
| className="hidden" | ||
| /> | ||
| <div className="flex items-center gap-4 mt-2"> | ||
| {(profilePic || user?.user_metadata?.avatar_url) ? ( | ||
| <img | ||
| src={profilePic ? URL.createObjectURL(profilePic) : user?.user_metadata?.avatar_url} | ||
| alt="Profile Preview" | ||
| className="h-20 w-20 rounded-full object-cover border-2 border-purple-500" | ||
| /> | ||
| ) : ( | ||
| <div className="h-20 w-20 rounded-full bg-gray-200 flex items-center justify-center text-gray-400">No Image</div> | ||
| )} | ||
| {profilePic && <div className="text-sm">{profilePic.name}</div>} | ||
| </div> | ||
| {profilePicError && <div className="text-red-500 text-sm mt-2">{profilePicError}</div>} | ||
| <div className="text-xs text-gray-500 mt-2">Max file size: 3MB. You can skip this step if you want to use your Google/YouTube profile image.</div> | ||
| </div> | ||
| </div> | ||
| ); | ||
|
|
||
| // Step 6: Review & Submit | ||
| const handleSubmit = async () => { | ||
| setSubmitting(true); | ||
| setSubmitError(""); | ||
| setSubmitSuccess(""); | ||
| setProgress(0); | ||
| let profile_image_url = null; | ||
| try { | ||
| // 1. Upload profile picture if provided | ||
| if (profilePic) { | ||
| setProgress(20); | ||
| const fileExt = profilePic.name.split('.').pop(); | ||
| const fileName = `${user?.id}_${Date.now()}.${fileExt}`; | ||
| const { data, error } = await supabase.storage.from('profile-pictures').upload(fileName, profilePic); | ||
| if (error) throw error; | ||
| profile_image_url = `${supabase.storage.from('profile-pictures').getPublicUrl(fileName).data.publicUrl}`; | ||
| } else if (user?.user_metadata?.avatar_url) { | ||
| profile_image_url = user.user_metadata.avatar_url; | ||
| } | ||
| setProgress(40); | ||
| // 2. Update users table | ||
| const categoryToSave = personal.category === 'Other' ? personal.otherCategory : personal.category; | ||
| const { error: userError } = await supabase.from('users').update({ | ||
| username: personal.name, | ||
| age: personal.age, | ||
| gender: personal.gender, | ||
| country: personal.country, | ||
| category: categoryToSave, | ||
| profile_image: profile_image_url, | ||
| role, | ||
| }).eq('id', user?.id); | ||
| if (userError) throw userError; | ||
| setProgress(60); | ||
| // 3. Insert social_profiles for each platform | ||
| for (const platform of selectedPlatforms) { | ||
| const details = platformDetails[platform]; | ||
| const p = pricing[platform]; | ||
| const profileData: any = { | ||
| user_id: user?.id, | ||
| platform, | ||
| per_post_cost: p?.per_post_cost ? Number(p.per_post_cost) : null, | ||
| per_story_cost: p?.per_story_cost ? Number(p.per_story_cost) : null, | ||
| per_reel_cost: p?.per_reel_cost ? Number(p.per_reel_cost) : null, | ||
| per_video_cost: p?.per_video_cost ? Number(p.per_video_cost) : null, | ||
| per_short_cost: p?.per_short_cost ? Number(p.per_short_cost) : null, | ||
| per_community_post_cost: p?.per_community_post_cost ? Number(p.per_community_post_cost) : null, | ||
| per_post_cost_currency: p?.currency || null, | ||
| per_story_cost_currency: p?.currency || null, | ||
| per_reel_cost_currency: p?.currency || null, | ||
| per_video_cost_currency: p?.currency || null, | ||
| per_short_cost_currency: p?.currency || null, | ||
| per_community_post_cost_currency: p?.currency || null, | ||
| }; | ||
| if (platform === 'YouTube') { | ||
| Object.assign(profileData, { | ||
| channel_id: details.channelId, | ||
| channel_name: details.channelName, | ||
| profile_image: details.profile_image, | ||
| subscriber_count: details.subscriber_count ? Number(details.subscriber_count) : null, | ||
| total_views: details.total_views ? Number(details.total_views) : null, | ||
| video_count: details.video_count ? Number(details.video_count) : null, | ||
| channel_url: details.channelUrl, | ||
| }); | ||
| } else { | ||
| Object.assign(profileData, { | ||
| username: details.profileUrl, | ||
| followers: details.followers ? Number(details.followers) : null, | ||
| posts: details.posts ? Number(details.posts) : null, | ||
| profile_image: null, | ||
| channel_url: details.profileUrl, | ||
| }); | ||
| } | ||
| // Upsert to avoid duplicates | ||
| const { error: spError } = await supabase.from('social_profiles').upsert(profileData, { onConflict: 'user_id,platform' }); | ||
| if (spError) throw spError; | ||
| } | ||
| setProgress(90); | ||
| setSubmitSuccess('Onboarding complete! Your details have been saved.'); | ||
| setProgress(100); | ||
| // Route based on role | ||
| if (role === "brand") { | ||
| setTimeout(() => navigate('/brand/dashboard'), 1200); | ||
| } else { | ||
| setTimeout(() => navigate('/dashboard'), 1200); | ||
| } | ||
| } catch (err: any) { | ||
| setSubmitError(err.message || 'Failed to submit onboarding data.'); | ||
| setProgress(0); | ||
| } finally { | ||
| setSubmitting(false); | ||
| } | ||
| }; | ||
|
|
||
| const renderReviewStep = () => ( | ||
| <div> | ||
| <h2 className="text-2xl font-bold mb-4">Review & Submit</h2> | ||
| {submitting && ( | ||
| <div className="w-full bg-gray-200 rounded-full h-3 mb-4 overflow-hidden"> | ||
| <div | ||
| className="bg-purple-600 h-3 rounded-full transition-all duration-300" | ||
| style={{ width: `${progress}%` }} | ||
| ></div> | ||
| </div> | ||
| )} | ||
| <div className="mb-4"> | ||
| <label className="block font-medium mb-2">Profile Picture</label> | ||
| <div className="flex items-center gap-4"> | ||
| {(profilePic || user?.user_metadata?.avatar_url) ? ( | ||
| <img | ||
| src={profilePic ? URL.createObjectURL(profilePic) : user?.user_metadata?.avatar_url} | ||
| alt="Profile Preview" | ||
| className="h-20 w-20 rounded-full object-cover border-2 border-purple-500" | ||
| /> | ||
| ) : ( | ||
| <div className="h-20 w-20 rounded-full bg-gray-200 flex items-center justify-center text-gray-400">No Image</div> | ||
| )} | ||
| {profilePic && <div className="text-sm">{profilePic.name}</div>} | ||
| </div> | ||
| </div> | ||
| <div className="mb-4"> | ||
| <h3 className="font-semibold">Personal Details</h3> | ||
| <ul className="text-sm"> | ||
| <li><b>Name:</b> {personal.name}</li> | ||
| <li><b>Email:</b> {personal.email}</li> | ||
| <li><b>Age:</b> {personal.age}</li> | ||
| <li><b>Gender:</b> {personal.gender}</li> | ||
| <li><b>Country:</b> {personal.country}</li> | ||
| <li><b>Category:</b> {personal.category === 'Other' ? personal.otherCategory : personal.category}</li> | ||
| </ul> | ||
| </div> | ||
| <div className="mb-4"> | ||
| <h3 className="font-semibold">Platforms</h3> | ||
| {selectedPlatforms.map(platform => ( | ||
| <div key={platform} className="mb-2"> | ||
| <b>{platform}</b> | ||
| <ul className="text-sm ml-4"> | ||
| {platform === 'YouTube' ? ( | ||
| <> | ||
| <li><b>Channel Name:</b> {platformDetails[platform]?.channelName}</li> | ||
| <li><b>Subscribers:</b> {platformDetails[platform]?.subscriber_count}</li> | ||
| <li><b>Videos:</b> {platformDetails[platform]?.video_count}</li> | ||
| <li><b>Views:</b> {platformDetails[platform]?.total_views}</li> | ||
| <li><b>Channel URL:</b> {platformDetails[platform]?.channelUrl}</li> | ||
| <li><b>Pricing:</b> Video: {pricing[platform]?.per_video_cost}, Short: {pricing[platform]?.per_short_cost}, Community Post: {pricing[platform]?.per_community_post_cost} ({pricing[platform]?.currency})</li> | ||
| </> | ||
| ) : ( | ||
| <> | ||
| <li><b>Profile URL:</b> {platformDetails[platform]?.profileUrl}</li> | ||
| <li><b>Followers:</b> {platformDetails[platform]?.followers}</li> | ||
| <li><b>Posts:</b> {platformDetails[platform]?.posts}</li> | ||
| <li><b>Pricing:</b> {platform === 'Instagram' ? `Post: ${pricing[platform]?.per_post_cost}, Story: ${pricing[platform]?.per_story_cost}, Reel: ${pricing[platform]?.per_reel_cost}` : `Post/Video: ${pricing[platform]?.per_post_cost || pricing[platform]?.per_video_cost}`} ({pricing[platform]?.currency})</li> | ||
| </> | ||
| )} | ||
| </ul> | ||
| </div> | ||
| ))} | ||
| </div> | ||
| {submitError && <div className="text-red-500 text-sm mb-2">{submitError}</div>} | ||
| {submitSuccess && <div className="text-green-600 text-sm mb-2">{submitSuccess}</div>} | ||
| </div> | ||
| ); | ||
|
|
||
| const handleNext = () => { | ||
| if (step === 1) { | ||
| const err = validatePersonal(); | ||
| if (err) { | ||
| setPersonalError(err); | ||
| return; | ||
| } else { | ||
| setPersonalError(""); | ||
| } | ||
| } | ||
| if (step === 3) { | ||
| const err = validatePlatformDetails(); | ||
| if (err) { | ||
| setPlatformDetailsError(err); | ||
| return; | ||
| } else { | ||
| setPlatformDetailsError(""); | ||
| } | ||
| } | ||
| if (step === 4) { | ||
| const err = validatePricing(); | ||
| if (err) { | ||
| setPricingError(err); | ||
| return; | ||
| } else { | ||
| setPricingError(""); | ||
| } | ||
| } | ||
| if (step < steps.length - 1) setStep(step + 1); | ||
| }; | ||
| const handleBack = () => { | ||
| if (step > 0) setStep(step - 1); | ||
| }; | ||
|
|
||
| // Brand onboarding steps | ||
| const brandSteps = [ | ||
| "Brand Details", | ||
| "Contact Information", | ||
| "Platforms", | ||
| "Social Links", | ||
| "Collaboration Preferences", | ||
| "Review & Submit", | ||
| ]; | ||
|
|
||
| // Brand Step 1: Brand Details | ||
| const companySizes = ["1-10", "11-50", "51-200", "201-1000", "1000+"]; | ||
| const industries = ["Tech", "Fashion", "Travel", "Food", "Fitness", "Beauty", "Gaming", "Education", "Music", "Finance", "Other"]; | ||
| const handleBrandLogoChange = (e: React.ChangeEvent<HTMLInputElement>) => { | ||
| if (e.target.files && e.target.files[0]) { | ||
| setBrandData({ ...brandData, logo: e.target.files[0] }); | ||
| setBrandLogoPreview(URL.createObjectURL(e.target.files[0])); | ||
| } | ||
| }; | ||
| const renderBrandDetailsStep = () => ( | ||
| <div> | ||
| <h2 className="text-2xl font-bold mb-4">Brand Details</h2> | ||
| <input | ||
| type="text" | ||
| placeholder="Brand Name" | ||
| value={brandData.brand_name} | ||
| onChange={e => setBrandData({ ...brandData, brand_name: e.target.value })} | ||
| className="w-full px-4 py-3 rounded-lg border border-gray-300 mb-2" | ||
| /> | ||
| <label className="block font-medium mb-1">Logo (optional)</label> | ||
| <label htmlFor="brand-logo-upload" className="inline-block px-4 py-2 bg-purple-600 text-white rounded-lg font-semibold cursor-pointer hover:bg-purple-700 mb-2">Choose Logo</label> | ||
| <input id="brand-logo-upload" type="file" accept="image/*" onChange={handleBrandLogoChange} className="hidden" /> | ||
| {brandLogoPreview && <img src={brandLogoPreview} alt="Logo Preview" className="h-16 w-16 rounded-full object-cover border mb-2" />} | ||
| <input | ||
| type="text" | ||
| placeholder="Website URL" | ||
| value={brandData.website_url} | ||
| onChange={e => setBrandData({ ...brandData, website_url: e.target.value })} | ||
| className="w-full px-4 py-3 rounded-lg border border-gray-300 mb-2" | ||
| /> | ||
| <select | ||
| value={brandData.industry} | ||
| onChange={e => setBrandData({ ...brandData, industry: e.target.value })} | ||
| className="w-full px-4 py-3 rounded-lg border border-gray-300 mb-2" | ||
| > | ||
| <option value="" disabled>Select Industry</option> | ||
| {industries.map(ind => <option key={ind} value={ind}>{ind}</option>)} | ||
| </select> | ||
| <select | ||
| value={brandData.company_size} | ||
| onChange={e => setBrandData({ ...brandData, company_size: e.target.value })} | ||
| className="w-full px-4 py-3 rounded-lg border border-gray-300 mb-2" | ||
| > | ||
| <option value="" disabled>Select Company Size</option> | ||
| {companySizes.map(size => <option key={size} value={size}>{size}</option>)} | ||
| </select> | ||
| <input | ||
| type="text" | ||
| placeholder="Location" | ||
| value={brandData.location} | ||
| onChange={e => setBrandData({ ...brandData, location: e.target.value })} | ||
| className="w-full px-4 py-3 rounded-lg border border-gray-300 mb-2" | ||
| /> | ||
| <textarea | ||
| placeholder="Description" | ||
| value={brandData.description} | ||
| onChange={e => setBrandData({ ...brandData, description: e.target.value })} | ||
| className="w-full px-4 py-3 rounded-lg border border-gray-300 mb-2" | ||
| rows={3} | ||
| /> | ||
| </div> | ||
| ); | ||
|
|
||
| // Brand Step 2: Contact Information | ||
| const renderBrandContactStep = () => ( | ||
| <div> | ||
| <h2 className="text-2xl font-bold mb-4">Contact Information</h2> | ||
| <input | ||
| type="text" | ||
| placeholder="Contact Person" | ||
| value={brandData.contact_person} | ||
| onChange={e => setBrandData({ ...brandData, contact_person: e.target.value })} | ||
| className="w-full px-4 py-3 rounded-lg border border-gray-300 mb-2" | ||
| /> | ||
| <input | ||
| type="email" | ||
| placeholder="Contact Email" | ||
| value={brandData.contact_email} | ||
| onChange={e => setBrandData({ ...brandData, contact_email: e.target.value })} | ||
| className="w-full px-4 py-3 rounded-lg border border-gray-300 mb-2" | ||
| /> | ||
| <input | ||
| type="text" | ||
| placeholder="Contact Phone" | ||
| value={brandData.contact_phone} | ||
| onChange={e => setBrandData({ ...brandData, contact_phone: e.target.value })} | ||
| className="w-full px-4 py-3 rounded-lg border border-gray-300 mb-2" | ||
| /> | ||
| <input | ||
| type="text" | ||
| placeholder="Role at Company" | ||
| value={brandData.role} | ||
| onChange={e => setBrandData({ ...brandData, role: e.target.value })} | ||
| className="w-full px-4 py-3 rounded-lg border border-gray-300 mb-2" | ||
| /> | ||
| </div> | ||
| ); | ||
|
|
||
| // Brand Step 3: Platforms | ||
| const allBrandPlatforms = [ | ||
| { name: "Instagram", key: "instagram_url" }, | ||
| { name: "YouTube", key: "youtube_url" }, | ||
| { name: "Facebook", key: "facebook_url" }, | ||
| { name: "Twitter", key: "twitter_url" }, | ||
| { name: "LinkedIn", key: "linkedin_url" }, | ||
| // Add TikTok if needed | ||
| ]; | ||
| const renderBrandPlatformsStep = () => ( | ||
| <div> | ||
| <h2 className="text-2xl font-bold mb-4">Which platforms is your brand on?</h2> | ||
| <div className="flex flex-wrap gap-4"> | ||
| {allBrandPlatforms.map(platform => ( | ||
| <button | ||
| onClick={() => navigate("/signup")} | ||
| className="w-full flex items-center justify-between bg-purple-600 text-white font-medium px-6 py-3 rounded-lg shadow-md hover:bg-purple-700 transition" | ||
| key={platform.name} | ||
| type="button" | ||
| onClick={() => { | ||
| setBrandData(prev => { | ||
| const exists = prev.platforms.includes(platform.name); | ||
| return { | ||
| ...prev, | ||
| platforms: exists | ||
| ? prev.platforms.filter(p => p !== platform.name) | ||
| : [...prev.platforms, platform.name], | ||
| }; | ||
| }); | ||
| }} | ||
| className={`px-6 py-3 rounded-lg border-2 font-semibold ${brandData.platforms.includes(platform.name) ? "border-purple-600 bg-purple-50" : "border-gray-300 bg-white"}`} | ||
| > | ||
| I'm a Brand | ||
| <ChevronRight className="h-5 w-5" /> | ||
| {platform.name} | ||
| </button> | ||
| ))} | ||
| </div> | ||
| </div> | ||
| ); | ||
|
|
||
| // Brand Step 4: Social Links (conditional) | ||
| const socialLinkExamples: Record<string, string> = { | ||
| instagram_url: "https://instagram.com/yourbrand", | ||
| youtube_url: "https://youtube.com/yourbrand", | ||
| facebook_url: "https://facebook.com/yourbrand", | ||
| twitter_url: "https://twitter.com/yourbrand", | ||
| linkedin_url: "https://linkedin.com/company/yourbrand", | ||
| }; | ||
| const renderBrandSocialLinksStep = () => ( | ||
| <div> | ||
| <h2 className="text-2xl font-bold mb-4">Social Links</h2> | ||
| {brandData.platforms.map(platform => { | ||
| const key = allBrandPlatforms.find(p => p.name === platform)?.key; | ||
| if (!key) return null; | ||
| return ( | ||
| <div key={platform} className="mb-2"> | ||
| <label className="block font-medium mb-1">{platform} URL</label> | ||
| <input | ||
| type="text" | ||
| placeholder={socialLinkExamples[key] || `Enter your ${platform} URL`} | ||
| value={brandData.social_links[key] || ""} | ||
| onChange={e => setBrandData({ | ||
| ...brandData, | ||
| social_links: { ...brandData.social_links, [key]: e.target.value }, | ||
| })} | ||
| className="w-full px-4 py-3 rounded-lg border border-gray-300" | ||
| /> | ||
| </div> | ||
| ); | ||
| })} | ||
| </div> | ||
| ); | ||
|
|
||
| // Brand Step 5: Collaboration Preferences | ||
| const collabTypes = ["Sponsored Posts", "Giveaways", "Product Reviews", "Long-term Partnerships", "Affiliate Marketing", "Events", "Content Creation", "Brand Ambassadorship", "Social Media Takeover", "Other"]; | ||
| const creatorCategories = ["Tech", "Fashion", "Travel", "Food", "Fitness", "Beauty", "Gaming", "Education", "Music", "Finance", "Other"]; | ||
| const brandValues = ["Sustainability", "Innovation", "Diversity", "Quality", "Community", "Transparency", "Customer Focus", "Creativity", "Integrity", "Other"]; | ||
| const tones = ["Professional", "Friendly", "Humorous", "Inspirational", "Bold", "Casual", "Formal", "Playful", "Serious", "Other"]; | ||
| const toggleMultiSelect = (field: keyof BrandData, value: string) => { | ||
| setBrandData(prev => { | ||
| const arr = prev[field] as string[]; | ||
| return { | ||
| ...prev, | ||
| [field]: arr.includes(value) ? arr.filter(v => v !== value) : [...arr, value], | ||
| }; | ||
| }); | ||
| }; | ||
| const renderBrandCollabPrefsStep = () => ( | ||
| <div> | ||
| <h2 className="text-2xl font-bold mb-4">Collaboration Preferences</h2> | ||
| <div className="mb-4"> | ||
| <label className="block font-medium mb-1">Collaboration Types</label> | ||
| <div className="flex flex-wrap gap-2"> | ||
| {collabTypes.map(type => ( | ||
| <button | ||
| key={type} | ||
| type="button" | ||
| onClick={() => toggleMultiSelect("collaboration_types", type)} | ||
| className={`px-4 py-2 rounded-lg border-2 text-sm ${brandData.collaboration_types.includes(type) ? "border-purple-600 bg-purple-50" : "border-gray-300 bg-white"}`} | ||
| > | ||
| {type} | ||
| </button> | ||
| ))} | ||
| </div> | ||
| </div> | ||
| <div className="mb-4"> | ||
| <label className="block font-medium mb-1">Preferred Creator Categories</label> | ||
| <div className="flex flex-wrap gap-2"> | ||
| {creatorCategories.map(cat => ( | ||
| <button | ||
| key={cat} | ||
| type="button" | ||
| onClick={() => toggleMultiSelect("preferred_creator_categories", cat)} | ||
| className={`px-4 py-2 rounded-lg border-2 text-sm ${brandData.preferred_creator_categories.includes(cat) ? "border-purple-600 bg-purple-50" : "border-gray-300 bg-white"}`} | ||
| > | ||
| {cat} | ||
| </button> | ||
| ))} | ||
| </div> | ||
| </div> | ||
| <div className="mb-4"> | ||
| <label className="block font-medium mb-1">Brand Values</label> | ||
| <div className="flex flex-wrap gap-2"> | ||
| {brandValues.map(val => ( | ||
| <button | ||
| key={val} | ||
| type="button" | ||
| onClick={() => toggleMultiSelect("brand_values", val)} | ||
| className={`px-4 py-2 rounded-lg border-2 text-sm ${brandData.brand_values.includes(val) ? "border-purple-600 bg-purple-50" : "border-gray-300 bg-white"}`} | ||
| > | ||
| {val} | ||
| </button> | ||
| ))} | ||
| </div> | ||
| </div> | ||
| <div className="mb-4"> | ||
| <label className="block font-medium mb-1">Preferred Tone</label> | ||
| <div className="flex flex-wrap gap-2"> | ||
| {tones.map(tone => ( | ||
| <button | ||
| key={tone} | ||
| type="button" | ||
| onClick={() => toggleMultiSelect("preferred_tone", tone)} | ||
| className={`px-4 py-2 rounded-lg border-2 text-sm ${brandData.preferred_tone.includes(tone) ? "border-purple-600 bg-purple-50" : "border-gray-300 bg-white"}`} | ||
| > | ||
| {tone} | ||
| </button> | ||
| ))} | ||
| </div> | ||
| </div> | ||
| </div> | ||
| ); | ||
|
|
||
| // Brand step validation | ||
| const validateBrandStep = () => { | ||
| if (brandStep === 0) { | ||
| if (!brandData.brand_name) return "Brand name is required."; | ||
| if (!brandData.website_url) return "Website URL is required."; | ||
| if (!brandData.industry) return "Industry is required."; | ||
| if (!brandData.company_size) return "Company size is required."; | ||
| if (!brandData.location) return "Location is required."; | ||
| if (!brandData.description) return "Description is required."; | ||
| } | ||
| if (brandStep === 1) { | ||
| if (!brandData.contact_person) return "Contact person is required."; | ||
| if (!brandData.contact_email || !/^[^@\s]+@[^@\s]+\.[^@\s]+$/.test(brandData.contact_email)) return "Valid contact email is required."; | ||
| } | ||
| if (brandStep === 2) { | ||
| if (!brandData.platforms.length) return "Select at least one platform."; | ||
| } | ||
| if (brandStep === 3) { | ||
| for (const platform of brandData.platforms) { | ||
| const key = allBrandPlatforms.find(p => p.name === platform)?.key; | ||
| if (key && !brandData.social_links[key]) return `Enter your ${platform} URL.`; | ||
| if (key && brandData.social_links[key] && !/^https?:\/\//.test(brandData.social_links[key])) return `${platform} URL must start with http:// or https://`; | ||
| } | ||
| } | ||
| if (brandStep === 4) { | ||
| if (!brandData.collaboration_types.length) return "Select at least one collaboration type."; | ||
| if (!brandData.preferred_creator_categories.length) return "Select at least one creator category."; | ||
| if (!brandData.brand_values.length) return "Select at least one brand value."; | ||
| if (!brandData.preferred_tone.length) return "Select at least one preferred tone."; | ||
| } | ||
| return ""; | ||
| }; | ||
| const handleBrandNext = () => { | ||
| const err = validateBrandStep(); | ||
| if (err) { | ||
| setBrandError(err); | ||
| return; | ||
| } else { | ||
| setBrandError(""); | ||
| } | ||
| if (brandStep < brandSteps.length - 1) setBrandStep(brandStep + 1); | ||
| }; | ||
| const handleBrandBack = () => { | ||
| if (brandStep > 0) setBrandStep(brandStep - 1); | ||
| }; | ||
|
|
||
| // Brand Step 6: Review & Submit | ||
| const [brandSubmitting, setBrandSubmitting] = useState(false); | ||
| const [brandSubmitError, setBrandSubmitError] = useState(""); | ||
| const [brandSubmitSuccess, setBrandSubmitSuccess] = useState(""); | ||
| const handleBrandSubmit = async () => { | ||
| setBrandSubmitting(true); | ||
| setBrandSubmitError(""); | ||
| setBrandSubmitSuccess(""); | ||
| let logo_url = null; | ||
| try { | ||
| // 1. Upload logo if provided | ||
| if (brandData.logo) { | ||
| const fileExt = brandData.logo.name.split('.').pop(); | ||
| const fileName = `${user?.id}_${Date.now()}.${fileExt}`; | ||
| const { data, error } = await supabase.storage.from('brand-logos').upload(fileName, brandData.logo); | ||
| if (error) throw error; | ||
| logo_url = supabase.storage.from('brand-logos').getPublicUrl(fileName).data.publicUrl; | ||
| } | ||
| // 2. Insert into brands table | ||
| const { error: brandError } = await supabase.from('brands').insert({ | ||
| user_id: user?.id, | ||
| brand_name: brandData.brand_name, | ||
| logo_url, | ||
| website_url: brandData.website_url, | ||
| industry: brandData.industry, | ||
| company_size: brandData.company_size, | ||
| location: brandData.location, | ||
| description: brandData.description, | ||
| contact_person: brandData.contact_person, | ||
| contact_email: brandData.contact_email, | ||
| contact_phone: brandData.contact_phone, | ||
| role: brandData.role, | ||
| instagram_url: brandData.social_links.instagram_url || null, | ||
| facebook_url: brandData.social_links.facebook_url || null, | ||
| twitter_url: brandData.social_links.twitter_url || null, | ||
| linkedin_url: brandData.social_links.linkedin_url || null, | ||
| youtube_url: brandData.social_links.youtube_url || null, | ||
| collaboration_types: brandData.collaboration_types, | ||
| preferred_creator_categories: brandData.preferred_creator_categories, | ||
| brand_values: brandData.brand_values, | ||
| preferred_tone: brandData.preferred_tone, | ||
| platforms: brandData.platforms, | ||
| }); | ||
| if (brandError) throw brandError; | ||
| setBrandSubmitSuccess("Brand onboarding complete! Redirecting to dashboard..."); | ||
| // Clear localStorage for brand onboarding | ||
| localStorage.removeItem("brandStep"); | ||
| localStorage.removeItem("brandData"); | ||
| setTimeout(() => navigate("/brand/dashboard"), 1200); | ||
| } catch (err: any) { | ||
| setBrandSubmitError(err.message || "Failed to submit brand onboarding data."); | ||
| } finally { | ||
| setBrandSubmitting(false); | ||
| } | ||
| }; | ||
| const renderBrandReviewStep = () => ( | ||
| <div> | ||
| <h2 className="text-2xl font-bold mb-4">Review & Submit</h2> | ||
| <div className="mb-4"> | ||
| <label className="block font-medium mb-2">Logo</label> | ||
| {(brandLogoPreview || brandData.logo) ? ( | ||
| <img src={brandLogoPreview || (brandData.logo ? URL.createObjectURL(brandData.logo) : undefined)} alt="Logo Preview" className="h-16 w-16 rounded-full object-cover border mb-2" /> | ||
| ) : ( | ||
| <div className="h-16 w-16 rounded-full bg-gray-200 flex items-center justify-center text-gray-400">No Logo</div> | ||
| )} | ||
| </div> | ||
| <div className="mb-4"> | ||
| <h3 className="font-semibold">Brand Details</h3> | ||
| <ul className="text-sm"> | ||
| <li><b>Name:</b> {brandData.brand_name}</li> | ||
| <li><b>Website:</b> {brandData.website_url}</li> | ||
| <li><b>Industry:</b> {brandData.industry}</li> | ||
| <li><b>Company Size:</b> {brandData.company_size}</li> | ||
| <li><b>Location:</b> {brandData.location}</li> | ||
| <li><b>Description:</b> {brandData.description}</li> | ||
| </ul> | ||
| </div> | ||
| <div className="mb-4"> | ||
| <h3 className="font-semibold">Contact Information</h3> | ||
| <ul className="text-sm"> | ||
| <li><b>Contact Person:</b> {brandData.contact_person}</li> | ||
| <li><b>Email:</b> {brandData.contact_email}</li> | ||
| <li><b>Phone:</b> {brandData.contact_phone}</li> | ||
| <li><b>Role:</b> {brandData.role}</li> | ||
| </ul> | ||
| </div> | ||
| <div className="mb-4"> | ||
| <h3 className="font-semibold">Platforms & Social Links</h3> | ||
| <ul className="text-sm"> | ||
| {brandData.platforms.map(platform => { | ||
| const key = allBrandPlatforms.find(p => p.name === platform)?.key; | ||
| return ( | ||
| <li key={platform}><b>{platform}:</b> {key ? brandData.social_links[key] : ""}</li> | ||
| ); | ||
| })} | ||
| </ul> | ||
| </div> | ||
| <div className="mb-4"> | ||
| <h3 className="font-semibold">Collaboration Preferences</h3> | ||
| <ul className="text-sm"> | ||
| <li><b>Collaboration Types:</b> {brandData.collaboration_types.join(", ")}</li> | ||
| <li><b>Preferred Creator Categories:</b> {brandData.preferred_creator_categories.join(", ")}</li> | ||
| <li><b>Brand Values:</b> {brandData.brand_values.join(", ")}</li> | ||
| <li><b>Preferred Tone:</b> {brandData.preferred_tone.join(", ")}</li> | ||
| </ul> | ||
| </div> | ||
| {brandSubmitError && <div className="text-red-500 text-sm mb-2">{brandSubmitError}</div>} | ||
| {brandSubmitSuccess && <div className="text-green-600 text-sm mb-2">{brandSubmitSuccess}</div>} | ||
| <button | ||
| type="button" | ||
| onClick={handleBrandSubmit} | ||
| disabled={brandSubmitting} | ||
| className="px-6 py-2 rounded-lg bg-green-600 text-white font-semibold disabled:opacity-50" | ||
| > | ||
| {brandSubmitting ? 'Submitting...' : 'Submit'} | ||
| </button> | ||
| </div> | ||
| ); | ||
|
|
||
| // Persist and restore brand onboarding state | ||
| useEffect(() => { | ||
| const savedStep = localStorage.getItem("brandStep"); | ||
| const savedData = localStorage.getItem("brandData"); | ||
| if (savedStep) setBrandStep(Number(savedStep)); | ||
| if (savedData) setBrandData(JSON.parse(savedData)); | ||
| }, []); | ||
| useEffect(() => { | ||
| localStorage.setItem("brandStep", String(brandStep)); | ||
| localStorage.setItem("brandData", JSON.stringify(brandData)); | ||
| }, [brandStep, brandData]); | ||
|
|
||
| return ( | ||
| <div className="min-h-screen flex flex-col items-center justify-center bg-gradient-to-br from-purple-50 to-indigo-50 dark:from-gray-900 dark:to-gray-800"> | ||
| <div className="w-full max-w-2xl bg-white dark:bg-gray-800 rounded-2xl shadow-xl p-8"> | ||
| {/* Stepper UI */} | ||
| <div className="flex justify-between mb-8"> | ||
| {role === "brand" | ||
| ? brandSteps.map((label, idx) => ( | ||
| <div key={label} className={`flex-1 text-center text-xs font-semibold ${idx === brandStep ? "text-purple-600" : "text-gray-400"}`}>{label}</div> | ||
| )) | ||
| : steps.map((label, idx) => ( | ||
| <div key={label} className={`flex-1 text-center text-xs font-semibold ${idx === step ? "text-purple-600" : "text-gray-400"}`}>{label}</div> | ||
| ))} | ||
| </div> | ||
| {/* Step Content */} | ||
| <div className="mb-8"> | ||
| {role === "brand" ? ( | ||
| <> | ||
| {brandStep === 0 && renderBrandDetailsStep()} | ||
| {brandStep === 1 && renderBrandContactStep()} | ||
| {brandStep === 2 && renderBrandPlatformsStep()} | ||
| {brandStep === 3 && renderBrandSocialLinksStep()} | ||
| {brandStep === 4 && renderBrandCollabPrefsStep()} | ||
| {brandStep === 5 && renderBrandReviewStep()} | ||
| </> | ||
| ) : ( | ||
| <> | ||
| {step === 0 && renderRoleStep()} | ||
| {step === 1 && renderPersonalStep()} | ||
| {step === 2 && renderPlatformStep()} | ||
| {step === 3 && renderPlatformDetailsStep()} | ||
| {step === 4 && renderPricingStep()} | ||
| {step === 5 && renderProfilePicStep()} | ||
| {step === 6 && renderReviewStep()} | ||
| </> | ||
| )} | ||
| </div> | ||
| {/* Navigation */} | ||
| <div className="flex justify-between"> | ||
| {role === "brand" ? ( | ||
| <> | ||
| <button | ||
| onClick={handleBrandBack} | ||
| disabled={brandStep === 0} | ||
| className="px-6 py-2 rounded-lg bg-gray-200 text-gray-700 font-semibold disabled:opacity-50" | ||
| > | ||
| Back | ||
| </button> | ||
| {brandStep < brandSteps.length - 1 ? ( | ||
| <button | ||
| onClick={handleBrandNext} | ||
| disabled={!!validateBrandStep()} | ||
| className="px-6 py-2 rounded-lg bg-purple-600 text-white font-semibold disabled:opacity-50" | ||
| > | ||
| Next | ||
| </button> | ||
| ) : null} | ||
| </> | ||
| ) : ( | ||
| <> | ||
| <button | ||
| onClick={handleBack} | ||
| disabled={step === 0} | ||
| className="px-6 py-2 rounded-lg bg-gray-200 text-gray-700 font-semibold disabled:opacity-50" | ||
| > | ||
| Back | ||
| </button> | ||
| {step < steps.length - 1 ? ( | ||
| <button | ||
| onClick={handleNext} | ||
| disabled={ | ||
| (step === 0 && !role) || | ||
| (step === 1 && !!validatePersonal()) || | ||
| (step === 2 && selectedPlatforms.length === 0) || | ||
| (step === 3 && !!validatePlatformDetails()) || | ||
| (step === 4 && !!validatePricing()) || | ||
| (step === 5 && !!profilePicError) | ||
| } | ||
| className="px-6 py-2 rounded-lg bg-purple-600 text-white font-semibold disabled:opacity-50" | ||
| > | ||
| Next | ||
| </button> | ||
| ) : ( | ||
| <button | ||
| onClick={handleSubmit} | ||
| disabled={submitting} | ||
| className="px-6 py-2 rounded-lg bg-green-600 text-white font-semibold disabled:opacity-50" | ||
| > | ||
| {submitting ? 'Submitting...' : 'Submit'} | ||
| </button> | ||
| )} | ||
| </> | ||
| )} | ||
| </div> | ||
| {brandError && <div className="text-red-500 text-sm mt-2">{brandError}</div>} | ||
| </div> | ||
| </div> | ||
| ); | ||
| } | ||
|
|
||
| // Platform detail components | ||
| function YouTubeDetails({ details, setDetails }: { details: any, setDetails: (d: any) => void }) { | ||
| const [input, setInput] = useState(details.channelUrl || ""); | ||
| const [loading, setLoading] = useState(false); | ||
| const [error, setError] = useState(""); | ||
| const [showInfo, setShowInfo] = useState(false); | ||
|
|
||
| const fetchChannel = async () => { | ||
| setLoading(true); | ||
| setError(""); | ||
| let channelId = input; | ||
| // Extract channel ID from URL if needed | ||
| if (input.includes("youtube.com")) { | ||
| const match = input.match(/(?:channel\/|user\/|c\/)?([\w-]{21,})/); | ||
| if (match) channelId = match[1]; | ||
| } | ||
| try { | ||
| const res = await fetch( | ||
| `https://www.googleapis.com/youtube/v3/channels?part=snippet,statistics&id=${channelId}&key=${YOUTUBE_API_KEY}` | ||
| ); | ||
| const data = await res.json(); | ||
| if (data.items && data.items.length > 0) { | ||
| const ch = data.items[0]; | ||
| setDetails({ | ||
| channelUrl: input, | ||
| channelId: ch.id, | ||
| channelName: ch.snippet.title, | ||
| profile_image: ch.snippet.thumbnails.default.url, | ||
| subscriber_count: ch.statistics.subscriberCount, | ||
| total_views: ch.statistics.viewCount, | ||
| video_count: ch.statistics.videoCount, | ||
| }); | ||
| } else { | ||
| setError("Channel not found"); | ||
| } | ||
| } catch (e) { | ||
| setError("Failed to fetch channel"); | ||
| } finally { | ||
| setLoading(false); | ||
| } | ||
| }; | ||
|
|
||
| return ( | ||
| <div className="space-y-2"> | ||
| <label className="block font-medium flex items-center gap-2"> | ||
| YouTube Channel URL or ID | ||
| <button | ||
| type="button" | ||
| onClick={() => setShowInfo(true)} | ||
| className="ml-1 text-purple-600 hover:text-purple-800 focus:outline-none" | ||
| aria-label="How to find your YouTube channel URL or ID" | ||
| > | ||
| <Info className="h-4 w-4" /> | ||
| </button> | ||
| </label> | ||
| <input | ||
| type="text" | ||
| value={input} | ||
| onChange={e => setInput(e.target.value)} | ||
| className="w-full px-4 py-2 rounded border border-gray-300" | ||
| placeholder="e.g. https://www.youtube.com/channel/UCxxxxxxxxxxxxxxxxxxx or channel ID" | ||
| /> | ||
| {/* Info Dialog */} | ||
| {showInfo && ( | ||
| <div className="fixed inset-0 z-50 flex items-center justify-center bg-black bg-opacity-40"> | ||
| <div className="bg-white dark:bg-gray-800 rounded-lg shadow-lg p-6 max-w-md w-full relative"> | ||
| <button | ||
| className="absolute top-2 right-2 text-gray-500 hover:text-gray-700" | ||
| onClick={() => setShowInfo(false)} | ||
| aria-label="Close" | ||
| > | ||
| × | ||
| </button> | ||
| <h3 className="text-lg font-bold mb-2">How to find your YouTube Channel URL or ID</h3> | ||
| <ol className="list-decimal list-inside space-y-2 text-sm text-gray-700 dark:text-gray-200"> | ||
| <li>Go to <a href="https://www.youtube.com" target="_blank" rel="noopener noreferrer" className="text-purple-600 underline">youtube.com</a> and sign in.</li> | ||
| <li>Click your profile picture at the top right and select <b>Your Channel</b>.</li> | ||
| <li>Click <b>Customize Channel</b> (top right).</li> | ||
| <li>Go to the <b>Basic info</b> tab.</li> | ||
| <li>Find the <b>Channel URL</b> section and copy the URL shown there.</li> | ||
| <li>Paste the full Channel URL above (e.g. <span className="font-mono">https://www.youtube.com/channel/UCxxxxxxxxxxxxxxxxxxx</span>).</li> | ||
| </ol> | ||
| </div> | ||
| </div> | ||
| )} | ||
| <button | ||
| type="button" | ||
| onClick={fetchChannel} | ||
| className="px-4 py-2 bg-purple-600 text-white rounded" | ||
| disabled={loading || !input} | ||
| > | ||
| {loading ? "Fetching..." : "Fetch Channel"} | ||
| </button> | ||
| {error && <div className="text-red-500 text-sm">{error}</div>} | ||
| {details.channelName && ( | ||
| <div className="mt-2 p-2 bg-gray-100 rounded"> | ||
| <div><b>Name:</b> {details.channelName}</div> | ||
| <div><b>Subscribers:</b> {details.subscriber_count}</div> | ||
| <div><b>Videos:</b> {details.video_count}</div> | ||
| <div><b>Views:</b> {details.total_views}</div> | ||
| </div> | ||
| )} | ||
| </div> | ||
| ); | ||
| } | ||
|
|
||
| function InstagramDetails({ details, setDetails }: { details: any, setDetails: (d: any) => void }) { | ||
| return ( | ||
| <div className="space-y-2"> | ||
| <label className="block font-medium">Instagram Profile URL</label> | ||
| <input | ||
| type="text" | ||
| value={details.profileUrl || ""} | ||
| onChange={e => setDetails({ ...details, profileUrl: e.target.value })} | ||
| className="w-full px-4 py-2 rounded border border-gray-300" | ||
| placeholder="Paste your Instagram profile URL" | ||
| /> | ||
| <label className="block font-medium">Followers</label> | ||
| <input | ||
| type="number" | ||
| value={details.followers || ""} | ||
| onChange={e => setDetails({ ...details, followers: e.target.value })} | ||
| className="w-full px-4 py-2 rounded border border-gray-300" | ||
| placeholder="Followers count" | ||
| /> | ||
| <label className="block font-medium">Posts</label> | ||
| <input | ||
| type="number" | ||
| value={details.posts || ""} | ||
| onChange={e => setDetails({ ...details, posts: e.target.value })} | ||
| className="w-full px-4 py-2 rounded border border-gray-300" | ||
| placeholder="Number of posts" | ||
| /> | ||
| </div> | ||
| ); | ||
| } | ||
|
|
||
| function FacebookDetails({ details, setDetails }: { details: any, setDetails: (d: any) => void }) { | ||
| return ( | ||
| <div className="space-y-2"> | ||
| <label className="block font-medium">Facebook Profile URL</label> | ||
| <input | ||
| type="text" | ||
| value={details.profileUrl || ""} | ||
| onChange={e => setDetails({ ...details, profileUrl: e.target.value })} | ||
| className="w-full px-4 py-2 rounded border border-gray-300" | ||
| placeholder="Paste your Facebook profile URL" | ||
| /> | ||
| <label className="block font-medium">Followers</label> | ||
| <input | ||
| type="number" | ||
| value={details.followers || ""} | ||
| onChange={e => setDetails({ ...details, followers: e.target.value })} | ||
| className="w-full px-4 py-2 rounded border border-gray-300" | ||
| placeholder="Followers count" | ||
| /> | ||
| <label className="block font-medium">Posts</label> | ||
| <input | ||
| type="number" | ||
| value={details.posts || ""} | ||
| onChange={e => setDetails({ ...details, posts: e.target.value })} | ||
| className="w-full px-4 py-2 rounded border border-gray-300" | ||
| placeholder="Number of posts" | ||
| /> | ||
| </div> | ||
| ); | ||
| } | ||
|
|
||
| function TikTokDetails({ details, setDetails }: { details: any, setDetails: (d: any) => void }) { | ||
| return ( | ||
| <div className="space-y-2"> | ||
| <label className="block font-medium">TikTok Profile URL</label> | ||
| <input | ||
| type="text" | ||
| value={details.profileUrl || ""} | ||
| onChange={e => setDetails({ ...details, profileUrl: e.target.value })} | ||
| className="w-full px-4 py-2 rounded border border-gray-300" | ||
| placeholder="Paste your TikTok profile URL" | ||
| /> | ||
| <label className="block font-medium">Followers</label> | ||
| <input | ||
| type="number" | ||
| value={details.followers || ""} | ||
| onChange={e => setDetails({ ...details, followers: e.target.value })} | ||
| className="w-full px-4 py-2 rounded border border-gray-300" | ||
| placeholder="Followers count" | ||
| /> | ||
| <label className="block font-medium">Posts</label> | ||
| <input | ||
| type="number" | ||
| value={details.posts || ""} | ||
| onChange={e => setDetails({ ...details, posts: e.target.value })} | ||
| className="w-full px-4 py-2 rounded border border-gray-300" | ||
| placeholder="Number of posts" | ||
| /> | ||
| </div> | ||
| ); | ||
| } | ||
|
|
||
| // Pricing components | ||
| function YouTubePricing({ pricing, setPricing }: { pricing: any, setPricing: (d: any) => void }) { | ||
| return ( | ||
| <div className="space-y-2"> | ||
| <label className="block font-medium">Per Video</label> | ||
| <input | ||
| type="number" | ||
| value={pricing.per_video_cost || ""} | ||
| onChange={e => setPricing({ ...pricing, per_video_cost: e.target.value })} | ||
| className="w-full px-4 py-2 rounded border border-gray-300" | ||
| placeholder="Price per video" | ||
| /> | ||
| <label className="block font-medium">Per Short</label> | ||
| <input | ||
| type="number" | ||
| value={pricing.per_short_cost || ""} | ||
| onChange={e => setPricing({ ...pricing, per_short_cost: e.target.value })} | ||
| className="w-full px-4 py-2 rounded border border-gray-300" | ||
| placeholder="Price per short" | ||
| /> | ||
| <label className="block font-medium">Per Community Post</label> | ||
| <input | ||
| type="number" | ||
| value={pricing.per_community_post_cost || ""} | ||
| onChange={e => setPricing({ ...pricing, per_community_post_cost: e.target.value })} | ||
| className="w-full px-4 py-2 rounded border border-gray-300" | ||
| placeholder="Price per community post" | ||
| /> | ||
| <label className="block font-medium">Currency</label> | ||
| <input | ||
| type="text" | ||
| value={pricing.currency || ""} | ||
| onChange={e => setPricing({ ...pricing, currency: e.target.value })} | ||
| className="w-full px-4 py-2 rounded border border-gray-300" | ||
| placeholder="e.g. USD, INR" | ||
| /> | ||
| </div> | ||
| ); | ||
| } | ||
|
|
||
| function InstagramPricing({ pricing, setPricing }: { pricing: any, setPricing: (d: any) => void }) { | ||
| return ( | ||
| <div className="space-y-2"> | ||
| <label className="block font-medium">Per Post</label> | ||
| <input | ||
| type="number" | ||
| value={pricing.per_post_cost || ""} | ||
| onChange={e => setPricing({ ...pricing, per_post_cost: e.target.value })} | ||
| className="w-full px-4 py-2 rounded border border-gray-300" | ||
| placeholder="Price per post" | ||
| /> | ||
| <label className="block font-medium">Per Story</label> | ||
| <input | ||
| type="number" | ||
| value={pricing.per_story_cost || ""} | ||
| onChange={e => setPricing({ ...pricing, per_story_cost: e.target.value })} | ||
| className="w-full px-4 py-2 rounded border border-gray-300" | ||
| placeholder="Price per story" | ||
| /> | ||
| <label className="block font-medium">Per Reel</label> | ||
| <input | ||
| type="number" | ||
| value={pricing.per_reel_cost || ""} | ||
| onChange={e => setPricing({ ...pricing, per_reel_cost: e.target.value })} | ||
| className="w-full px-4 py-2 rounded border border-gray-300" | ||
| placeholder="Price per reel" | ||
| /> | ||
| <label className="block font-medium">Currency</label> | ||
| <input | ||
| type="text" | ||
| value={pricing.currency || ""} | ||
| onChange={e => setPricing({ ...pricing, currency: e.target.value })} | ||
| className="w-full px-4 py-2 rounded border border-gray-300" | ||
| placeholder="e.g. USD, INR" | ||
| /> | ||
| </div> | ||
| ); | ||
| } | ||
|
|
||
| function FacebookPricing({ pricing, setPricing }: { pricing: any, setPricing: (d: any) => void }) { | ||
| return ( | ||
| <div className="space-y-2"> | ||
| <label className="block font-medium">Per Post</label> | ||
| <input | ||
| type="number" | ||
| value={pricing.per_post_cost || ""} | ||
| onChange={e => setPricing({ ...pricing, per_post_cost: e.target.value })} | ||
| className="w-full px-4 py-2 rounded border border-gray-300" | ||
| placeholder="Price per post" | ||
| /> | ||
| <label className="block font-medium">Currency</label> | ||
| <input | ||
| type="text" | ||
| value={pricing.currency || ""} | ||
| onChange={e => setPricing({ ...pricing, currency: e.target.value })} | ||
| className="w-full px-4 py-2 rounded border border-gray-300" | ||
| placeholder="e.g. USD, INR" | ||
| /> | ||
| </div> | ||
| ); | ||
| } | ||
|
|
||
| function TikTokPricing({ pricing, setPricing }: { pricing: any, setPricing: (d: any) => void }) { | ||
| return ( | ||
| <div className="space-y-2"> | ||
| <label className="block font-medium">Per Video</label> | ||
| <input | ||
| type="number" | ||
| value={pricing.per_video_cost || ""} | ||
| onChange={e => setPricing({ ...pricing, per_video_cost: e.target.value })} | ||
| className="w-full px-4 py-2 rounded border border-gray-300" | ||
| placeholder="Price per video" | ||
| /> | ||
| <label className="block font-medium">Currency</label> | ||
| <input | ||
| type="text" | ||
| value={pricing.currency || ""} | ||
| onChange={e => setPricing({ ...pricing, currency: e.target.value })} | ||
| className="w-full px-4 py-2 rounded border border-gray-300" | ||
| placeholder="e.g. USD, INR" | ||
| /> | ||
| </div> | ||
| ); | ||
| } |
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
🛠️ Refactor suggestion
Break up this large component into smaller, manageable pieces.
This component is 1487 lines long, making it difficult to maintain and test. Consider splitting it into smaller components.
Suggested structure:
components/
onboarding/
Onboarding.tsx (main orchestrator)
CreatorOnboarding.tsx
BrandOnboarding.tsx
steps/
RoleSelection.tsx
PersonalDetails.tsx
PlatformSelection.tsx
PlatformDetails.tsx
Pricing.tsx
ProfilePicture.tsx
ReviewSubmit.tsx
platform-components/
YouTubeDetails.tsx
InstagramDetails.tsx
FacebookDetails.tsx
TikTokDetails.tsx
hooks/
useOnboardingState.ts
useValidation.ts
useSubmission.ts
utils/
validation.ts
api.ts
This would improve:
- Code organization and maintainability
- Testing capabilities
- Reusability of components
- Performance (smaller bundle sizes with code splitting)
Would you like me to help create this modular structure?
🧰 Tools
🪛 Biome (1.9.4)
[error] 392-392: Change to an optional chain.
Unsafe fix: Change to an optional chain.
(lint/complexity/useOptionalChain)
[error] 645-645: Change to an optional chain.
Unsafe fix: Change to an optional chain.
(lint/complexity/useOptionalChain)
🤖 Prompt for AI Agents
In Frontend/src/components/Onboarding.tsx lines 1 to 1487, the component is
excessively large and complex, making it hard to maintain and test. To fix this,
refactor by splitting the file into smaller, focused components and hooks as
suggested: create separate files for main orchestrator (Onboarding.tsx),
CreatorOnboarding.tsx, BrandOnboarding.tsx, individual step components under
steps/, platform-specific components under platform-components/, and custom
hooks for state, validation, and submission logic under hooks/. Also, move
utility functions to utils/. This modular structure will improve code
organization, reusability, and testability.
| const handleSubmit = async () => { | ||
| setSubmitting(true); | ||
| setSubmitError(""); | ||
| setSubmitSuccess(""); | ||
| setProgress(0); | ||
| let profile_image_url = null; | ||
| try { | ||
| // 1. Upload profile picture if provided | ||
| if (profilePic) { | ||
| setProgress(20); | ||
| const fileExt = profilePic.name.split('.').pop(); | ||
| const fileName = `${user?.id}_${Date.now()}.${fileExt}`; | ||
| const { data, error } = await supabase.storage.from('profile-pictures').upload(fileName, profilePic); | ||
| if (error) throw error; | ||
| profile_image_url = `${supabase.storage.from('profile-pictures').getPublicUrl(fileName).data.publicUrl}`; | ||
| } else if (user?.user_metadata?.avatar_url) { | ||
| profile_image_url = user.user_metadata.avatar_url; | ||
| } | ||
| setProgress(40); | ||
| // 2. Update users table | ||
| const categoryToSave = personal.category === 'Other' ? personal.otherCategory : personal.category; | ||
| const { error: userError } = await supabase.from('users').update({ | ||
| username: personal.name, | ||
| age: personal.age, | ||
| gender: personal.gender, | ||
| country: personal.country, | ||
| category: categoryToSave, | ||
| profile_image: profile_image_url, | ||
| role, | ||
| }).eq('id', user?.id); | ||
| if (userError) throw userError; | ||
| setProgress(60); | ||
| // 3. Insert social_profiles for each platform | ||
| for (const platform of selectedPlatforms) { | ||
| const details = platformDetails[platform]; | ||
| const p = pricing[platform]; | ||
| const profileData: any = { | ||
| user_id: user?.id, | ||
| platform, | ||
| per_post_cost: p?.per_post_cost ? Number(p.per_post_cost) : null, | ||
| per_story_cost: p?.per_story_cost ? Number(p.per_story_cost) : null, | ||
| per_reel_cost: p?.per_reel_cost ? Number(p.per_reel_cost) : null, | ||
| per_video_cost: p?.per_video_cost ? Number(p.per_video_cost) : null, | ||
| per_short_cost: p?.per_short_cost ? Number(p.per_short_cost) : null, | ||
| per_community_post_cost: p?.per_community_post_cost ? Number(p.per_community_post_cost) : null, | ||
| per_post_cost_currency: p?.currency || null, | ||
| per_story_cost_currency: p?.currency || null, | ||
| per_reel_cost_currency: p?.currency || null, | ||
| per_video_cost_currency: p?.currency || null, | ||
| per_short_cost_currency: p?.currency || null, | ||
| per_community_post_cost_currency: p?.currency || null, | ||
| }; | ||
| if (platform === 'YouTube') { | ||
| Object.assign(profileData, { | ||
| channel_id: details.channelId, | ||
| channel_name: details.channelName, | ||
| profile_image: details.profile_image, | ||
| subscriber_count: details.subscriber_count ? Number(details.subscriber_count) : null, | ||
| total_views: details.total_views ? Number(details.total_views) : null, | ||
| video_count: details.video_count ? Number(details.video_count) : null, | ||
| channel_url: details.channelUrl, | ||
| }); | ||
| } else { | ||
| Object.assign(profileData, { | ||
| username: details.profileUrl, | ||
| followers: details.followers ? Number(details.followers) : null, | ||
| posts: details.posts ? Number(details.posts) : null, | ||
| profile_image: null, | ||
| channel_url: details.profileUrl, | ||
| }); | ||
| } | ||
| // Upsert to avoid duplicates | ||
| const { error: spError } = await supabase.from('social_profiles').upsert(profileData, { onConflict: 'user_id,platform' }); | ||
| if (spError) throw spError; | ||
| } | ||
| setProgress(90); | ||
| setSubmitSuccess('Onboarding complete! Your details have been saved.'); | ||
| setProgress(100); | ||
| // Route based on role | ||
| if (role === "brand") { | ||
| setTimeout(() => navigate('/brand/dashboard'), 1200); | ||
| } else { | ||
| setTimeout(() => navigate('/dashboard'), 1200); | ||
| } | ||
| } catch (err: any) { | ||
| setSubmitError(err.message || 'Failed to submit onboarding data.'); | ||
| setProgress(0); | ||
| } finally { | ||
| setSubmitting(false); | ||
| } | ||
| }; |
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
🛠️ Refactor suggestion
Improve error handling and add cleanup on failure.
The submit handler uploads files before database operations. If database operations fail, uploaded files aren't cleaned up.
Consider this improved error handling approach:
const handleSubmit = async () => {
setSubmitting(true);
setSubmitError("");
setSubmitSuccess("");
setProgress(0);
let profile_image_url = null;
+ let uploadedFileName = null;
+
try {
// 1. Upload profile picture if provided
if (profilePic) {
setProgress(20);
const fileExt = profilePic.name.split('.').pop();
const fileName = `${user?.id}_${Date.now()}.${fileExt}`;
+ uploadedFileName = fileName;
const { data, error } = await supabase.storage.from('profile-pictures').upload(fileName, profilePic);
if (error) throw error;
profile_image_url = `${supabase.storage.from('profile-pictures').getPublicUrl(fileName).data.publicUrl}`;
} else if (user?.user_metadata?.avatar_url) {
profile_image_url = user.user_metadata.avatar_url;
}
// ... rest of the code ...
} catch (err: any) {
+ // Cleanup uploaded file on error
+ if (uploadedFileName) {
+ try {
+ await supabase.storage.from('profile-pictures').remove([uploadedFileName]);
+ } catch (cleanupError) {
+ console.error("Failed to cleanup uploaded file:", cleanupError);
+ }
+ }
setSubmitError(err.message || 'Failed to submit onboarding data.');
setProgress(0);
} finally {
setSubmitting(false);
}
};Also consider wrapping all database operations in a transaction-like pattern or implementing a rollback mechanism for better data consistency.
📝 Committable suggestion
‼️ IMPORTANT
Carefully review the code before committing. Ensure that it accurately replaces the highlighted code, contains no missing lines, and has no issues with indentation. Thoroughly test & benchmark the code to ensure it meets the requirements.
| const handleSubmit = async () => { | |
| setSubmitting(true); | |
| setSubmitError(""); | |
| setSubmitSuccess(""); | |
| setProgress(0); | |
| let profile_image_url = null; | |
| try { | |
| // 1. Upload profile picture if provided | |
| if (profilePic) { | |
| setProgress(20); | |
| const fileExt = profilePic.name.split('.').pop(); | |
| const fileName = `${user?.id}_${Date.now()}.${fileExt}`; | |
| const { data, error } = await supabase.storage.from('profile-pictures').upload(fileName, profilePic); | |
| if (error) throw error; | |
| profile_image_url = `${supabase.storage.from('profile-pictures').getPublicUrl(fileName).data.publicUrl}`; | |
| } else if (user?.user_metadata?.avatar_url) { | |
| profile_image_url = user.user_metadata.avatar_url; | |
| } | |
| setProgress(40); | |
| // 2. Update users table | |
| const categoryToSave = personal.category === 'Other' ? personal.otherCategory : personal.category; | |
| const { error: userError } = await supabase.from('users').update({ | |
| username: personal.name, | |
| age: personal.age, | |
| gender: personal.gender, | |
| country: personal.country, | |
| category: categoryToSave, | |
| profile_image: profile_image_url, | |
| role, | |
| }).eq('id', user?.id); | |
| if (userError) throw userError; | |
| setProgress(60); | |
| // 3. Insert social_profiles for each platform | |
| for (const platform of selectedPlatforms) { | |
| const details = platformDetails[platform]; | |
| const p = pricing[platform]; | |
| const profileData: any = { | |
| user_id: user?.id, | |
| platform, | |
| per_post_cost: p?.per_post_cost ? Number(p.per_post_cost) : null, | |
| per_story_cost: p?.per_story_cost ? Number(p.per_story_cost) : null, | |
| per_reel_cost: p?.per_reel_cost ? Number(p.per_reel_cost) : null, | |
| per_video_cost: p?.per_video_cost ? Number(p.per_video_cost) : null, | |
| per_short_cost: p?.per_short_cost ? Number(p.per_short_cost) : null, | |
| per_community_post_cost: p?.per_community_post_cost ? Number(p.per_community_post_cost) : null, | |
| per_post_cost_currency: p?.currency || null, | |
| per_story_cost_currency: p?.currency || null, | |
| per_reel_cost_currency: p?.currency || null, | |
| per_video_cost_currency: p?.currency || null, | |
| per_short_cost_currency: p?.currency || null, | |
| per_community_post_cost_currency: p?.currency || null, | |
| }; | |
| if (platform === 'YouTube') { | |
| Object.assign(profileData, { | |
| channel_id: details.channelId, | |
| channel_name: details.channelName, | |
| profile_image: details.profile_image, | |
| subscriber_count: details.subscriber_count ? Number(details.subscriber_count) : null, | |
| total_views: details.total_views ? Number(details.total_views) : null, | |
| video_count: details.video_count ? Number(details.video_count) : null, | |
| channel_url: details.channelUrl, | |
| }); | |
| } else { | |
| Object.assign(profileData, { | |
| username: details.profileUrl, | |
| followers: details.followers ? Number(details.followers) : null, | |
| posts: details.posts ? Number(details.posts) : null, | |
| profile_image: null, | |
| channel_url: details.profileUrl, | |
| }); | |
| } | |
| // Upsert to avoid duplicates | |
| const { error: spError } = await supabase.from('social_profiles').upsert(profileData, { onConflict: 'user_id,platform' }); | |
| if (spError) throw spError; | |
| } | |
| setProgress(90); | |
| setSubmitSuccess('Onboarding complete! Your details have been saved.'); | |
| setProgress(100); | |
| // Route based on role | |
| if (role === "brand") { | |
| setTimeout(() => navigate('/brand/dashboard'), 1200); | |
| } else { | |
| setTimeout(() => navigate('/dashboard'), 1200); | |
| } | |
| } catch (err: any) { | |
| setSubmitError(err.message || 'Failed to submit onboarding data.'); | |
| setProgress(0); | |
| } finally { | |
| setSubmitting(false); | |
| } | |
| }; | |
| const handleSubmit = async () => { | |
| setSubmitting(true); | |
| setSubmitError(""); | |
| setSubmitSuccess(""); | |
| setProgress(0); | |
| let profile_image_url = null; | |
| let uploadedFileName = null; | |
| try { | |
| // 1. Upload profile picture if provided | |
| if (profilePic) { | |
| setProgress(20); | |
| const fileExt = profilePic.name.split('.').pop(); | |
| const fileName = `${user?.id}_${Date.now()}.${fileExt}`; | |
| uploadedFileName = fileName; | |
| const { data, error } = await supabase | |
| .storage | |
| .from('profile-pictures') | |
| .upload(fileName, profilePic); | |
| if (error) throw error; | |
| profile_image_url = supabase | |
| .storage | |
| .from('profile-pictures') | |
| .getPublicUrl(fileName) | |
| .data | |
| .publicUrl; | |
| } else if (user?.user_metadata?.avatar_url) { | |
| profile_image_url = user.user_metadata.avatar_url; | |
| } | |
| setProgress(40); | |
| // 2. Update users table | |
| const categoryToSave = personal.category === 'Other' | |
| ? personal.otherCategory | |
| : personal.category; | |
| const { error: userError } = await supabase | |
| .from('users') | |
| .update({ | |
| username: personal.name, | |
| age: personal.age, | |
| gender: personal.gender, | |
| country: personal.country, | |
| category: categoryToSave, | |
| profile_image: profile_image_url, | |
| role, | |
| }) | |
| .eq('id', user?.id); | |
| if (userError) throw userError; | |
| setProgress(60); | |
| // 3. Insert social_profiles for each platform | |
| for (const platform of selectedPlatforms) { | |
| const details = platformDetails[platform]; | |
| const p = pricing[platform]; | |
| const profileData: any = { | |
| user_id: user?.id, | |
| platform, | |
| per_post_cost: p?.per_post_cost ? Number(p.per_post_cost) : null, | |
| per_story_cost: p?.per_story_cost ? Number(p.per_story_cost) : null, | |
| per_reel_cost: p?.per_reel_cost ? Number(p.per_reel_cost) : null, | |
| per_video_cost: p?.per_video_cost ? Number(p.per_video_cost) : null, | |
| per_short_cost: p?.per_short_cost ? Number(p.per_short_cost) : null, | |
| per_community_post_cost: p?.per_community_post_cost | |
| ? Number(p.per_community_post_cost) | |
| : null, | |
| per_post_cost_currency: p?.currency || null, | |
| per_story_cost_currency: p?.currency || null, | |
| per_reel_cost_currency: p?.currency || null, | |
| per_video_cost_currency: p?.currency || null, | |
| per_short_cost_currency: p?.currency || null, | |
| per_community_post_cost_currency: p?.currency || null, | |
| }; | |
| if (platform === 'YouTube') { | |
| Object.assign(profileData, { | |
| channel_id: details.channelId, | |
| channel_name: details.channelName, | |
| profile_image: details.profile_image, | |
| subscriber_count: details.subscriber_count | |
| ? Number(details.subscriber_count) | |
| : null, | |
| total_views: details.total_views | |
| ? Number(details.total_views) | |
| : null, | |
| video_count: details.video_count | |
| ? Number(details.video_count) | |
| : null, | |
| channel_url: details.channelUrl, | |
| }); | |
| } else { | |
| Object.assign(profileData, { | |
| username: details.profileUrl, | |
| followers: details.followers ? Number(details.followers) : null, | |
| posts: details.posts ? Number(details.posts) : null, | |
| profile_image: null, | |
| channel_url: details.profileUrl, | |
| }); | |
| } | |
| // Upsert to avoid duplicates | |
| const { error: spError } = await supabase | |
| .from('social_profiles') | |
| .upsert(profileData, { onConflict: 'user_id,platform' }); | |
| if (spError) throw spError; | |
| } | |
| setProgress(90); | |
| setSubmitSuccess('Onboarding complete! Your details have been saved.'); | |
| setProgress(100); | |
| // Route based on role | |
| if (role === "brand") { | |
| setTimeout(() => navigate('/brand/dashboard'), 1200); | |
| } else { | |
| setTimeout(() => navigate('/dashboard'), 1200); | |
| } | |
| } catch (err: any) { | |
| // Cleanup uploaded file on error | |
| if (uploadedFileName) { | |
| try { | |
| await supabase | |
| .storage | |
| .from('profile-pictures') | |
| .remove([uploadedFileName]); | |
| } catch (cleanupError) { | |
| console.error("Failed to cleanup uploaded file:", cleanupError); | |
| } | |
| } | |
| setSubmitError(err.message || 'Failed to submit onboarding data.'); | |
| setProgress(0); | |
| } finally { | |
| setSubmitting(false); | |
| } | |
| }; |
🤖 Prompt for AI Agents
In Frontend/src/components/Onboarding.tsx lines 436 to 526, the handleSubmit
function uploads a profile picture before performing database updates, but if
any database operation fails, the uploaded file is not deleted, causing
potential orphaned files. To fix this, add error handling that deletes the
uploaded profile picture from storage if subsequent database operations fail.
Additionally, consider grouping the database updates and inserts into a
transaction or simulate a rollback by deleting or reverting changes if any step
fails, ensuring data consistency. Implement cleanup logic in the catch block to
remove partial uploads and maintain a consistent state.
| fetch("/api/trending-niches") | ||
| .then(res => { | ||
| if (!res.ok) throw new Error("Failed to fetch trending niches"); | ||
| return res.json(); | ||
| }) | ||
| .then(data => { | ||
| setNiches(data); | ||
| setLoading(false); | ||
| }) | ||
| .catch(err => { | ||
| setError(err.message); | ||
| setLoading(false); | ||
| }); | ||
| }, []); |
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
🛠️ Refactor suggestion
Improve error handling and add retry mechanism for API calls.
The current error handling is basic and doesn't provide a good user experience when the API fails.
Consider adding a retry mechanism and better error UI:
useEffect(() => {
+ let retryCount = 0;
+ const maxRetries = 3;
+
+ const fetchNiches = async () => {
+ try {
+ const res = await fetch("/api/trending-niches");
+ if (!res.ok) throw new Error(`Failed to fetch: ${res.status}`);
+ const data = await res.json();
+ setNiches(data);
+ setLoading(false);
+ } catch (err) {
+ if (retryCount < maxRetries) {
+ retryCount++;
+ setTimeout(() => fetchNiches(), 1000 * retryCount); // Exponential backoff
+ } else {
+ setError(err.message || "Failed to load trending niches");
+ setLoading(false);
+ }
+ }
+ };
+
+ fetchNiches();
- fetch("/api/trending-niches")
- .then(res => {
- if (!res.ok) throw new Error("Failed to fetch trending niches");
- return res.json();
- })
- .then(data => {
- setNiches(data);
- setLoading(false);
- })
- .catch(err => {
- setError(err.message);
- setLoading(false);
- });
}, []);Also improve the error UI:
-if (error) return <div>Error: {error}</div>;
+if (error) return (
+ <div className="text-center p-8">
+ <p className="text-red-600 mb-4">Unable to load trending niches</p>
+ <button
+ onClick={() => window.location.reload()}
+ className="px-4 py-2 bg-purple-600 text-white rounded-lg hover:bg-purple-700"
+ >
+ Try Again
+ </button>
+ </div>
+);📝 Committable suggestion
‼️ IMPORTANT
Carefully review the code before committing. Ensure that it accurately replaces the highlighted code, contains no missing lines, and has no issues with indentation. Thoroughly test & benchmark the code to ensure it meets the requirements.
| fetch("/api/trending-niches") | |
| .then(res => { | |
| if (!res.ok) throw new Error("Failed to fetch trending niches"); | |
| return res.json(); | |
| }) | |
| .then(data => { | |
| setNiches(data); | |
| setLoading(false); | |
| }) | |
| .catch(err => { | |
| setError(err.message); | |
| setLoading(false); | |
| }); | |
| }, []); | |
| // Replace the existing fetch logic in useEffect | |
| useEffect(() => { | |
| let retryCount = 0; | |
| const maxRetries = 3; | |
| const fetchNiches = async () => { | |
| try { | |
| const res = await fetch("/api/trending-niches"); | |
| if (!res.ok) throw new Error(`Failed to fetch: ${res.status}`); | |
| const data = await res.json(); | |
| setNiches(data); | |
| setLoading(false); | |
| } catch (err) { | |
| if (retryCount < maxRetries) { | |
| retryCount++; | |
| setTimeout(() => fetchNiches(), 1000 * retryCount); // Exponential backoff | |
| } else { | |
| setError(err.message || "Failed to load trending niches"); | |
| setLoading(false); | |
| } | |
| } | |
| }; | |
| fetchNiches(); | |
| }, []); | |
| // Replace the simple error line with a richer UI | |
| if (error) return ( | |
| <div className="text-center p-8"> | |
| <p className="text-red-600 mb-4">Unable to load trending niches</p> | |
| <button | |
| onClick={() => window.location.reload()} | |
| className="px-4 py-2 bg-purple-600 text-white rounded-lg hover:bg-purple-700" | |
| > | |
| Try Again | |
| </button> | |
| </div> | |
| ); |
🤖 Prompt for AI Agents
In Frontend/src/pages/HomePage.tsx around lines 273 to 286, the fetch call for
trending niches has basic error handling without retries or enhanced user
feedback. Implement a retry mechanism that attempts the fetch multiple times
before failing, with delays between retries. Additionally, improve the error UI
by providing more informative messages or visual cues to the user when an error
occurs. Update the state management to reflect loading, success, and error
states clearly during retries.
Co-authored-by: coderabbitai[bot] <136622811+coderabbitai[bot]@users.noreply.github.com>
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
Actionable comments posted: 2
♻️ Duplicate comments (1)
Backend/app/routes/ai.py (1)
32-36: Raw Gemini response logged → sensitive data leakage
This exact issue was raised in a previous review and is still present. Please remove or sanitise theseloggingmodule at an appropriate level.Suggested minimal change:
-import logging -... -print("Gemini raw response:", resp.text) -... -print("Gemini parsed JSON:", data) -... -print("Gemini text to parse as JSON:", text) +import logging +logger = logging.getLogger(__name__) +... +logger.debug("Gemini API call succeeded")
🧹 Nitpick comments (2)
Backend/app/routes/ai.py (2)
18-20: Avoid crashing the whole app at import-timeRaising a
ValueErrorwhen env-vars are missing will stop the entire FastAPI process from starting, even for endpoints that don’t depend on Gemini/Supabase. Prefer validating inside a startup event-handler or within the route itself so the service can still come up (e.g. health-checks) and return a 500 only for the affected endpoint.Example:
-# Validate required environment variables -if not all([SUPABASE_URL, SUPABASE_KEY, GEMINI_API_KEY]): - raise ValueError("Missing required environment variables: SUPABASE_URL, SUPABASE_KEY, GEMINI_API_KEY") +def _validate_env(): + missing = [k for k, v in { + "SUPABASE_URL": SUPABASE_URL, + "SUPABASE_KEY": SUPABASE_KEY, + "GEMINI_API_KEY": GEMINI_API_KEY, + }.items() if not v] + if missing: + raise RuntimeError(f"Missing required env vars: {', '.join(missing)}")…and call
_validate_env()insidefetch_from_gemini()or a@app.on_event("startup")handler.
57-65: Inefficient per-row inserts – switch to bulk upsertInserting inside a loop issues 6 separate HTTP calls to Supabase. Use a single bulk
insert()with the list, or anupsert()to avoid duplicates and cut latency by >80 %.- for niche in niches: - supabase.table("trending_niches").insert({ - "name": niche["name"], - "insight": niche["insight"], - "global_activity": int(niche["global_activity"]), - "fetched_at": today - }).execute() + payload = [ + { + "name": n["name"], + "insight": n["insight"], + "global_activity": int(n["global_activity"]), + "fetched_at": today, + } + for n in niches + ] + supabase.table("trending_niches").insert(payload).execute()
📜 Review details
Configuration used: CodeRabbit UI
Review profile: CHILL
Plan: Pro
📒 Files selected for processing (1)
Backend/app/routes/ai.py(1 hunks)
🧰 Additional context used
🧠 Learnings (1)
Backend/app/routes/ai.py (1)
Learnt from: muntaxir4
PR: AOSSIE-Org/InPactAI#56
File: Backend/app/services/redis_client.py:1-4
Timestamp: 2025-05-07T21:28:06.358Z
Learning: Hardcoded Redis connection parameters in Backend/app/services/redis_client.py are intentional during development, with plans to implement environment variable configuration later during production preparation.
🪛 Flake8 (7.2.0)
Backend/app/routes/ai.py
[error] 23-23: expected 2 blank lines, found 1
(E302)
[error] 26-26: too many blank lines (2)
(E303)
[error] 44-44: expected 2 blank lines, found 1
(E302)
| text = data['candidates'][0]['content']['parts'][0]['text'] | ||
| print("Gemini text to parse as JSON:", text) | ||
| # Remove Markdown code block if present | ||
| if text.strip().startswith('```'): | ||
| text = text.strip().split('\n', 1)[1] # Remove the first line (```json) | ||
| text = text.rsplit('```', 1)[0] # Remove the last ``` | ||
| text = text.strip() | ||
| return json.loads(text) |
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
🛠️ Refactor suggestion
Unprotected JSON parse may explode on malformed output
If Gemini returns non-JSON or extra prose, json.loads will raise and the entire request falls into the broad except block. Wrap parsing in a dedicated try/except ValueError and surface a 502/500 with context instead of silent fallback.
- return json.loads(text)
+ try:
+ return json.loads(text)
+ except ValueError as exc:
+ logger.warning("Gemini returned non-JSON payload: %s", text[:120])
+ raise RuntimeError("Gemini payload parsing failed") from excCommittable suggestion skipped: line range outside the PR's diff.
🤖 Prompt for AI Agents
In Backend/app/routes/ai.py around lines 35 to 42, the call to json.loads is
unprotected and will raise an exception if the text is not valid JSON, causing
the entire request to fall into a broad except block. Wrap the json.loads call
in a try/except block that catches ValueError, and in the except block, return
or raise a 502 or 500 error with a clear message indicating JSON parsing failed,
so the error is surfaced with context instead of silently falling back.
|
@chandansgowda kindly review and merge |
…as Supabase handles authentication
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
Actionable comments posted: 0
♻️ Duplicate comments (1)
Backend/app/routes/post.py (1)
4-12: Remove unused imports to clean up the codebase.The static analysis tools correctly identified that these imports are unused. This file uses Supabase client directly for database operations, making the SQLAlchemy imports unnecessary.
🧹 Nitpick comments (1)
Backend/app/routes/post.py (1)
14-14: Remove duplicate FastAPI import.Line 14 imports
APIRouterandHTTPExceptionfrom FastAPI, but these are already imported at line 1. This creates a duplicate import that should be removed.-from fastapi import APIRouter, HTTPException
📜 Review details
Configuration used: CodeRabbit UI
Review profile: CHILL
Plan: Pro
📒 Files selected for processing (2)
Backend/app/models/models.py(2 hunks)Backend/app/routes/post.py(1 hunks)
🚧 Files skipped from review as they are similar to previous changes (1)
- Backend/app/models/models.py
🧰 Additional context used
🧬 Code Graph Analysis (1)
Backend/app/routes/post.py (1)
Backend/app/models/models.py (6)
User(25-53)AudienceInsights(57-72)Sponsorship(76-92)UserPost(96-110)SponsorshipApplication(114-128)SponsorshipPayment(146-162)
🪛 Ruff (0.11.9)
Backend/app/routes/post.py
4-4: ..db.db.AsyncSessionLocal imported but unused
Remove unused import: ..db.db.AsyncSessionLocal
(F401)
6-6: ..models.models.User imported but unused
Remove unused import
(F401)
6-6: ..models.models.AudienceInsights imported but unused
Remove unused import
(F401)
6-6: ..models.models.Sponsorship imported but unused
Remove unused import
(F401)
6-6: ..models.models.UserPost imported but unused
Remove unused import
(F401)
7-7: ..models.models.SponsorshipApplication imported but unused
Remove unused import
(F401)
7-7: ..models.models.SponsorshipPayment imported but unused
Remove unused import
(F401)
7-7: ..models.models.Collaboration imported but unused
Remove unused import
(F401)
🪛 Flake8 (7.2.0)
Backend/app/routes/post.py
[error] 4-4: '..db.db.AsyncSessionLocal' imported but unused
(F401)
[error] 5-5: '..models.models.User' imported but unused
(F401)
[error] 5-5: '..models.models.AudienceInsights' imported but unused
(F401)
[error] 5-5: '..models.models.Sponsorship' imported but unused
(F401)
[error] 5-5: '..models.models.UserPost' imported but unused
(F401)
[error] 5-5: '..models.models.SponsorshipApplication' imported but unused
(F401)
[error] 5-5: '..models.models.SponsorshipPayment' imported but unused
(F401)
[error] 5-5: '..models.models.Collaboration' imported but unused
(F401)
🪛 Pylint (3.3.7)
Backend/app/routes/post.py
[error] 4-4: Attempted relative import beyond top-level package
(E0402)
[error] 5-8: Attempted relative import beyond top-level package
(E0402)
🔇 Additional comments (1)
Backend/app/routes/post.py (1)
38-53: Authentication refactor correctly implemented.The removal of
password_hashfrom the user creation is correct and aligns with the authentication refactor where Supabase handles authentication externally. The user creation logic properly inserts all necessary fields while delegating authentication to Supabase.
…Tube channel fetch error handling, add Gemini API timeout and retry, and various DRY and security improvements
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
Actionable comments posted: 3
♻️ Duplicate comments (6)
Backend/app/routes/match.py (1)
2-4: Clean up unused imports and commented code.Since the Supabase client initialization is handled in the service layer, these imports and commented code are no longer needed.
-from supabase import create_client, Client -import os -from dotenv import load_dotenv - -# Load environment variables -# load_dotenv() -# url: str = os.getenv("SUPABASE_URL") -# key: str = os.getenv("SUPABASE_KEY") -# supabase: Client = create_client(url, key)Also applies to: 7-11
Backend/app/routes/ai.py (2)
43-43: Remove debug statements that could expose sensitive data.These print statements could log sensitive API responses in production environments.
- print("Gemini raw response:", resp.text) + # Log success without exposing sensitive data + print("Gemini API request successful") data = resp.json() - print("Gemini parsed JSON:", data) text = data['candidates'][0]['content']['parts'][0]['text'] - print("Gemini text to parse as JSON:", text)Also applies to: 45-45, 47-47
53-53: Add explicit error handling for JSON parsing.The JSON parsing could fail if Gemini returns malformed data.
- return json.loads(text) + try: + return json.loads(text) + except ValueError as exc: + print(f"Warning: Gemini returned non-JSON payload: {text[:120]}") + raise RuntimeError("Failed to parse Gemini response as JSON") from excFrontend/src/components/Onboarding.tsx (2)
1-1496: Consider breaking this large component into smaller, focused modules.At 1487 lines, this component is extremely difficult to maintain, test, and debug. While the functionality is comprehensive, the size creates significant technical debt.
Consider refactoring into a modular structure:
- Separate components for each onboarding step
- Custom hooks for state management and validation
- Utility functions for API calls and data processing
- Platform-specific components in dedicated files
This would improve code organization, reusability, and testability significantly.
436-526: Implement cleanup for failed submissions.The file upload occurs before database operations, but uploaded files aren't cleaned up if database operations fail, potentially creating orphaned files.
const handleSubmit = async () => { setSubmitting(true); setSubmitError(""); setSubmitSuccess(""); setProgress(0); let profile_image_url = null; + let uploadedFileName = null; + try { // 1. Upload profile picture if provided if (profilePic) { setProgress(20); const fileExt = profilePic.name.split('.').pop(); const fileName = `${user?.id}_${Date.now()}.${fileExt}`; + uploadedFileName = fileName; const { data, error } = await supabase.storage.from('profile-pictures').upload(fileName, profilePic); if (error) throw error; profile_image_url = `${supabase.storage.from('profile-pictures').getPublicUrl(fileName).data.publicUrl}`; } // ... rest of database operations ... } catch (err: any) { + // Cleanup uploaded file on error + if (uploadedFileName) { + try { + await supabase.storage.from('profile-pictures').remove([uploadedFileName]); + } catch (cleanupError) { + console.error("Failed to cleanup uploaded file:", cleanupError); + } + } setSubmitError(err.message || 'Failed to submit onboarding data.'); setProgress(0); } finally { setSubmitting(false); } };Frontend/src/pages/HomePage.tsx (1)
273-286: The API error handling still needs improvement as previously identified.This is the same issue identified in previous reviews regarding basic error handling without retries or enhanced user feedback.
The fetch call still lacks a retry mechanism and proper error UI. Please refer to the previous review comment for the detailed implementation suggestions.
🧹 Nitpick comments (7)
Backend/app/main.py (1)
10-11: Remove unused imports to clean up the code.The
loggingandosimports are not used anywhere in this file.-import logging -import osBackend/app/routes/match.py (1)
15-27: Add proper spacing and improve error messages.The endpoint logic is correct, but formatting and error messages could be improved.
+ @router.get("/creators-for-brand/{sponsorship_id}") def get_creators_for_brand(sponsorship_id: str): matches = match_creators_for_brand(sponsorship_id) if not matches: - raise HTTPException(status_code=404, detail="No matching creators found.") + raise HTTPException(status_code=404, detail="No matching creators found for this sponsorship.") return {"matches": matches} + @router.get("/brands-for-creator/{creator_id}") def get_brands_for_creator(creator_id: str): matches = match_brands_for_creator(creator_id) if not matches: - raise HTTPException(status_code=404, detail="No matching brand campaigns found.") + raise HTTPException(status_code=404, detail="No matching sponsorship opportunities found for this creator.") return {"matches": matches} - -# Placeholder for endpoints, logic to be added nextFrontend/src/components/Onboarding.tsx (1)
392-392: Consider using optional chaining for safer property access.These property access patterns could benefit from optional chaining to prevent potential runtime errors.
Examples of improvements:
-if (e.target.files && e.target.files[0]) { +if (e.target.files?.[0]) { -if (errData && errData.detail) errMsg = errData.detail; +errMsg = errData?.detail || errMsg;Also applies to: 645-645, 1189-1189
Frontend/src/pages/HomePage.tsx (4)
34-262: Consider extracting data constants to separate modules for better maintainability.The file contains several large constant arrays that make it quite lengthy. These could be moved to separate data files or potentially fetched from backend APIs for dynamic content.
Consider creating separate files:
+// Create src/data/features.ts +export const features = [ + // ... existing features data +]; +// Create src/data/mockData.ts +export const successStories = [ + // ... existing success stories data +]; +export const brandShowcase = [ + // ... existing brand showcase data +];Then import them:
+import { features } from '../data/features'; +import { successStories, brandShowcase } from '../data/mockData'; -const features = [ - // ... remove from here -];
378-442: Consider extracting intersection observer logic into custom hooks.The component manages multiple intersection observers which could be abstracted into reusable custom hooks for better maintainability and testability.
Create custom hooks:
// Create src/hooks/useIntersectionObserver.ts function useIntersectionObserver(threshold = 0.1) { const [isVisible, setIsVisible] = useState(false); const [hasAnimated, setHasAnimated] = useState(false); const ref = useRef(null); useEffect(() => { const observer = new IntersectionObserver( (entries) => { const [entry] = entries; if (entry.isIntersecting && !hasAnimated) { setIsVisible(true); setHasAnimated(true); } }, { threshold } ); if (ref.current) observer.observe(ref.current); return () => { if (ref.current) observer.unobserve(ref.current); }; }, [hasAnimated, threshold]); return { ref, isVisible }; }Then use it in the component:
-const [isTrendingVisible, setIsTrendingVisible] = useState(false); -const [hasAnimatedTrending, setHasAnimatedTrending] = useState(false); +const { ref: trendingRef, isVisible: isTrendingVisible } = useIntersectionObserver();
445-694: Consider splitting authenticated and non-authenticated layouts into separate components.The component is quite large and handles two distinct user experiences. Splitting these into separate components would improve maintainability and testing.
Extract into separate components:
// Create AuthenticatedHomePage.tsx function AuthenticatedHomePage({ user }: { user: User }) { // ... authenticated user layout } // Create UnauthenticatedHomePage.tsx function UnauthenticatedHomePage() { // ... non-authenticated user layout } // Then in HomePage.tsx: export default function HomePage() { const { isAuthenticated, user } = useAuth(); if (isAuthenticated && user) { return <AuthenticatedHomePage user={user} />; } return <UnauthenticatedHomePage />; }
497-498: Consider replacing inline styles with CSS classes for animation delays.Inline styles with
animationDelaycould be moved to CSS classes for better maintainability.Create CSS classes:
/* Add to your CSS file */ .animate-bounce-delay-500 { animation-delay: 0.5s; } .animate-bounce-delay-1000 { animation-delay: 1s; }Then replace:
-<div className="absolute -bottom-4 -left-4 w-12 h-12 bg-gradient-to-br from-purple-400 to-pink-500 rounded-full flex items-center justify-center shadow-lg animate-bounce" style={{animationDelay: '0.5s'}}> +<div className="absolute -bottom-4 -left-4 w-12 h-12 bg-gradient-to-br from-purple-400 to-pink-500 rounded-full flex items-center justify-center shadow-lg animate-bounce animate-bounce-delay-500">Also applies to: 815-816
📜 Review details
Configuration used: CodeRabbit UI
Review profile: CHILL
Plan: Pro
📒 Files selected for processing (8)
Backend/.env-example(1 hunks)Backend/app/main.py(2 hunks)Backend/app/routes/ai.py(1 hunks)Backend/app/routes/match.py(1 hunks)Frontend/src/components/Onboarding.tsx(1 hunks)Frontend/src/components/main-nav.tsx(1 hunks)Frontend/src/pages/BasicDetails.tsx(0 hunks)Frontend/src/pages/HomePage.tsx(2 hunks)
💤 Files with no reviewable changes (1)
- Frontend/src/pages/BasicDetails.tsx
🚧 Files skipped from review as they are similar to previous changes (2)
- Backend/.env-example
- Frontend/src/components/main-nav.tsx
🧰 Additional context used
🧠 Learnings (2)
Backend/app/routes/ai.py (1)
Learnt from: muntaxir4
PR: AOSSIE-Org/InPactAI#56
File: Backend/app/services/redis_client.py:1-4
Timestamp: 2025-05-07T21:28:06.358Z
Learning: Hardcoded Redis connection parameters in Backend/app/services/redis_client.py are intentional during development, with plans to implement environment variable configuration later during production preparation.
Backend/app/routes/match.py (1)
Learnt from: muntaxir4
PR: AOSSIE-Org/InPactAI#56
File: Backend/app/services/redis_client.py:1-4
Timestamp: 2025-05-07T21:28:06.358Z
Learning: Hardcoded Redis connection parameters in Backend/app/services/redis_client.py are intentional during development, with plans to implement environment variable configuration later during production preparation.
🧬 Code Graph Analysis (3)
Frontend/src/components/Onboarding.tsx (2)
Frontend/src/context/AuthContext.tsx (1)
useAuth(216-222)Frontend/src/utils/supabase.tsx (1)
supabase(11-11)
Backend/app/routes/ai.py (1)
Frontend/src/utils/supabase.tsx (1)
supabase(11-11)
Backend/app/routes/match.py (1)
Backend/app/services/db_service.py (2)
match_creators_for_brand(13-48)match_brands_for_creator(51-85)
🪛 Biome (1.9.4)
Frontend/src/components/Onboarding.tsx
[error] 392-392: Change to an optional chain.
Unsafe fix: Change to an optional chain.
(lint/complexity/useOptionalChain)
[error] 645-645: Change to an optional chain.
Unsafe fix: Change to an optional chain.
(lint/complexity/useOptionalChain)
[error] 1189-1189: Change to an optional chain.
Unsafe fix: Change to an optional chain.
(lint/complexity/useOptionalChain)
🪛 Ruff (0.11.9)
Backend/app/main.py
10-10: logging imported but unused
Remove unused import: logging
(F401)
11-11: os imported but unused
Remove unused import: os
(F401)
Backend/app/routes/ai.py
101-101: Within an except clause, raise exceptions with raise ... from err or raise ... from None to distinguish them from errors in exception handling
(B904)
Backend/app/routes/match.py
2-2: supabase.create_client imported but unused
Remove unused import
(F401)
2-2: supabase.Client imported but unused
Remove unused import
(F401)
3-3: os imported but unused
Remove unused import: os
(F401)
4-4: dotenv.load_dotenv imported but unused
Remove unused import: dotenv.load_dotenv
(F401)
🪛 Flake8 (7.2.0)
Backend/app/main.py
[error] 10-10: 'logging' imported but unused
(F401)
[error] 11-11: 'os' imported but unused
(F401)
Backend/app/routes/ai.py
[error] 25-25: expected 2 blank lines, found 1
(E302)
[error] 55-55: expected 2 blank lines, found 1
(E302)
[error] 84-84: expected 2 blank lines after class or function definition, found 1
(E305)
[error] 86-86: expected 2 blank lines, found 1
(E302)
Backend/app/routes/match.py
[error] 2-2: 'supabase.create_client' imported but unused
(F401)
[error] 2-2: 'supabase.Client' imported but unused
(F401)
[error] 3-3: 'os' imported but unused
(F401)
[error] 4-4: 'dotenv.load_dotenv' imported but unused
(F401)
[error] 15-15: expected 2 blank lines, found 1
(E302)
[error] 22-22: expected 2 blank lines, found 1
(E302)
🪛 Pylint (3.3.7)
Backend/app/routes/match.py
[error] 5-5: Attempted relative import beyond top-level package
(E0402)
🔇 Additional comments (7)
Backend/app/main.py (1)
3-8: LGTM! Router integration looks correct.The new route imports and router includes are properly implemented to expose the matching and AI endpoints.
Also applies to: 14-14, 56-58
Backend/app/routes/ai.py (3)
19-21: Excellent addition of environment variable validation.This prevents runtime errors from missing configuration and provides clear feedback.
30-41: Great implementation of retry strategy.The retry logic with exponential backoff and proper status codes will improve reliability for external API calls.
86-101: YouTube proxy endpoint looks secure and well-implemented.The API key is properly protected on the backend and error handling is appropriate.
Frontend/src/components/Onboarding.tsx (2)
24-24: Good security improvement removing frontend API key exposure.Commenting out the YouTube API key and using the backend proxy is the correct approach for security.
1182-1214: YouTube channel fetching logic is well-implemented.The backend proxy usage, error handling, and channel data extraction are all properly implemented for security and functionality.
Frontend/src/pages/HomePage.tsx (1)
337-376: Excellent refactoring to address code duplication.Good work extracting the duplicate "Why Choose Inpact AI" section into a reusable component. This properly addresses the DRY principle violation identified in previous reviews.
| <img | ||
| src="/Home.png" | ||
| alt="Hero Image" | ||
| className="rounded-xl object-cover w-full h-auto shadow-lg" | ||
| /> |
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
🛠️ Refactor suggestion
Same image accessibility issue applies here.
This image also needs proper alt text and error handling.
Apply the same improvements as suggested for the authenticated version:
-<img
- src="/Home.png"
- alt="Hero Image"
- className="rounded-xl object-cover w-full h-auto shadow-lg"
-/>
+<img
+ src="/Home.png"
+ alt="Inpact AI platform showcase featuring creator-brand collaboration tools and analytics dashboard"
+ className="rounded-xl object-cover w-full h-auto shadow-lg"
+ onError={(e) => {
+ e.currentTarget.src = '/fallback-hero.png';
+ }}
+ loading="lazy"
+/>📝 Committable suggestion
‼️ IMPORTANT
Carefully review the code before committing. Ensure that it accurately replaces the highlighted code, contains no missing lines, and has no issues with indentation. Thoroughly test & benchmark the code to ensure it meets the requirements.
| <img | |
| src="/Home.png" | |
| alt="Hero Image" | |
| className="rounded-xl object-cover w-full h-auto shadow-lg" | |
| /> | |
| <img | |
| src="/Home.png" | |
| alt="Inpact AI platform showcase featuring creator-brand collaboration tools and analytics dashboard" | |
| className="rounded-xl object-cover w-full h-auto shadow-lg" | |
| onError={(e) => { | |
| e.currentTarget.src = '/fallback-hero.png'; | |
| }} | |
| loading="lazy" | |
| /> |
🤖 Prompt for AI Agents
In Frontend/src/pages/HomePage.tsx around lines 804 to 808, the img element uses
a generic alt text "Hero Image" and lacks error handling. Update the alt
attribute to a more descriptive text that conveys the image's purpose or content
for better accessibility. Additionally, implement error handling for the image
loading failure, such as adding an onError handler to provide fallback behavior
or display an alternative element.
| <div className="space-y-6 w-full"> | ||
| {/* Main Welcome Heading */} | ||
| <h1 className="text-5xl lg:text-7xl font-bold tracking-tight text-gray-900 leading-tight w-full text-center lg:text-left"> | ||
| Welcome, <span className="bg-gradient-to-r from-purple-600 via-blue-600 to-indigo-600 bg-clip-text text-transparent animate-gradient">{user.user_metadata?.name || user.email?.split('@')[0]}</span> |
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
🛠️ Refactor suggestion
Improve user data access with proper fallback handling.
The user name extraction could be more robust and handle edge cases better.
-Welcome, <span className="bg-gradient-to-r from-purple-600 via-blue-600 to-indigo-600 bg-clip-text text-transparent animate-gradient">{user.user_metadata?.name || user.email?.split('@')[0]}</span>
+Welcome, <span className="bg-gradient-to-r from-purple-600 via-blue-600 to-indigo-600 bg-clip-text text-transparent animate-gradient">
+ {user.user_metadata?.name ||
+ user.user_metadata?.full_name ||
+ user.email?.split('@')[0] ||
+ 'Creator'}
+</span>📝 Committable suggestion
‼️ IMPORTANT
Carefully review the code before committing. Ensure that it accurately replaces the highlighted code, contains no missing lines, and has no issues with indentation. Thoroughly test & benchmark the code to ensure it meets the requirements.
| Welcome, <span className="bg-gradient-to-r from-purple-600 via-blue-600 to-indigo-600 bg-clip-text text-transparent animate-gradient">{user.user_metadata?.name || user.email?.split('@')[0]}</span> | |
| Welcome, <span className="bg-gradient-to-r from-purple-600 via-blue-600 to-indigo-600 bg-clip-text text-transparent animate-gradient"> | |
| {user.user_metadata?.name || | |
| user.user_metadata?.full_name || | |
| user.email?.split('@')[0] || | |
| 'Creator'} | |
| </span> |
🤖 Prompt for AI Agents
In Frontend/src/pages/HomePage.tsx at line 514, improve the user name extraction
by adding more robust fallback handling. Instead of directly accessing
user.user_metadata?.name or splitting user.email, add checks to ensure these
values exist and are valid strings before using them. Provide a sensible default
fallback string if neither is available to avoid potential runtime errors or
displaying undefined values.
| src="/Home.png" | ||
| alt="Dashboard Preview" | ||
| className="rounded-xl object-cover w-full h-auto shadow-lg" | ||
| /> |
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
🛠️ Refactor suggestion
Add proper alt text and error handling for images.
The image lacks descriptive alt text and has no error handling for failed loads.
-<img
- src="/Home.png"
- alt="Dashboard Preview"
- className="rounded-xl object-cover w-full h-auto shadow-lg"
-/>
+<img
+ src="/Home.png"
+ alt="Inpact AI dashboard interface showing analytics, collaboration tools, and creator-brand matching features"
+ className="rounded-xl object-cover w-full h-auto shadow-lg"
+ onError={(e) => {
+ e.currentTarget.src = '/fallback-dashboard.png';
+ }}
+ loading="lazy"
+/>📝 Committable suggestion
‼️ IMPORTANT
Carefully review the code before committing. Ensure that it accurately replaces the highlighted code, contains no missing lines, and has no issues with indentation. Thoroughly test & benchmark the code to ensure it meets the requirements.
| src="/Home.png" | |
| alt="Dashboard Preview" | |
| className="rounded-xl object-cover w-full h-auto shadow-lg" | |
| /> | |
| <img | |
| src="/Home.png" | |
| alt="Inpact AI dashboard interface showing analytics, collaboration tools, and creator-brand matching features" | |
| className="rounded-xl object-cover w-full h-auto shadow-lg" | |
| onError={(e) => { | |
| e.currentTarget.src = '/fallback-dashboard.png'; | |
| }} | |
| loading="lazy" | |
| /> |
🤖 Prompt for AI Agents
In Frontend/src/pages/HomePage.tsx around lines 488 to 491, the image element
uses a generic alt text "Dashboard Preview" which is not descriptive, and there
is no error handling for image load failures. Update the alt attribute to a more
descriptive text that clearly conveys the image content. Additionally, implement
error handling for the image by adding an onError event handler that sets a
fallback image or handles the error gracefully to improve accessibility and user
experience.
chandansgowda
left a comment
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
Approving now.. but we'll have to make the code cleaner going forward.
|
|
||
| def fetch_from_gemini(): | ||
| prompt = ( | ||
| "List the top 6 trending content niches for creators and brands this week. For each, provide: name (the niche), insight (a short qualitative reason why it's trending), and global_activity (a number from 1 to 5, where 5 means very high global activity in this category, and 1 means low).Return as a JSON array of objects with keys: name, insight, global_activity." |
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
- how will gemini know this week's trend? is this grounded on google trends data?
- why are we making api calls.. lets use sdks
📝 Description
This PR introduces several major improvements and new features to the InPactAI platform:
These changes enhance both the user experience and the backend logic, making the platform more robust, intelligent, and visually appealing.
🔧 Changes Made
📷 Screenshots or Visual Changes (if applicable)
Summary by CodeRabbit
New Features
Improvements
Bug Fixes
Chores & Documentation
Style