Section 3 Completed
This commit is contained in:
95
CHANGELOG.md
95
CHANGELOG.md
@@ -1,7 +1,7 @@
|
|||||||
# IGNY8 Change Log
|
# IGNY8 Change Log
|
||||||
|
|
||||||
**Current Version:** 1.1.3
|
**Current Version:** 1.1.5
|
||||||
**Last Updated:** December 27, 2025
|
**Last Updated:** January 2, 2025
|
||||||
|
|
||||||
---
|
---
|
||||||
|
|
||||||
@@ -9,6 +9,8 @@
|
|||||||
|
|
||||||
| Version | Date | Summary |
|
| Version | Date | Summary |
|
||||||
|---------|------|---------|
|
|---------|------|---------|
|
||||||
|
| 1.1.5 | Jan 2, 2025 | Section 3 WORKFLOW modules - Planner, Writer, Progress Modal fixes |
|
||||||
|
| 1.1.4 | Dec 27, 2025 | Section 2 SETUP modules - Add Keywords, Content Settings, Sites fixes |
|
||||||
| 1.1.3 | Dec 27, 2025 | Merged RULES.md into .rules |
|
| 1.1.3 | Dec 27, 2025 | Merged RULES.md into .rules |
|
||||||
| 1.1.2 | Dec 27, 2025 | Module status documentation, TODOS.md |
|
| 1.1.2 | Dec 27, 2025 | Module status documentation, TODOS.md |
|
||||||
| 1.1.1 | Dec 27, 2025 | Simplified AI agent rules file |
|
| 1.1.1 | Dec 27, 2025 | Simplified AI agent rules file |
|
||||||
@@ -22,6 +24,95 @@
|
|||||||
|
|
||||||
---
|
---
|
||||||
|
|
||||||
|
## v1.1.5 - January 2, 2025
|
||||||
|
|
||||||
|
### Section 3 WORKFLOW Modules Implementation
|
||||||
|
|
||||||
|
**Planner Module:**
|
||||||
|
- **DELETED** `KeywordOpportunities.tsx` - orphaned page, Add Keywords is source of truth
|
||||||
|
- Removed route `/planner/keyword-opportunities` from App.tsx
|
||||||
|
- Updated Clusters table to show ideas count badge: "X ideas" (green) or "No ideas" (gray)
|
||||||
|
- Made cluster name clickable in Ideas table - navigates to `/planner/clusters/:id`
|
||||||
|
|
||||||
|
**Writer Module:**
|
||||||
|
- Fixed duplicate tags/categories display in ContentView template
|
||||||
|
- Removed redundant tags/categories section that appeared below the main metadata
|
||||||
|
- Tags and categories now display only once in the Topic section
|
||||||
|
|
||||||
|
**Progress Modals:**
|
||||||
|
- Fixed placeholder "X" text in image prompt progress modals:
|
||||||
|
- Changed "Mapping Content for X Image Prompts" → "Mapping content for image prompts"
|
||||||
|
- Changed "Writing X In‑article Image Prompts" → "Writing In‑article Image Prompts"
|
||||||
|
- Changed "Featured Image and X In‑article..." → "Image prompts ready for generation"
|
||||||
|
- All step labels now show actual counts when available, clean fallbacks otherwise
|
||||||
|
|
||||||
|
### Files Changed
|
||||||
|
- `frontend/src/App.tsx` - Removed KeywordOpportunities import/route
|
||||||
|
- `frontend/src/config/pages/clusters.config.tsx` - Ideas count badge styling
|
||||||
|
- `frontend/src/config/pages/ideas.config.tsx` - Clickable cluster link
|
||||||
|
- `frontend/src/templates/ContentViewTemplate.tsx` - Removed duplicate tags/categories
|
||||||
|
- `frontend/src/components/common/ProgressModal.tsx` - Fixed placeholder texts
|
||||||
|
- `docs/30-FRONTEND/PAGES.md` - Removed KeywordOpportunities references, updated version
|
||||||
|
- `docs/20-API/ENDPOINTS.md` - Added Content Settings API documentation
|
||||||
|
|
||||||
|
### Files Deleted
|
||||||
|
- `frontend/src/pages/Planner/KeywordOpportunities.tsx`
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## v1.1.4 - December 27, 2025
|
||||||
|
|
||||||
|
### Section 2 SETUP Modules Implementation
|
||||||
|
|
||||||
|
**Add Keywords Page (`/setup/add-keywords`):**
|
||||||
|
- Added "Not Yet Added Only" filter toggle to show only keywords not in workflow
|
||||||
|
- Added keyword count summary: "X keywords in your workflow • Y available to add"
|
||||||
|
- Added "Next: Plan Your Content →" CTA button (appears after adding keywords)
|
||||||
|
- Added "Keyword Research coming soon!" teaser text
|
||||||
|
- Sector requirement tooltip already existed - no changes needed
|
||||||
|
|
||||||
|
**Content Settings Page (`/account/content-settings`):**
|
||||||
|
- **NEW Backend API**: Created `/v1/system/settings/content/<pk>/` endpoints
|
||||||
|
- `GET /v1/system/settings/content/content_generation/` - Retrieve settings
|
||||||
|
- `POST /v1/system/settings/content/content_generation/save/` - Save settings
|
||||||
|
- `GET /v1/system/settings/content/publishing/` - Retrieve settings
|
||||||
|
- `POST /v1/system/settings/content/publishing/save/` - Save settings
|
||||||
|
- Content Generation tab now persists: append_to_prompt, default_tone, default_length
|
||||||
|
- Publishing tab now persists: auto_publish_enabled, auto_sync_enabled
|
||||||
|
- Fixed false "saved" confirmation - now actually saves to backend
|
||||||
|
|
||||||
|
**Sites Module:**
|
||||||
|
- **NEW Component**: Created `SiteSetupChecklist` component showing setup progress
|
||||||
|
- Displays checklist: Site created, Industry/Sectors, WordPress integration, Keywords
|
||||||
|
- Progress bar with percentage
|
||||||
|
- "Complete Setup" button linking to first incomplete item
|
||||||
|
- "Ready to create content!" message when all complete
|
||||||
|
- Updated Site Dashboard to use SiteSetupChecklist instead of mock stats
|
||||||
|
- Removed mock statistics that showed all zeros
|
||||||
|
|
||||||
|
**Cleanup:**
|
||||||
|
- Deleted `pages/Sites/Manage.tsx` (redundant duplicate of List.tsx)
|
||||||
|
- Removed empty `pages/Sites/Builder/` folder structure
|
||||||
|
- Removed `/sites/manage` route from App.tsx
|
||||||
|
- Removed SiteManage lazy import from App.tsx
|
||||||
|
|
||||||
|
### Files Changed
|
||||||
|
- `frontend/src/pages/Setup/IndustriesSectorsKeywords.tsx`
|
||||||
|
- `frontend/src/pages/account/ContentSettingsPage.tsx`
|
||||||
|
- `frontend/src/pages/Sites/Dashboard.tsx`
|
||||||
|
- `frontend/src/App.tsx`
|
||||||
|
- `backend/igny8_core/modules/system/settings_views.py`
|
||||||
|
- `backend/igny8_core/modules/system/urls.py`
|
||||||
|
|
||||||
|
### Files Created
|
||||||
|
- `frontend/src/components/sites/SiteSetupChecklist.tsx`
|
||||||
|
|
||||||
|
### Files Deleted
|
||||||
|
- `frontend/src/pages/Sites/Manage.tsx`
|
||||||
|
- `frontend/src/pages/Sites/Builder/` (empty directory)
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
## v1.1.3 - December 27, 2025
|
## v1.1.3 - December 27, 2025
|
||||||
|
|
||||||
### Changed
|
### Changed
|
||||||
|
|||||||
@@ -135,6 +135,8 @@ All endpoints require authentication unless noted.
|
|||||||
| GET | `/settings/integrations/image_generation/` | Get image settings | Current config |
|
| GET | `/settings/integrations/image_generation/` | Get image settings | Current config |
|
||||||
| PUT | `/settings/integrations/image_generation/` | Save image settings | Update config |
|
| PUT | `/settings/integrations/image_generation/` | Save image settings | Update config |
|
||||||
| POST | `/settings/integrations/test/` | Test connection | Verify API keys |
|
| POST | `/settings/integrations/test/` | Test connection | Verify API keys |
|
||||||
|
| GET | `/settings/content/{key}/` | `ContentSettingsViewSet.retrieve` | Get content settings |
|
||||||
|
| POST | `/settings/content/{key}/save/` | `ContentSettingsViewSet.save_settings` | Save content settings |
|
||||||
| GET | `/prompts/` | List prompts | All prompts |
|
| GET | `/prompts/` | List prompts | All prompts |
|
||||||
| GET | `/prompts/{type}/` | Get prompt | Specific prompt |
|
| GET | `/prompts/{type}/` | Get prompt | Specific prompt |
|
||||||
| PUT | `/prompts/{type}/` | Save prompt | Update prompt |
|
| PUT | `/prompts/{type}/` | Save prompt | Update prompt |
|
||||||
@@ -143,6 +145,10 @@ All endpoints require authentication unless noted.
|
|||||||
| PUT | `/modules/` | Save modules | Update enabled |
|
| PUT | `/modules/` | Save modules | Update enabled |
|
||||||
| GET | `/health/` | Health check | System status |
|
| GET | `/health/` | Health check | System status |
|
||||||
|
|
||||||
|
**Content Settings Keys:**
|
||||||
|
- `content_generation` - AI writing settings (default_article_length, default_tone, include_faq, enable_internal_linking, etc.)
|
||||||
|
- `publishing` - Publishing defaults (default_publish_status, auto_schedule, schedule_frequency, schedule_times)
|
||||||
|
|
||||||
---
|
---
|
||||||
|
|
||||||
## Automation Endpoints (`/api/v1/automation/`)
|
## Automation Endpoints (`/api/v1/automation/`)
|
||||||
|
|||||||
@@ -1,7 +1,7 @@
|
|||||||
# Frontend Pages & Routes
|
# Frontend Pages & Routes
|
||||||
|
|
||||||
**Last Verified:** December 25, 2025
|
**Last Verified:** January 2, 2025
|
||||||
**Version:** 1.1.0
|
**Version:** 1.1.4
|
||||||
**Framework:** React 19 + TypeScript + React Router 6 + Vite
|
**Framework:** React 19 + TypeScript + React Router 6 + Vite
|
||||||
|
|
||||||
---
|
---
|
||||||
@@ -41,7 +41,14 @@ Routes defined in `/frontend/src/App.tsx`:
|
|||||||
|
|
||||||
| Route | File | Description |
|
| Route | File | Description |
|
||||||
|-------|------|-------------|
|
|-------|------|-------------|
|
||||||
| `/setup/add-keywords` | `Setup/AddKeywords.tsx` | Browse/add seed keywords from global database |
|
| `/setup/add-keywords` | `Setup/IndustriesSectorsKeywords.tsx` | Browse/add seed keywords from global database |
|
||||||
|
|
||||||
|
**Features:**
|
||||||
|
- Industry → Sector → Keywords browse hierarchy
|
||||||
|
- "Show not-added only" filter toggle
|
||||||
|
- Real-time keyword count summary (added/total)
|
||||||
|
- "Next: Plan Your Content" CTA button
|
||||||
|
- "Keyword Research" coming soon teaser
|
||||||
|
|
||||||
### Content Settings
|
### Content Settings
|
||||||
|
|
||||||
@@ -49,14 +56,18 @@ Routes defined in `/frontend/src/App.tsx`:
|
|||||||
|-------|------|-------------|
|
|-------|------|-------------|
|
||||||
| `/account/content-settings` | `account/ContentSettingsPage.tsx` | 3 tabs: Content Generation, Publishing, Image Settings |
|
| `/account/content-settings` | `account/ContentSettingsPage.tsx` | 3 tabs: Content Generation, Publishing, Image Settings |
|
||||||
|
|
||||||
|
**API Endpoints:** Settings persisted via `/api/v1/system/settings/content/{key}/`
|
||||||
|
|
||||||
### Sites
|
### Sites
|
||||||
|
|
||||||
| Route | File | Description |
|
| Route | File | Description |
|
||||||
|-------|------|-------------|
|
|-------|------|-------------|
|
||||||
| `/sites` | `Sites/List.tsx` | Site listing with filters |
|
| `/sites` | `Sites/List.tsx` | Site listing with setup checklist per site |
|
||||||
| `/sites/:id/dashboard` | `Sites/SiteDashboard.tsx` | Individual site overview |
|
| `/sites/:id/dashboard` | `Sites/Dashboard.tsx` | Site overview with setup checklist |
|
||||||
| `/sites/:id/settings` | `Sites/SiteSettings.tsx` | Site settings (General, Integrations, Content Types) |
|
| `/sites/:id/settings` | `Sites/Settings.tsx` | Site settings (General, Integrations, Content Types) |
|
||||||
| `/sites/:id/content` | `Sites/SiteContent.tsx` | Site content management |
|
|
||||||
|
**Components:**
|
||||||
|
- `SiteSetupChecklist` - Shows setup progress (site created, industry/sectors, WordPress, keywords)
|
||||||
|
|
||||||
### Thinker (Admin Only)
|
### Thinker (Admin Only)
|
||||||
|
|
||||||
@@ -80,7 +91,6 @@ Routes defined in `/frontend/src/App.tsx`:
|
|||||||
| `/planner/clusters` | `Planner/Clusters.tsx` | Cluster listing, AI clustering | Clusters |
|
| `/planner/clusters` | `Planner/Clusters.tsx` | Cluster listing, AI clustering | Clusters |
|
||||||
| `/planner/clusters/:id` | `Planner/ClusterView.tsx` | Individual cluster view | - |
|
| `/planner/clusters/:id` | `Planner/ClusterView.tsx` | Individual cluster view | - |
|
||||||
| `/planner/ideas` | `Planner/Ideas.tsx` | Content ideas, queue to writer | Ideas |
|
| `/planner/ideas` | `Planner/Ideas.tsx` | Content ideas, queue to writer | Ideas |
|
||||||
| `/planner/keyword-opportunities` | `Planner/KeywordOpportunities.tsx` | Seed keyword discovery (hidden) | - |
|
|
||||||
|
|
||||||
### Writer
|
### Writer
|
||||||
|
|
||||||
@@ -228,18 +238,15 @@ frontend/src/pages/
|
|||||||
│ ├── Keywords.tsx
|
│ ├── Keywords.tsx
|
||||||
│ ├── Clusters.tsx
|
│ ├── Clusters.tsx
|
||||||
│ ├── ClusterView.tsx
|
│ ├── ClusterView.tsx
|
||||||
│ ├── Ideas.tsx
|
│ └── Ideas.tsx
|
||||||
│ └── KeywordOpportunities.tsx # Not in nav
|
|
||||||
├── Settings/
|
├── Settings/
|
||||||
│ └── IntegrationPage.tsx # AI Models (admin)
|
│ └── IntegrationPage.tsx # AI Models (admin)
|
||||||
├── Setup/
|
├── Setup/
|
||||||
│ └── AddKeywords.tsx
|
│ └── IndustriesSectorsKeywords.tsx # Add Keywords page
|
||||||
├── Sites/
|
├── Sites/
|
||||||
│ ├── List.tsx
|
│ ├── List.tsx # Site listing
|
||||||
│ ├── SiteDashboard.tsx
|
│ ├── Dashboard.tsx # Site overview + checklist
|
||||||
│ ├── SiteSettings.tsx
|
│ └── Settings.tsx # Site configuration
|
||||||
│ ├── SiteContent.tsx
|
|
||||||
│ └── Manage.tsx # Possibly redundant
|
|
||||||
├── Thinker/
|
├── Thinker/
|
||||||
│ ├── Prompts.tsx
|
│ ├── Prompts.tsx
|
||||||
│ ├── AuthorProfiles.tsx
|
│ ├── AuthorProfiles.tsx
|
||||||
@@ -303,10 +310,9 @@ Dashboard
|
|||||||
|
|
||||||
## Known Issues (from Audit)
|
## Known Issues (from Audit)
|
||||||
|
|
||||||
1. **KeywordOpportunities** not accessible from navigation
|
1. **Linker/Optimizer Dashboards** exist but not exposed in navigation
|
||||||
2. **Linker/Optimizer Dashboards** exist but not exposed
|
2. **Help sub-pages** are placeholders
|
||||||
3. **Help sub-pages** are placeholders
|
3. **ContentView** is read-only (no editing capability)
|
||||||
4. **ContentView** is read-only (no editing capability)
|
4. Legacy redirects may cause confusion
|
||||||
5. Legacy redirects may cause confusion
|
|
||||||
|
|
||||||
See `/PRE-LAUNCH-AUDIT.md` for complete issue list.
|
See `/PRE-LAUNCH-AUDIT.md` for complete issue list.
|
||||||
|
|||||||
@@ -25,7 +25,6 @@ const Keywords = lazy(() => import("./pages/Planner/Keywords"));
|
|||||||
const Clusters = lazy(() => import("./pages/Planner/Clusters"));
|
const Clusters = lazy(() => import("./pages/Planner/Clusters"));
|
||||||
const ClusterDetail = lazy(() => import("./pages/Planner/ClusterDetail"));
|
const ClusterDetail = lazy(() => import("./pages/Planner/ClusterDetail"));
|
||||||
const Ideas = lazy(() => import("./pages/Planner/Ideas"));
|
const Ideas = lazy(() => import("./pages/Planner/Ideas"));
|
||||||
const KeywordOpportunities = lazy(() => import("./pages/Planner/KeywordOpportunities"));
|
|
||||||
|
|
||||||
// Writer Module - Lazy loaded
|
// Writer Module - Lazy loaded
|
||||||
const WriterDashboard = lazy(() => import("./pages/Writer/Dashboard"));
|
const WriterDashboard = lazy(() => import("./pages/Writer/Dashboard"));
|
||||||
@@ -203,7 +202,6 @@ export default function App() {
|
|||||||
|
|
||||||
{/* Reference Data */}
|
{/* Reference Data */}
|
||||||
<Route path="/reference/seed-keywords" element={<SeedKeywords />} />
|
<Route path="/reference/seed-keywords" element={<SeedKeywords />} />
|
||||||
<Route path="/planner/keyword-opportunities" element={<KeywordOpportunities />} />
|
|
||||||
<Route path="/reference/industries" element={<ReferenceIndustries />} />
|
<Route path="/reference/industries" element={<ReferenceIndustries />} />
|
||||||
|
|
||||||
{/* Setup Pages */}
|
{/* Setup Pages */}
|
||||||
|
|||||||
@@ -127,7 +127,7 @@ const getSuccessMessage = (functionId?: string, title?: string, stepLogs?: any[]
|
|||||||
}
|
}
|
||||||
|
|
||||||
// Default message
|
// Default message
|
||||||
return 'Featured Image and X In‑article Image Prompts ready for image generation';
|
return 'Image prompts ready for generation';
|
||||||
}
|
}
|
||||||
return 'Task completed successfully.';
|
return 'Task completed successfully.';
|
||||||
};
|
};
|
||||||
@@ -180,9 +180,9 @@ const getStepsForFunction = (functionId?: string, title?: string): Array<{phase:
|
|||||||
// Image prompt generation
|
// Image prompt generation
|
||||||
return [
|
return [
|
||||||
{ phase: 'INIT', label: 'Checking content and image slots' },
|
{ phase: 'INIT', label: 'Checking content and image slots' },
|
||||||
{ phase: 'PREP', label: 'Mapping Content for X Image Prompts' },
|
{ phase: 'PREP', label: 'Mapping content for image prompts' },
|
||||||
{ phase: 'AI_CALL', label: 'Writing Featured Image Prompts' },
|
{ phase: 'AI_CALL', label: 'Writing Featured Image Prompts' },
|
||||||
{ phase: 'PARSE', label: 'Writing X In‑article Image Prompts' },
|
{ phase: 'PARSE', label: 'Writing In‑article Image Prompts' },
|
||||||
{ phase: 'SAVE', label: 'Assigning Prompts to Dedicated Slots' },
|
{ phase: 'SAVE', label: 'Assigning Prompts to Dedicated Slots' },
|
||||||
];
|
];
|
||||||
}
|
}
|
||||||
@@ -457,7 +457,7 @@ export default function ProgressModal({
|
|||||||
return `Mapping Content for ${match[1]} Image Prompts`;
|
return `Mapping Content for ${match[1]} Image Prompts`;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
return 'Mapping Content for X Image Prompts';
|
return 'Mapping content for image prompts';
|
||||||
} else if (stepPhase === 'AI_CALL') {
|
} else if (stepPhase === 'AI_CALL') {
|
||||||
// For AI_CALL: Show "Writing Featured Image Prompts"
|
// For AI_CALL: Show "Writing Featured Image Prompts"
|
||||||
return 'Writing Featured Image Prompts';
|
return 'Writing Featured Image Prompts';
|
||||||
@@ -475,7 +475,7 @@ export default function ProgressModal({
|
|||||||
return `Writing ${match[1]} In‑article Image Prompts`;
|
return `Writing ${match[1]} In‑article Image Prompts`;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
return 'Writing X In‑article Image Prompts';
|
return 'Writing In‑article Image Prompts';
|
||||||
} else if (stepPhase === 'SAVE') {
|
} else if (stepPhase === 'SAVE') {
|
||||||
// For SAVE: Extract prompt count from message
|
// For SAVE: Extract prompt count from message
|
||||||
const promptCount = extractCount(/(\d+)\s+Prompts/i) || extractCount(/(\d+)\s+prompt/i);
|
const promptCount = extractCount(/(\d+)\s+Prompts/i) || extractCount(/(\d+)\s+prompt/i);
|
||||||
|
|||||||
@@ -137,7 +137,17 @@ export const createClustersPageConfig = (
|
|||||||
sortable: false, // Backend doesn't support sorting by ideas_count
|
sortable: false, // Backend doesn't support sorting by ideas_count
|
||||||
sortField: 'ideas_count',
|
sortField: 'ideas_count',
|
||||||
width: '120px',
|
width: '120px',
|
||||||
render: (value: number) => value.toLocaleString(),
|
render: (value: number) => (
|
||||||
|
<Badge
|
||||||
|
color={value > 0 ? 'success' : 'light'}
|
||||||
|
size="xs"
|
||||||
|
variant="soft"
|
||||||
|
>
|
||||||
|
<span className="text-[11px] font-normal">
|
||||||
|
{value > 0 ? `${value.toLocaleString()} ideas` : 'No ideas'}
|
||||||
|
</span>
|
||||||
|
</Badge>
|
||||||
|
),
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
key: 'volume',
|
key: 'volume',
|
||||||
|
|||||||
@@ -4,6 +4,7 @@
|
|||||||
*/
|
*/
|
||||||
|
|
||||||
import React from 'react';
|
import React from 'react';
|
||||||
|
import { Link } from 'react-router-dom';
|
||||||
import {
|
import {
|
||||||
titleColumn,
|
titleColumn,
|
||||||
sectorColumn,
|
sectorColumn,
|
||||||
@@ -164,7 +165,19 @@ export const createIdeasPageConfig = (
|
|||||||
sortable: false, // Backend doesn't support sorting by keyword_cluster_id
|
sortable: false, // Backend doesn't support sorting by keyword_cluster_id
|
||||||
sortField: 'keyword_cluster_id',
|
sortField: 'keyword_cluster_id',
|
||||||
width: '200px',
|
width: '200px',
|
||||||
render: (_value: string, row: ContentIdea) => row.keyword_cluster_name || '-',
|
render: (_value: string, row: ContentIdea) => {
|
||||||
|
if (row.keyword_cluster_id && row.keyword_cluster_name) {
|
||||||
|
return (
|
||||||
|
<Link
|
||||||
|
to={`/planner/clusters/${row.keyword_cluster_id}`}
|
||||||
|
className="text-brand-600 hover:text-brand-700 dark:text-brand-400 dark:hover:text-brand-300 hover:underline"
|
||||||
|
>
|
||||||
|
{row.keyword_cluster_name}
|
||||||
|
</Link>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
return '-';
|
||||||
|
},
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
...statusColumn,
|
...statusColumn,
|
||||||
|
|||||||
@@ -1,680 +0,0 @@
|
|||||||
/**
|
|
||||||
* Keyword Opportunities Page
|
|
||||||
* Shows available SeedKeywords for the active site/sectors
|
|
||||||
* Allows users to add keywords to their workflow
|
|
||||||
*/
|
|
||||||
|
|
||||||
import { useState, useEffect, useRef, useMemo, useCallback } from 'react';
|
|
||||||
import TablePageTemplate from '../../templates/TablePageTemplate';
|
|
||||||
import {
|
|
||||||
fetchSeedKeywords,
|
|
||||||
SeedKeyword,
|
|
||||||
SeedKeywordResponse,
|
|
||||||
addSeedKeywordsToWorkflow,
|
|
||||||
} from '../../services/api';
|
|
||||||
import { useSiteStore } from '../../store/siteStore';
|
|
||||||
import { useSectorStore } from '../../store/sectorStore';
|
|
||||||
import { usePageSizeStore } from '../../store/pageSizeStore';
|
|
||||||
import { useToast } from '../../components/ui/toast/ToastContainer';
|
|
||||||
import { getDifficultyLabelFromNumber, getDifficultyRange, getDifficultyNumber } from '../../utils/difficulty';
|
|
||||||
import Badge from '../../components/ui/badge/Badge';
|
|
||||||
import { formatRelativeDate } from '../../utils/date';
|
|
||||||
import { BoltIcon, PlusIcon } from '../../icons';
|
|
||||||
import PageHeader from '../../components/common/PageHeader';
|
|
||||||
|
|
||||||
export default function KeywordOpportunities() {
|
|
||||||
const toast = useToast();
|
|
||||||
const { activeSite } = useSiteStore();
|
|
||||||
const { activeSector, loadSectorsForSite } = useSectorStore();
|
|
||||||
const { pageSize } = usePageSizeStore();
|
|
||||||
|
|
||||||
// Data state
|
|
||||||
const [seedKeywords, setSeedKeywords] = useState<(SeedKeyword & { isAdded?: boolean })[]>([]);
|
|
||||||
const [loading, setLoading] = useState(true);
|
|
||||||
const [showContent, setShowContent] = useState(false);
|
|
||||||
const [selectedIds, setSelectedIds] = useState<string[]>([]);
|
|
||||||
// Track recently added keywords to preserve their state during reload
|
|
||||||
const recentlyAddedRef = useRef<Set<number>>(new Set());
|
|
||||||
|
|
||||||
// Pagination state
|
|
||||||
const [currentPage, setCurrentPage] = useState(1);
|
|
||||||
const [totalPages, setTotalPages] = useState(1);
|
|
||||||
const [totalCount, setTotalCount] = useState(0);
|
|
||||||
|
|
||||||
// Sorting state
|
|
||||||
const [sortBy, setSortBy] = useState<string>('keyword');
|
|
||||||
const [sortDirection, setSortDirection] = useState<'asc' | 'desc'>('asc');
|
|
||||||
|
|
||||||
// Filter state
|
|
||||||
const [searchTerm, setSearchTerm] = useState('');
|
|
||||||
const [countryFilter, setCountryFilter] = useState('');
|
|
||||||
const [difficultyFilter, setDifficultyFilter] = useState('');
|
|
||||||
const [volumeMin, setVolumeMin] = useState<number | ''>('');
|
|
||||||
const [volumeMax, setVolumeMax] = useState<number | ''>('');
|
|
||||||
|
|
||||||
// Load sectors for active site
|
|
||||||
useEffect(() => {
|
|
||||||
if (activeSite?.id) {
|
|
||||||
loadSectorsForSite(activeSite.id);
|
|
||||||
}
|
|
||||||
// eslint-disable-next-line react-hooks/exhaustive-deps
|
|
||||||
}, [activeSite?.id]); // loadSectorsForSite is stable from Zustand store, no need to include it
|
|
||||||
|
|
||||||
// Load seed keywords
|
|
||||||
const loadSeedKeywords = useCallback(async () => {
|
|
||||||
if (!activeSite || !activeSite.industry) {
|
|
||||||
setSeedKeywords([]);
|
|
||||||
setTotalCount(0);
|
|
||||||
setTotalPages(1);
|
|
||||||
setLoading(false);
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
setLoading(true);
|
|
||||||
setShowContent(false);
|
|
||||||
|
|
||||||
try {
|
|
||||||
// Get already-attached keywords across ALL sectors for this site
|
|
||||||
let attachedSeedKeywordIds = new Set<number>();
|
|
||||||
try {
|
|
||||||
const { fetchKeywords, fetchSiteSectors } = await import('../../services/api');
|
|
||||||
// Get all sectors for the site
|
|
||||||
const sectors = await fetchSiteSectors(activeSite.id);
|
|
||||||
|
|
||||||
// Check keywords in all sectors
|
|
||||||
for (const sector of sectors) {
|
|
||||||
try {
|
|
||||||
const keywordsData = await fetchKeywords({
|
|
||||||
site_id: activeSite.id,
|
|
||||||
sector_id: sector.id,
|
|
||||||
page_size: 1000, // Get all to check which are attached
|
|
||||||
});
|
|
||||||
(keywordsData.results || []).forEach((k: any) => {
|
|
||||||
// seed_keyword_id is write_only in serializer, so use seed_keyword.id instead
|
|
||||||
const seedKeywordId = k.seed_keyword_id || (k.seed_keyword && k.seed_keyword.id);
|
|
||||||
if (seedKeywordId) {
|
|
||||||
attachedSeedKeywordIds.add(Number(seedKeywordId));
|
|
||||||
}
|
|
||||||
});
|
|
||||||
} catch (err) {
|
|
||||||
// If keywords fetch fails for a sector, continue with others
|
|
||||||
console.warn(`Could not fetch attached keywords for sector ${sector.id}:`, err);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
} catch (err) {
|
|
||||||
// If sectors fetch fails, continue without filtering
|
|
||||||
console.warn('Could not fetch sectors or attached keywords:', err);
|
|
||||||
}
|
|
||||||
|
|
||||||
// Build filters - fetch ALL results by paginating through all pages
|
|
||||||
const baseFilters: any = {
|
|
||||||
industry: activeSite.industry,
|
|
||||||
page_size: 1000, // Use reasonable page size (API might have max limit)
|
|
||||||
};
|
|
||||||
|
|
||||||
// Add sector filter if active sector is selected
|
|
||||||
// IMPORTANT: Filter by industry_sector (IndustrySector ID) which is what SeedKeyword.sector references
|
|
||||||
if (activeSector && activeSector.industry_sector) {
|
|
||||||
baseFilters.sector = activeSector.industry_sector;
|
|
||||||
}
|
|
||||||
|
|
||||||
if (searchTerm) baseFilters.search = searchTerm;
|
|
||||||
if (countryFilter) baseFilters.country = countryFilter;
|
|
||||||
|
|
||||||
// Fetch ALL pages to get complete dataset
|
|
||||||
let allResults: SeedKeyword[] = [];
|
|
||||||
let currentPageNum = 1;
|
|
||||||
let hasMore = true;
|
|
||||||
|
|
||||||
while (hasMore) {
|
|
||||||
const filters = { ...baseFilters, page: currentPageNum };
|
|
||||||
const data: SeedKeywordResponse = await fetchSeedKeywords(filters);
|
|
||||||
|
|
||||||
if (data.results && data.results.length > 0) {
|
|
||||||
allResults = [...allResults, ...data.results];
|
|
||||||
}
|
|
||||||
|
|
||||||
// Check if there are more pages
|
|
||||||
hasMore = data.next !== null && data.next !== undefined;
|
|
||||||
currentPageNum++;
|
|
||||||
|
|
||||||
// Safety limit to prevent infinite loops
|
|
||||||
if (currentPageNum > 100) {
|
|
||||||
console.warn('Reached maximum page limit (100) while fetching seed keywords');
|
|
||||||
break;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// Mark already-attached keywords instead of filtering them out
|
|
||||||
// Also check recentlyAddedRef to preserve state for keywords just added
|
|
||||||
let filteredResults = allResults.map(sk => {
|
|
||||||
const isAdded = attachedSeedKeywordIds.has(Number(sk.id)) || recentlyAddedRef.current.has(Number(sk.id));
|
|
||||||
return {
|
|
||||||
...sk,
|
|
||||||
isAdded: Boolean(isAdded) // Explicitly convert to boolean true/false
|
|
||||||
};
|
|
||||||
});
|
|
||||||
|
|
||||||
if (difficultyFilter) {
|
|
||||||
const difficultyNum = parseInt(difficultyFilter);
|
|
||||||
const label = getDifficultyLabelFromNumber(difficultyNum);
|
|
||||||
if (label !== null) {
|
|
||||||
const range = getDifficultyRange(label);
|
|
||||||
if (range) {
|
|
||||||
filteredResults = filteredResults.filter(
|
|
||||||
sk => sk.difficulty >= range.min && sk.difficulty <= range.max
|
|
||||||
);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
if (volumeMin !== '' && volumeMin !== null && volumeMin !== undefined) {
|
|
||||||
filteredResults = filteredResults.filter(sk => sk.volume >= Number(volumeMin));
|
|
||||||
}
|
|
||||||
if (volumeMax !== '' && volumeMax !== null && volumeMax !== undefined) {
|
|
||||||
filteredResults = filteredResults.filter(sk => sk.volume <= Number(volumeMax));
|
|
||||||
}
|
|
||||||
|
|
||||||
// Apply client-side sorting
|
|
||||||
if (sortBy) {
|
|
||||||
filteredResults.sort((a, b) => {
|
|
||||||
let aVal: any;
|
|
||||||
let bVal: any;
|
|
||||||
|
|
||||||
if (sortBy === 'keyword') {
|
|
||||||
aVal = a.keyword.toLowerCase();
|
|
||||||
bVal = b.keyword.toLowerCase();
|
|
||||||
} else if (sortBy === 'volume') {
|
|
||||||
aVal = a.volume;
|
|
||||||
bVal = b.volume;
|
|
||||||
} else if (sortBy === 'difficulty') {
|
|
||||||
aVal = a.difficulty;
|
|
||||||
bVal = b.difficulty;
|
|
||||||
} else if (sortBy === 'intent') {
|
|
||||||
aVal = a.intent.toLowerCase();
|
|
||||||
bVal = b.intent.toLowerCase();
|
|
||||||
} else {
|
|
||||||
return 0;
|
|
||||||
}
|
|
||||||
|
|
||||||
if (aVal < bVal) return sortDirection === 'asc' ? -1 : 1;
|
|
||||||
if (aVal > bVal) return sortDirection === 'asc' ? 1 : -1;
|
|
||||||
return 0;
|
|
||||||
});
|
|
||||||
}
|
|
||||||
|
|
||||||
// Calculate total count and pages from filtered results
|
|
||||||
const totalFiltered = filteredResults.length;
|
|
||||||
const pageSizeNum = pageSize || 10;
|
|
||||||
|
|
||||||
// Apply client-side pagination
|
|
||||||
const startIndex = (currentPage - 1) * pageSizeNum;
|
|
||||||
const endIndex = startIndex + pageSizeNum;
|
|
||||||
const paginatedResults = filteredResults.slice(startIndex, endIndex);
|
|
||||||
|
|
||||||
|
|
||||||
setSeedKeywords(paginatedResults);
|
|
||||||
setTotalCount(totalFiltered);
|
|
||||||
setTotalPages(Math.ceil(totalFiltered / pageSizeNum));
|
|
||||||
|
|
||||||
setShowContent(true);
|
|
||||||
} catch (error: any) {
|
|
||||||
console.error('Error loading seed keywords:', error);
|
|
||||||
toast.error(`Failed to load keyword opportunities: ${error.message}`);
|
|
||||||
setSeedKeywords([]);
|
|
||||||
setTotalCount(0);
|
|
||||||
setTotalPages(1);
|
|
||||||
} finally {
|
|
||||||
setLoading(false);
|
|
||||||
}
|
|
||||||
}, [activeSite, activeSector, currentPage, pageSize, searchTerm, countryFilter, difficultyFilter, volumeMin, volumeMax, sortBy, sortDirection]);
|
|
||||||
|
|
||||||
// Load data on mount and when filters change (excluding search - handled separately)
|
|
||||||
useEffect(() => {
|
|
||||||
loadSeedKeywords();
|
|
||||||
}, [loadSeedKeywords]);
|
|
||||||
|
|
||||||
// Debounced search - reset to page 1 when search term changes
|
|
||||||
useEffect(() => {
|
|
||||||
const timer = setTimeout(() => {
|
|
||||||
setCurrentPage(1);
|
|
||||||
}, 500);
|
|
||||||
|
|
||||||
return () => clearTimeout(timer);
|
|
||||||
}, [searchTerm]); // Only depend on searchTerm
|
|
||||||
|
|
||||||
// Handle pageSize changes - reload data when pageSize changes
|
|
||||||
// Note: loadSeedKeywords will be recreated when pageSize changes (it's in its dependencies)
|
|
||||||
// The effect that depends on loadSeedKeywords will handle the reload
|
|
||||||
// We just need to reset to page 1
|
|
||||||
useEffect(() => {
|
|
||||||
setCurrentPage(1);
|
|
||||||
}, [pageSize]); // Only depend on pageSize
|
|
||||||
|
|
||||||
// Handle sorting
|
|
||||||
const handleSort = (field: string, direction: 'asc' | 'desc') => {
|
|
||||||
setSortBy(field || 'keyword');
|
|
||||||
setSortDirection(direction);
|
|
||||||
setCurrentPage(1);
|
|
||||||
};
|
|
||||||
|
|
||||||
// Handle adding keywords to workflow
|
|
||||||
const handleAddToWorkflow = useCallback(async (seedKeywordIds: number[]) => {
|
|
||||||
if (!activeSite) {
|
|
||||||
toast.error('Please select an active site first');
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
// Get sector to use - use activeSector if available, otherwise get first available sector
|
|
||||||
let sectorToUse = activeSector;
|
|
||||||
if (!sectorToUse) {
|
|
||||||
try {
|
|
||||||
const { fetchSiteSectors } = await import('../../services/api');
|
|
||||||
const sectors = await fetchSiteSectors(activeSite.id);
|
|
||||||
if (sectors.length === 0) {
|
|
||||||
toast.error('No sectors available for this site. Please create a sector first.');
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
sectorToUse = {
|
|
||||||
id: sectors[0].id,
|
|
||||||
name: sectors[0].name,
|
|
||||||
slug: sectors[0].slug,
|
|
||||||
site_id: activeSite.id,
|
|
||||||
is_active: sectors[0].is_active !== false,
|
|
||||||
industry_sector: sectors[0].industry_sector || null,
|
|
||||||
};
|
|
||||||
} catch (error: any) {
|
|
||||||
toast.error(`Failed to get sectors: ${error.message}`);
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
try {
|
|
||||||
const result = await addSeedKeywordsToWorkflow(
|
|
||||||
seedKeywordIds,
|
|
||||||
activeSite.id,
|
|
||||||
sectorToUse.id
|
|
||||||
);
|
|
||||||
|
|
||||||
if (result.success) {
|
|
||||||
// Show success message with created count
|
|
||||||
if (result.created > 0) {
|
|
||||||
toast.success(`Successfully added ${result.created} keyword(s) to workflow`);
|
|
||||||
}
|
|
||||||
|
|
||||||
// Show skipped count if any
|
|
||||||
if (result.skipped > 0) {
|
|
||||||
toast.warning(`${result.skipped} keyword(s) were skipped (already exist or validation failed)`);
|
|
||||||
}
|
|
||||||
|
|
||||||
// Show detailed errors if any
|
|
||||||
if (result.errors && result.errors.length > 0) {
|
|
||||||
result.errors.forEach((error: string) => {
|
|
||||||
toast.error(error, { duration: 8000 });
|
|
||||||
});
|
|
||||||
}
|
|
||||||
|
|
||||||
// Only track and mark as added if actually created
|
|
||||||
if (result.created > 0) {
|
|
||||||
// Track these as recently added to preserve state during reload
|
|
||||||
seedKeywordIds.forEach(id => {
|
|
||||||
recentlyAddedRef.current.add(id);
|
|
||||||
});
|
|
||||||
|
|
||||||
// Immediately update state to mark keywords as added - this gives instant feedback
|
|
||||||
setSeedKeywords(prevKeywords =>
|
|
||||||
prevKeywords.map(kw =>
|
|
||||||
seedKeywordIds.includes(kw.id)
|
|
||||||
? { ...kw, isAdded: true }
|
|
||||||
: kw
|
|
||||||
)
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
// Clear selection
|
|
||||||
setSelectedIds([]);
|
|
||||||
|
|
||||||
// Don't reload immediately - the state is already updated
|
|
||||||
// The recentlyAddedRef will ensure they stay marked as added
|
|
||||||
// Only reload if user changes filters/pagination
|
|
||||||
} else {
|
|
||||||
toast.error(`Failed to add keywords: ${result.errors?.join(', ') || 'Unknown error'}`);
|
|
||||||
}
|
|
||||||
} catch (error: any) {
|
|
||||||
toast.error(`Failed to add keywords: ${error.message}`);
|
|
||||||
}
|
|
||||||
}, [activeSite, activeSector, toast]);
|
|
||||||
|
|
||||||
// Handle bulk add selected - filter out already added keywords
|
|
||||||
const handleBulkAddSelected = useCallback(async (ids: string[]) => {
|
|
||||||
if (ids.length === 0) {
|
|
||||||
toast.error('Please select at least one keyword');
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
// Filter out already added keywords
|
|
||||||
const availableIds = ids.filter(id => {
|
|
||||||
const keyword = seedKeywords.find(sk => String(sk.id) === id);
|
|
||||||
return keyword && !keyword.isAdded;
|
|
||||||
});
|
|
||||||
|
|
||||||
if (availableIds.length === 0) {
|
|
||||||
toast.error('All selected keywords are already added to workflow');
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
if (availableIds.length < ids.length) {
|
|
||||||
toast.info(`${ids.length - availableIds.length} keyword(s) were already added and were skipped`);
|
|
||||||
}
|
|
||||||
|
|
||||||
const seedKeywordIds = availableIds.map(id => parseInt(id));
|
|
||||||
await handleAddToWorkflow(seedKeywordIds);
|
|
||||||
}, [handleAddToWorkflow, toast, seedKeywords]);
|
|
||||||
|
|
||||||
// Handle add all - fetch all keywords for site/sectors, not just current page
|
|
||||||
const handleAddAll = useCallback(async () => {
|
|
||||||
if (!activeSite || !activeSite.industry) {
|
|
||||||
toast.error('Please select an active site first');
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
try {
|
|
||||||
// Fetch ALL seed keywords for the site/sectors (no pagination)
|
|
||||||
const filters: any = {
|
|
||||||
industry: activeSite.industry,
|
|
||||||
page_size: 1000, // Large page size to get all
|
|
||||||
};
|
|
||||||
|
|
||||||
if (activeSector?.industry_sector) {
|
|
||||||
filters.sector = activeSector.industry_sector;
|
|
||||||
}
|
|
||||||
|
|
||||||
const data: SeedKeywordResponse = await fetchSeedKeywords(filters);
|
|
||||||
const allSeedKeywords = data.results || [];
|
|
||||||
|
|
||||||
if (allSeedKeywords.length === 0) {
|
|
||||||
toast.error('No keywords available to add');
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
// Get already-added keywords to filter them out
|
|
||||||
const { fetchKeywords, fetchSiteSectors } = await import('../../services/api');
|
|
||||||
const sectors = await fetchSiteSectors(activeSite.id);
|
|
||||||
let attachedSeedKeywordIds = new Set<number>();
|
|
||||||
|
|
||||||
for (const sector of sectors) {
|
|
||||||
try {
|
|
||||||
const keywordsData = await fetchKeywords({
|
|
||||||
site_id: activeSite.id,
|
|
||||||
sector_id: sector.id,
|
|
||||||
page_size: 1000,
|
|
||||||
});
|
|
||||||
(keywordsData.results || []).forEach((k: any) => {
|
|
||||||
// seed_keyword_id is write_only in serializer, so use seed_keyword.id instead
|
|
||||||
const seedKeywordId = k.seed_keyword_id || (k.seed_keyword && k.seed_keyword.id);
|
|
||||||
if (seedKeywordId) {
|
|
||||||
attachedSeedKeywordIds.add(Number(seedKeywordId));
|
|
||||||
}
|
|
||||||
});
|
|
||||||
} catch (err) {
|
|
||||||
console.warn(`Could not fetch attached keywords for sector ${sector.id}:`, err);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// Filter out already added keywords
|
|
||||||
const availableKeywords = allSeedKeywords.filter(sk => !attachedSeedKeywordIds.has(sk.id));
|
|
||||||
|
|
||||||
if (availableKeywords.length === 0) {
|
|
||||||
toast.error('All keywords are already added to workflow');
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
if (availableKeywords.length < allSeedKeywords.length) {
|
|
||||||
toast.info(`${allSeedKeywords.length - availableKeywords.length} keyword(s) were already added and were skipped`);
|
|
||||||
}
|
|
||||||
|
|
||||||
const seedKeywordIds = availableKeywords.map(sk => sk.id);
|
|
||||||
await handleAddToWorkflow(seedKeywordIds);
|
|
||||||
} catch (error: any) {
|
|
||||||
toast.error(`Failed to load all keywords: ${error.message}`);
|
|
||||||
}
|
|
||||||
}, [activeSite, activeSector, handleAddToWorkflow, toast]);
|
|
||||||
|
|
||||||
// Page config
|
|
||||||
const pageConfig = useMemo(() => {
|
|
||||||
const showSectorColumn = !activeSector; // Show when viewing all sectors
|
|
||||||
|
|
||||||
return {
|
|
||||||
columns: [
|
|
||||||
{
|
|
||||||
key: 'keyword',
|
|
||||||
label: 'Keyword',
|
|
||||||
sortable: true,
|
|
||||||
sortField: 'keyword',
|
|
||||||
},
|
|
||||||
...(showSectorColumn ? [{
|
|
||||||
key: 'sector_name',
|
|
||||||
label: 'Sector',
|
|
||||||
sortable: false,
|
|
||||||
render: (_value: string, row: SeedKeyword) => (
|
|
||||||
<Badge color="info" size="sm" variant="light">
|
|
||||||
{row.sector_name || '-'}
|
|
||||||
</Badge>
|
|
||||||
),
|
|
||||||
}] : []),
|
|
||||||
{
|
|
||||||
key: 'volume',
|
|
||||||
label: 'Volume',
|
|
||||||
sortable: true,
|
|
||||||
sortField: 'volume',
|
|
||||||
render: (value: number) => value.toLocaleString(),
|
|
||||||
},
|
|
||||||
{
|
|
||||||
key: 'difficulty',
|
|
||||||
label: 'Difficulty',
|
|
||||||
sortable: true,
|
|
||||||
sortField: 'difficulty',
|
|
||||||
align: 'center' as const,
|
|
||||||
render: (value: number) => {
|
|
||||||
const difficultyNum = getDifficultyNumber(value);
|
|
||||||
const difficultyBadgeVariant = 'light';
|
|
||||||
const difficultyBadgeColor =
|
|
||||||
typeof difficultyNum === 'number' && difficultyNum === 1
|
|
||||||
? 'success'
|
|
||||||
: typeof difficultyNum === 'number' && difficultyNum === 2
|
|
||||||
? 'success'
|
|
||||||
: typeof difficultyNum === 'number' && difficultyNum === 3
|
|
||||||
? 'warning'
|
|
||||||
: typeof difficultyNum === 'number' && difficultyNum === 4
|
|
||||||
? 'error'
|
|
||||||
: typeof difficultyNum === 'number' && difficultyNum === 5
|
|
||||||
? 'error'
|
|
||||||
: 'light';
|
|
||||||
return typeof difficultyNum === 'number' ? (
|
|
||||||
<Badge
|
|
||||||
color={difficultyBadgeColor}
|
|
||||||
variant={difficultyBadgeVariant}
|
|
||||||
size="sm"
|
|
||||||
>
|
|
||||||
{difficultyNum}
|
|
||||||
</Badge>
|
|
||||||
) : (
|
|
||||||
difficultyNum
|
|
||||||
);
|
|
||||||
},
|
|
||||||
},
|
|
||||||
{
|
|
||||||
key: 'country',
|
|
||||||
label: 'Country',
|
|
||||||
sortable: true,
|
|
||||||
sortField: 'country',
|
|
||||||
render: (value: string) => {
|
|
||||||
const countryNames: Record<string, string> = {
|
|
||||||
'US': 'United States',
|
|
||||||
'CA': 'Canada',
|
|
||||||
'GB': 'United Kingdom',
|
|
||||||
'AE': 'United Arab Emirates',
|
|
||||||
'AU': 'Australia',
|
|
||||||
'IN': 'India',
|
|
||||||
'PK': 'Pakistan',
|
|
||||||
};
|
|
||||||
return (
|
|
||||||
<Badge
|
|
||||||
color="info"
|
|
||||||
size="sm"
|
|
||||||
variant="light"
|
|
||||||
>
|
|
||||||
{value || '-'}
|
|
||||||
</Badge>
|
|
||||||
);
|
|
||||||
},
|
|
||||||
},
|
|
||||||
],
|
|
||||||
filters: [
|
|
||||||
{
|
|
||||||
key: 'search',
|
|
||||||
label: 'Search',
|
|
||||||
type: 'text',
|
|
||||||
placeholder: 'Search keywords...',
|
|
||||||
},
|
|
||||||
{
|
|
||||||
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' },
|
|
||||||
],
|
|
||||||
},
|
|
||||||
],
|
|
||||||
bulkActions: !activeSector ? [] : [
|
|
||||||
{
|
|
||||||
key: 'add_selected_to_workflow',
|
|
||||||
label: 'Add Selected Keywords',
|
|
||||||
variant: 'primary' as const,
|
|
||||||
},
|
|
||||||
],
|
|
||||||
};
|
|
||||||
}, [activeSector]);
|
|
||||||
|
|
||||||
return (
|
|
||||||
<>
|
|
||||||
<PageHeader
|
|
||||||
title="Find & Add Keywords to Your Site"
|
|
||||||
badge={{ icon: <BoltIcon />, color: 'orange' }}
|
|
||||||
/>
|
|
||||||
|
|
||||||
{/* Show info banner when no sector is selected */}
|
|
||||||
{!activeSector && activeSite && (
|
|
||||||
<div className="mx-6 mt-6 mb-4">
|
|
||||||
<div className="bg-blue-50 dark:bg-blue-900/20 border border-blue-200 dark:border-blue-800 rounded-lg p-4">
|
|
||||||
<div className="flex items-start gap-3">
|
|
||||||
<div className="flex-shrink-0">
|
|
||||||
<svg className="w-5 h-5 text-blue-600 dark:text-blue-400 mt-0.5" fill="currentColor" viewBox="0 0 20 20">
|
|
||||||
<path fillRule="evenodd" d="M18 10a8 8 0 11-16 0 8 8 0 0116 0zm-7-4a1 1 0 11-2 0 1 1 0 012 0zM9 9a1 1 0 000 2v3a1 1 0 001 1h1a1 1 0 100-2v-3a1 1 0 00-1-1H9z" clipRule="evenodd" />
|
|
||||||
</svg>
|
|
||||||
</div>
|
|
||||||
<div className="flex-1">
|
|
||||||
<h3 className="text-sm font-medium text-blue-900 dark:text-blue-200">
|
|
||||||
Choose a Topic Area First
|
|
||||||
</h3>
|
|
||||||
<p className="mt-1 text-sm text-blue-700 dark:text-blue-300">
|
|
||||||
Pick a topic area first, then add keywords - You need to choose what you're writing about before adding search terms to target
|
|
||||||
</p>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
)}
|
|
||||||
|
|
||||||
<TablePageTemplate
|
|
||||||
columns={pageConfig.columns}
|
|
||||||
data={seedKeywords}
|
|
||||||
loading={loading}
|
|
||||||
showContent={showContent}
|
|
||||||
filters={pageConfig.filters}
|
|
||||||
filterValues={{
|
|
||||||
search: searchTerm,
|
|
||||||
country: countryFilter,
|
|
||||||
difficulty: difficultyFilter,
|
|
||||||
}}
|
|
||||||
onFilterChange={(key, value) => {
|
|
||||||
const stringValue = value === null || value === undefined ? '' : String(value);
|
|
||||||
|
|
||||||
if (key === 'search') {
|
|
||||||
setSearchTerm(stringValue);
|
|
||||||
} else if (key === 'country') {
|
|
||||||
setCountryFilter(stringValue);
|
|
||||||
setCurrentPage(1);
|
|
||||||
} else if (key === 'difficulty') {
|
|
||||||
setDifficultyFilter(stringValue);
|
|
||||||
setCurrentPage(1);
|
|
||||||
}
|
|
||||||
}}
|
|
||||||
onRowAction={async (actionKey: string, row: SeedKeyword & { isAdded?: boolean }) => {
|
|
||||||
if (actionKey === 'add_to_workflow') {
|
|
||||||
// Check if sector is selected
|
|
||||||
if (!activeSector) {
|
|
||||||
toast.error('Please select a sector first');
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
// Don't allow adding already-added keywords
|
|
||||||
if (row.isAdded) {
|
|
||||||
toast.info('This keyword is already added to workflow');
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
await handleAddToWorkflow([row.id]);
|
|
||||||
}
|
|
||||||
}}
|
|
||||||
bulkActions={pageConfig.bulkActions}
|
|
||||||
onBulkAction={async (actionKey: string, ids: string[]) => {
|
|
||||||
if (actionKey === 'add_selected_to_workflow') {
|
|
||||||
if (!activeSector) {
|
|
||||||
toast.error('Please select a sector first');
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
await handleBulkAddSelected(ids);
|
|
||||||
}
|
|
||||||
}}
|
|
||||||
onCreate={activeSector ? handleAddAll : undefined}
|
|
||||||
createLabel="Add All to Workflow"
|
|
||||||
onCreateIcon={<PlusIcon />}
|
|
||||||
pagination={{
|
|
||||||
currentPage,
|
|
||||||
totalPages,
|
|
||||||
totalCount,
|
|
||||||
onPageChange: setCurrentPage,
|
|
||||||
}}
|
|
||||||
sorting={{
|
|
||||||
sortBy,
|
|
||||||
sortDirection,
|
|
||||||
onSort: handleSort,
|
|
||||||
}}
|
|
||||||
selection={{
|
|
||||||
selectedIds,
|
|
||||||
onSelectionChange: setSelectedIds,
|
|
||||||
}}
|
|
||||||
/>
|
|
||||||
</>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
@@ -998,48 +998,6 @@ export default function ContentViewTemplate({ content, loading, onBack }: Conten
|
|||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
{/* Tags and Categories from taxonomy_terms_data */}
|
|
||||||
{content.taxonomy_terms_data && content.taxonomy_terms_data.length > 0 ? (
|
|
||||||
<div className="mt-6 pt-6 border-t border-gray-200 dark:border-gray-700">
|
|
||||||
<div className="flex flex-wrap gap-4">
|
|
||||||
{content.taxonomy_terms_data.filter(term => term.taxonomy_type === 'tag').length > 0 && (
|
|
||||||
<div className="flex items-center gap-2">
|
|
||||||
<TagIcon className="w-4 h-4 text-gray-400" />
|
|
||||||
<div className="flex flex-wrap gap-2">
|
|
||||||
{content.taxonomy_terms_data
|
|
||||||
.filter(term => term.taxonomy_type === 'tag')
|
|
||||||
.map((tag) => (
|
|
||||||
<span
|
|
||||||
key={tag.id}
|
|
||||||
className="px-3 py-1 bg-brand-50 dark:bg-brand-900/20 text-brand-700 dark:text-brand-300 rounded-full text-xs font-medium"
|
|
||||||
>
|
|
||||||
{tag.name}
|
|
||||||
</span>
|
|
||||||
))}
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
)}
|
|
||||||
{content.taxonomy_terms_data.filter(term => term.taxonomy_type === 'category').length > 0 && (
|
|
||||||
<div className="flex items-center gap-2">
|
|
||||||
<span className="text-xs text-gray-500 dark:text-gray-400">Categories:</span>
|
|
||||||
<div className="flex flex-wrap gap-2">
|
|
||||||
{content.taxonomy_terms_data
|
|
||||||
.filter(term => term.taxonomy_type === 'category')
|
|
||||||
.map((category) => (
|
|
||||||
<span
|
|
||||||
key={category.id}
|
|
||||||
className="px-3 py-1 bg-purple-50 dark:bg-purple-900/20 text-purple-700 dark:text-purple-300 rounded-full text-xs font-medium"
|
|
||||||
>
|
|
||||||
{category.name}
|
|
||||||
</span>
|
|
||||||
))}
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
)}
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
) : null}
|
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
{/* Action Buttons - Conditional based on status */}
|
{/* Action Buttons - Conditional based on status */}
|
||||||
|
|||||||
@@ -240,3 +240,50 @@ Site Setup Progress
|
|||||||
| Cleanup | Remove Site Builder and content fetching APIs/models |
|
| Cleanup | Remove Site Builder and content fetching APIs/models |
|
||||||
|
|
||||||
---
|
---
|
||||||
|
|
||||||
|
Status after implementation
|
||||||
|
|
||||||
|
|
||||||
|
## Summary of Section 2 Implementation
|
||||||
|
|
||||||
|
### 2.1 Add Keywords (IndustriesSectorsKeywords.tsx)
|
||||||
|
- ✅ Added `showNotAddedOnly` filter state with "Not Yet Added Only" filter option
|
||||||
|
- ✅ Added `addedCount` and `availableCount` state variables for keyword count tracking
|
||||||
|
- ✅ Added keyword count summary showing "X keywords in your workflow • Y available to add"
|
||||||
|
- ✅ Added "Next: Plan Your Content →" CTA button that appears when keywords are added
|
||||||
|
- ✅ Added "Looking for more keywords? Keyword Research coming soon!" teaser text
|
||||||
|
- ✅ Sector requirement tooltip already existed - no changes needed
|
||||||
|
- ✅ No visible import buttons to remove (dead code existed but was not exposed)
|
||||||
|
|
||||||
|
### 2.2 Content Settings (ContentSettingsPage.tsx)
|
||||||
|
- ✅ Created new backend API endpoint `/v1/system/settings/content/<pk>/` for content_generation and publishing
|
||||||
|
- ✅ Added `ContentSettingsViewSet` to backend with retrieve/update/save actions
|
||||||
|
- ✅ Updated frontend to load content_generation and publishing settings from API
|
||||||
|
- ✅ Updated frontend to save content_generation settings (append_to_prompt, default_tone, default_length)
|
||||||
|
- ✅ Updated frontend to save publishing settings (auto_publish_enabled, auto_sync_enabled)
|
||||||
|
- ✅ Removed TODO comments - settings now actually persist
|
||||||
|
|
||||||
|
### 2.3 Sites
|
||||||
|
- ✅ Created `SiteSetupChecklist` component showing setup progress with checklist
|
||||||
|
- ✅ Updated Dashboard.tsx to use the new checklist component
|
||||||
|
- ✅ Removed mock stats from Dashboard (were showing all zeros)
|
||||||
|
- ✅ Deleted Manage.tsx (redundant duplicate of List.tsx)
|
||||||
|
- ✅ Removed empty `Builder/` folder structure
|
||||||
|
- ✅ Removed routes to deleted pages in App.tsx
|
||||||
|
- ✅ Site Settings already defaults to "general" tab - no changes needed
|
||||||
|
|
||||||
|
### Files Modified
|
||||||
|
1. IndustriesSectorsKeywords.tsx
|
||||||
|
2. ContentSettingsPage.tsx
|
||||||
|
3. settings_views.py
|
||||||
|
4. urls.py
|
||||||
|
5. Dashboard.tsx
|
||||||
|
6. App.tsx
|
||||||
|
|
||||||
|
### Files Created
|
||||||
|
1. SiteSetupChecklist.tsx
|
||||||
|
|
||||||
|
### Files Deleted
|
||||||
|
1. Manage.tsx
|
||||||
|
2. `frontend/src/pages/Sites/Builder/` (empty folder)
|
||||||
|
|
||||||
|
|||||||
@@ -310,4 +310,42 @@ Not part of current phase. No issues to address.
|
|||||||
| Error recovery | API to retry failed items |
|
| Error recovery | API to retry failed items |
|
||||||
| Notification logging | Ensure AI runs are logged for notification display |
|
| Notification logging | Ensure AI runs are logged for notification display |
|
||||||
|
|
||||||
---
|
---
|
||||||
|
## After Implementation
|
||||||
|
Section 3 implementation is complete. Here's a summary of all changes made:
|
||||||
|
|
||||||
|
## Section 3 WORKFLOW Modules - Implementation Complete ✅
|
||||||
|
|
||||||
|
### Completed Tasks:
|
||||||
|
|
||||||
|
1. **Delete KeywordOpportunities.tsx** ✅
|
||||||
|
- Deleted the orphaned page file
|
||||||
|
- Removed import and route from App.tsx
|
||||||
|
- Updated PAGES.md documentation
|
||||||
|
|
||||||
|
2. **Add cluster ideas badge** ✅
|
||||||
|
- Modified clusters.config.tsx
|
||||||
|
- Ideas column now shows badge: "X ideas" (green) or "No ideas" (gray)
|
||||||
|
|
||||||
|
3. **Fix ContentView duplicate tags/categories** ✅
|
||||||
|
- Modified ContentViewTemplate.tsx
|
||||||
|
- Removed duplicate tags/categories section that appeared below metadata
|
||||||
|
|
||||||
|
4. **Fix progress modal placeholder texts** ✅
|
||||||
|
- Modified ProgressModal.tsx
|
||||||
|
- Fixed "X" placeholders in step labels with cleaner fallbacks
|
||||||
|
- "Mapping Content for X Image Prompts" → "Mapping content for image prompts"
|
||||||
|
- "Writing X In‑article Image Prompts" → "Writing In‑article Image Prompts"
|
||||||
|
- Success message fallback cleaned up
|
||||||
|
|
||||||
|
5. **Add queued count to Ideas** ✅
|
||||||
|
- Already implemented via headerMetrics showing New/Queued/Completed counts
|
||||||
|
|
||||||
|
6. **Clickable cluster in Ideas** ✅
|
||||||
|
- Modified ideas.config.tsx
|
||||||
|
- Cluster name now links to `/planner/clusters/:id`
|
||||||
|
|
||||||
|
### Documentation Updated:
|
||||||
|
- CHANGELOG.md - Added v1.1.5 section
|
||||||
|
- ENDPOINTS.md - Added Content Settings API docs
|
||||||
|
- PAGES.md - Removed KeywordOpportunities, updated version
|
||||||
Reference in New Issue
Block a user