Phase 2, 2.1 and 2.2 complete
This commit is contained in:
@@ -1,11 +1,11 @@
|
||||
/**
|
||||
* Step 4: Add Keywords
|
||||
* Initial keyword input for content pipeline
|
||||
* Supports two modes: High Opportunity Keywords or Manual Entry
|
||||
*/
|
||||
import React, { useState } from 'react';
|
||||
import React, { useState, useEffect, useMemo } from 'react';
|
||||
import Button from '../../ui/button/Button';
|
||||
import IconButton from '../../ui/button/IconButton';
|
||||
import InputField from '../../form/input/InputField';
|
||||
import { Card } from '../../ui/card';
|
||||
import Badge from '../../ui/badge/Badge';
|
||||
import Alert from '../../ui/alert/Alert';
|
||||
@@ -15,8 +15,18 @@ import {
|
||||
ListIcon,
|
||||
PlusIcon,
|
||||
CloseIcon,
|
||||
BoltIcon,
|
||||
PencilIcon,
|
||||
CheckCircleIcon,
|
||||
} from '../../../icons';
|
||||
import { createKeyword } from '../../../services/api';
|
||||
import {
|
||||
createKeyword,
|
||||
fetchSeedKeywords,
|
||||
addSeedKeywordsToWorkflow,
|
||||
fetchSiteSectors,
|
||||
fetchIndustries,
|
||||
SeedKeyword,
|
||||
} from '../../../services/api';
|
||||
import { useToast } from '../../ui/toast/ToastContainer';
|
||||
import type { WizardData } from '../OnboardingWizard';
|
||||
|
||||
@@ -26,6 +36,24 @@ interface Step4AddKeywordsProps {
|
||||
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({
|
||||
@@ -33,14 +61,182 @@ export default function Step4AddKeywords({
|
||||
updateData,
|
||||
onNext,
|
||||
onBack,
|
||||
onSkip
|
||||
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();
|
||||
@@ -126,66 +322,334 @@ export default function Step4AddKeywords({
|
||||
}
|
||||
};
|
||||
|
||||
const SUGGESTIONS = [
|
||||
'best [product] for [use case]',
|
||||
'how to [action] with [tool]',
|
||||
'[topic] guide for beginners',
|
||||
'[industry] trends 2025',
|
||||
'[problem] solutions for [audience]',
|
||||
];
|
||||
// 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-6">
|
||||
<div className="flex items-center gap-2 mb-2">
|
||||
<h2 className="text-xl font-bold text-gray-900 dark:text-white">
|
||||
Add Target Keywords
|
||||
</h2>
|
||||
<Badge tone="neutral" variant="soft">Optional</Badge>
|
||||
</div>
|
||||
<p className="text-gray-600 dark:text-gray-400">
|
||||
Add keywords to start your content pipeline. These will be used to generate content ideas and articles.
|
||||
<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} />
|
||||
<Alert variant="error" title="Error" message={error} className="mb-6" />
|
||||
)}
|
||||
|
||||
{/* Keyword Input */}
|
||||
<div className="mb-4">
|
||||
<label className="block text-sm font-medium text-gray-700 dark:text-gray-300 mb-1">
|
||||
<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-2">
|
||||
<div className="flex gap-3">
|
||||
<div className="relative flex-1">
|
||||
<ListIcon className="absolute left-3 top-1/2 -translate-y-1/2 w-4 h-4 text-gray-400" />
|
||||
<InputField
|
||||
<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="pl-10"
|
||||
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-4 h-4" />}
|
||||
icon={<PlusIcon className="w-5 h-5" />}
|
||||
/>
|
||||
</div>
|
||||
<p className="text-xs text-gray-500 mt-1">
|
||||
<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-4 mb-4 min-h-[120px] bg-gray-50 dark:bg-gray-800">
|
||||
<Card className="p-5 mb-6 min-h-[140px] bg-gray-50 dark:bg-gray-800">
|
||||
{keywords.length === 0 ? (
|
||||
<div className="text-center py-6 text-gray-500 dark:text-gray-400">
|
||||
<ListIcon className="w-8 h-8 mx-auto mb-2 opacity-50" />
|
||||
<p className="text-sm">No keywords added yet</p>
|
||||
<p className="text-xs">Start typing or paste keywords above</p>
|
||||
<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">
|
||||
@@ -194,11 +658,12 @@ export default function Step4AddKeywords({
|
||||
key={keyword}
|
||||
tone="neutral"
|
||||
variant="soft"
|
||||
className="gap-1 pr-1"
|
||||
size="md"
|
||||
className="gap-2 pr-2 text-base"
|
||||
>
|
||||
{keyword}
|
||||
<IconButton
|
||||
icon={<CloseIcon className="w-3 h-3" />}
|
||||
icon={<CloseIcon className="w-4 h-4" />}
|
||||
onClick={() => handleRemoveKeyword(keyword)}
|
||||
variant="ghost"
|
||||
size="xs"
|
||||
@@ -211,7 +676,7 @@ export default function Step4AddKeywords({
|
||||
)}
|
||||
</Card>
|
||||
|
||||
<div className="flex items-center justify-between text-sm text-gray-500 mb-4">
|
||||
<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
|
||||
@@ -225,53 +690,43 @@ export default function Step4AddKeywords({
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* Keyword Suggestions */}
|
||||
<Card className="p-4 bg-brand-50 dark:bg-brand-900/20 border-brand-200 dark:border-brand-800 mb-6">
|
||||
<h4 className="font-medium text-brand-900 dark:text-brand-100 text-sm mb-2">
|
||||
Keyword Ideas
|
||||
</h4>
|
||||
<div className="flex flex-wrap gap-2">
|
||||
{SUGGESTIONS.map((suggestion, index) => (
|
||||
<span
|
||||
key={index}
|
||||
className="text-xs text-brand-700 dark:text-brand-300 bg-brand-100 dark:bg-brand-800/50 px-2 py-1 rounded"
|
||||
>
|
||||
{suggestion}
|
||||
</span>
|
||||
))}
|
||||
</div>
|
||||
</Card>
|
||||
|
||||
{/* Info Alert */}
|
||||
<Alert
|
||||
variant="info"
|
||||
title="Add keywords later"
|
||||
message="You can add more keywords later from the Planner page. The automation will process these keywords and generate content automatically."
|
||||
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">
|
||||
<div className="flex items-center justify-between pt-4 border-t border-gray-200 dark:border-gray-700">
|
||||
<Button
|
||||
variant="ghost"
|
||||
tone="neutral"
|
||||
onClick={onBack}
|
||||
startIcon={<ArrowLeftIcon className="w-4 h-4" />}
|
||||
size="md"
|
||||
onClick={() => setMode(null)}
|
||||
startIcon={<ArrowLeftIcon className="w-5 h-5" />}
|
||||
>
|
||||
Back
|
||||
</Button>
|
||||
<div className="flex gap-2">
|
||||
<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-4 h-4" /> : undefined}
|
||||
endIcon={!isAdding ? <ArrowRightIcon className="w-5 h-5" /> : undefined}
|
||||
>
|
||||
{isAdding ? 'Adding...' : `Add ${keywords.length} Keyword${keywords.length !== 1 ? 's' : ''}`}
|
||||
</Button>
|
||||
|
||||
Reference in New Issue
Block a user