feat: Sprint 1 - Deep discovery, lead persistence, and dashboard nav
- Add BacklinkOutreachScraper (Exa + DuckDuckGo deep scraping) - Extend DB and Pydantic models for lead enrichment columns - Add StorageService methods for lead CRUD with auto-migration - Add backend endpoints: deep discover, campaign detail, lead management - Extend frontend API client and store with discovery + lead actions - Create BacklinkOutreachDashboard component with campaigns/discover/leads tabs - Register route at /backlink-outreach under SEO feature flag - Add nav entry under Enterprise & Advanced in tool categories
This commit is contained in:
@@ -78,6 +78,9 @@ const ProductAnimationStudio = React.lazy(() => import('./components/ProductMark
|
||||
const ProductVideoStudio = React.lazy(() => import('./components/ProductMarketing').then(m => ({ default: m.ProductVideoStudio })));
|
||||
const ProductAvatarStudio = React.lazy(() => import('./components/ProductMarketing').then(m => ({ default: m.ProductAvatarStudio })));
|
||||
|
||||
// BacklinkOutreach barrel (1 export)
|
||||
const BacklinkOutreachDashboard = React.lazy(() => import('./components/BacklinkOutreach').then(m => ({ default: m.BacklinkOutreachDashboard })));
|
||||
|
||||
// Root route that chooses Landing (signed out) or InitialRouteHandler (signed in)
|
||||
const RootRoute: React.FC = () => {
|
||||
const { isSignedIn } = useAuth();
|
||||
@@ -189,6 +192,7 @@ const App: React.FC = () => {
|
||||
<Route path="/dashboard" element={<ProtectedRoute><MainDashboard /></ProtectedRoute>} />
|
||||
<Route path="/seo" element={<ProtectedRoute><FeatureRoute feature="seo"><SEODashboard /></FeatureRoute></ProtectedRoute>} />
|
||||
<Route path="/seo-dashboard" element={<ProtectedRoute><FeatureRoute feature="seo"><SEODashboard /></FeatureRoute></ProtectedRoute>} />
|
||||
<Route path="/backlink-outreach" element={<ProtectedRoute><FeatureRoute feature="seo"><BacklinkOutreachDashboard /></FeatureRoute></ProtectedRoute>} />
|
||||
<Route path="/content-planning" element={<ProtectedRoute><FeatureRoute feature="content-planning"><ContentPlanningDashboard /></FeatureRoute></ProtectedRoute>} />
|
||||
<Route path="/facebook-writer" element={<ProtectedRoute><FeatureRoute feature="social"><FacebookWriter /></FeatureRoute></ProtectedRoute>} />
|
||||
<Route path="/linkedin-writer" element={<ProtectedRoute><FeatureRoute feature="social"><LinkedInWriter /></FeatureRoute></ProtectedRoute>} />
|
||||
|
||||
@@ -104,3 +104,87 @@ export const fetchBacklinkReportingSnapshot = async (): Promise<BacklinkReportin
|
||||
|
||||
export const createBacklinkCampaign = async (payload: BacklinkCampaignCreateRequest): Promise<BacklinkCampaignCreateResponse> => (await apiClient.post('/api/backlink-outreach/campaigns', payload)).data;
|
||||
export const listBacklinkCampaigns = async (user_id: string, workspace_id: string): Promise<BacklinkCampaignListResponse> => (await apiClient.get('/api/backlink-outreach/campaigns', { params: { user_id, workspace_id } })).data;
|
||||
|
||||
// -- Deep Discovery --
|
||||
|
||||
export interface EnrichedOpportunity {
|
||||
url: string;
|
||||
domain: string;
|
||||
page_title: string;
|
||||
snippet: string;
|
||||
full_text: string;
|
||||
email: string | null;
|
||||
contact_page: string | null;
|
||||
confidence_score: number;
|
||||
quality_score: number;
|
||||
word_count: number;
|
||||
has_guest_post_guidelines: boolean;
|
||||
discovery_source: string;
|
||||
}
|
||||
|
||||
export interface DeepDiscoveryRequest {
|
||||
keyword: string;
|
||||
max_results?: number;
|
||||
campaign_id?: string;
|
||||
}
|
||||
|
||||
export interface DeepDiscoveryResponse {
|
||||
keyword: string;
|
||||
source: string;
|
||||
total_found: number;
|
||||
opportunities: EnrichedOpportunity[];
|
||||
}
|
||||
|
||||
export const discoverDeepBacklinkOpportunities = async (payload: DeepDiscoveryRequest): Promise<DeepDiscoveryResponse> => (await apiClient.post('/api/backlink-outreach/discover/deep', payload)).data;
|
||||
|
||||
// -- Leads --
|
||||
|
||||
export interface LeadRecord {
|
||||
lead_id: string;
|
||||
campaign_id: string;
|
||||
url: string | null;
|
||||
domain: string;
|
||||
page_title: string;
|
||||
snippet: string;
|
||||
email: string | null;
|
||||
confidence_score: number;
|
||||
discovery_source: string;
|
||||
status: string;
|
||||
notes: string | null;
|
||||
created_at: string | null;
|
||||
}
|
||||
|
||||
export interface LeadListResponse {
|
||||
leads: LeadRecord[];
|
||||
total: number;
|
||||
}
|
||||
|
||||
export interface LeadCreateRequest {
|
||||
campaign_id: string;
|
||||
url: string;
|
||||
domain: string;
|
||||
email?: string;
|
||||
page_title?: string;
|
||||
snippet?: string;
|
||||
confidence_score?: number;
|
||||
notes?: string;
|
||||
}
|
||||
|
||||
export interface LeadStatusUpdateRequest {
|
||||
status: string;
|
||||
notes?: string;
|
||||
}
|
||||
|
||||
export interface CampaignDetailResponse {
|
||||
campaign_id: string;
|
||||
name: string;
|
||||
status: string;
|
||||
created_at: string | null;
|
||||
lead_count: number;
|
||||
leads: LeadRecord[];
|
||||
}
|
||||
|
||||
export const fetchCampaignDetail = async (campaign_id: string, user_id: string): Promise<CampaignDetailResponse> => (await apiClient.get(`/api/backlink-outreach/campaigns/${campaign_id}`, { params: { user_id } })).data;
|
||||
export const fetchCampaignLeads = async (campaign_id: string, user_id: string, status?: string): Promise<LeadListResponse> => (await apiClient.get(`/api/backlink-outreach/campaigns/${campaign_id}/leads`, { params: { user_id, status } })).data;
|
||||
export const addLeadToCampaign = async (campaign_id: string, payload: LeadCreateRequest): Promise<LeadRecord> => (await apiClient.post(`/api/backlink-outreach/campaigns/${campaign_id}/leads`, payload)).data;
|
||||
export const updateLeadStatus = async (lead_id: string, payload: LeadStatusUpdateRequest): Promise<LeadRecord> => (await apiClient.patch(`/api/backlink-outreach/leads/${lead_id}/status`, payload)).data;
|
||||
|
||||
65
frontend/src/api/blogAsset.ts
Normal file
65
frontend/src/api/blogAsset.ts
Normal file
@@ -0,0 +1,65 @@
|
||||
import { apiClient } from './client';
|
||||
|
||||
export interface BlogAsset {
|
||||
id: number;
|
||||
title: string | null;
|
||||
description: string | null;
|
||||
tags: string[];
|
||||
phase: string;
|
||||
research_keywords: string | null;
|
||||
topic: string | null;
|
||||
selected_title: string | null;
|
||||
word_count_target: number | null;
|
||||
has_research: boolean;
|
||||
has_outline: boolean;
|
||||
has_content: boolean;
|
||||
has_seo: boolean;
|
||||
has_publish: boolean;
|
||||
created_at: string | null;
|
||||
updated_at: string | null;
|
||||
}
|
||||
|
||||
export interface BlogAssetFull extends BlogAsset {
|
||||
research_data?: any;
|
||||
outline_data?: any;
|
||||
content_data?: any;
|
||||
seo_data?: any;
|
||||
publish_data?: any;
|
||||
}
|
||||
|
||||
export interface CreateAssetParams {
|
||||
research_keywords: string;
|
||||
topic?: string;
|
||||
word_count_target?: number;
|
||||
}
|
||||
|
||||
export interface UpdateAssetParams {
|
||||
phase?: 'research' | 'outline' | 'content' | 'seo' | 'publish';
|
||||
topic?: string;
|
||||
selected_title?: string;
|
||||
word_count_target?: number;
|
||||
research_data?: any;
|
||||
outline_data?: any;
|
||||
content_data?: any;
|
||||
seo_data?: any;
|
||||
publish_data?: any;
|
||||
}
|
||||
|
||||
class BlogAssetAPI {
|
||||
async create(params: CreateAssetParams): Promise<{ success: boolean; asset: BlogAsset; existing: boolean }> {
|
||||
const res = await apiClient.post('/api/blog/asset', params);
|
||||
return res.data;
|
||||
}
|
||||
|
||||
async update(assetId: number, params: UpdateAssetParams): Promise<{ success: boolean; asset: BlogAsset }> {
|
||||
const res = await apiClient.put(`/api/blog/asset/${assetId}`, params);
|
||||
return res.data;
|
||||
}
|
||||
|
||||
async get(assetId: number): Promise<{ success: boolean; asset: BlogAssetFull }> {
|
||||
const res = await apiClient.get(`/api/blog/asset/${assetId}`);
|
||||
return res.data;
|
||||
}
|
||||
}
|
||||
|
||||
export const blogAssetAPI = new BlogAssetAPI();
|
||||
@@ -0,0 +1,240 @@
|
||||
import React, { useCallback, useEffect, useState } from 'react';
|
||||
import { useBacklinkOutreachStore } from '../../stores/backlinkOutreachStore';
|
||||
|
||||
const BacklinkOutreachDashboard: React.FC = () => {
|
||||
const {
|
||||
campaigns, selectedCampaign, discoveredOpportunities,
|
||||
isLoading, isDiscovering, error,
|
||||
fetchCampaigns, createCampaign, selectCampaign,
|
||||
deepDiscover, clearDiscoveries,
|
||||
} = useBacklinkOutreachStore();
|
||||
|
||||
const [activeTab, setActiveTab] = useState<'campaigns' | 'discover' | 'leads'>('campaigns');
|
||||
const [newCampaignName, setNewCampaignName] = useState('');
|
||||
const [keyword, setKeyword] = useState('');
|
||||
|
||||
useEffect(() => {
|
||||
fetchCampaigns('default', 'default');
|
||||
}, [fetchCampaigns]);
|
||||
|
||||
const handleCreateCampaign = useCallback(async () => {
|
||||
if (!newCampaignName.trim()) return;
|
||||
const id = await createCampaign('default', 'default', newCampaignName.trim());
|
||||
if (id) {
|
||||
setNewCampaignName('');
|
||||
setActiveTab('discover');
|
||||
}
|
||||
}, [newCampaignName, createCampaign]);
|
||||
|
||||
const handleDiscover = useCallback(async () => {
|
||||
if (!keyword.trim()) return;
|
||||
await deepDiscover(keyword.trim(), 15);
|
||||
}, [keyword, deepDiscover]);
|
||||
|
||||
const handleDiscoverAndSave = useCallback(async (campaignId: string) => {
|
||||
if (!keyword.trim()) return;
|
||||
await deepDiscover(keyword.trim(), 15, campaignId);
|
||||
}, [keyword, deepDiscover]);
|
||||
|
||||
return (
|
||||
<div style={{ padding: '24px', maxWidth: '1200px', margin: '0 auto' }}>
|
||||
<h1>Backlink Outreach</h1>
|
||||
<p style={{ color: '#666', marginBottom: '24px' }}>
|
||||
Discover guest post opportunities, manage campaigns, and track outreach.
|
||||
</p>
|
||||
|
||||
{/* Tabs */}
|
||||
<div style={{ display: 'flex', gap: '8px', marginBottom: '24px', borderBottom: '2px solid #eee', paddingBottom: '8px' }}>
|
||||
{(['campaigns', 'discover', 'leads'] as const).map((tab) => (
|
||||
<button
|
||||
key={tab}
|
||||
onClick={() => setActiveTab(tab)}
|
||||
style={{
|
||||
padding: '8px 20px',
|
||||
border: 'none',
|
||||
background: activeTab === tab ? '#1976D2' : 'transparent',
|
||||
color: activeTab === tab ? '#fff' : '#666',
|
||||
borderRadius: '6px',
|
||||
cursor: 'pointer',
|
||||
fontWeight: activeTab === tab ? 600 : 400,
|
||||
}}
|
||||
>
|
||||
{tab === 'campaigns' ? 'Campaigns' : tab === 'discover' ? 'Discover' : 'Leads'}
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
|
||||
{error && (
|
||||
<div style={{ padding: '12px', background: '#ffebee', color: '#c62828', borderRadius: '6px', marginBottom: '16px' }}>
|
||||
{error}
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Tab: Campaigns */}
|
||||
{activeTab === 'campaigns' && (
|
||||
<div>
|
||||
<div style={{ display: 'flex', gap: '12px', marginBottom: '20px' }}>
|
||||
<input
|
||||
type="text"
|
||||
value={newCampaignName}
|
||||
onChange={(e) => setNewCampaignName(e.target.value)}
|
||||
placeholder="Campaign name"
|
||||
style={{ flex: 1, padding: '10px 14px', border: '1px solid #ddd', borderRadius: '6px' }}
|
||||
/>
|
||||
<button
|
||||
onClick={handleCreateCampaign}
|
||||
disabled={!newCampaignName.trim() || isLoading}
|
||||
style={{
|
||||
padding: '10px 24px', background: '#1976D2', color: '#fff',
|
||||
border: 'none', borderRadius: '6px', cursor: 'pointer', fontWeight: 600,
|
||||
}}
|
||||
>
|
||||
{isLoading ? 'Creating...' : 'Create Campaign'}
|
||||
</button>
|
||||
</div>
|
||||
|
||||
{campaigns.length === 0 && !isLoading && (
|
||||
<p style={{ color: '#999' }}>No campaigns yet. Create one to get started.</p>
|
||||
)}
|
||||
|
||||
{campaigns.map((c) => (
|
||||
<div
|
||||
key={c.campaign_id}
|
||||
onClick={() => { selectCampaign(c.campaign_id, 'default'); setActiveTab('leads'); }}
|
||||
style={{
|
||||
padding: '16px', marginBottom: '8px', border: '1px solid #e0e0e0',
|
||||
borderRadius: '8px', cursor: 'pointer', background: '#fafafa',
|
||||
}}
|
||||
>
|
||||
<div style={{ fontWeight: 600 }}>{c.name}</div>
|
||||
<div style={{ fontSize: '13px', color: '#888', marginTop: '4px' }}>
|
||||
Status: {c.status}
|
||||
{c.created_at && <> · Created: {new Date(c.created_at).toLocaleDateString()}</>}
|
||||
</div>
|
||||
</div>
|
||||
))}
|
||||
{isLoading && <p style={{ color: '#999' }}>Loading campaigns...</p>}
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Tab: Discover */}
|
||||
{activeTab === 'discover' && (
|
||||
<div>
|
||||
<div style={{ display: 'flex', gap: '12px', marginBottom: '20px' }}>
|
||||
<input
|
||||
type="text"
|
||||
value={keyword}
|
||||
onChange={(e) => setKeyword(e.target.value)}
|
||||
placeholder="Enter keyword (e.g. 'AI marketing')"
|
||||
style={{ flex: 1, padding: '10px 14px', border: '1px solid #ddd', borderRadius: '6px' }}
|
||||
/>
|
||||
<button
|
||||
onClick={handleDiscover}
|
||||
disabled={!keyword.trim() || isDiscovering}
|
||||
style={{
|
||||
padding: '10px 24px', background: '#2e7d32', color: '#fff',
|
||||
border: 'none', borderRadius: '6px', cursor: 'pointer', fontWeight: 600,
|
||||
}}
|
||||
>
|
||||
{isDiscovering ? 'Searching...' : 'Discover'}
|
||||
</button>
|
||||
</div>
|
||||
|
||||
{isDiscovering && <p style={{ color: '#666' }}>Searching for opportunities using Exa + DuckDuckGo...</p>}
|
||||
|
||||
{discoveredOpportunities.length > 0 && (
|
||||
<div>
|
||||
<div style={{ display: 'flex', justifyContent: 'space-between', alignItems: 'center', marginBottom: '12px' }}>
|
||||
<span style={{ fontWeight: 600 }}>Found {discoveredOpportunities.length} opportunities</span>
|
||||
<button
|
||||
onClick={clearDiscoveries}
|
||||
style={{ padding: '6px 16px', background: 'transparent', border: '1px solid #ccc', borderRadius: '4px', cursor: 'pointer' }}
|
||||
>
|
||||
Clear
|
||||
</button>
|
||||
</div>
|
||||
{discoveredOpportunities.map((opp, i) => (
|
||||
<div
|
||||
key={`${opp.url}-${i}`}
|
||||
style={{
|
||||
padding: '14px', marginBottom: '8px', border: '1px solid #e0e0e0',
|
||||
borderRadius: '8px', background: '#fff',
|
||||
}}
|
||||
>
|
||||
<div style={{ fontWeight: 600, marginBottom: '4px' }}>
|
||||
<a href={opp.url} target="_blank" rel="noopener noreferrer" style={{ color: '#1976D2', textDecoration: 'none' }}>
|
||||
{opp.page_title || opp.domain}
|
||||
</a>
|
||||
</div>
|
||||
<div style={{ fontSize: '13px', color: '#666', marginBottom: '4px' }}>{opp.domain}</div>
|
||||
{opp.snippet && (
|
||||
<div style={{ fontSize: '13px', color: '#555', marginBottom: '6px' }}>{opp.snippet.slice(0, 200)}...</div>
|
||||
)}
|
||||
<div style={{ display: 'flex', gap: '12px', fontSize: '12px', color: '#888' }}>
|
||||
<span>Quality: {(opp.quality_score * 100).toFixed(0)}%</span>
|
||||
<span>Confidence: {(opp.confidence_score * 100).toFixed(0)}%</span>
|
||||
<span>Words: {opp.word_count}</span>
|
||||
{opp.has_guest_post_guidelines && <span style={{ color: '#2e7d32' }}>Has guidelines</span>}
|
||||
{opp.email && <span style={{ color: '#1565c0' }}>Email found</span>}
|
||||
</div>
|
||||
<div style={{ marginTop: '8px' }}>
|
||||
<button
|
||||
onClick={() => campaigns.length > 0 && handleDiscoverAndSave(campaigns[0].campaign_id)}
|
||||
disabled={campaigns.length === 0}
|
||||
style={{
|
||||
padding: '6px 14px', fontSize: '12px', background: '#f5f5f5',
|
||||
border: '1px solid #ddd', borderRadius: '4px', cursor: campaigns.length > 0 ? 'pointer' : 'not-allowed',
|
||||
}}
|
||||
>
|
||||
Save to first campaign
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Tab: Leads */}
|
||||
{activeTab === 'leads' && (
|
||||
<div>
|
||||
{selectedCampaign ? (
|
||||
<div>
|
||||
<h3 style={{ marginBottom: '8px' }}>{selectedCampaign.name}</h3>
|
||||
<p style={{ fontSize: '14px', color: '#666', marginBottom: '16px' }}>
|
||||
Status: {selectedCampaign.status} · {selectedCampaign.lead_count} leads
|
||||
</p>
|
||||
{selectedCampaign.leads.length === 0 && (
|
||||
<p style={{ color: '#999' }}>No leads yet. Go to Discover tab to find opportunities.</p>
|
||||
)}
|
||||
{selectedCampaign.leads.map((lead) => (
|
||||
<div
|
||||
key={lead.lead_id}
|
||||
style={{
|
||||
padding: '14px', marginBottom: '8px', border: '1px solid #e0e0e0',
|
||||
borderRadius: '8px', background: '#fff',
|
||||
}}
|
||||
>
|
||||
<div style={{ fontWeight: 600 }}>{lead.page_title || lead.domain}</div>
|
||||
<div style={{ fontSize: '13px', color: '#888', marginTop: '4px' }}>
|
||||
{lead.url && <a href={lead.url} target="_blank" rel="noopener noreferrer" style={{ color: '#1976D2' }}>{lead.url}</a>}
|
||||
</div>
|
||||
<div style={{ display: 'flex', gap: '12px', fontSize: '12px', color: '#888', marginTop: '6px' }}>
|
||||
<span>Status: <strong>{lead.status}</strong></span>
|
||||
{lead.email && <span>Email: {lead.email}</span>}
|
||||
<span>Source: {lead.discovery_source}</span>
|
||||
</div>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
) : (
|
||||
<p style={{ color: '#999' }}>Select a campaign from the Campaigns tab to view its leads.</p>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
export default BacklinkOutreachDashboard;
|
||||
1
frontend/src/components/BacklinkOutreach/index.ts
Normal file
1
frontend/src/components/BacklinkOutreach/index.ts
Normal file
@@ -0,0 +1 @@
|
||||
export { default as BacklinkOutreachDashboard } from './BacklinkOutreachDashboard';
|
||||
@@ -18,7 +18,8 @@ import {
|
||||
CalendarMonth as CalendarIcon,
|
||||
AudioFile as AudioIcon,
|
||||
Image as ImageIcon,
|
||||
VideoLibrary as VideoIcon
|
||||
VideoLibrary as VideoIcon,
|
||||
Link as LinkIcon
|
||||
} from '@mui/icons-material';
|
||||
import MenuBookIcon from '@mui/icons-material/MenuBook';
|
||||
import { ToolCategories } from '../components/shared/types';
|
||||
@@ -127,6 +128,16 @@ export const toolCategories: ToolCategories = {
|
||||
isPinned: true,
|
||||
isHighlighted: true
|
||||
},
|
||||
{
|
||||
name: 'Backlink Outreach',
|
||||
description: 'Discover guest post opportunities with AI-powered deep scraping',
|
||||
icon: React.createElement(LinkIcon),
|
||||
status: 'beta',
|
||||
path: '/backlink-outreach',
|
||||
features: ['AI Discovery', 'Guest Post Opportunities', 'Campaign Management'],
|
||||
isPinned: true,
|
||||
isHighlighted: true
|
||||
},
|
||||
{
|
||||
name: 'AI Content Strategy Generator',
|
||||
description: 'Comprehensive content planning with market intelligence',
|
||||
|
||||
105
frontend/src/hooks/useBlogAsset.ts
Normal file
105
frontend/src/hooks/useBlogAsset.ts
Normal file
@@ -0,0 +1,105 @@
|
||||
import { useState, useCallback, useRef } from 'react';
|
||||
import { blogAssetAPI, BlogAssetFull, BlogAsset } from '../api/blogAsset';
|
||||
import { debug } from '../utils/debug';
|
||||
|
||||
export function useBlogAsset() {
|
||||
const [assetId, setAssetId] = useState<number | null>(null);
|
||||
const [asset, setAsset] = useState<BlogAssetFull | null>(null);
|
||||
const [loading, setLoading] = useState(false);
|
||||
const [error, setError] = useState<string | null>(null);
|
||||
const createInProgressRef = useRef(false);
|
||||
|
||||
const createAsset = useCallback(async (
|
||||
researchKeywords: string,
|
||||
topic?: string,
|
||||
wordCountTarget?: number,
|
||||
): Promise<number | null> => {
|
||||
if (createInProgressRef.current) return assetId;
|
||||
createInProgressRef.current = true;
|
||||
setLoading(true);
|
||||
setError(null);
|
||||
try {
|
||||
const result = await blogAssetAPI.create({
|
||||
research_keywords: researchKeywords,
|
||||
topic,
|
||||
word_count_target: wordCountTarget,
|
||||
});
|
||||
const newId = result.asset.id;
|
||||
setAssetId(newId);
|
||||
setAsset(result.asset as BlogAssetFull);
|
||||
debug.log('[BlogAsset] Created:', newId, 'existing:', result.existing);
|
||||
return newId;
|
||||
} catch (err) {
|
||||
const msg = err instanceof Error ? err.message : 'Failed to create asset';
|
||||
setError(msg);
|
||||
debug.error('[BlogAsset] Create failed:', msg);
|
||||
return null;
|
||||
} finally {
|
||||
setLoading(false);
|
||||
createInProgressRef.current = false;
|
||||
}
|
||||
}, [assetId]);
|
||||
|
||||
const updatePhase = useCallback(async (
|
||||
phase: 'research' | 'outline' | 'content' | 'seo' | 'publish',
|
||||
data?: any,
|
||||
extra?: Record<string, any>,
|
||||
) => {
|
||||
if (assetId === null || assetId === undefined) return;
|
||||
setLoading(true);
|
||||
try {
|
||||
const payload: any = { phase };
|
||||
if (data) payload[`${phase}_data`] = data;
|
||||
if (extra) Object.assign(payload, extra);
|
||||
const result = await blogAssetAPI.update(assetId, payload);
|
||||
setAsset((prev: BlogAssetFull | null) => ({
|
||||
...(prev || {}),
|
||||
...result.asset,
|
||||
...(data ? { [`${phase}_data`]: data } : {}),
|
||||
}) as BlogAssetFull);
|
||||
debug.log('[BlogAsset] Updated phase:', phase, 'asset_id:', assetId);
|
||||
} catch (err) {
|
||||
const msg = err instanceof Error ? err.message : 'Failed to update asset';
|
||||
setError(msg);
|
||||
debug.error('[BlogAsset] Update failed:', msg);
|
||||
} finally {
|
||||
setLoading(false);
|
||||
}
|
||||
}, [assetId]);
|
||||
|
||||
const loadAsset = useCallback(async (id: number): Promise<BlogAssetFull | null> => {
|
||||
setLoading(true);
|
||||
setError(null);
|
||||
try {
|
||||
const result = await blogAssetAPI.get(id);
|
||||
setAssetId(id);
|
||||
setAsset(result.asset);
|
||||
debug.log('[BlogAsset] Loaded:', id, 'phase:', result.asset.phase);
|
||||
return result.asset;
|
||||
} catch (err) {
|
||||
const msg = err instanceof Error ? err.message : 'Failed to load asset';
|
||||
setError(msg);
|
||||
debug.error('[BlogAsset] Load failed:', msg);
|
||||
return null;
|
||||
} finally {
|
||||
setLoading(false);
|
||||
}
|
||||
}, []);
|
||||
|
||||
const resetAsset = useCallback(() => {
|
||||
setAssetId(null);
|
||||
setAsset(null);
|
||||
setError(null);
|
||||
}, []);
|
||||
|
||||
return {
|
||||
assetId,
|
||||
asset,
|
||||
loading,
|
||||
error,
|
||||
createAsset,
|
||||
updatePhase,
|
||||
loadAsset,
|
||||
resetAsset,
|
||||
};
|
||||
}
|
||||
@@ -4,9 +4,14 @@ import {
|
||||
BacklinkCampaignRecord,
|
||||
BacklinkCoverageResponse,
|
||||
BacklinkModuleRecord,
|
||||
CampaignDetailResponse,
|
||||
createBacklinkCampaign,
|
||||
discoverDeepBacklinkOpportunities,
|
||||
EnrichedOpportunity,
|
||||
fetchBacklinkMigrationCoverage,
|
||||
fetchBacklinkModuleRegistry,
|
||||
fetchCampaignDetail,
|
||||
LeadRecord,
|
||||
listBacklinkCampaigns,
|
||||
} from '../api/backlinkOutreachApi';
|
||||
|
||||
@@ -14,18 +19,29 @@ interface BacklinkOutreachStore {
|
||||
modules: BacklinkModuleRecord[];
|
||||
coverage: BacklinkCoverageResponse | null;
|
||||
campaigns: BacklinkCampaignRecord[];
|
||||
selectedCampaign: CampaignDetailResponse | null;
|
||||
discoveredOpportunities: EnrichedOpportunity[];
|
||||
leads: LeadRecord[];
|
||||
isLoading: boolean;
|
||||
isDiscovering: boolean;
|
||||
error: string | null;
|
||||
refreshBacklinkRegistry: () => Promise<void>;
|
||||
fetchCampaigns: (userId: string, workspaceId: string) => Promise<void>;
|
||||
createCampaign: (userId: string, workspaceId: string, name: string) => Promise<string | null>;
|
||||
selectCampaign: (campaignId: string, userId: string) => Promise<void>;
|
||||
deepDiscover: (keyword: string, maxResults?: number, campaignId?: string) => Promise<EnrichedOpportunity[]>;
|
||||
clearDiscoveries: () => void;
|
||||
}
|
||||
|
||||
export const useBacklinkOutreachStore = create<BacklinkOutreachStore>((set) => ({
|
||||
modules: [],
|
||||
coverage: null,
|
||||
campaigns: [],
|
||||
selectedCampaign: null,
|
||||
discoveredOpportunities: [],
|
||||
leads: [],
|
||||
isLoading: false,
|
||||
isDiscovering: false,
|
||||
error: null,
|
||||
refreshBacklinkRegistry: async () => {
|
||||
set({ isLoading: true, error: null });
|
||||
@@ -71,4 +87,31 @@ export const useBacklinkOutreachStore = create<BacklinkOutreachStore>((set) => (
|
||||
return null;
|
||||
}
|
||||
},
|
||||
selectCampaign: async (campaignId: string, userId: string) => {
|
||||
set({ isLoading: true, error: null });
|
||||
try {
|
||||
const detail = await fetchCampaignDetail(campaignId, userId);
|
||||
set({ selectedCampaign: detail, leads: detail.leads, isLoading: false });
|
||||
} catch (error: any) {
|
||||
set({
|
||||
isLoading: false,
|
||||
error: error?.message ?? 'Failed to load campaign',
|
||||
});
|
||||
}
|
||||
},
|
||||
deepDiscover: async (keyword: string, maxResults?: number, campaignId?: string) => {
|
||||
set({ isDiscovering: true, error: null });
|
||||
try {
|
||||
const result = await discoverDeepBacklinkOpportunities({ keyword, max_results: maxResults, campaign_id: campaignId });
|
||||
set({ discoveredOpportunities: result.opportunities, isDiscovering: false });
|
||||
return result.opportunities;
|
||||
} catch (error: any) {
|
||||
set({
|
||||
isDiscovering: false,
|
||||
error: error?.message ?? 'Discovery failed',
|
||||
});
|
||||
return [];
|
||||
}
|
||||
},
|
||||
clearDiscoveries: () => set({ discoveredOpportunities: [] }),
|
||||
}));
|
||||
|
||||
Reference in New Issue
Block a user