This commit is contained in:
alorig
2025-11-18 05:21:27 +05:00
parent a0f3e3a778
commit 9a6d47b91b
34 changed files with 3258 additions and 9 deletions

View File

@@ -0,0 +1,83 @@
/**
* Integration Status Component
* Phase 6: Site Integration & Multi-Destination Publishing
*/
import React from 'react';
import { CheckCircleIcon, XCircleIcon, ClockIcon, SyncIcon } from 'lucide-react';
interface IntegrationStatusProps {
syncEnabled: boolean;
syncStatus: 'success' | 'failed' | 'pending' | 'syncing';
lastSyncAt: string | null;
syncError?: string | null;
}
export default function IntegrationStatus({
syncEnabled,
syncStatus,
lastSyncAt,
syncError,
}: IntegrationStatusProps) {
const getStatusIcon = () => {
switch (syncStatus) {
case 'success':
return <CheckCircleIcon className="w-5 h-5 text-green-500" />;
case 'failed':
return <XCircleIcon className="w-5 h-5 text-red-500" />;
case 'syncing':
return <SyncIcon className="w-5 h-5 text-blue-500 animate-spin" />;
default:
return <ClockIcon className="w-5 h-5 text-gray-400" />;
}
};
const getStatusText = () => {
switch (syncStatus) {
case 'success':
return 'Synced';
case 'failed':
return 'Sync Failed';
case 'syncing':
return 'Syncing...';
default:
return 'Not Synced';
}
};
const formatDate = (dateString: string | null) => {
if (!dateString) return 'Never';
try {
const date = new Date(dateString);
return date.toLocaleString();
} catch {
return 'Invalid Date';
}
};
return (
<div className="space-y-2">
<div className="flex items-center gap-2">
{getStatusIcon()}
<span className="text-sm font-medium">{getStatusText()}</span>
</div>
{syncEnabled && (
<div className="text-xs text-gray-600 dark:text-gray-400">
<div>Last sync: {formatDate(lastSyncAt)}</div>
{syncError && (
<div className="text-red-600 dark:text-red-400 mt-1">
Error: {syncError}
</div>
)}
</div>
)}
{!syncEnabled && (
<div className="text-xs text-gray-500 dark:text-gray-500">
Two-way sync is disabled
</div>
)}
</div>
);
}

View File

@@ -0,0 +1,31 @@
/**
* Platform Selector Component
* Phase 6: Site Integration & Multi-Destination Publishing
*/
import React from 'react';
import SelectDropdown from '../form/SelectDropdown';
interface PlatformSelectorProps {
value: string;
onChange: (value: string) => void;
disabled?: boolean;
}
const PLATFORMS = [
{ value: 'wordpress', label: 'WordPress' },
{ value: 'shopify', label: 'Shopify' },
{ value: 'custom', label: 'Custom API' },
];
export default function PlatformSelector({ value, onChange, disabled }: PlatformSelectorProps) {
return (
<SelectDropdown
options={PLATFORMS}
value={value}
onChange={(e) => onChange(e.target.value)}
disabled={disabled}
placeholder="Select platform"
/>
);
}

View File

