Phase 2, 2.1 and 2.2 complete

This commit is contained in:
IGNY8 VPS (Salman)
2026-01-05 08:17:56 +00:00
parent abc6c011ea
commit cb8e747387
14 changed files with 1834 additions and 552 deletions

View File

@@ -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>