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