@@ -0,0 +1,339 @@
/**
* Site Integrations Section
* Phase 6: Site Integration & Multi-Destination Publishing
*/
import React, { useState, useEffect } from 'react';
import { PlusIcon, TrashIcon, TestTubeIcon, SyncIcon } from 'lucide-react';
import Button from '../ui/button/Button';
import { Modal } from '../ui/modal';
import FormModal, { FormField } from '../common/FormModal';
import PlatformSelector from './PlatformSelector';
import IntegrationStatus from './IntegrationStatus';
import { useToast } from '../ui/toast/ToastContainer';
import { fetchAPI } from '../../services/api';
interface SiteIntegration {
id: number;
site: number;
site_name?: string;
platform: string;
platform_type: string;
is_active: boolean;
sync_enabled: boolean;
sync_status: 'success' | 'failed' | 'pending' | 'syncing';
last_sync_at: string | null;
sync_error: string | null;
}
interface SiteIntegrationsSectionProps {
siteId?: number;
}
export default function SiteIntegrationsSection({ siteId }: SiteIntegrationsSectionProps) {
const toast = useToast();
const [integrations, setIntegrations] = useState<SiteIntegration[]>([]);
const [loading, setLoading] = useState(true);
const [showAddModal, setShowAddModal] = useState(false);
const [showEditModal, setShowEditModal] = useState(false);
const [selectedIntegration, setSelectedIntegration] = useState<SiteIntegration | null>(null);
const [isSaving, setIsSaving] = useState(false);
const [isTesting, setIsTesting] = useState(false);
useEffect(() => {
loadIntegrations();
}, [siteId]);
const loadIntegrations = async () => {
try {
setLoading(true);
const params = siteId ? `?site=${siteId}` : '';
const data = await fetchAPI(`/v1/integration/integrations/${params}`);
if (data && Array.isArray(data)) {
setIntegrations(data);
}
} catch (error: any) {
toast.error(`Failed to load integrations: ${error.message}`);
} finally {
setLoading(false);
}
};
const handleAdd = () => {
setSelectedIntegration(null);
setShowAddModal(true);
};
const handleEdit = (integration: SiteIntegration) => {
setSelectedIntegration(integration);
setShowEditModal(true);
};
const handleDelete = async (id: number) => {
if (!confirm('Are you sure you want to delete this integration?')) return;
try {
await fetchAPI(`/v1/integration/integrations/${id}/`, {
method: 'DELETE',
});
toast.success('Integration deleted successfully');
loadIntegrations();
} catch (error: any) {
toast.error(`Failed to delete integration: ${error.message}`);
}
};
const handleTestConnection = async (integration: SiteIntegration) => {
setIsTesting(true);
try {
const data = await fetchAPI(`/v1/integration/integrations/${integration.id}/test_connection/`, {
method: 'POST',
});
if (data?.success) {
toast.success(data.message || 'Connection test successful');
} else {
toast.error(data?.message || 'Connection test failed');
}
loadIntegrations();
} catch (error: any) {
toast.error(`Connection test failed: ${error.message}`);
} finally {
setIsTesting(false);
}
};
const handleSync = async (integration: SiteIntegration) => {
try {
const data = await fetchAPI(`/v1/integration/integrations/${integration.id}/sync/`, {
method: 'POST',
body: JSON.stringify({
direction: 'both',
}),
});
if (data?.success) {
toast.success(`Sync completed. ${data.synced_count || 0} items synced`);
} else {
toast.error('Sync failed');
}
loadIntegrations();
} catch (error: any) {
toast.error(`Sync failed: ${error.message}`);
}
};
const handleSave = async (formData: Record<string, any>) => {
setIsSaving(true);
try {
const payload = {
site: siteId || formData.site,
platform: formData.platform,
platform_type: formData.platform_type || 'cms',
config_json: {
site_url: formData.site_url,
...(formData.platform === 'shopify' && {
shop_domain: formData.shop_domain,
}),
},
credentials_json: {
username: formData.username,
app_password: formData.app_password,
...(formData.platform === 'shopify' && {
access_token: formData.access_token,
}),
},
is_active: formData.is_active !== false,
sync_enabled: formData.sync_enabled || false,
};
if (selectedIntegration) {
// Update
await fetchAPI(`/v1/integration/integrations/${selectedIntegration.id}/`, {
method: 'PUT',
body: JSON.stringify(payload),
});
toast.success('Integration updated successfully');
} else {
// Create
await fetchAPI('/v1/integration/integrations/', {
method: 'POST',
body: JSON.stringify(payload),
});
toast.success('Integration created successfully');
}
setShowAddModal(false);
setShowEditModal(false);
loadIntegrations();
} catch (error: any) {
toast.error(`Failed to save integration: ${error.message}`);
} finally {
setIsSaving(false);
}
};
const getFields = (): FormField[] => {
const platform = selectedIntegration?.platform || 'wordpress';
const baseFields: FormField[] = [
{
name: 'platform',
label: 'Platform',
type: 'custom',
component: (
<PlatformSelector
value={platform}
onChange={() => {}}
disabled={!!selectedIntegration}
/>
),
required: true,
},
];
if (platform === 'wordpress') {
baseFields.push(
{ name: 'site_url', label: 'WordPress Site URL', type: 'text', required: true, placeholder: 'https://example.com' },
{ name: 'username', label: 'Username', type: 'text', required: true },
{ name: 'app_password', label: 'Application Password', type: 'password', required: true }
);
} else if (platform === 'shopify') {
baseFields.push(
{ name: 'shop_domain', label: 'Shop Domain', type: 'text', required: true, placeholder: 'myshop.myshopify.com' },
{ name: 'access_token', label: 'Access Token', type: 'password', required: true }
);
} else {
baseFields.push(
{ name: 'site_url', label: 'API URL', type: 'text', required: true },
{ name: 'username', label: 'API Key', type: 'text', required: true },
{ name: 'app_password', label: 'API Secret', type: 'password', required: false }
);
}
baseFields.push(
{ name: 'is_active', label: 'Active', type: 'checkbox', defaultValue: true },
{ name: 'sync_enabled', label: 'Enable Two-Way Sync', type: 'checkbox', defaultValue: false }
);
return baseFields;
};
if (loading) {
return <div>Loading integrations...</div>;
}
return (
<div className="space-y-6">
<div className="flex justify-between items-center">
<div>
<h2 className="text-2xl font-bold text-gray-900 dark:text-white">Site Integrations</h2>
<p className="text-sm text-gray-600 dark:text-gray-400 mt-1">
Connect your sites to external platforms (WordPress, Shopify, Custom APIs)
</p>
</div>
<Button onClick={handleAdd} variant="primary">
<PlusIcon className="w-4 h-4 mr-2" />
Add Integration
</Button>
</div>
{integrations.length === 0 ? (
<div className="text-center py-12 border border-dashed border-gray-300 dark:border-gray-700 rounded-lg">
<p className="text-gray-600 dark:text-gray-400">No integrations configured</p>
<Button onClick={handleAdd} variant="outline" className="mt-4">
Add Your First Integration
</Button>
</div>
) : (
<div className="grid grid-cols-1 md:grid-cols-2 lg:grid-cols-3 gap-4">
{integrations.map((integration) => (
<div
key={integration.id}
className="border border-gray-200 dark:border-gray-700 rounded-lg p-4 space-y-3"
>
<div className="flex justify-between items-start">
<div>
<h3 className="font-semibold text-gray-900 dark:text-white capitalize">
{integration.platform}
</h3>
{integration.site_name && (
<p className="text-sm text-gray-600 dark:text-gray-400">
{integration.site_name}
</p>
)}
</div>
<div className="flex gap-2">
<Button
variant="ghost"
size="sm"
onClick={() => handleTestConnection(integration)}
disabled={isTesting}
title="Test Connection"
>
<TestTubeIcon className="w-4 h-4" />
</Button>
<Button
variant="ghost"
size="sm"
onClick={() => handleDelete(integration.id)}
title="Delete"
>
<TrashIcon className="w-4 h-4" />
</Button>
</div>
</div>
<IntegrationStatus
syncEnabled={integration.sync_enabled}
syncStatus={integration.sync_status}
lastSyncAt={integration.last_sync_at}
syncError={integration.sync_error}
/>
<div className="flex gap-2 pt-2 border-t border-gray-200 dark:border-gray-700">
<Button
variant="outline"
size="sm"
onClick={() => handleEdit(integration)}
className="flex-1"
>
Edit
</Button>
{integration.sync_enabled && (
<Button
variant="outline"
size="sm"
onClick={() => handleSync(integration)}
className="flex-1"
>
<SyncIcon className="w-4 h-4 mr-1" />
Sync
</Button>
)}
</div>
</div>
))}
</div>
)}
{/* Add/Edit Modal */}
<FormModal
isOpen={showAddModal || showEditModal}
onClose={() => {
setShowAddModal(false);
setShowEditModal(false);
}}
onSubmit={handleSave}
title={selectedIntegration ? 'Edit Integration' : 'Add Integration'}
fields={getFields()}
submitLabel="Save"
cancelLabel="Cancel"
isLoading={isSaving}
initialValues={selectedIntegration ? {
platform: selectedIntegration.platform,
site_url: selectedIntegration.site_name || '',
is_active: selectedIntegration.is_active,
sync_enabled: selectedIntegration.sync_enabled,
} : undefined}
/>
</div>
);
}

