diff --git a/backend/igny8_core/modules/planner/serializers.py b/backend/igny8_core/modules/planner/serializers.py index 99e5f478..73635a98 100644 --- a/backend/igny8_core/modules/planner/serializers.py +++ b/backend/igny8_core/modules/planner/serializers.py @@ -16,10 +16,21 @@ class KeywordSerializer(serializers.ModelSerializer): intent = serializers.CharField(read_only=True) # From seed_keyword.intent # SeedKeyword relationship - # Required for create, optional for update (can change seed_keyword or just update other fields) + # Optional for create - can either provide seed_keyword_id OR custom keyword fields seed_keyword_id = serializers.IntegerField(write_only=True, required=False) seed_keyword = SeedKeywordSerializer(read_only=True) + # Custom keyword fields (write-only, for creating new seed keywords on-the-fly) + custom_keyword = serializers.CharField(write_only=True, required=False, allow_blank=False) + custom_volume = serializers.IntegerField(write_only=True, required=False, allow_null=True) + custom_difficulty = serializers.IntegerField(write_only=True, required=False, allow_null=True) + custom_intent = serializers.ChoiceField( + write_only=True, + required=False, + choices=['informational', 'navigational', 'transactional', 'commercial'], + default='informational' + ) + # Overrides volume_override = serializers.IntegerField(required=False, allow_null=True) difficulty_override = serializers.IntegerField(required=False, allow_null=True) @@ -40,6 +51,10 @@ class KeywordSerializer(serializers.ModelSerializer): 'volume', 'difficulty', 'intent', + 'custom_keyword', # Write-only field for creating custom keywords + 'custom_volume', # Write-only + 'custom_difficulty', # Write-only + 'custom_intent', # Write-only 'volume_override', 'difficulty_override', 'cluster_id', @@ -61,28 +76,94 @@ class KeywordSerializer(serializers.ModelSerializer): self.fields['attribute_values'] = serializers.JSONField(read_only=True) def validate(self, attrs): - """Validate that seed_keyword_id is provided for create operations""" - # For create operations, seed_keyword_id is required - if self.instance is None and 'seed_keyword_id' not in attrs: - raise serializers.ValidationError({'seed_keyword_id': 'This field is required when creating a keyword.'}) + """Validate that either seed_keyword_id OR custom keyword fields are provided""" + # For create operations, need either seed_keyword_id OR custom keyword + if self.instance is None: + has_seed_keyword = 'seed_keyword_id' in attrs + has_custom_keyword = 'custom_keyword' in attrs + + if not has_seed_keyword and not has_custom_keyword: + raise serializers.ValidationError({ + 'keyword': 'Either provide seed_keyword_id to link an existing keyword, or provide custom_keyword to create a new one.' + }) + + if has_custom_keyword: + # Validate custom keyword fields + if not attrs.get('custom_keyword', '').strip(): + raise serializers.ValidationError({'custom_keyword': 'Keyword text cannot be empty.'}) + + if attrs.get('custom_volume') is None: + raise serializers.ValidationError({'custom_volume': 'Volume is required when creating a custom keyword.'}) + + if attrs.get('custom_difficulty') is None: + raise serializers.ValidationError({'custom_difficulty': 'Difficulty is required when creating a custom keyword.'}) + return attrs def create(self, validated_data): - """Create Keywords instance with seed_keyword""" - seed_keyword_id = validated_data.pop('seed_keyword_id', None) - if not seed_keyword_id: - raise serializers.ValidationError({'seed_keyword_id': 'This field is required when creating a keyword.'}) + """Create Keywords instance with seed_keyword (existing or newly created)""" + # Extract custom keyword fields + custom_keyword = validated_data.pop('custom_keyword', None) + custom_volume = validated_data.pop('custom_volume', None) + custom_difficulty = validated_data.pop('custom_difficulty', None) + custom_intent = validated_data.pop('custom_intent', None) or 'informational' - try: - seed_keyword = SeedKeyword.objects.get(id=seed_keyword_id) - except SeedKeyword.DoesNotExist: - raise serializers.ValidationError({'seed_keyword_id': f'SeedKeyword with id {seed_keyword_id} does not exist'}) + # Get site and sector - they're passed as objects via save() in the view + site = validated_data.get('site') + sector = validated_data.get('sector') + + if not site or not sector: + raise serializers.ValidationError('Site and sector are required.') + + # Determine which seed_keyword to use + if custom_keyword: + # Create or get SeedKeyword for this custom keyword + if not site.industry: + raise serializers.ValidationError({'site': 'Site must have an industry assigned.'}) + + if not sector.industry_sector: + raise serializers.ValidationError({'sector': 'Sector must have an industry_sector assigned.'}) + + # Get or create the SeedKeyword + seed_keyword, created = SeedKeyword.objects.get_or_create( + keyword=custom_keyword.strip().lower(), + industry=site.industry, + sector=sector.industry_sector, + defaults={ + 'volume': custom_volume or 0, + 'difficulty': custom_difficulty or 0, + 'intent': custom_intent or 'informational', + 'is_active': True, + } + ) + + # If it existed, optionally update values (or keep existing ones) + # For now, we'll keep existing values if the seed keyword already exists + + validated_data['seed_keyword'] = seed_keyword + else: + # Use provided seed_keyword_id + seed_keyword_id = validated_data.pop('seed_keyword_id', None) + if not seed_keyword_id: + raise serializers.ValidationError({'seed_keyword_id': 'This field is required when not providing a custom keyword.'}) + + try: + seed_keyword = SeedKeyword.objects.get(id=seed_keyword_id) + except SeedKeyword.DoesNotExist: + raise serializers.ValidationError({'seed_keyword_id': f'SeedKeyword with id {seed_keyword_id} does not exist'}) + + validated_data['seed_keyword'] = seed_keyword - validated_data['seed_keyword'] = seed_keyword return super().create(validated_data) def update(self, instance, validated_data): - """Update Keywords instance with seed_keyword""" + """Update Keywords instance - only cluster_id and status can be updated""" + # Remove custom keyword fields if present (they shouldn't be in update) + validated_data.pop('custom_keyword', None) + validated_data.pop('custom_volume', None) + validated_data.pop('custom_difficulty', None) + validated_data.pop('custom_intent', None) + # seed_keyword_id is optional for updates - only update if provided if 'seed_keyword_id' in validated_data: seed_keyword_id = validated_data.pop('seed_keyword_id') diff --git a/frontend/src/config/pages/keywords.config.tsx b/frontend/src/config/pages/keywords.config.tsx index 26a5043d..73ee4f28 100644 --- a/frontend/src/config/pages/keywords.config.tsx +++ b/frontend/src/config/pages/keywords.config.tsx @@ -102,11 +102,11 @@ export const createKeywordsPageConfig = ( handlers: { clusters: Array<{ id: number; name: string }>; activeSector: { id: number; name: string } | null; - availableSeedKeywords: Array<{ id: number; keyword: string; volume: number; difficulty: number; intent: string }>; formData: { - seed_keyword_id: number; - volume_override?: number | null; - difficulty_override?: number | null; + keyword?: string; + volume?: number | null; + difficulty?: number | null; + intent?: string; cluster_id?: number | null; status: string; }; @@ -504,42 +504,53 @@ export const createKeywordsPageConfig = ( // They're automatically loaded by TablePageTemplate based on the current route formFields: (clusters: Array<{ id: number; name: string }>) => [ { - key: 'seed_keyword_id', - label: 'Seed Keyword', - type: 'select', - placeholder: 'Select a seed keyword', - value: (handlers.formData.seed_keyword_id && handlers.formData.seed_keyword_id > 0) ? handlers.formData.seed_keyword_id.toString() : '', + key: 'keyword', + label: 'Keyword', + type: 'text', + placeholder: 'Enter keyword (e.g., best massage chairs)', + value: handlers.formData.keyword || '', onChange: (value: any) => - handlers.setFormData({ ...handlers.formData, seed_keyword_id: value ? parseInt(value) : 0 }), + handlers.setFormData({ ...handlers.formData, keyword: value }), required: true, - options: [ - { value: '', label: 'Select a keyword...' }, - ...handlers.availableSeedKeywords.map((sk) => ({ - value: sk.id.toString(), - label: `${sk.keyword} (Vol: ${sk.volume.toLocaleString()}, Diff: ${sk.difficulty}, ${sk.intent})`, - })), - ], }, { - key: 'volume_override', - label: 'Volume Override (optional)', + key: 'volume', + label: 'Search Volume', type: 'number', - placeholder: 'Leave empty to use seed keyword volume', - value: handlers.formData.volume_override ?? '', + placeholder: 'Monthly search volume', + value: handlers.formData.volume ?? '', onChange: (value: any) => - handlers.setFormData({ ...handlers.formData, volume_override: value ? parseInt(value) : null }), + handlers.setFormData({ ...handlers.formData, volume: value ? parseInt(value) : null }), + required: true, + min: 0, }, { - key: 'difficulty_override', - label: 'Difficulty Override (optional)', + key: 'difficulty', + label: 'Difficulty (0-100)', type: 'number', - placeholder: 'Leave empty to use seed keyword difficulty', - value: handlers.formData.difficulty_override ?? '', + placeholder: 'SEO difficulty score', + value: handlers.formData.difficulty ?? '', onChange: (value: any) => - handlers.setFormData({ ...handlers.formData, difficulty_override: value ? parseInt(value) : null }), + handlers.setFormData({ ...handlers.formData, difficulty: value ? parseInt(value) : null }), + required: true, min: 0, max: 100, }, + { + key: 'intent', + label: 'Search Intent', + type: 'select', + value: handlers.formData.intent || 'informational', + onChange: (value: any) => + handlers.setFormData({ ...handlers.formData, intent: value }), + required: true, + options: [ + { value: 'informational', label: 'Informational' }, + { value: 'navigational', label: 'Navigational' }, + { value: 'transactional', label: 'Transactional' }, + { value: 'commercial', label: 'Commercial' }, + ], + }, { key: 'cluster_id', label: 'Cluster', diff --git a/frontend/src/pages/Planner/Clusters.tsx b/frontend/src/pages/Planner/Clusters.tsx index 27c4ce41..d6994a98 100644 --- a/frontend/src/pages/Planner/Clusters.tsx +++ b/frontend/src/pages/Planner/Clusters.tsx @@ -380,7 +380,7 @@ export default function Clusters() { resetForm(); loadClusters(); } catch (error: any) { - toast.error(`Failed to save: ${error.message}`); + toast.error(error.message || 'Unable to save cluster. Please try again.'); } }; diff --git a/frontend/src/pages/Planner/Ideas.tsx b/frontend/src/pages/Planner/Ideas.tsx index 76f080c7..a9ec0464 100644 --- a/frontend/src/pages/Planner/Ideas.tsx +++ b/frontend/src/pages/Planner/Ideas.tsx @@ -290,7 +290,7 @@ export default function Ideas() { resetForm(); loadIdeas(); } catch (error: any) { - toast.error(`Failed to save: ${error.message}`); + toast.error(error.message || 'Unable to save idea. Please try again.'); } }; diff --git a/frontend/src/pages/Planner/Keywords.tsx b/frontend/src/pages/Planner/Keywords.tsx index 7bc0dadf..56c34033 100644 --- a/frontend/src/pages/Planner/Keywords.tsx +++ b/frontend/src/pages/Planner/Keywords.tsx @@ -20,8 +20,6 @@ import { Cluster, API_BASE_URL, autoClusterKeywords, - fetchSeedKeywords, - SeedKeyword, } from '../../services/api'; import { useSiteStore } from '../../store/siteStore'; import { useSectorStore } from '../../store/sectorStore'; @@ -48,9 +46,7 @@ export default function Keywords() { // Data state const [keywords, setKeywords] = useState([]); const [clusters, setClusters] = useState([]); - const [availableSeedKeywords, setAvailableSeedKeywords] = useState([]); const [loading, setLoading] = useState(true); - const [loadingSeedKeywords, setLoadingSeedKeywords] = useState(false); // Filter state - match Keywords.tsx const [searchTerm, setSearchTerm] = useState(''); @@ -82,9 +78,10 @@ export default function Keywords() { const [isEditMode, setIsEditMode] = useState(false); const [editingKeyword, setEditingKeyword] = useState(null); const [formData, setFormData] = useState({ - seed_keyword_id: 0, - volume_override: null, - difficulty_override: null, + keyword: '', + volume: null, + difficulty: null, + intent: 'informational', cluster_id: null, status: 'new', }); @@ -131,43 +128,6 @@ export default function Keywords() { } }, [activeSite, loadSectorsForSite]); - // Load available SeedKeywords when site and sector are selected - useEffect(() => { - const loadAvailableSeedKeywords = async () => { - if (!activeSite || !activeSector || !activeSite.industry) { - setAvailableSeedKeywords([]); - return; - } - - try { - setLoadingSeedKeywords(true); - // Fetch SeedKeywords for the site's industry and sector's industry_sector - const response = await fetchSeedKeywords({ - industry: activeSite.industry, - sector: activeSector.industry_sector || undefined, - }); - - // Filter out SeedKeywords that are already attached to this site/sector - const attachedSeedKeywordIds = new Set( - keywords.map(k => k.seed_keyword_id) - ); - - const available = (response.results || []).filter( - sk => !attachedSeedKeywordIds.has(sk.id) - ); - - setAvailableSeedKeywords(available); - } catch (error: any) { - console.error('Failed to load available seed keywords:', error); - setAvailableSeedKeywords([]); - } finally { - setLoadingSeedKeywords(false); - } - }; - - loadAvailableSeedKeywords(); - }, [activeSite, activeSector, keywords]); - // Load clusters for filter dropdown useEffect(() => { const loadClusters = async () => { @@ -573,11 +533,12 @@ export default function Keywords() { const resetForm = useCallback(() => { setFormData({ - seed_keyword_id: 0, - volume_override: null, - difficulty_override: null, + keyword: '', + volume: null, + difficulty: null, + intent: 'informational', cluster_id: null, - status: 'pending', + status: 'new', }); setIsEditMode(false); setEditingKeyword(null); @@ -637,7 +598,6 @@ export default function Keywords() { return createKeywordsPageConfig({ clusters, activeSector, - availableSeedKeywords, formData, setFormData, // Filter state handlers @@ -669,7 +629,6 @@ export default function Keywords() { }, [ clusters, activeSector, - availableSeedKeywords, formData, searchTerm, statusFilter, @@ -714,8 +673,18 @@ export default function Keywords() { return; } - if (!formData.seed_keyword_id) { - toast.error('Please select a seed keyword'); + if (!formData.keyword?.trim()) { + toast.error('Please enter a keyword'); + return; + } + + if (formData.volume === null || formData.volume === undefined) { + toast.error('Please enter search volume'); + return; + } + + if (formData.difficulty === null || formData.difficulty === undefined) { + toast.error('Please enter difficulty score'); return; } @@ -727,14 +696,15 @@ export default function Keywords() { sector_id: sectorId, }; + console.log('Creating keyword with data:', keywordData); await createKeyword(keywordData); - toast.success('Keyword attached successfully'); + toast.success('Keyword created successfully'); } setIsModalOpen(false); resetForm(); loadKeywords(); } catch (error: any) { - toast.error(`Failed to save: ${error.message}`); + toast.error(error.message || 'Unable to save keyword. Please try again.'); } }; @@ -743,9 +713,10 @@ export default function Keywords() { setEditingKeyword(keyword); setIsEditMode(true); setFormData({ - seed_keyword_id: keyword.seed_keyword_id, - volume_override: keyword.volume_override || null, - difficulty_override: keyword.difficulty_override || null, + keyword: keyword.keyword, + volume: keyword.volume, + difficulty: keyword.difficulty, + intent: keyword.intent, cluster_id: keyword.cluster_id, status: keyword.status, }); @@ -918,7 +889,7 @@ export default function Keywords() { {/* Create/Edit Modal */} { setIsModalOpen(false); diff --git a/frontend/src/pages/Setup/IndustriesSectorsKeywords.tsx b/frontend/src/pages/Setup/IndustriesSectorsKeywords.tsx index fdc37751..a3cc38e5 100644 --- a/frontend/src/pages/Setup/IndustriesSectorsKeywords.tsx +++ b/frontend/src/pages/Setup/IndustriesSectorsKeywords.tsx @@ -354,10 +354,13 @@ export default function IndustriesSectorsKeywords() { // Clear selection setSelectedIds([]); } else { - toast.error(`Failed to add keywords: ${result.errors?.join(', ') || 'Unknown error'}`); + // Show user-friendly error message (errors array already contains clean messages) + const errorMsg = result.errors?.[0] || 'Unable to add keywords. Please try again.'; + toast.error(errorMsg); } } catch (error: any) { - toast.error(`Failed to add keywords: ${error.message}`); + // Show user-friendly error message (error.message is already cleaned) + toast.error(error.message || 'Unable to add keywords. Please try again.'); } }, [activeSite, activeSector, toast]); diff --git a/frontend/src/services/api.ts b/frontend/src/services/api.ts index ab7b3a22..2535539a 100644 --- a/frontend/src/services/api.ts +++ b/frontend/src/services/api.ts @@ -101,6 +101,29 @@ const getAuthToken = (): string | null => { return null; }; +/** + * Extract user-friendly error message from API error + * Removes technical prefixes like "Failed to save:", "Failed to load:", etc. + * if the backend error message is already descriptive + */ +export function getUserFriendlyError(error: any, fallback: string = 'An error occurred. Please try again.'): string { + const message = error?.message || error?.error || fallback; + + // If the message already describes a limit or specific problem, use it directly + if (message.includes('limit exceeded') || + message.includes('not found') || + message.includes('already exists') || + message.includes('invalid') || + message.includes('required') || + message.includes('permission') || + message.includes('upgrade')) { + return message; + } + + // Otherwise return the message as-is + return message; +} + // Get refresh token from store - try Zustand store first, then localStorage as fallback const getRefreshToken = (): string | null => { try { @@ -376,9 +399,11 @@ export async function fetchAPI(endpoint: string, options?: RequestInit & { timeo }); // Attach error data to error object so it can be accessed in catch block - const apiError = new Error(`API Error (${response.status}): ${errorType} - ${errorMessage}`); + // Use clean user-friendly message without technical jargon + const apiError = new Error(errorMessage); (apiError as any).response = errorData; (apiError as any).status = response.status; + (apiError as any).errorType = errorType; throw apiError; } @@ -493,7 +518,11 @@ export interface Keyword { } export interface KeywordCreateData { - seed_keyword_id: number; + keyword?: string; // For creating new custom keywords + volume?: number | null; // For custom keywords + difficulty?: number | null; // For custom keywords + intent?: string; // For custom keywords + seed_keyword_id?: number; // For linking existing seed keywords (optional) volume_override?: number | null; difficulty_override?: number | null; cluster_id?: number | null; @@ -554,9 +583,27 @@ export async function fetchKeyword(id: number): Promise { } export async function createKeyword(data: KeywordCreateData): Promise { + // Transform frontend field names to backend field names + const requestData: any = { + ...data, + }; + + // If creating a custom keyword, map to backend field names + if (data.keyword) { + requestData.custom_keyword = data.keyword; + requestData.custom_volume = data.volume; + requestData.custom_difficulty = data.difficulty; + requestData.custom_intent = data.intent || 'informational'; + // Remove the frontend-only fields + delete requestData.keyword; + delete requestData.volume; + delete requestData.difficulty; + delete requestData.intent; + } + return fetchAPI('/v1/planner/keywords/', { method: 'POST', - body: JSON.stringify(data), + body: JSON.stringify(requestData), }); } @@ -1988,13 +2035,14 @@ export async function addSeedKeywordsToWorkflow(seedKeywordIds: number[], siteId return { success: true, ...response } as any; } catch (error: any) { - // Error responses are thrown by fetchAPI - return as failed result instead of re-throwing - // This allows component to handle limit errors gracefully + // Error responses are thrown by fetchAPI - return as failed result + // Extract clean user-friendly message (error.message is already cleaned in fetchAPI) + const userMessage = error.message || 'Failed to add keywords'; return { success: false, created: 0, skipped: 0, - errors: [error.message || 'Failed to add keywords'] + errors: [userMessage] }; } }