738 lines
26 KiB
TypeScript
738 lines
26 KiB
TypeScript
/**
|
|
* 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<WizardData>) => 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<KeywordMode>(null);
|
|
const [keywords, setKeywords] = useState<string[]>([]);
|
|
const [inputValue, setInputValue] = useState('');
|
|
const [isAdding, setIsAdding] = useState(false);
|
|
const [error, setError] = useState<string | null>(null);
|
|
|
|
// High opportunity keywords state - by sector
|
|
const [loadingOpportunities, setLoadingOpportunities] = useState(false);
|
|
const [sectorKeywordData, setSectorKeywordData] = useState<SectorKeywordData[]>([]);
|
|
|
|
// 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<string | null>(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 (
|
|
<div>
|
|
<div className="mb-8">
|
|
<div className="flex items-center gap-3 mb-3">
|
|
<h2 className="text-2xl font-bold text-gray-900 dark:text-white">
|
|
Add Target Keywords
|
|
</h2>
|
|
<Badge tone="neutral" variant="soft" size="sm">Optional</Badge>
|
|
</div>
|
|
<p className="text-base text-gray-600 dark:text-gray-400">
|
|
Add keywords to start your content pipeline. Choose how you'd like to add keywords.
|
|
</p>
|
|
</div>
|
|
|
|
{/* Mode Selection Cards - Icon and title in same row */}
|
|
<div className="grid grid-cols-1 md:grid-cols-2 gap-4 mb-8">
|
|
{/* High Opportunity Keywords */}
|
|
<button
|
|
onClick={() => setMode('opportunity')}
|
|
className="p-6 rounded-xl border-2 border-gray-200 dark:border-gray-700 bg-white dark:bg-gray-800 text-left hover:border-brand-300 dark:hover:border-brand-700 hover:shadow-md transition-all group"
|
|
>
|
|
<div className="flex items-center gap-4 mb-3">
|
|
<div className="size-12 rounded-xl bg-brand-100 dark:bg-brand-900/50 flex items-center justify-center text-brand-600 dark:text-brand-400 group-hover:scale-110 transition-transform flex-shrink-0">
|
|
<BoltIcon className="w-6 h-6" />
|
|
</div>
|
|
<h3 className="text-xl font-semibold text-gray-900 dark:text-white">
|
|
High Opportunity Keywords
|
|
</h3>
|
|
</div>
|
|
<p className="text-base text-gray-500 dark:text-gray-400 mb-3">
|
|
Select top keywords curated for your sectors. Includes volume and difficulty rankings.
|
|
</p>
|
|
<div className="flex items-center gap-2 text-brand-600 dark:text-brand-400 font-medium">
|
|
<span>Select top keywords</span>
|
|
<ArrowRightIcon className="w-4 h-4" />
|
|
</div>
|
|
</button>
|
|
|
|
{/* Manual Entry */}
|
|
<button
|
|
onClick={() => setMode('manual')}
|
|
className="p-6 rounded-xl border-2 border-gray-200 dark:border-gray-700 bg-white dark:bg-gray-800 text-left hover:border-brand-300 dark:hover:border-brand-700 hover:shadow-md transition-all group"
|
|
>
|
|
<div className="flex items-center gap-4 mb-3">
|
|
<div className="size-12 rounded-xl bg-gray-100 dark:bg-gray-700 flex items-center justify-center text-gray-600 dark:text-gray-400 group-hover:scale-110 transition-transform flex-shrink-0">
|
|
<PencilIcon className="w-6 h-6" />
|
|
</div>
|
|
<h3 className="text-xl font-semibold text-gray-900 dark:text-white">
|
|
Add Manually
|
|
</h3>
|
|
</div>
|
|
<p className="text-base text-gray-500 dark:text-gray-400 mb-3">
|
|
Enter your own keywords one by one or paste a list. Perfect if you have specific topics in mind.
|
|
</p>
|
|
<div className="flex items-center gap-2 text-gray-600 dark:text-gray-400 font-medium">
|
|
<span>Type or paste keywords</span>
|
|
<ArrowRightIcon className="w-4 h-4" />
|
|
</div>
|
|
</button>
|
|
</div>
|
|
|
|
{/* Selected Sectors Display */}
|
|
{data.selectedSectors.length > 0 && (
|
|
<Card className="p-5 bg-gray-50 dark:bg-gray-800/50 mb-6">
|
|
<h4 className="text-base font-medium text-gray-700 dark:text-gray-300 mb-3">
|
|
Your Selected Sectors
|
|
</h4>
|
|
<div className="flex flex-wrap gap-2">
|
|
{data.selectedSectors.map((sector) => (
|
|
<Badge key={sector} tone="brand" variant="soft" size="md" className="text-base px-3 py-1.5">
|
|
{sector.replace(/-/g, ' ').replace(/\b\w/g, l => l.toUpperCase())}
|
|
</Badge>
|
|
))}
|
|
</div>
|
|
</Card>
|
|
)}
|
|
|
|
{/* Actions */}
|
|
<div className="flex items-center justify-between pt-4 border-t border-gray-200 dark:border-gray-700">
|
|
<Button
|
|
variant="ghost"
|
|
tone="neutral"
|
|
size="md"
|
|
onClick={onBack}
|
|
startIcon={<ArrowLeftIcon className="w-5 h-5" />}
|
|
>
|
|
Back
|
|
</Button>
|
|
<span className="text-sm text-gray-500 dark:text-gray-400">
|
|
Step <span className="font-semibold text-gray-700 dark:text-gray-300">{currentStep}</span> of <span className="font-semibold text-gray-700 dark:text-gray-300">{totalSteps}</span>
|
|
</span>
|
|
<Button
|
|
variant="ghost"
|
|
tone="neutral"
|
|
size="md"
|
|
onClick={onSkip}
|
|
>
|
|
Skip for now
|
|
</Button>
|
|
</div>
|
|
</div>
|
|
);
|
|
}
|
|
|
|
// 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 (
|
|
<div>
|
|
<div className="mb-8">
|
|
<button
|
|
onClick={() => setMode(null)}
|
|
className="flex items-center gap-2 text-base text-gray-500 hover:text-gray-700 dark:hover:text-gray-300 mb-4"
|
|
>
|
|
<ArrowLeftIcon className="w-4 h-4" />
|
|
<span>Back to options</span>
|
|
</button>
|
|
<h2 className="text-2xl font-bold text-gray-900 dark:text-white mb-2">
|
|
High Opportunity Keywords
|
|
</h2>
|
|
<p className="text-base text-gray-600 dark:text-gray-400">
|
|
Add top keywords for each of your sectors. Keywords will be added to your planner workflow.
|
|
</p>
|
|
</div>
|
|
|
|
{error && (
|
|
<Alert variant="error" title="Error" message={error} className="mb-6" />
|
|
)}
|
|
|
|
{loadingOpportunities ? (
|
|
<div className="flex items-center justify-center py-12">
|
|
<div className="animate-spin rounded-full h-8 w-8 border-b-2 border-brand-500" />
|
|
</div>
|
|
) : (
|
|
<>
|
|
{/* Sector columns with 2 options each */}
|
|
<div className="grid grid-cols-1 md:grid-cols-2 lg:grid-cols-3 gap-6 mb-6 items-start">
|
|
{sectorKeywordData.map((sector) => (
|
|
<div key={sector.sectorSlug} className="flex flex-col gap-3">
|
|
<h4 className="text-lg font-semibold text-gray-900 dark:text-white border-b border-gray-200 dark:border-gray-700 pb-2">
|
|
{sector.sectorName}
|
|
</h4>
|
|
|
|
{sector.options.map((option) => {
|
|
const addingKey = `${sector.sectorSlug}-${option.type}`;
|
|
const isAdding = addingOption === addingKey;
|
|
|
|
return (
|
|
<Card
|
|
key={option.type}
|
|
className={`p-4 transition-all flex-1 flex flex-col ${
|
|
option.added
|
|
? 'border-success-300 dark:border-success-700 bg-success-50 dark:bg-success-900/20'
|
|
: 'hover:border-brand-300 dark:hover:border-brand-700'
|
|
}`}
|
|
>
|
|
<div className="flex items-center justify-between mb-3">
|
|
<div>
|
|
<h5 className="text-base font-medium text-gray-900 dark:text-white">
|
|
{option.label}
|
|
</h5>
|
|
<p className="text-xs text-gray-500 dark:text-gray-400">
|
|
{option.keywords.length} keywords
|
|
</p>
|
|
</div>
|
|
{option.added ? (
|
|
<Badge tone="success" variant="soft" size="sm">
|
|
<CheckCircleIcon className="w-3 h-3 mr-1" />
|
|
Added
|
|
</Badge>
|
|
) : (
|
|
<Button
|
|
variant="primary"
|
|
size="xs"
|
|
onClick={() => handleAddSectorKeywords(sector.sectorSlug, option.type)}
|
|
disabled={isAdding}
|
|
>
|
|
{isAdding ? 'Adding...' : 'Add All'}
|
|
</Button>
|
|
)}
|
|
</div>
|
|
|
|
{/* Show first 3 keywords with +X more */}
|
|
<div className="flex flex-wrap gap-1.5 flex-1">
|
|
{option.keywords.slice(0, 3).map((kw) => (
|
|
<Badge
|
|
key={kw.id}
|
|
tone={option.added ? 'success' : 'neutral'}
|
|
variant="soft"
|
|
size="xs"
|
|
className="text-xs"
|
|
>
|
|
{kw.keyword}
|
|
</Badge>
|
|
))}
|
|
{option.keywords.length > 3 && (
|
|
<Badge tone="neutral" variant="outline" size="xs" className="text-xs">
|
|
+{option.keywords.length - 3} more
|
|
</Badge>
|
|
)}
|
|
</div>
|
|
</Card>
|
|
);
|
|
})}
|
|
</div>
|
|
))}
|
|
</div>
|
|
|
|
{/* Summary */}
|
|
{addedCount > 0 && (
|
|
<Card className="p-4 bg-success-50 dark:bg-success-900/20 border-success-200 dark:border-success-800 mb-6">
|
|
<div className="flex items-center gap-3">
|
|
<CheckCircleIcon className="w-5 h-5 text-success-600 dark:text-success-400" />
|
|
<span className="text-base text-success-700 dark:text-success-300">
|
|
{addedCount} keywords added to your workflow
|
|
</span>
|
|
</div>
|
|
</Card>
|
|
)}
|
|
</>
|
|
)}
|
|
|
|
{/* Actions */}
|
|
<div className="flex items-center justify-between pt-4 border-t border-gray-200 dark:border-gray-700">
|
|
<Button
|
|
variant="ghost"
|
|
tone="neutral"
|
|
size="md"
|
|
onClick={() => setMode(null)}
|
|
startIcon={<ArrowLeftIcon className="w-5 h-5" />}
|
|
>
|
|
Back
|
|
</Button>
|
|
<span className="text-sm text-gray-500 dark:text-gray-400">
|
|
Step <span className="font-semibold text-gray-700 dark:text-gray-300">{currentStep}</span> of <span className="font-semibold text-gray-700 dark:text-gray-300">{totalSteps}</span>
|
|
</span>
|
|
<div className="flex gap-3">
|
|
{!allOptionsAdded && (
|
|
<Button
|
|
variant="ghost"
|
|
tone="neutral"
|
|
size="md"
|
|
onClick={onSkip}
|
|
>
|
|
Skip for now
|
|
</Button>
|
|
)}
|
|
<Button
|
|
variant="primary"
|
|
size="md"
|
|
onClick={onNext}
|
|
disabled={addedCount === 0}
|
|
endIcon={<ArrowRightIcon className="w-5 h-5" />}
|
|
>
|
|
{addedCount > 0 ? 'Continue' : 'Add keywords first'}
|
|
</Button>
|
|
</div>
|
|
</div>
|
|
</div>
|
|
);
|
|
}
|
|
|
|
// Manual entry mode
|
|
return (
|
|
<div>
|
|
<div className="mb-8">
|
|
<button
|
|
onClick={() => setMode(null)}
|
|
className="flex items-center gap-2 text-base text-gray-500 hover:text-gray-700 dark:hover:text-gray-300 mb-4"
|
|
>
|
|
<ArrowLeftIcon className="w-4 h-4" />
|
|
<span>Back to options</span>
|
|
</button>
|
|
<h2 className="text-2xl font-bold text-gray-900 dark:text-white mb-2">
|
|
Add Keywords Manually
|
|
</h2>
|
|
<p className="text-base text-gray-600 dark:text-gray-400">
|
|
Enter keywords one by one or paste a list. Press Enter or click + to add each keyword.
|
|
</p>
|
|
</div>
|
|
|
|
{error && (
|
|
<Alert variant="error" title="Error" message={error} className="mb-6" />
|
|
)}
|
|
|
|
{/* Keyword Input */}
|
|
<div className="mb-6">
|
|
<label className="block text-base font-medium text-gray-700 dark:text-gray-300 mb-2">
|
|
Enter keywords (press Enter or paste multiple)
|
|
</label>
|
|
<div className="flex gap-3">
|
|
<div className="relative flex-1">
|
|
<ListIcon className="absolute left-4 top-1/2 -translate-y-1/2 w-5 h-5 text-gray-400" />
|
|
<input
|
|
value={inputValue}
|
|
onChange={(e) => 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"
|
|
/>
|
|
</div>
|
|
<IconButton
|
|
variant="outline"
|
|
size="md"
|
|
onClick={handleAddKeyword}
|
|
disabled={!inputValue.trim()}
|
|
icon={<PlusIcon className="w-5 h-5" />}
|
|
/>
|
|
</div>
|
|
<p className="text-sm text-gray-500 mt-2">
|
|
Tip: Paste a comma-separated list or one keyword per line
|
|
</p>
|
|
</div>
|
|
|
|
{/* Keywords List */}
|
|
<Card className="p-5 mb-6 min-h-[140px] bg-gray-50 dark:bg-gray-800">
|
|
{keywords.length === 0 ? (
|
|
<div className="text-center py-8 text-gray-500 dark:text-gray-400">
|
|
<ListIcon className="w-10 h-10 mx-auto mb-3 opacity-50" />
|
|
<p className="text-base">No keywords added yet</p>
|
|
<p className="text-sm">Start typing or paste keywords above</p>
|
|
</div>
|
|
) : (
|
|
<div className="flex flex-wrap gap-2">
|
|
{keywords.map((keyword) => (
|
|
<Badge
|
|
key={keyword}
|
|
tone="neutral"
|
|
variant="soft"
|
|
size="md"
|
|
className="gap-2 pr-2 text-base"
|
|
>
|
|
{keyword}
|
|
<IconButton
|
|
icon={<CloseIcon className="w-4 h-4" />}
|
|
onClick={() => handleRemoveKeyword(keyword)}
|
|
variant="ghost"
|
|
size="xs"
|
|
className="ml-1"
|
|
aria-label="Remove keyword"
|
|
/>
|
|
</Badge>
|
|
))}
|
|
</div>
|
|
)}
|
|
</Card>
|
|
|
|
<div className="flex items-center justify-between text-base text-gray-500 mb-6">
|
|
<span>{keywords.length} keyword{keywords.length !== 1 ? 's' : ''} added</span>
|
|
{keywords.length > 0 && (
|
|
<Button
|
|
variant="ghost"
|
|
size="sm"
|
|
onClick={() => setKeywords([])}
|
|
className="text-error-500 hover:text-error-600"
|
|
>
|
|
Clear all
|
|
</Button>
|
|
)}
|
|
</div>
|
|
|
|
{/* Info Alert */}
|
|
<Alert
|
|
variant="info"
|
|
title="Add more keywords later"
|
|
message="You can add more keywords from the Planner page. The automation will process these keywords and generate content automatically."
|
|
className="mb-6"
|
|
/>
|
|
|
|
{/* Actions */}
|
|
<div className="flex items-center justify-between pt-4 border-t border-gray-200 dark:border-gray-700">
|
|
<Button
|
|
variant="ghost"
|
|
tone="neutral"
|
|
size="md"
|
|
onClick={() => setMode(null)}
|
|
startIcon={<ArrowLeftIcon className="w-5 h-5" />}
|
|
>
|
|
Back
|
|
</Button>
|
|
<span className="text-sm text-gray-500 dark:text-gray-400">
|
|
Step <span className="font-semibold text-gray-700 dark:text-gray-300">{currentStep}</span> of <span className="font-semibold text-gray-700 dark:text-gray-300">{totalSteps}</span>
|
|
</span>
|
|
<div className="flex gap-3">
|
|
<Button
|
|
variant="ghost"
|
|
tone="neutral"
|
|
size="md"
|
|
onClick={onSkip}
|
|
>
|
|
Skip for now
|
|
</Button>
|
|
<Button
|
|
variant="primary"
|
|
size="md"
|
|
onClick={handleSubmitKeywords}
|
|
disabled={isAdding || keywords.length === 0}
|
|
endIcon={!isAdding ? <ArrowRightIcon className="w-5 h-5" /> : undefined}
|
|
>
|
|
{isAdding ? 'Adding...' : `Add ${keywords.length} Keyword${keywords.length !== 1 ? 's' : ''}`}
|
|
</Button>
|
|
</div>
|
|
</div>
|
|
</div>
|
|
);
|
|
}
|