SEction 9-10
This commit is contained in:
280
frontend/src/components/onboarding/steps/Step4AddKeywords.tsx
Normal file
280
frontend/src/components/onboarding/steps/Step4AddKeywords.tsx
Normal file
@@ -0,0 +1,280 @@
|
||||
/**
|
||||
* Step 4: Add Keywords
|
||||
* Initial keyword input for content pipeline
|
||||
*/
|
||||
import React, { useState } from 'react';
|
||||
import Button from '../../ui/button/Button';
|
||||
import { Card } from '../../ui/card';
|
||||
import Badge from '../../ui/badge/Badge';
|
||||
import Alert from '../../ui/alert/Alert';
|
||||
import {
|
||||
ArrowRightIcon,
|
||||
ArrowLeftIcon,
|
||||
ListIcon,
|
||||
PlusIcon,
|
||||
CloseIcon,
|
||||
} from '../../../icons';
|
||||
import { createKeyword } 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;
|
||||
}
|
||||
|
||||
export default function Step4AddKeywords({
|
||||
data,
|
||||
updateData,
|
||||
onNext,
|
||||
onBack,
|
||||
onSkip
|
||||
}: Step4AddKeywordsProps) {
|
||||
const toast = useToast();
|
||||
|
||||
const [keywords, setKeywords] = useState<string[]>([]);
|
||||
const [inputValue, setInputValue] = useState('');
|
||||
const [isAdding, setIsAdding] = useState(false);
|
||||
const [error, setError] = useState<string | null>(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);
|
||||
}
|
||||
};
|
||||
|
||||
const SUGGESTIONS = [
|
||||
'best [product] for [use case]',
|
||||
'how to [action] with [tool]',
|
||||
'[topic] guide for beginners',
|
||||
'[industry] trends 2025',
|
||||
'[problem] solutions for [audience]',
|
||||
];
|
||||
|
||||
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.
|
||||
</p>
|
||||
</div>
|
||||
|
||||
{error && (
|
||||
<Alert variant="error" title="Error" message={error} />
|
||||
)}
|
||||
|
||||
{/* Keyword Input */}
|
||||
<div className="mb-4">
|
||||
<label className="block text-sm font-medium text-gray-700 dark:text-gray-300 mb-1">
|
||||
Enter keywords (press Enter or paste multiple)
|
||||
</label>
|
||||
<div className="flex gap-2">
|
||||
<div className="relative flex-1">
|
||||
<ListIcon className="absolute left-3 top-1/2 -translate-y-1/2 w-4 h-4 text-gray-400" />
|
||||
<input
|
||||
type="text"
|
||||
value={inputValue}
|
||||
onChange={(e) => setInputValue(e.target.value)}
|
||||
onKeyDown={handleKeyDown}
|
||||
onPaste={handlePaste}
|
||||
placeholder="Enter a keyword..."
|
||||
className="w-full pl-10 pr-4 py-2 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>
|
||||
<Button
|
||||
variant="outline"
|
||||
onClick={handleAddKeyword}
|
||||
disabled={!inputValue.trim()}
|
||||
>
|
||||
<PlusIcon className="w-4 h-4" />
|
||||
</Button>
|
||||
</div>
|
||||
<p className="text-xs text-gray-500 mt-1">
|
||||
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">
|
||||
{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>
|
||||
) : (
|
||||
<div className="flex flex-wrap gap-2">
|
||||
{keywords.map((keyword) => (
|
||||
<Badge
|
||||
key={keyword}
|
||||
tone="neutral"
|
||||
variant="soft"
|
||||
className="gap-1 pr-1"
|
||||
>
|
||||
{keyword}
|
||||
<button
|
||||
onClick={() => handleRemoveKeyword(keyword)}
|
||||
className="ml-1 p-0.5 hover:bg-gray-300 dark:hover:bg-gray-600 rounded"
|
||||
>
|
||||
<CloseIcon className="w-3 h-3" />
|
||||
</button>
|
||||
</Badge>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
</Card>
|
||||
|
||||
<div className="flex items-center justify-between text-sm text-gray-500 mb-4">
|
||||
<span>{keywords.length} keyword{keywords.length !== 1 ? 's' : ''} added</span>
|
||||
{keywords.length > 0 && (
|
||||
<button
|
||||
onClick={() => setKeywords([])}
|
||||
className="text-red-500 hover:text-red-600"
|
||||
>
|
||||
Clear all
|
||||
</button>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* Keyword Suggestions */}
|
||||
<Card className="p-4 bg-blue-50 dark:bg-blue-900/20 border-blue-200 dark:border-blue-800 mb-6">
|
||||
<h4 className="font-medium text-blue-900 dark:text-blue-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-blue-700 dark:text-blue-300 bg-blue-100 dark:bg-blue-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."
|
||||
/>
|
||||
|
||||
{/* Actions */}
|
||||
<div className="flex items-center justify-between">
|
||||
<Button
|
||||
variant="ghost"
|
||||
onClick={onBack}
|
||||
className="gap-2"
|
||||
>
|
||||
<ArrowLeftIcon className="w-4 h-4" />
|
||||
Back
|
||||
</Button>
|
||||
<div className="flex gap-2">
|
||||
<Button
|
||||
variant="ghost"
|
||||
onClick={onSkip}
|
||||
>
|
||||
Skip for now
|
||||
</Button>
|
||||
<Button
|
||||
variant="primary"
|
||||
onClick={handleSubmitKeywords}
|
||||
disabled={isAdding || keywords.length === 0}
|
||||
className="gap-2"
|
||||
>
|
||||
{isAdding ? 'Adding...' : `Add ${keywords.length} Keyword${keywords.length !== 1 ? 's' : ''}`}
|
||||
<ArrowRightIcon className="w-4 h-4" />
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
Reference in New Issue
Block a user