View File

@@ -0,0 +1,242 @@
/**
* Publishing Rules Component
* Phase 6: Site Integration & Multi-Destination Publishing
*/
import React, { useState } from 'react';
import { PlusIcon, TrashIcon, ArrowUpIcon, ArrowDownIcon } from 'lucide-react';
import Button from '../ui/button/Button';
import Checkbox from '../form/input/Checkbox';
import Label from '../form/Label';
import SelectDropdown from '../form/SelectDropdown';
export interface PublishingRule {
id: string;
content_type: string;
trigger: 'auto' | 'manual' | 'scheduled';
destinations: string[];
priority: number;
enabled: boolean;
schedule?: string; // Cron expression for scheduled
}
interface PublishingRulesProps {
rules: PublishingRule[];
onChange: (rules: PublishingRule[]) => void;
}
const CONTENT_TYPES = [
{ value: 'blog_post', label: 'Blog Post' },
{ value: 'page', label: 'Page' },
{ value: 'product', label: 'Product' },
{ value: 'all', label: 'All Content' },
];
const TRIGGERS = [
{ value: 'auto', label: 'Auto-Publish' },
{ value: 'manual', label: 'Manual' },
{ value: 'scheduled', label: 'Scheduled' },
];
const DESTINATIONS = [
{ value: 'sites', label: 'IGNY8 Sites' },
{ value: 'wordpress', label: 'WordPress' },
{ value: 'shopify', label: 'Shopify' },
];
export default function PublishingRules({ rules, onChange }: PublishingRulesProps) {
const [localRules, setLocalRules] = useState<PublishingRule[]>(rules);
const handleAddRule = () => {
const newRule: PublishingRule = {
id: `rule_${Date.now()}`,
content_type: 'all',
trigger: 'manual',
destinations: ['sites'],
priority: localRules.length + 1,
enabled: true,
};
const updated = [...localRules, newRule];
setLocalRules(updated);
onChange(updated);
};
const handleDeleteRule = (id: string) => {
const updated = localRules.filter((r) => r.id !== id);
setLocalRules(updated);
onChange(updated);
};
const handleUpdateRule = (id: string, field: keyof PublishingRule, value: any) => {
const updated = localRules.map((rule) =>
rule.id === id ? { ...rule, [field]: value } : rule
);
setLocalRules(updated);
onChange(updated);
};
const handleMoveRule = (id: string, direction: 'up' | 'down') => {
const index = localRules.findIndex((r) => r.id === id);
if (index === -1) return;
const newIndex = direction === 'up' ? index - 1 : index + 1;
if (newIndex < 0 || newIndex >= localRules.length) return;
const updated = [...localRules];
[updated[index], updated[newIndex]] = [updated[newIndex], updated[index]];
// Update priorities
updated.forEach((rule, i) => {
rule.priority = i + 1;
});
setLocalRules(updated);
onChange(updated);
};
const handleToggleDestinations = (ruleId: string, destination: string) => {
const rule = localRules.find((r) => r.id === ruleId);
if (!rule) return;
const destinations = rule.destinations.includes(destination)
? rule.destinations.filter((d) => d !== destination)
: [...rule.destinations, destination];
handleUpdateRule(ruleId, 'destinations', destinations);
};
return (
<div className="space-y-4">
<div className="flex justify-between items-center">
<div>
<h3 className="text-lg font-semibold text-gray-900 dark:text-white">
Publishing Rules
</h3>
<p className="text-sm text-gray-600 dark:text-gray-400 mt-1">
Configure how and where content is published
</p>
</div>
<Button onClick={handleAddRule} variant="primary" size="sm">
<PlusIcon className="w-4 h-4 mr-2" />
Add Rule
</Button>
</div>
{localRules.length === 0 ? (
<div className="text-center py-8 border border-dashed border-gray-300 dark:border-gray-700 rounded-lg">
<p className="text-gray-600 dark:text-gray-400 mb-4">No publishing rules configured</p>
<Button onClick={handleAddRule} variant="outline">
Add Your First Rule
</Button>
</div>
) : (
<div className="space-y-3">
{localRules.map((rule, index) => (
<div
key={rule.id}
className="border border-gray-200 dark:border-gray-700 rounded-lg p-4 space-y-3"
>
<div className="flex items-start justify-between">
<div className="flex-1 grid grid-cols-1 md:grid-cols-3 gap-4">
<div>
<Label>Content Type</Label>
<SelectDropdown
options={CONTENT_TYPES}
value={rule.content_type}
onChange={(e) =>
handleUpdateRule(rule.id, 'content_type', e.target.value)
}
/>
</div>
<div>
<Label>Trigger</Label>
<SelectDropdown
options={TRIGGERS}
value={rule.trigger}
onChange={(e) =>
handleUpdateRule(rule.id, 'trigger', e.target.value)
}
/>
</div>
<div>
<Label>Priority</Label>
<div className="flex items-center gap-2">
<Button
variant="ghost"
size="sm"
onClick={() => handleMoveRule(rule.id, 'up')}
disabled={index === 0}
>
<ArrowUpIcon className="w-4 h-4" />
</Button>
<span className="text-sm font-medium">{rule.priority}</span>
<Button
variant="ghost"
size="sm"
onClick={() => handleMoveRule(rule.id, 'down')}
disabled={index === localRules.length - 1}
>
<ArrowDownIcon className="w-4 h-4" />
</Button>
</div>
</div>
</div>
<div className="flex items-center gap-2 ml-4">
<Checkbox
checked={rule.enabled}
onChange={(e) =>
handleUpdateRule(rule.id, 'enabled', e.target.checked)
}
label="Enabled"
/>
<Button
variant="ghost"
size="sm"
onClick={() => handleDeleteRule(rule.id)}
>
<TrashIcon className="w-4 h-4" />
</Button>
</div>
</div>
<div>
<Label>Destinations</Label>
<div className="flex flex-wrap gap-3 mt-2">
{DESTINATIONS.map((dest) => (
<Checkbox
key={dest.value}
checked={rule.destinations.includes(dest.value)}
onChange={() => handleToggleDestinations(rule.id, dest.value)}
label={dest.label}
/>
))}
</div>
</div>
{rule.trigger === 'scheduled' && (
<div>
<Label>Schedule (Cron Expression)</Label>
<input
type="text"
value={rule.schedule || ''}
onChange={(e) =>
handleUpdateRule(rule.id, 'schedule', e.target.value)
}
placeholder="0 0 * * *"
className="mt-1 w-full px-3 py-2 border border-gray-300 dark:border-gray-700 rounded-md dark:bg-gray-800 dark:text-white"
/>
<p className="text-xs text-gray-500 dark:text-gray-400 mt-1">
Cron format: minute hour day month weekday
</p>
</div>
)}
</div>
))}
</div>
)}
</div>
);
}