/** * Step 4: Add Keywords * Initial keyword input for content pipeline * Supports two modes: High Opportunity Keywords or Manual Entry */ import React, { useState, useEffect, useMemo } from 'react'; import Button from '../../ui/button/Button'; import IconButton from '../../ui/button/IconButton'; import { Card } from '../../ui/card'; import Badge from '../../ui/badge/Badge'; import Alert from '../../ui/alert/Alert'; import { ArrowRightIcon, ArrowLeftIcon, ListIcon, PlusIcon, CloseIcon, BoltIcon, PencilIcon, CheckCircleIcon, } from '../../../icons'; import { createKeyword, fetchSeedKeywords, addSeedKeywordsToWorkflow, fetchSiteSectors, fetchIndustries, SeedKeyword, } from '../../../services/api'; import { useToast } from '../../ui/toast/ToastContainer'; import type { WizardData } from '../OnboardingWizard'; interface Step4AddKeywordsProps { data: WizardData; updateData: (updates: Partial) => void; onNext: () => void; onBack: () => void; onSkip: () => void; currentStep: number; totalSteps: number; } type KeywordMode = 'opportunity' | 'manual' | null; interface SectorKeywordOption { type: 'high-volume' | 'low-difficulty'; label: string; keywords: SeedKeyword[]; added: boolean; } interface SectorKeywordData { sectorSlug: string; sectorName: string; sectorId: number; options: SectorKeywordOption[]; } export default function Step4AddKeywords({ data, updateData, onNext, onBack, onSkip, currentStep, totalSteps, }: Step4AddKeywordsProps) { const toast = useToast(); const [mode, setMode] = useState(null); const [keywords, setKeywords] = useState([]); const [inputValue, setInputValue] = useState(''); const [isAdding, setIsAdding] = useState(false); const [error, setError] = useState(null); // High opportunity keywords state - by sector const [loadingOpportunities, setLoadingOpportunities] = useState(false); const [sectorKeywordData, setSectorKeywordData] = useState([]); // Load sector keywords when opportunity mode is selected useEffect(() => { if (mode === 'opportunity' && data.selectedSectors.length > 0 && data.createdSiteId) { loadSectorKeywords(); } }, [mode, data.selectedSectors, data.createdSiteId]); const loadSectorKeywords = async () => { if (!data.createdSiteId || !data.industrySlug) { setError('Site must be created first to load keywords'); return; } setLoadingOpportunities(true); setError(null); try { // Get site sectors with their IDs const siteSectors = await fetchSiteSectors(data.createdSiteId); // Get industry ID from industries API const industriesResponse = await fetchIndustries(); const industry = industriesResponse.industries?.find(i => i.slug === data.industrySlug); if (!industry?.id) { setError('Could not find industry information'); return; } const sectorData: SectorKeywordData[] = []; for (const sectorSlug of data.selectedSectors) { // Find the site sector to get its ID const siteSector = siteSectors.find((s: any) => s.slug === sectorSlug); if (!siteSector) continue; // Find the industry sector ID for this sector slug const industrySector = industry.sectors?.find(s => s.slug === sectorSlug); if (!industrySector) continue; // Get the sector ID from industry_sector relationship const sectorId = siteSector.id; const sectorName = siteSector.name || sectorSlug.replace(/-/g, ' ').replace(/\b\w/g, l => l.toUpperCase()); // Fetch seed keywords for this sector from the database // Get all keywords sorted by volume (descending) for high volume const highVolumeResponse = await fetchSeedKeywords({ industry: industry.id, page_size: 500, // Get enough to filter }); // Filter keywords matching this sector slug and sort by volume const sectorKeywords = highVolumeResponse.results.filter( kw => kw.sector_slug === sectorSlug ); // Top 50 by highest volume const highVolumeKeywords = [...sectorKeywords] .sort((a, b) => (b.volume || 0) - (a.volume || 0)) .slice(0, 50); // Top 50 by lowest difficulty (KD) const lowDifficultyKeywords = [...sectorKeywords] .sort((a, b) => (a.difficulty || 100) - (b.difficulty || 100)) .slice(0, 50); sectorData.push({ sectorSlug, sectorName, sectorId, options: [ { type: 'high-volume', label: 'Top 50 High Volume', keywords: highVolumeKeywords, added: false, }, { type: 'low-difficulty', label: 'Top 50 Low Difficulty', keywords: lowDifficultyKeywords, added: false, }, ], }); } setSectorKeywordData(sectorData); if (sectorData.length === 0) { setError('No seed keywords found for your selected sectors. Try adding keywords manually.'); } } catch (err: any) { console.error('Failed to load sector keywords:', err); setError(err.message || 'Failed to load keywords from database'); } finally { setLoadingOpportunities(false); } }; // Track which option is being added (sectorSlug-optionType) const [addingOption, setAddingOption] = useState(null); // Handle adding all keywords from a sector option using bulk API const handleAddSectorKeywords = async (sectorSlug: string, optionType: 'high-volume' | 'low-difficulty') => { const sector = sectorKeywordData.find(s => s.sectorSlug === sectorSlug); if (!sector || !data.createdSiteId) { setError('Site must be created before adding keywords'); return; } const option = sector.options.find(o => o.type === optionType); if (!option || option.added || option.keywords.length === 0) return; const addingKey = `${sectorSlug}-${optionType}`; setAddingOption(addingKey); setError(null); try { // Get seed keyword IDs const seedKeywordIds = option.keywords.map(kw => kw.id); // Use the bulk add API to add keywords to workflow const result = await addSeedKeywordsToWorkflow( seedKeywordIds, data.createdSiteId, sector.sectorId ); if (result.success && result.created > 0) { // Mark option as added setSectorKeywordData(prev => prev.map(s => s.sectorSlug === sectorSlug ? { ...s, options: s.options.map(o => o.type === optionType ? { ...o, added: true } : o ) } : s )); // Update count setKeywords(prev => [...prev, ...option.keywords.map(k => k.keyword)]); let message = `Added ${result.created} keywords to ${data.siteName} - ${sector.sectorName}`; if (result.skipped && result.skipped > 0) { message += ` (${result.skipped} skipped)`; } toast.success(message); } else if (result.errors && result.errors.length > 0) { setError(result.errors[0]); } else { setError('No keywords were added. They may already exist in your workflow.'); } } catch (err: any) { setError(err.message || 'Failed to add keywords to workflow'); } finally { setAddingOption(null); } }; const handleAddKeyword = () => { const keyword = inputValue.trim(); if (!keyword) return; if (keywords.includes(keyword)) { toast.warning('Keyword already added'); return; } if (keywords.length >= 50) { toast.warning('Maximum 50 keywords allowed in onboarding'); return; } setKeywords([...keywords, keyword]); setInputValue(''); }; const handleRemoveKeyword = (keyword: string) => { setKeywords(keywords.filter(k => k !== keyword)); }; const handleKeyDown = (e: React.KeyboardEvent) => { if (e.key === 'Enter') { e.preventDefault(); handleAddKeyword(); } }; const handlePaste = (e: React.ClipboardEvent) => { e.preventDefault(); const text = e.clipboardData.getData('text'); const newKeywords = text .split(/[\n,;]+/) .map(k => k.trim()) .filter(k => k && !keywords.includes(k)); const totalKeywords = [...keywords, ...newKeywords].slice(0, 50); setKeywords(totalKeywords); if (newKeywords.length > 0) { toast.success(`Added ${Math.min(newKeywords.length, 50 - keywords.length)} keywords`); } }; const handleSubmitKeywords = async () => { if (!data.createdSiteId || keywords.length === 0) return; setError(null); setIsAdding(true); try { // Create keywords one by one (could be optimized with bulk endpoint later) let successCount = 0; for (const keyword of keywords) { try { await createKeyword({ keyword, status: 'unprocessed', }); successCount++; } catch (err) { console.warn(`Failed to add keyword "${keyword}":`, err); } } if (successCount > 0) { updateData({ keywordsAdded: true, keywordsCount: successCount }); toast.success(`Added ${successCount} keywords to your pipeline!`); onNext(); } else { setError('Failed to add keywords. Please try again.'); } } catch (err: any) { setError(err.message || 'Failed to add keywords'); } finally { setIsAdding(false); } }; // Mode selection view if (mode === null) { return (

Add Target Keywords

Optional

Add keywords to start your content pipeline. Choose how you'd like to add keywords.

{/* Mode Selection Cards - Icon and title in same row */}
{/* High Opportunity Keywords */} {/* Manual Entry */}
{/* Selected Sectors Display */} {data.selectedSectors.length > 0 && (

Your Selected Sectors

{data.selectedSectors.map((sector) => ( {sector.replace(/-/g, ' ').replace(/\b\w/g, l => l.toUpperCase())} ))}
)} {/* Actions */}
Step {currentStep} of {totalSteps}
); } // High Opportunity Keywords mode if (mode === 'opportunity') { const addedCount = sectorKeywordData.reduce((acc, s) => acc + s.options.filter(o => o.added).reduce((sum, o) => sum + o.keywords.length, 0), 0 ); const allOptionsAdded = sectorKeywordData.every(s => s.options.every(o => o.added)); return (

