limits vlaiadtion adn keywrods forms

This commit is contained in:
IGNY8 VPS (Salman)
2025-12-12 20:05:21 +00:00
parent 9824e9a4dc
commit 44ecd3fa7d
7 changed files with 224 additions and 110 deletions

View File

@@ -16,10 +16,21 @@ class KeywordSerializer(serializers.ModelSerializer):
intent = serializers.CharField(read_only=True) # From seed_keyword.intent intent = serializers.CharField(read_only=True) # From seed_keyword.intent
# SeedKeyword relationship # 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_id = serializers.IntegerField(write_only=True, required=False)
seed_keyword = SeedKeywordSerializer(read_only=True) 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 # Overrides
volume_override = serializers.IntegerField(required=False, allow_null=True) volume_override = serializers.IntegerField(required=False, allow_null=True)
difficulty_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', 'volume',
'difficulty', 'difficulty',
'intent', 'intent',
'custom_keyword', # Write-only field for creating custom keywords
'custom_volume', # Write-only
'custom_difficulty', # Write-only
'custom_intent', # Write-only
'volume_override', 'volume_override',
'difficulty_override', 'difficulty_override',
'cluster_id', 'cluster_id',
@@ -61,17 +76,76 @@ class KeywordSerializer(serializers.ModelSerializer):
self.fields['attribute_values'] = serializers.JSONField(read_only=True) self.fields['attribute_values'] = serializers.JSONField(read_only=True)
def validate(self, attrs): def validate(self, attrs):
"""Validate that seed_keyword_id is provided for create operations""" """Validate that either seed_keyword_id OR custom keyword fields are provided"""
# For create operations, seed_keyword_id is required # For create operations, need either seed_keyword_id OR custom keyword
if self.instance is None and 'seed_keyword_id' not in attrs: if self.instance is None:
raise serializers.ValidationError({'seed_keyword_id': 'This field is required when creating a keyword.'}) 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 return attrs
def create(self, validated_data): def create(self, validated_data):
"""Create Keywords instance with seed_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'
# 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) seed_keyword_id = validated_data.pop('seed_keyword_id', None)
if not seed_keyword_id: if not seed_keyword_id:
raise serializers.ValidationError({'seed_keyword_id': 'This field is required when creating a keyword.'}) raise serializers.ValidationError({'seed_keyword_id': 'This field is required when not providing a custom keyword.'})
try: try:
seed_keyword = SeedKeyword.objects.get(id=seed_keyword_id) seed_keyword = SeedKeyword.objects.get(id=seed_keyword_id)
@@ -79,10 +153,17 @@ class KeywordSerializer(serializers.ModelSerializer):
raise serializers.ValidationError({'seed_keyword_id': f'SeedKeyword with id {seed_keyword_id} does not exist'}) 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) return super().create(validated_data)
def update(self, instance, 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 # seed_keyword_id is optional for updates - only update if provided
if 'seed_keyword_id' in validated_data: if 'seed_keyword_id' in validated_data:
seed_keyword_id = validated_data.pop('seed_keyword_id') seed_keyword_id = validated_data.pop('seed_keyword_id')

View File

@@ -102,11 +102,11 @@ export const createKeywordsPageConfig = (
handlers: { handlers: {
clusters: Array<{ id: number; name: string }>; clusters: Array<{ id: number; name: string }>;
activeSector: { id: number; name: string } | null; activeSector: { id: number; name: string } | null;
availableSeedKeywords: Array<{ id: number; keyword: string; volume: number; difficulty: number; intent: string }>;
formData: { formData: {
seed_keyword_id: number; keyword?: string;
volume_override?: number | null; volume?: number | null;
difficulty_override?: number | null; difficulty?: number | null;
intent?: string;
cluster_id?: number | null; cluster_id?: number | null;
status: string; status: string;
}; };
@@ -504,42 +504,53 @@ export const createKeywordsPageConfig = (
// They're automatically loaded by TablePageTemplate based on the current route // They're automatically loaded by TablePageTemplate based on the current route
formFields: (clusters: Array<{ id: number; name: string }>) => [ formFields: (clusters: Array<{ id: number; name: string }>) => [
{ {
key: 'seed_keyword_id', key: 'keyword',
label: 'Seed Keyword', label: 'Keyword',
type: 'select', type: 'text',
placeholder: 'Select a seed keyword', placeholder: 'Enter keyword (e.g., best massage chairs)',
value: (handlers.formData.seed_keyword_id && handlers.formData.seed_keyword_id > 0) ? handlers.formData.seed_keyword_id.toString() : '', value: handlers.formData.keyword || '',
onChange: (value: any) => onChange: (value: any) =>
handlers.setFormData({ ...handlers.formData, seed_keyword_id: value ? parseInt(value) : 0 }), handlers.setFormData({ ...handlers.formData, keyword: value }),
required: true, 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', key: 'volume',
label: 'Volume Override (optional)', label: 'Search Volume',
type: 'number', type: 'number',
placeholder: 'Leave empty to use seed keyword volume', placeholder: 'Monthly search volume',
value: handlers.formData.volume_override ?? '', value: handlers.formData.volume ?? '',
onChange: (value: any) => 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', key: 'difficulty',
label: 'Difficulty Override (optional)', label: 'Difficulty (0-100)',
type: 'number', type: 'number',
placeholder: 'Leave empty to use seed keyword difficulty', placeholder: 'SEO difficulty score',
value: handlers.formData.difficulty_override ?? '', value: handlers.formData.difficulty ?? '',
onChange: (value: any) => 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, min: 0,
max: 100, 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', key: 'cluster_id',
label: 'Cluster', label: 'Cluster',

View File

@@ -380,7 +380,7 @@ export default function Clusters() {
resetForm(); resetForm();
loadClusters(); loadClusters();
} catch (error: any) { } catch (error: any) {
toast.error(`Failed to save: ${error.message}`); toast.error(error.message || 'Unable to save cluster. Please try again.');
} }
}; };

View File

@@ -290,7 +290,7 @@ export default function Ideas() {
resetForm(); resetForm();
loadIdeas(); loadIdeas();
} catch (error: any) { } catch (error: any) {
toast.error(`Failed to save: ${error.message}`); toast.error(error.message || 'Unable to save idea. Please try again.');
} }
}; };

View File

@@ -20,8 +20,6 @@ import {
Cluster, Cluster,
API_BASE_URL, API_BASE_URL,
autoClusterKeywords, autoClusterKeywords,
fetchSeedKeywords,
SeedKeyword,
} from '../../services/api'; } from '../../services/api';
import { useSiteStore } from '../../store/siteStore'; import { useSiteStore } from '../../store/siteStore';
import { useSectorStore } from '../../store/sectorStore'; import { useSectorStore } from '../../store/sectorStore';
@@ -48,9 +46,7 @@ export default function Keywords() {
// Data state // Data state
const [keywords, setKeywords] = useState<Keyword[]>([]); const [keywords, setKeywords] = useState<Keyword[]>([]);
const [clusters, setClusters] = useState<Cluster[]>([]); const [clusters, setClusters] = useState<Cluster[]>([]);
const [availableSeedKeywords, setAvailableSeedKeywords] = useState<SeedKeyword[]>([]);
const [loading, setLoading] = useState(true); const [loading, setLoading] = useState(true);
const [loadingSeedKeywords, setLoadingSeedKeywords] = useState(false);
// Filter state - match Keywords.tsx // Filter state - match Keywords.tsx
const [searchTerm, setSearchTerm] = useState(''); const [searchTerm, setSearchTerm] = useState('');
@@ -82,9 +78,10 @@ export default function Keywords() {
const [isEditMode, setIsEditMode] = useState(false); const [isEditMode, setIsEditMode] = useState(false);
const [editingKeyword, setEditingKeyword] = useState<Keyword | null>(null); const [editingKeyword, setEditingKeyword] = useState<Keyword | null>(null);
const [formData, setFormData] = useState<KeywordCreateData>({ const [formData, setFormData] = useState<KeywordCreateData>({
seed_keyword_id: 0, keyword: '',
volume_override: null, volume: null,
difficulty_override: null, difficulty: null,
intent: 'informational',
cluster_id: null, cluster_id: null,
status: 'new', status: 'new',
}); });
@@ -131,43 +128,6 @@ export default function Keywords() {
} }
}, [activeSite, loadSectorsForSite]); }, [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 // Load clusters for filter dropdown
useEffect(() => { useEffect(() => {
const loadClusters = async () => { const loadClusters = async () => {
@@ -573,11 +533,12 @@ export default function Keywords() {
const resetForm = useCallback(() => { const resetForm = useCallback(() => {
setFormData({ setFormData({
seed_keyword_id: 0, keyword: '',
volume_override: null, volume: null,
difficulty_override: null, difficulty: null,
intent: 'informational',
cluster_id: null, cluster_id: null,
status: 'pending', status: 'new',
}); });
setIsEditMode(false); setIsEditMode(false);
setEditingKeyword(null); setEditingKeyword(null);
@@ -637,7 +598,6 @@ export default function Keywords() {
return createKeywordsPageConfig({ return createKeywordsPageConfig({
clusters, clusters,
activeSector, activeSector,
availableSeedKeywords,
formData, formData,
setFormData, setFormData,
// Filter state handlers // Filter state handlers
@@ -669,7 +629,6 @@ export default function Keywords() {
}, [ }, [
clusters, clusters,
activeSector, activeSector,
availableSeedKeywords,
formData, formData,
searchTerm, searchTerm,
statusFilter, statusFilter,
@@ -714,8 +673,18 @@ export default function Keywords() {
return; return;
} }
if (!formData.seed_keyword_id) { if (!formData.keyword?.trim()) {
toast.error('Please select a seed keyword'); 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; return;
} }
@@ -727,14 +696,15 @@ export default function Keywords() {
sector_id: sectorId, sector_id: sectorId,
}; };
console.log('Creating keyword with data:', keywordData);
await createKeyword(keywordData); await createKeyword(keywordData);
toast.success('Keyword attached successfully'); toast.success('Keyword created successfully');
} }
setIsModalOpen(false); setIsModalOpen(false);
resetForm(); resetForm();
loadKeywords(); loadKeywords();
} catch (error: any) { } 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); setEditingKeyword(keyword);
setIsEditMode(true); setIsEditMode(true);
setFormData({ setFormData({
seed_keyword_id: keyword.seed_keyword_id, keyword: keyword.keyword,
volume_override: keyword.volume_override || null, volume: keyword.volume,
difficulty_override: keyword.difficulty_override || null, difficulty: keyword.difficulty,
intent: keyword.intent,
cluster_id: keyword.cluster_id, cluster_id: keyword.cluster_id,
status: keyword.status, status: keyword.status,
}); });
@@ -918,7 +889,7 @@ export default function Keywords() {
{/* Create/Edit Modal */} {/* Create/Edit Modal */}
<FormModal <FormModal
key={`keyword-form-${isEditMode ? editingKeyword?.id : 'new'}-${formData.seed_keyword_id}-${formData.status}`} key={`keyword-form-${isEditMode ? editingKeyword?.id : 'new'}`}
isOpen={isModalOpen} isOpen={isModalOpen}
onClose={() => { onClose={() => {
setIsModalOpen(false); setIsModalOpen(false);

View File

@@ -354,10 +354,13 @@ export default function IndustriesSectorsKeywords() {
// Clear selection // Clear selection
setSelectedIds([]); setSelectedIds([]);
} else { } 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) { } 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]); }, [activeSite, activeSector, toast]);

View File

@@ -101,6 +101,29 @@ const getAuthToken = (): string | null => {
return 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 // Get refresh token from store - try Zustand store first, then localStorage as fallback
const getRefreshToken = (): string | null => { const getRefreshToken = (): string | null => {
try { 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 // 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).response = errorData;
(apiError as any).status = response.status; (apiError as any).status = response.status;
(apiError as any).errorType = errorType;
throw apiError; throw apiError;
} }
@@ -493,7 +518,11 @@ export interface Keyword {
} }
export interface KeywordCreateData { 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; volume_override?: number | null;
difficulty_override?: number | null; difficulty_override?: number | null;
cluster_id?: number | null; cluster_id?: number | null;
@@ -554,9 +583,27 @@ export async function fetchKeyword(id: number): Promise<Keyword> {
} }
export async function createKeyword(data: KeywordCreateData): Promise<Keyword> { export async function createKeyword(data: KeywordCreateData): Promise<Keyword> {
// 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/', { return fetchAPI('/v1/planner/keywords/', {
method: 'POST', 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; return { success: true, ...response } as any;
} catch (error: any) { } catch (error: any) {
// Error responses are thrown by fetchAPI - return as failed result instead of re-throwing // Error responses are thrown by fetchAPI - return as failed result
// This allows component to handle limit errors gracefully // Extract clean user-friendly message (error.message is already cleaned in fetchAPI)
const userMessage = error.message || 'Failed to add keywords';
return { return {
success: false, success: false,
created: 0, created: 0,
skipped: 0, skipped: 0,
errors: [error.message || 'Failed to add keywords'] errors: [userMessage]
}; };
} }
} }