limits vlaiadtion adn keywrods forms
This commit is contained in:
@@ -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')
|
||||||
|
|||||||
@@ -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',
|
||||||
|
|||||||
@@ -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.');
|
||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
|
|||||||
@@ -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.');
|
||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
|
|||||||
@@ -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);
|
||||||
|
|||||||
@@ -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]);
|
||||||
|
|
||||||
|
|||||||
@@ -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]
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
Reference in New Issue
Block a user