High Opportunity Keywords

Add top keywords for each of your sectors. Keywords will be added to your planner workflow.

{error && ( )} {loadingOpportunities ? (
) : ( <> {/* Sector columns with 2 options each */}
{sectorKeywordData.map((sector) => (

{sector.sectorName}

{sector.options.map((option) => { const addingKey = `${sector.sectorSlug}-${option.type}`; const isAdding = addingOption === addingKey; return (
{option.label}

{option.keywords.length} keywords

{option.added ? ( Added ) : ( )}
{/* Show first 3 keywords with +X more */}
{option.keywords.slice(0, 3).map((kw) => ( {kw.keyword} ))} {option.keywords.length > 3 && ( +{option.keywords.length - 3} more )}
); })}
))}
{/* Summary */} {addedCount > 0 && (
{addedCount} keywords added to your workflow
)} )} {/* Actions */}
Step {currentStep} of {totalSteps}
{!allOptionsAdded && ( )}
); } // Manual entry mode return (

Add Keywords Manually

Enter keywords one by one or paste a list. Press Enter or click + to add each keyword.

{error && ( )} {/* Keyword Input */}
setInputValue(e.target.value)} onKeyDown={handleKeyDown} onPaste={handlePaste} placeholder="Enter a keyword..." className="w-full pl-12 pr-4 py-3 text-base border border-gray-200 dark:border-gray-700 rounded-lg bg-white dark:bg-gray-900 text-gray-900 dark:text-white focus:ring-2 focus:ring-brand-500 focus:border-transparent" />
} />

Tip: Paste a comma-separated list or one keyword per line

{/* Keywords List */} {keywords.length === 0 ? (

No keywords added yet

Start typing or paste keywords above

) : (
{keywords.map((keyword) => ( {keyword} } onClick={() => handleRemoveKeyword(keyword)} variant="ghost" size="xs" className="ml-1" aria-label="Remove keyword" /> ))}
)}
{keywords.length} keyword{keywords.length !== 1 ? 's' : ''} added {keywords.length > 0 && ( )}
{/* Info Alert */} {/* Actions */}
Step {currentStep} of {totalSteps}
); }