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

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