560 lines
19 KiB
TypeScript
560 lines
19 KiB
TypeScript
/**
|
|
* Keywords Page Configuration
|
|
* Centralized config for Keywords page table, filters, and actions
|
|
*
|
|
* This config is fully dynamic - all columns, filters, actions, and form fields
|
|
* are defined here instead of inline in the component.
|
|
*/
|
|
|
|
import React from 'react';
|
|
import {
|
|
keywordColumn,
|
|
volumeColumn,
|
|
difficultyColumn,
|
|
countryColumn,
|
|
clusterColumn,
|
|
sectorColumn,
|
|
statusColumn,
|
|
createdWithActionsColumn,
|
|
} from '../snippets/columns.snippets';
|
|
// Icons removed - bulkActions and rowActions are now in table-actions.config.tsx
|
|
import Badge from '../../components/ui/badge/Badge';
|
|
import { getDifficultyNumber, getDifficultyOptions, getDifficultyValueFromNumber } from '../../utils/difficulty';
|
|
import { formatRelativeDate } from '../../utils/date';
|
|
import { Keyword } from '../../services/api';
|
|
import Input from '../../components/form/input/InputField';
|
|
import Label from '../../components/form/Label';
|
|
import Button from '../../components/ui/button/Button';
|
|
// SelectDropdown not used directly in config - removed
|
|
|
|
// Type definitions
|
|
export interface ColumnConfig {
|
|
key: string;
|
|
label: string;
|
|
sortable?: boolean;
|
|
sortField?: string;
|
|
align?: 'left' | 'center' | 'right';
|
|
width?: string;
|
|
render?: (value: any, row: any) => React.ReactNode;
|
|
defaultVisible?: boolean; // Whether column is visible by default (default: true)
|
|
}
|
|
|
|
// BulkActionConfig and RowActionConfig are now in table-actions.config.tsx
|
|
|
|
export interface FormFieldConfig {
|
|
key: string;
|
|
label: string;
|
|
type: 'text' | 'number' | 'select';
|
|
placeholder?: string;
|
|
required?: boolean;
|
|
min?: number;
|
|
max?: number;
|
|
className?: string;
|
|
options?: Array<{ value: string; label: string }>;
|
|
value: any; // Required for FormModal
|
|
onChange: (value: any) => void; // Required for FormModal
|
|
}
|
|
|
|
export interface FilterConfig {
|
|
key: string;
|
|
label: string;
|
|
type: 'text' | 'select' | 'daterange' | 'range' | 'custom';
|
|
placeholder?: string;
|
|
options?: Array<{ value: string; label: string }>;
|
|
min?: number;
|
|
max?: number;
|
|
step?: number;
|
|
className?: string;
|
|
customRender?: () => React.ReactNode; // For complex custom filters like volume range
|
|
dynamicOptions?: string; // e.g., 'clusters' - flag for dynamic option loading
|
|
}
|
|
|
|
export interface HeaderMetricConfig {
|
|
label: string;
|
|
value: number;
|
|
accentColor: 'blue' | 'green' | 'amber' | 'purple';
|
|
calculate: (data: { keywords: any[]; totalCount: number; clusters: any[] }) => number;
|
|
}
|
|
|
|
export interface KeywordsPageConfig {
|
|
columns: ColumnConfig[];
|
|
filters: FilterConfig[];
|
|
// bulkActions and rowActions are now global - defined in table-actions.config.tsx
|
|
formFields: (clusters: Array<{ id: number; name: string }>) => FormFieldConfig[];
|
|
headerMetrics: HeaderMetricConfig[];
|
|
exportConfig: {
|
|
endpoint: string;
|
|
filename: string;
|
|
formats: Array<'csv' | 'json'>;
|
|
};
|
|
importConfig: {
|
|
endpoint: string;
|
|
acceptedFormats: string[];
|
|
maxFileSize: number;
|
|
};
|
|
}
|
|
|
|
/**
|
|
* Factory function to create keywords page config
|
|
* Accepts handlers/closures from component for actions that need them
|
|
*/
|
|
export const createKeywordsPageConfig = (
|
|
handlers: {
|
|
clusters: Array<{ id: number; name: string }>;
|
|
activeSector: { id: number; name: string } | null;
|
|
formData: {
|
|
keyword?: string;
|
|
volume?: number | null;
|
|
difficulty?: number | null;
|
|
country?: string;
|
|
cluster_id?: number | null;
|
|
status: string;
|
|
};
|
|
setFormData: React.Dispatch<React.SetStateAction<any>>;
|
|
// Filter state handlers
|
|
searchTerm: string;
|
|
setSearchTerm: (value: string) => void;
|
|
statusFilter: string;
|
|
setStatusFilter: (value: string) => void;
|
|
countryFilter: string;
|
|
setCountryFilter: (value: string) => void;
|
|
difficultyFilter: string;
|
|
setDifficultyFilter: (value: string) => void;
|
|
clusterFilter: string;
|
|
setClusterFilter: (value: string) => void;
|
|
volumeMin: number | '';
|
|
volumeMax: number | '';
|
|
setVolumeMin: (value: number | '') => void;
|
|
setVolumeMax: (value: number | '') => void;
|
|
isVolumeDropdownOpen: boolean;
|
|
setIsVolumeDropdownOpen: (value: boolean) => void;
|
|
tempVolumeMin: number | '';
|
|
tempVolumeMax: number | '';
|
|
setTempVolumeMin: (value: number | '') => void;
|
|
setTempVolumeMax: (value: number | '') => void;
|
|
volumeButtonRef: React.RefObject<HTMLButtonElement | null>;
|
|
volumeDropdownRef: React.RefObject<HTMLDivElement | null>;
|
|
setCurrentPage: (page: number) => void;
|
|
loadKeywords: () => Promise<void>;
|
|
}
|
|
): KeywordsPageConfig => {
|
|
const showSectorColumn = !handlers.activeSector; // Show when viewing all sectors
|
|
|
|
return {
|
|
columns: [
|
|
{
|
|
...keywordColumn,
|
|
sortable: false, // Backend doesn't support sorting by keyword field
|
|
sortField: 'seed_keyword__keyword',
|
|
width: '300px',
|
|
render: (value: string) => (
|
|
<span>
|
|
{value || '-'}
|
|
</span>
|
|
),
|
|
},
|
|
// Sector column - only show when viewing all sectors
|
|
...(showSectorColumn ? [{
|
|
...sectorColumn,
|
|
render: (value: string, row: Keyword) => (
|
|
<Badge color="info" size="xs" variant="soft">
|
|
{row.sector_name || '-'}
|
|
</Badge>
|
|
),
|
|
}] : []),
|
|
{
|
|
...volumeColumn,
|
|
sortable: true,
|
|
sortField: 'seed_keyword__volume', // Backend expects seed_keyword__volume
|
|
align: 'center' as const,
|
|
headingAlign: 'center' as const,
|
|
render: (value: number) => value.toLocaleString(),
|
|
},
|
|
{
|
|
...clusterColumn,
|
|
sortable: false, // Backend doesn't support sorting by cluster_id
|
|
sortField: 'cluster_id',
|
|
width: '300px',
|
|
render: (_value: string, row: Keyword) => row.cluster_name ? (
|
|
<span className="text-gray-800 dark:text-white">
|
|
{row.cluster_name}
|
|
</span>
|
|
) : <span className="text-gray-400">-</span>,
|
|
},
|
|
{
|
|
...difficultyColumn,
|
|
sortable: true,
|
|
sortField: 'seed_keyword__difficulty', // Backend expects seed_keyword__difficulty
|
|
align: 'center' as const,
|
|
headingAlign: 'center' as const,
|
|
render: (value: number) => {
|
|
const difficultyNum = getDifficultyNumber(value);
|
|
const difficultyBadgeColor =
|
|
typeof difficultyNum === 'number' && difficultyNum === 1
|
|
? 'success'
|
|
: typeof difficultyNum === 'number' && difficultyNum === 2
|
|
? 'success'
|
|
: typeof difficultyNum === 'number' && difficultyNum === 3
|
|
? 'amber'
|
|
: typeof difficultyNum === 'number' && difficultyNum === 4
|
|
? 'error'
|
|
: typeof difficultyNum === 'number' && difficultyNum === 5
|
|
? 'error'
|
|
: 'gray';
|
|
return typeof difficultyNum === 'number' ? (
|
|
<Badge color={difficultyBadgeColor} variant="soft" size="xs">
|
|
{difficultyNum}
|
|
</Badge>
|
|
) : (
|
|
difficultyNum
|
|
);
|
|
},
|
|
},
|
|
{
|
|
...countryColumn,
|
|
sortable: false, // Backend doesn't support sorting by country
|
|
sortField: 'seed_keyword__country',
|
|
align: 'center' as const,
|
|
headingAlign: 'center' as const,
|
|
render: (value: string) => (
|
|
<Badge color="info" size="xs" variant="soft">
|
|
{value || '-'}
|
|
</Badge>
|
|
),
|
|
},
|
|
{
|
|
...statusColumn,
|
|
sortable: true,
|
|
sortField: 'status',
|
|
render: (value: string) => {
|
|
const properCase = value ? value.charAt(0).toUpperCase() + value.slice(1) : '-';
|
|
return (
|
|
<Badge
|
|
color={
|
|
value === 'mapped'
|
|
? 'success'
|
|
: value === 'new'
|
|
? 'amber'
|
|
: 'error'
|
|
}
|
|
size="xs"
|
|
variant="soft"
|
|
>
|
|
{properCase}
|
|
</Badge>
|
|
);
|
|
},
|
|
},
|
|
{
|
|
...createdWithActionsColumn,
|
|
label: 'Added',
|
|
sortable: true,
|
|
sortField: 'created_at',
|
|
width: '130px',
|
|
render: (value: string) => formatRelativeDate(value),
|
|
},
|
|
],
|
|
filters: [
|
|
{
|
|
key: 'search',
|
|
label: 'Search',
|
|
type: 'text',
|
|
placeholder: 'Search keywords...',
|
|
},
|
|
{
|
|
key: 'status',
|
|
label: 'Status',
|
|
type: 'select',
|
|
options: [
|
|
{ value: '', label: 'All Status' },
|
|
{ value: 'new', label: 'New' },
|
|
{ value: 'mapped', label: 'Mapped' },
|
|
],
|
|
},
|
|
{
|
|
key: 'country',
|
|
label: 'Country',
|
|
type: 'select',
|
|
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' },
|
|
],
|
|
},
|
|
{
|
|
key: 'difficulty',
|
|
label: 'Difficulty',
|
|
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' },
|
|
],
|
|
},
|
|
{
|
|
key: 'volume',
|
|
label: 'Volume Range',
|
|
type: 'custom',
|
|
customRender: () => (
|
|
<div className="relative flex-1 min-w-[140px]">
|
|
<button
|
|
ref={handlers.volumeButtonRef}
|
|
type="button"
|
|
onClick={() => {
|
|
handlers.setIsVolumeDropdownOpen(!handlers.isVolumeDropdownOpen);
|
|
handlers.setTempVolumeMin(handlers.volumeMin);
|
|
handlers.setTempVolumeMax(handlers.volumeMax);
|
|
}}
|
|
className={`igny8-select-styled h-9 w-full appearance-none rounded-lg border border-gray-300 bg-transparent px-3 py-2 pr-10 text-sm shadow-theme-xs focus:border-brand-300 focus:outline-hidden focus:ring-3 focus:ring-brand-500/10 dark:border-gray-700 dark:bg-gray-900 dark:focus:border-brand-800 ${
|
|
handlers.volumeMin || handlers.volumeMax
|
|
? "text-gray-800 dark:text-white/90"
|
|
: "text-gray-400 dark:text-gray-400"
|
|
} ${
|
|
handlers.isVolumeDropdownOpen
|
|
? "border-brand-300 ring-3 ring-brand-500/10 dark:border-brand-800"
|
|
: ""
|
|
}`}
|
|
>
|
|
<span className="block text-left truncate">
|
|
{handlers.volumeMin || handlers.volumeMax
|
|
? `Vol: ${handlers.volumeMin || 'Min'} - ${handlers.volumeMax || 'Max'}`
|
|
: 'Volume Range'}
|
|
</span>
|
|
<span className="absolute inset-y-0 right-0 flex items-center pr-3 pointer-events-none">
|
|
<svg
|
|
className="h-4 w-4 text-gray-400"
|
|
fill="none"
|
|
viewBox="0 0 24 24"
|
|
stroke="currentColor"
|
|
>
|
|
<path
|
|
strokeLinecap="round"
|
|
strokeLinejoin="round"
|
|
strokeWidth={2}
|
|
d="M19 9l-7 7-7-7"
|
|
/>
|
|
</svg>
|
|
</span>
|
|
</button>
|
|
|
|
{/* Dropdown Menu */}
|
|
{handlers.isVolumeDropdownOpen && (
|
|
<div
|
|
ref={handlers.volumeDropdownRef}
|
|
className="absolute z-50 left-0 right-0 mt-1 rounded-lg border border-gray-200 bg-white shadow-theme-lg dark:border-gray-800 dark:bg-gray-dark overflow-hidden p-4 min-w-[280px]"
|
|
>
|
|
<div className="space-y-3">
|
|
<div>
|
|
<Label htmlFor="vol-min" className="text-xs mb-1">Min Volume</Label>
|
|
<Input
|
|
id="vol-min"
|
|
type="number"
|
|
placeholder="Min"
|
|
value={handlers.tempVolumeMin}
|
|
onChange={(e) => {
|
|
const val = e.target.value;
|
|
handlers.setTempVolumeMin(val === '' ? '' : parseInt(val) || '');
|
|
}}
|
|
className="w-full h-9"
|
|
/>
|
|
</div>
|
|
<div>
|
|
<Label htmlFor="vol-max" className="text-xs mb-1">Max Volume</Label>
|
|
<Input
|
|
id="vol-max"
|
|
type="number"
|
|
placeholder="Max"
|
|
value={handlers.tempVolumeMax}
|
|
onChange={(e) => {
|
|
const val = e.target.value;
|
|
handlers.setTempVolumeMax(val === '' ? '' : parseInt(val) || '');
|
|
}}
|
|
className="w-full h-9"
|
|
/>
|
|
</div>
|
|
<div className="flex gap-2 pt-2">
|
|
<Button
|
|
size="sm"
|
|
variant="primary"
|
|
onClick={async () => {
|
|
const newMin = handlers.tempVolumeMin === '' ? '' : Number(handlers.tempVolumeMin);
|
|
const newMax = handlers.tempVolumeMax === '' ? '' : Number(handlers.tempVolumeMax);
|
|
handlers.setIsVolumeDropdownOpen(false);
|
|
handlers.setVolumeMin(newMin);
|
|
handlers.setVolumeMax(newMax);
|
|
handlers.setCurrentPage(1);
|
|
setTimeout(() => {
|
|
handlers.loadKeywords();
|
|
}, 0);
|
|
}}
|
|
className="flex-1"
|
|
>
|
|
OK
|
|
</Button>
|
|
<Button
|
|
size="sm"
|
|
variant="secondary"
|
|
onClick={() => {
|
|
handlers.setIsVolumeDropdownOpen(false);
|
|
handlers.setTempVolumeMin(handlers.volumeMin);
|
|
handlers.setTempVolumeMax(handlers.volumeMax);
|
|
}}
|
|
className="flex-1"
|
|
>
|
|
Cancel
|
|
</Button>
|
|
</div>
|
|
</div>
|
|
</div>
|
|
)}
|
|
</div>
|
|
),
|
|
},
|
|
{
|
|
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: [
|
|
{
|
|
label: 'Keywords',
|
|
value: 0,
|
|
accentColor: 'blue' as const,
|
|
calculate: (data) => data.totalCount || 0,
|
|
tooltip: 'Total keywords added to site wrokflow. Minimum 5 Keywords are needed for clustering.',
|
|
},
|
|
{
|
|
label: 'Clustered',
|
|
value: 0,
|
|
accentColor: 'green' as const,
|
|
calculate: (data) => data.keywords.filter((k: Keyword) => k.cluster_id).length,
|
|
tooltip: 'Keywords grouped into topical clusters. Clustered keywords are ready for content ideation.',
|
|
},
|
|
{
|
|
label: 'Unmapped',
|
|
value: 0,
|
|
accentColor: 'amber' as const,
|
|
calculate: (data) => data.keywords.filter((k: Keyword) => !k.cluster_id).length,
|
|
tooltip: 'Unclustered keywords waiting to be organized. Select keywords and use Auto-Cluster to group them.',
|
|
},
|
|
{
|
|
label: 'Volume',
|
|
value: 0,
|
|
accentColor: 'purple' as const,
|
|
calculate: (data) => data.keywords.reduce((sum: number, k: Keyword) => sum + (k.volume || 0), 0),
|
|
tooltip: 'Total monthly search volume across all keywords. Higher volume = more traffic potential.',
|
|
},
|
|
],
|
|
// bulkActions and rowActions are now global - defined in table-actions.config.ts
|
|
// They're automatically loaded by TablePageTemplate based on the current route
|
|
formFields: (clusters: Array<{ id: number; name: string }>) => [
|
|
{
|
|
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, keyword: value }),
|
|
required: true,
|
|
},
|
|
{
|
|
key: 'volume',
|
|
label: 'Search Volume',
|
|
type: 'number',
|
|
placeholder: 'Monthly search volume',
|
|
value: handlers.formData.volume ?? '',
|
|
onChange: (value: any) =>
|
|
handlers.setFormData({ ...handlers.formData, volume: value ? parseInt(value) : null }),
|
|
required: true,
|
|
min: 0,
|
|
},
|
|
{
|
|
key: 'difficulty',
|
|
label: 'Difficulty (0-100)',
|
|
type: 'number',
|
|
placeholder: 'SEO difficulty score',
|
|
value: handlers.formData.difficulty ?? '',
|
|
onChange: (value: any) =>
|
|
handlers.setFormData({ ...handlers.formData, difficulty: value ? parseInt(value) : null }),
|
|
required: true,
|
|
min: 0,
|
|
max: 100,
|
|
},
|
|
{
|
|
key: 'country',
|
|
label: 'Country',
|
|
type: 'select',
|
|
value: handlers.formData.country || 'US',
|
|
onChange: (value: any) =>
|
|
handlers.setFormData({ ...handlers.formData, country: value }),
|
|
required: true,
|
|
options: [
|
|
{ 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' },
|
|
],
|
|
},
|
|
{
|
|
key: 'cluster_id',
|
|
label: 'Cluster',
|
|
type: 'select',
|
|
value: handlers.formData.cluster_id?.toString() || '',
|
|
onChange: (value: any) =>
|
|
handlers.setFormData({
|
|
...handlers.formData,
|
|
cluster_id: value ? parseInt(value) : null,
|
|
}),
|
|
options: [
|
|
{ value: '', label: 'No Cluster' },
|
|
...clusters.map((c) => ({ value: c.id.toString(), label: c.name })),
|
|
],
|
|
},
|
|
{
|
|
key: 'status',
|
|
label: 'Status',
|
|
type: 'select',
|
|
value: handlers.formData.status || 'new',
|
|
onChange: (value: any) =>
|
|
handlers.setFormData({ ...handlers.formData, status: value }),
|
|
options: [
|
|
{ value: 'new', label: 'New' },
|
|
{ value: 'mapped', label: 'Mapped' },
|
|
],
|
|
},
|
|
],
|
|
exportConfig: {
|
|
endpoint: '/v1/planner/keywords/export/',
|
|
filename: 'keywords',
|
|
formats: ['csv', 'json'],
|
|
},
|
|
importConfig: {
|
|
endpoint: '/v1/planner/keywords/import_keywords/',
|
|
acceptedFormats: ['.csv'],
|
|
maxFileSize: 5 * 1024 * 1024, // 5MB
|
|
},
|
|
};
|
|
};
|
|
|