Merge branch 'pr-486'
This commit is contained in:
106
frontend/src/api/backlinkOutreachApi.ts
Normal file
106
frontend/src/api/backlinkOutreachApi.ts
Normal file
@@ -0,0 +1,106 @@
|
||||
import { apiClient } from './client';
|
||||
|
||||
export interface BacklinkModuleRecord {
|
||||
identifier: 'backlink' | 'outreach' | 'guest_post' | string;
|
||||
module_path: string;
|
||||
purpose: string;
|
||||
}
|
||||
|
||||
export interface BacklinkModuleRegistryResponse {
|
||||
feature: string;
|
||||
modules: BacklinkModuleRecord[];
|
||||
}
|
||||
|
||||
export interface BacklinkCoverageResponse {
|
||||
legacy_reference: string;
|
||||
implemented_count: number;
|
||||
planned_count: number;
|
||||
implemented: string[];
|
||||
planned: string[];
|
||||
}
|
||||
|
||||
export interface BacklinkQueryTemplatesResponse {
|
||||
keyword: string;
|
||||
queries: string[];
|
||||
}
|
||||
|
||||
export interface BacklinkDiscoveryRequest {
|
||||
keyword: string;
|
||||
max_results?: number;
|
||||
}
|
||||
|
||||
export interface BacklinkOpportunity {
|
||||
url: string;
|
||||
title: string;
|
||||
snippet: string;
|
||||
confidence_score: number;
|
||||
}
|
||||
|
||||
|
||||
|
||||
export interface BacklinkPolicyValidationRequest {
|
||||
user_id: string;
|
||||
workspace_id: string;
|
||||
campaign_id: string;
|
||||
recipient_email: string;
|
||||
recipient_domain: string;
|
||||
recipient_region: string;
|
||||
legal_basis: string;
|
||||
approved_by_human: boolean;
|
||||
unsubscribe_url?: string;
|
||||
sender_identity: string;
|
||||
idempotency_key: string;
|
||||
}
|
||||
|
||||
export interface BacklinkPolicyValidationResponse {
|
||||
allowed: boolean;
|
||||
reasons: string[];
|
||||
final_status: string;
|
||||
}
|
||||
|
||||
export interface BacklinkReportingSnapshot {
|
||||
send_volume: number;
|
||||
decision_events: number;
|
||||
response_rate: number;
|
||||
placement_conversion: number;
|
||||
}
|
||||
|
||||
export interface BacklinkDiscoveryResponse {
|
||||
keyword: string;
|
||||
queries: string[];
|
||||
opportunities: BacklinkOpportunity[];
|
||||
}
|
||||
|
||||
export interface BacklinkCampaignRecord {
|
||||
campaign_id: string;
|
||||
name: string;
|
||||
status: string;
|
||||
created_at?: string;
|
||||
}
|
||||
|
||||
export interface BacklinkCampaignCreateRequest {
|
||||
user_id: string;
|
||||
workspace_id: string;
|
||||
name: string;
|
||||
}
|
||||
|
||||
export interface BacklinkCampaignCreateResponse {
|
||||
campaign_id: string;
|
||||
name: string;
|
||||
status: string;
|
||||
}
|
||||
|
||||
export interface BacklinkCampaignListResponse {
|
||||
campaigns: BacklinkCampaignRecord[];
|
||||
}
|
||||
|
||||
export const fetchBacklinkModuleRegistry = async (): Promise<BacklinkModuleRegistryResponse> => (await apiClient.get('/api/backlink-outreach/modules')).data;
|
||||
export const fetchBacklinkMigrationCoverage = async (): Promise<BacklinkCoverageResponse> => (await apiClient.get('/api/backlink-outreach/migration-coverage')).data;
|
||||
export const fetchBacklinkQueryTemplates = async (keyword: string): Promise<BacklinkQueryTemplatesResponse> => (await apiClient.get('/api/backlink-outreach/query-templates', { params: { keyword } })).data;
|
||||
export const discoverBacklinkOpportunities = async (payload: BacklinkDiscoveryRequest): Promise<BacklinkDiscoveryResponse> => (await apiClient.post('/api/backlink-outreach/discover', payload)).data;
|
||||
|
||||
export const validateBacklinkPolicy = async (payload: BacklinkPolicyValidationRequest): Promise<BacklinkPolicyValidationResponse> => (await apiClient.post('/api/backlink-outreach/policy-validate', payload)).data;
|
||||
export const fetchBacklinkReportingSnapshot = async (): Promise<BacklinkReportingSnapshot> => (await apiClient.get('/api/backlink-outreach/reporting')).data;
|
||||
|
||||
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;
|
||||
@@ -0,0 +1,96 @@
|
||||
import React, { useCallback, useEffect, useState } from 'react';
|
||||
|
||||
import { fetchBacklinkQueryTemplates } from '../../api/backlinkOutreachApi';
|
||||
import { useBacklinkOutreachStore } from '../../stores/backlinkOutreachStore';
|
||||
|
||||
const BacklinkOutreachModuleList: React.FC = () => {
|
||||
const { modules, coverage, campaigns, isLoading, error, refreshBacklinkRegistry, fetchCampaigns, createCampaign } = useBacklinkOutreachStore();
|
||||
const [queryPreview, setQueryPreview] = useState<string[]>([]);
|
||||
const [newCampaignName, setNewCampaignName] = useState('');
|
||||
|
||||
useEffect(() => {
|
||||
refreshBacklinkRegistry();
|
||||
}, [refreshBacklinkRegistry]);
|
||||
|
||||
useEffect(() => {
|
||||
const loadPreview = async () => {
|
||||
const response = await fetchBacklinkQueryTemplates('seo');
|
||||
setQueryPreview(response.queries.slice(0, 3));
|
||||
};
|
||||
loadPreview().catch(() => setQueryPreview([]));
|
||||
}, []);
|
||||
|
||||
useEffect(() => {
|
||||
fetchCampaigns('default', 'default').catch(() => {});
|
||||
}, [fetchCampaigns]);
|
||||
|
||||
const handleCreateCampaign = useCallback(async () => {
|
||||
if (!newCampaignName.trim()) return;
|
||||
await createCampaign('default', 'default', newCampaignName.trim());
|
||||
setNewCampaignName('');
|
||||
}, [newCampaignName, createCampaign]);
|
||||
|
||||
return (
|
||||
<section>
|
||||
<h3>Backlink Outreach Modules</h3>
|
||||
{isLoading && <p>Loading backlink module registry…</p>}
|
||||
{error && <p>{error}</p>}
|
||||
{!isLoading && !error && (
|
||||
<>
|
||||
<ul>
|
||||
{modules.map((module) => (
|
||||
<li key={`${module.identifier}:${module.module_path}`}>
|
||||
<strong>{module.identifier}</strong>: {module.module_path}
|
||||
</li>
|
||||
))}
|
||||
</ul>
|
||||
{coverage && (
|
||||
<>
|
||||
<p>
|
||||
Legacy parity: {coverage.implemented_count} implemented / {coverage.planned_count} planned
|
||||
</p>
|
||||
<p>Legacy reference: {coverage.legacy_reference}</p>
|
||||
</>
|
||||
)}
|
||||
{queryPreview.length > 0 && (
|
||||
<>
|
||||
<h4>Legacy query template preview (keyword: seo)</h4>
|
||||
<ul>
|
||||
{queryPreview.map((query) => (
|
||||
<li key={query}>{query}</li>
|
||||
))}
|
||||
</ul>
|
||||
</>
|
||||
)}
|
||||
|
||||
<h4>Campaigns</h4>
|
||||
{campaigns.length === 0 ? (
|
||||
<p>No campaigns yet. Create one below.</p>
|
||||
) : (
|
||||
<ul>
|
||||
{campaigns.map((c) => (
|
||||
<li key={c.campaign_id}>
|
||||
<strong>{c.name}</strong> ({c.status})
|
||||
{c.created_at && <span> — {new Date(c.created_at).toLocaleDateString()}</span>}
|
||||
</li>
|
||||
))}
|
||||
</ul>
|
||||
)}
|
||||
<div>
|
||||
<input
|
||||
type="text"
|
||||
value={newCampaignName}
|
||||
onChange={(e) => setNewCampaignName(e.target.value)}
|
||||
placeholder="Campaign name"
|
||||
/>
|
||||
<button onClick={handleCreateCampaign} disabled={!newCampaignName.trim()}>
|
||||
Create Campaign
|
||||
</button>
|
||||
</div>
|
||||
</>
|
||||
)}
|
||||
</section>
|
||||
);
|
||||
};
|
||||
|
||||
export default BacklinkOutreachModuleList;
|
||||
74
frontend/src/stores/backlinkOutreachStore.ts
Normal file
74
frontend/src/stores/backlinkOutreachStore.ts
Normal file
@@ -0,0 +1,74 @@
|
||||
import { create } from 'zustand';
|
||||
|
||||
import {
|
||||
BacklinkCampaignRecord,
|
||||
BacklinkCoverageResponse,
|
||||
BacklinkModuleRecord,
|
||||
createBacklinkCampaign,
|
||||
fetchBacklinkMigrationCoverage,
|
||||
fetchBacklinkModuleRegistry,
|
||||
listBacklinkCampaigns,
|
||||
} from '../api/backlinkOutreachApi';
|
||||
|
||||
interface BacklinkOutreachStore {
|
||||
modules: BacklinkModuleRecord[];
|
||||
coverage: BacklinkCoverageResponse | null;
|
||||
campaigns: BacklinkCampaignRecord[];
|
||||
isLoading: boolean;
|
||||
error: string | null;
|
||||
refreshBacklinkRegistry: () => Promise<void>;
|
||||
fetchCampaigns: (userId: string, workspaceId: string) => Promise<void>;
|
||||
createCampaign: (userId: string, workspaceId: string, name: string) => Promise<string | null>;
|
||||
}
|
||||
|
||||
export const useBacklinkOutreachStore = create<BacklinkOutreachStore>((set) => ({
|
||||
modules: [],
|
||||
coverage: null,
|
||||
campaigns: [],
|
||||
isLoading: false,
|
||||
error: null,
|
||||
refreshBacklinkRegistry: async () => {
|
||||
set({ isLoading: true, error: null });
|
||||
try {
|
||||
const [registryPayload, coveragePayload] = await Promise.all([
|
||||
fetchBacklinkModuleRegistry(),
|
||||
fetchBacklinkMigrationCoverage(),
|
||||
]);
|
||||
set({ modules: registryPayload.modules, coverage: coveragePayload, isLoading: false });
|
||||
} catch (error: any) {
|
||||
set({
|
||||
isLoading: false,
|
||||
error: error?.message ?? 'Failed to load backlink module registry',
|
||||
});
|
||||
}
|
||||
},
|
||||
fetchCampaigns: async (userId: string, workspaceId: string) => {
|
||||
set({ isLoading: true, error: null });
|
||||
try {
|
||||
const response = await listBacklinkCampaigns(userId, workspaceId);
|
||||
set({ campaigns: response.campaigns, isLoading: false });
|
||||
} catch (error: any) {
|
||||
set({
|
||||
isLoading: false,
|
||||
error: error?.message ?? 'Failed to load campaigns',
|
||||
});
|
||||
}
|
||||
},
|
||||
createCampaign: async (userId: string, workspaceId: string, name: string) => {
|
||||
set({ isLoading: true, error: null });
|
||||
try {
|
||||
const result = await createBacklinkCampaign({ user_id: userId, workspace_id: workspaceId, name });
|
||||
set((state) => ({
|
||||
campaigns: [...state.campaigns, { campaign_id: result.campaign_id, name: result.name, status: result.status }],
|
||||
isLoading: false,
|
||||
}));
|
||||
return result.campaign_id;
|
||||
} catch (error: any) {
|
||||
set({
|
||||
isLoading: false,
|
||||
error: error?.message ?? 'Failed to create campaign',
|
||||
});
|
||||
return null;
|
||||
}
|
||||
},
|
||||
}));
|
||||
@@ -8,4 +8,5 @@ export { useSemanticDashboardStore } from './semanticDashboardStore';
|
||||
export type { DashboardStore } from './dashboardStore';
|
||||
export type { SEODashboardStore } from './seoDashboardStore';
|
||||
export type { SharedDashboardState } from './sharedDashboardStore';
|
||||
export type { SemanticDashboardStore } from './semanticDashboardStore';
|
||||
export type { SemanticDashboardStore } from './semanticDashboardStore';
|
||||
export { useBacklinkOutreachStore } from './backlinkOutreachStore';
|
||||
|
||||
Reference in New Issue
Block a user