{options.map((option) => {
diff --git a/frontend/src/config/pages/ideas.config.tsx b/frontend/src/config/pages/ideas.config.tsx
index 9b9f5fa3..f4b1d79d 100644
--- a/frontend/src/config/pages/ideas.config.tsx
+++ b/frontend/src/config/pages/ideas.config.tsx
@@ -86,6 +86,11 @@ export const createIdeasPageConfig = (
typeFilter: string;
setTypeFilter: (value: string) => void;
setCurrentPage: (page: number) => void;
+ // Dynamic filter options
+ statusOptions?: Array<{ value: string; label: string }>;
+ contentTypeOptions?: Array<{ value: string; label: string }>;
+ contentStructureOptions?: Array<{ value: string; label: string }>;
+ clusterOptions?: Array<{ value: string; label: string }>;
}
): IdeasPageConfig => {
const showSectorColumn = !handlers.activeSector; // Show when viewing all sectors
@@ -233,9 +238,14 @@ export const createIdeasPageConfig = (
type: 'select',
options: [
{ value: '', label: 'All Status' },
- { value: 'new', label: 'New' },
- { value: 'queued', label: 'Queued' },
- { value: 'completed', label: 'Completed' },
+ ...(handlers.statusOptions && handlers.statusOptions.length > 0
+ ? handlers.statusOptions
+ : [
+ { value: 'new', label: 'New' },
+ { value: 'queued', label: 'Queued' },
+ { value: 'completed', label: 'Completed' },
+ ]
+ ),
],
},
{
@@ -244,24 +254,25 @@ export const createIdeasPageConfig = (
type: 'select',
options: [
{ value: '', label: 'All Structures' },
- // Post
- { value: 'article', label: 'Article' },
- { value: 'guide', label: 'Guide' },
- { value: 'comparison', label: 'Comparison' },
- { value: 'review', label: 'Review' },
- { value: 'listicle', label: 'Listicle' },
- // Page
- { value: 'landing_page', label: 'Landing Page' },
- { value: 'business_page', label: 'Business Page' },
- { value: 'service_page', label: 'Service Page' },
- { value: 'general', label: 'General' },
- { value: 'cluster_hub', label: 'Cluster Hub' },
- // Product
- { value: 'product_page', label: 'Product Page' },
- // Taxonomy
- { value: 'category_archive', label: 'Category Archive' },
- { value: 'tag_archive', label: 'Tag Archive' },
- { value: 'attribute_archive', label: 'Attribute Archive' },
+ ...(handlers.contentStructureOptions && handlers.contentStructureOptions.length > 0
+ ? handlers.contentStructureOptions
+ : [
+ { value: 'article', label: 'Article' },
+ { value: 'guide', label: 'Guide' },
+ { value: 'comparison', label: 'Comparison' },
+ { value: 'review', label: 'Review' },
+ { value: 'listicle', label: 'Listicle' },
+ { value: 'landing_page', label: 'Landing Page' },
+ { value: 'business_page', label: 'Business Page' },
+ { value: 'service_page', label: 'Service Page' },
+ { value: 'general', label: 'General' },
+ { value: 'cluster_hub', label: 'Cluster Hub' },
+ { value: 'product_page', label: 'Product Page' },
+ { value: 'category_archive', label: 'Category Archive' },
+ { value: 'tag_archive', label: 'Tag Archive' },
+ { value: 'attribute_archive', label: 'Attribute Archive' },
+ ]
+ ),
],
},
{
@@ -270,23 +281,29 @@ export const createIdeasPageConfig = (
type: 'select',
options: [
{ value: '', label: 'All Types' },
- { value: 'post', label: 'Post' },
- { value: 'page', label: 'Page' },
- { value: 'product', label: 'Product' },
- { value: 'taxonomy', label: 'Taxonomy' },
+ ...(handlers.contentTypeOptions && handlers.contentTypeOptions.length > 0
+ ? handlers.contentTypeOptions
+ : [
+ { value: 'post', label: 'Post' },
+ { value: 'page', label: 'Page' },
+ { value: 'product', label: 'Product' },
+ { value: 'taxonomy', label: 'Taxonomy' },
+ ]
+ ),
],
},
{
key: 'keyword_cluster_id',
label: 'Cluster',
type: 'select',
- options: (() => {
- return [
- { value: '', label: 'All Clusters' },
- ...handlers.clusters.map((c) => ({ value: c.id.toString(), label: c.name })),
- ];
- })(),
dynamicOptions: 'clusters',
+ options: [
+ { value: '', label: 'All Clusters' },
+ ...(handlers.clusterOptions && handlers.clusterOptions.length > 0
+ ? handlers.clusterOptions
+ : handlers.clusters.map((c) => ({ value: c.id.toString(), label: c.name }))
+ ),
+ ],
},
],
formFields: (clusters: Array<{ id: number; name: string }>) => [
diff --git a/frontend/src/config/pages/keywords.config.tsx b/frontend/src/config/pages/keywords.config.tsx
index dac79069..7ecc200c 100644
--- a/frontend/src/config/pages/keywords.config.tsx
+++ b/frontend/src/config/pages/keywords.config.tsx
@@ -140,6 +140,7 @@ export const createKeywordsPageConfig = (
countryOptions?: Array<{ value: string; label: string }>;
statusOptions?: Array<{ value: string; label: string }>;
clusterOptions?: Array<{ value: string; label: string }>;
+ difficultyOptions?: Array<{ value: string; label: string }>;
}
): KeywordsPageConfig => {
const showSectorColumn = !handlers.activeSector; // Show when viewing all sectors
@@ -308,11 +309,16 @@ export const createKeywordsPageConfig = (
type: 'select',
options: [
{ value: '', label: 'All Difficulty' },
- { value: '1', label: '1 - Very Easy' },
- { value: '2', label: '2 - Easy' },
- { value: '3', label: '3 - Medium' },
- { value: '4', label: '4 - Hard' },
- { value: '5', label: '5 - Very Hard' },
+ ...(handlers.difficultyOptions && handlers.difficultyOptions.length > 0
+ ? handlers.difficultyOptions
+ : [
+ { value: '1', label: '1 - Very Easy' },
+ { value: '2', label: '2 - Easy' },
+ { value: '3', label: '3 - Medium' },
+ { value: '4', label: '4 - Hard' },
+ { value: '5', label: '5 - Very Hard' },
+ ]
+ ),
],
},
{
@@ -448,19 +454,6 @@ export const createKeywordsPageConfig = (
),
},
- {
- key: 'cluster_id',
- label: 'Cluster',
- type: 'select',
- options: (() => {
- // Dynamically generate options from current clusters
- return [
- { value: '', label: 'All Clusters' },
- ...handlers.clusters.map((c) => ({ value: c.id.toString(), label: c.name })),
- ];
- })(),
- className: 'w-40',
- },
],
headerMetrics: [
{
diff --git a/frontend/src/config/structureMapping.ts b/frontend/src/config/structureMapping.ts
index 866467c7..3b11de02 100644
--- a/frontend/src/config/structureMapping.ts
+++ b/frontend/src/config/structureMapping.ts
@@ -1,8 +1,87 @@
/**
* Structure mapping configuration
* Maps content types to their valid structures and provides label mappings
+ * Also contains shared filter options for reuse across pages
*/
+// ============================================================================
+// SHARED FILTER OPTIONS - Use these in page configs to avoid duplication
+// ============================================================================
+
+/** Status options for Keywords page */
+export const KEYWORD_STATUS_OPTIONS = [
+ { value: '', label: 'All Status' },
+ { value: 'new', label: 'New' },
+ { value: 'mapped', label: 'Mapped' },
+];
+
+/** Status options for Clusters page */
+export const CLUSTER_STATUS_OPTIONS = [
+ { value: '', label: 'All Status' },
+ { value: 'new', label: 'New' },
+ { value: 'mapped', label: 'Mapped' },
+];
+
+/** Status options for Ideas page */
+export const IDEAS_STATUS_OPTIONS = [
+ { value: '', label: 'All Status' },
+ { value: 'new', label: 'New' },
+ { value: 'queued', label: 'Queued' },
+ { value: 'completed', label: 'Completed' },
+];
+
+/** Status options for Content/Review/Approved pages */
+export const CONTENT_STATUS_OPTIONS = [
+ { value: '', label: 'All Status' },
+ { value: 'draft', label: 'Draft' },
+ { value: 'review', label: 'Review' },
+ { value: 'approved', label: 'Approved' },
+ { value: 'published', label: 'Published' },
+];
+
+/** Site status options for content publishing */
+export const SITE_STATUS_OPTIONS = [
+ { value: '', label: 'All Site Status' },
+ { value: 'not_published', label: 'Not Published' },
+ { value: 'scheduled', label: 'Scheduled' },
+ { value: 'publishing', label: 'Publishing' },
+ { value: 'published', label: 'Published' },
+ { value: 'failed', label: 'Failed' },
+];
+
+/** Source options for content */
+export const SOURCE_OPTIONS = [
+ { value: '', label: 'All Sources' },
+ { value: 'igny8', label: 'IGNY8' },
+ { value: 'wordpress', label: 'WordPress' },
+];
+
+/** Country options - used for keywords */
+export const COUNTRY_OPTIONS = [
+ { value: '', label: 'All Countries' },
+ { value: 'US', label: 'United States' },
+ { value: 'CA', label: 'Canada' },
+ { value: 'GB', label: 'United Kingdom' },
+ { value: 'AE', label: 'United Arab Emirates' },
+ { value: 'AU', label: 'Australia' },
+ { value: 'IN', label: 'India' },
+ { value: 'PK', label: 'Pakistan' },
+];
+
+/** Difficulty options (1-5 scale) */
+export const DIFFICULTY_OPTIONS = [
+ { value: '', label: 'All Difficulty' },
+ { value: '1', label: '1 - Very Easy' },
+ { value: '2', label: '2 - Easy' },
+ { value: '3', label: '3 - Medium' },
+ { value: '4', label: '4 - Hard' },
+ { value: '5', label: '5 - Very Hard' },
+];
+
+// ============================================================================
+// CONTENT TYPE AND STRUCTURE OPTIONS
+// ============================================================================
+
export const CONTENT_TYPE_OPTIONS = [
{ value: 'post', label: 'Post' },
{ value: 'page', label: 'Page' },
@@ -10,6 +89,35 @@ export const CONTENT_TYPE_OPTIONS = [
{ value: 'taxonomy', label: 'Taxonomy' },
];
+/** Content type filter options (with "All" option) */
+export const CONTENT_TYPE_FILTER_OPTIONS = [
+ { value: '', label: 'All Types' },
+ ...CONTENT_TYPE_OPTIONS,
+];
+
+/** Content structure filter options (with "All" option) */
+export const CONTENT_STRUCTURE_FILTER_OPTIONS = [
+ { value: '', label: 'All Structures' },
+ // Post structures
+ { value: 'article', label: 'Article' },
+ { value: 'guide', label: 'Guide' },
+ { value: 'comparison', label: 'Comparison' },
+ { value: 'review', label: 'Review' },
+ { value: 'listicle', label: 'Listicle' },
+ // Page structures
+ { value: 'landing_page', label: 'Landing Page' },
+ { value: 'business_page', label: 'Business Page' },
+ { value: 'service_page', label: 'Service Page' },
+ { value: 'general', label: 'General' },
+ { value: 'cluster_hub', label: 'Cluster Hub' },
+ // Product structures
+ { value: 'product_page', label: 'Product Page' },
+ // Taxonomy structures
+ { value: 'category_archive', label: 'Category Archive' },
+ { value: 'tag_archive', label: 'Tag Archive' },
+ { value: 'attribute_archive', label: 'Attribute Archive' },
+];
+
export const CONTENT_STRUCTURE_BY_TYPE: Record
> = {
post: [
{ value: 'article', label: 'Article' },
diff --git a/frontend/src/pages/Planner/Ideas.tsx b/frontend/src/pages/Planner/Ideas.tsx
index 075ea889..cb9e9a20 100644
--- a/frontend/src/pages/Planner/Ideas.tsx
+++ b/frontend/src/pages/Planner/Ideas.tsx
@@ -19,6 +19,8 @@ import {
ContentIdeaCreateData,
fetchClusters,
Cluster,
+ fetchPlannerIdeasFilterOptions,
+ FilterOption,
} from '../../services/api';
import FormModal from '../../components/common/FormModal';
import ProgressModal from '../../components/common/ProgressModal';
@@ -49,6 +51,12 @@ export default function Ideas() {
const [totalPending, setTotalPending] = useState(0);
const [totalImagesCount, setTotalImagesCount] = useState(0);
+ // Dynamic filter options
+ const [statusOptions, setStatusOptions] = useState([]);
+ const [contentTypeOptions, setContentTypeOptions] = useState([]);
+ const [contentStructureOptions, setContentStructureOptions] = useState([]);
+ const [clusterOptions, setClusterOptions] = useState([]);
+
// Filter state
const [searchTerm, setSearchTerm] = useState('');
const [statusFilter, setStatusFilter] = useState('');
@@ -85,7 +93,7 @@ export default function Ideas() {
// Progress modal for AI functions
const progressModal = useProgressModal();
- // Load clusters for filter dropdown
+ // Load clusters for form dropdown (all clusters)
useEffect(() => {
const loadClusters = async () => {
try {
@@ -98,6 +106,26 @@ export default function Ideas() {
loadClusters();
}, []);
+ // Load dynamic filter options based on current site's data
+ const loadFilterOptions = useCallback(async () => {
+ if (!activeSite) return;
+
+ try {
+ const options = await fetchPlannerIdeasFilterOptions(activeSite.id);
+ setStatusOptions(options.statuses || []);
+ setContentTypeOptions(options.content_types || []);
+ setContentStructureOptions(options.content_structures || []);
+ setClusterOptions(options.clusters || []);
+ } catch (error) {
+ console.error('Error loading filter options:', error);
+ }
+ }, [activeSite]);
+
+ // Load filter options when site changes
+ useEffect(() => {
+ loadFilterOptions();
+ }, [loadFilterOptions]);
+
// Load total metrics for footer widget (site-wide totals, no sector filter)
const loadTotalMetrics = useCallback(async () => {
try {
@@ -302,8 +330,13 @@ export default function Ideas() {
typeFilter,
setTypeFilter,
setCurrentPage,
+ // Dynamic filter options
+ statusOptions,
+ contentTypeOptions,
+ contentStructureOptions,
+ clusterOptions,
});
- }, [clusters, activeSector, formData, searchTerm, statusFilter, clusterFilter, structureFilter, typeFilter]);
+ }, [clusters, activeSector, formData, searchTerm, statusFilter, clusterFilter, structureFilter, typeFilter, statusOptions, contentTypeOptions, contentStructureOptions, clusterOptions]);
// Calculate header metrics - use totalInTasks/totalPending from API calls (not page data)
// This ensures metrics show correct totals across all pages, not just current page
diff --git a/frontend/src/pages/Planner/Keywords.tsx b/frontend/src/pages/Planner/Keywords.tsx
index cf655a82..d828467d 100644
--- a/frontend/src/pages/Planner/Keywords.tsx
+++ b/frontend/src/pages/Planner/Keywords.tsx
@@ -60,6 +60,7 @@ export default function Keywords() {
const [countryOptions, setCountryOptions] = useState([]);
const [statusOptions, setStatusOptions] = useState([]);
const [clusterOptions, setClusterOptions] = useState([]);
+ const [difficultyOptions, setDifficultyOptions] = useState([]);
// Filter state - match Keywords.tsx
const [searchTerm, setSearchTerm] = useState('');
@@ -123,24 +124,62 @@ export default function Keywords() {
loadClusters();
}, []);
- // Load dynamic filter options based on current site's data
- const loadFilterOptions = useCallback(async () => {
+ // Load dynamic filter options based on current site's data and applied filters
+ // This implements cascading filters - each filter's options reflect what's available
+ // given the other currently applied filters
+ const loadFilterOptions = useCallback(async (currentFilters?: {
+ status?: string;
+ country?: string;
+ cluster_id?: string;
+ difficulty_min?: number;
+ difficulty_max?: number;
+ }) => {
if (!activeSite) return;
try {
- const options = await fetchPlannerKeywordFilterOptions(activeSite.id);
+ const options = await fetchPlannerKeywordFilterOptions(activeSite.id, currentFilters);
setCountryOptions(options.countries || []);
setStatusOptions(options.statuses || []);
setClusterOptions(options.clusters || []);
+ setDifficultyOptions(options.difficulties || []);
} catch (error) {
console.error('Error loading filter options:', error);
}
}, [activeSite]);
- // Load filter options when site changes
+ // Parse difficulty filter to min/max values
+ // Backend uses: 1=0-10, 2=11-30, 3=31-50, 4=51-70, 5=71-100
+ const getDifficultyRange = useCallback((filter: string): { min?: number; max?: number } => {
+ if (!filter) return {};
+ const level = parseInt(filter, 10);
+ if (isNaN(level)) return {};
+ // Map difficulty level to raw difficulty range matching backend logic
+ const ranges: Record = {
+ 1: { min: 0, max: 10 },
+ 2: { min: 11, max: 30 },
+ 3: { min: 31, max: 50 },
+ 4: { min: 51, max: 70 },
+ 5: { min: 71, max: 100 },
+ };
+ return ranges[level] || {};
+ }, []);
+
+ // Load filter options when site changes (initial load with no filters)
useEffect(() => {
loadFilterOptions();
- }, [loadFilterOptions]);
+ }, [activeSite]);
+
+ // Reload filter options when any filter changes (cascading filters)
+ useEffect(() => {
+ const { min: difficultyMin, max: difficultyMax } = getDifficultyRange(difficultyFilter);
+ loadFilterOptions({
+ status: statusFilter || undefined,
+ country: countryFilter || undefined,
+ cluster_id: clusterFilter || undefined,
+ difficulty_min: difficultyMin,
+ difficulty_max: difficultyMax,
+ });
+ }, [statusFilter, countryFilter, clusterFilter, difficultyFilter, loadFilterOptions, getDifficultyRange]);
// Load total metrics for footer widget (site-wide totals, no sector filter)
const loadTotalMetrics = useCallback(async () => {
@@ -553,6 +592,7 @@ export default function Keywords() {
countryOptions,
statusOptions,
clusterOptions,
+ difficultyOptions,
});
}, [
clusters,
@@ -573,6 +613,7 @@ export default function Keywords() {
countryOptions,
statusOptions,
clusterOptions,
+ difficultyOptions,
]);
// Calculate header metrics - use totalClustered/totalUnmapped from API calls (not page data)
@@ -715,7 +756,7 @@ export default function Keywords() {
status: statusFilter,
country: countryFilter,
difficulty: difficultyFilter,
- cluster_id: clusterFilter,
+ cluster: clusterFilter,
volumeMin: volumeMin,
volumeMax: volumeMax,
}}
@@ -741,7 +782,7 @@ export default function Keywords() {
} else if (key === 'difficulty') {
setDifficultyFilter(stringValue);
setCurrentPage(1);
- } else if (key === 'cluster_id') {
+ } else if (key === 'cluster') {
setClusterFilter(stringValue);
setCurrentPage(1);
}
diff --git a/frontend/src/services/api.ts b/frontend/src/services/api.ts
index 53ec0474..971f4998 100644
--- a/frontend/src/services/api.ts
+++ b/frontend/src/services/api.ts
@@ -751,11 +751,70 @@ export interface PlannerKeywordFilterOptions {
countries: FilterOption[];
statuses: FilterOption[];
clusters: FilterOption[];
+ difficulties: FilterOption[];
}
-export async function fetchPlannerKeywordFilterOptions(siteId?: number): Promise {
+// Filter options request with current filter state for cascading
+export interface KeywordFilterOptionsRequest {
+ site_id?: number;
+ status?: string;
+ country?: string;
+ difficulty_min?: number;
+ difficulty_max?: number;
+ cluster_id?: string;
+}
+
+export async function fetchPlannerKeywordFilterOptions(
+ siteId?: number,
+ filters?: KeywordFilterOptionsRequest
+): Promise {
+ const params = new URLSearchParams();
+ if (siteId) params.append('site_id', siteId.toString());
+ if (filters?.status) params.append('status', filters.status);
+ if (filters?.country) params.append('country', filters.country);
+ if (filters?.difficulty_min !== undefined) params.append('difficulty_min', filters.difficulty_min.toString());
+ if (filters?.difficulty_max !== undefined) params.append('difficulty_max', filters.difficulty_max.toString());
+ if (filters?.cluster_id) params.append('cluster_id', filters.cluster_id);
+
+ const queryString = params.toString();
+ return fetchAPI(`/v1/planner/keywords/filter_options/${queryString ? `?${queryString}` : ''}`);
+}
+
+// Clusters filter options
+export interface PlannerClusterFilterOptions {
+ statuses: FilterOption[];
+}
+
+export async function fetchPlannerClusterFilterOptions(siteId?: number): Promise {
const queryParams = siteId ? `?site_id=${siteId}` : '';
- return fetchAPI(`/v1/planner/keywords/filter_options/${queryParams}`);
+ return fetchAPI(`/v1/planner/clusters/filter_options/${queryParams}`);
+}
+
+// Ideas filter options
+export interface PlannerIdeasFilterOptions {
+ statuses: FilterOption[];
+ content_types: FilterOption[];
+ content_structures: FilterOption[];
+ clusters: FilterOption[];
+}
+
+export async function fetchPlannerIdeasFilterOptions(siteId?: number): Promise {
+ const queryParams = siteId ? `?site_id=${siteId}` : '';
+ return fetchAPI(`/v1/planner/ideas/filter_options/${queryParams}`);
+}
+
+// Content filter options (Writer module)
+export interface WriterContentFilterOptions {
+ statuses: FilterOption[];
+ site_statuses: FilterOption[];
+ content_types: FilterOption[];
+ content_structures: FilterOption[];
+ sources: FilterOption[];
+}
+
+export async function fetchWriterContentFilterOptions(siteId?: number): Promise {
+ const queryParams = siteId ? `?site_id=${siteId}` : '';
+ return fetchAPI(`/v1/writer/content/filter_options/${queryParams}`);
}
// Clusters-specific API functions
diff --git a/frontend/src/styles/design-system.css b/frontend/src/styles/design-system.css
index b98eed7a..4842542f 100644
--- a/frontend/src/styles/design-system.css
+++ b/frontend/src/styles/design-system.css
@@ -517,18 +517,23 @@
SECTION 6: FORM ELEMENT STYLES
=================================================================== */
-/* Styled Select/Dropdown with custom chevron */
-.igny8-select-styled {
+/* Styled Select/Dropdown with custom chevron - for native