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
# 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')

View File

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

View File

@@ -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.');
}
};

View File

@@ -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.');
}
};

View File

@@ -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<Keyword[]>([]);
const [clusters, setClusters] = useState<Cluster[]>([]);
const [availableSeedKeywords, setAvailableSeedKeywords] = useState<SeedKeyword[]>([]);
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<Keyword | null>(null);
const [formData, setFormData] = useState<KeywordCreateData>({
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 */}
<FormModal
key={`keyword-form-${isEditMode ? editingKeyword?.id : 'new'}-${formData.seed_keyword_id}-${formData.status}`}
key={`keyword-form-${isEditMode ? editingKeyword?.id : 'new'}`}
isOpen={isModalOpen}
onClose={() => {
setIsModalOpen(false);

View File

@@ -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]);

View File

@@ -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<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/', {
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]
};
}
}