Section 2 Part 3
This commit is contained in:
@@ -3,6 +3,7 @@ interface ComponentCardProps {
|
|||||||
children: React.ReactNode;
|
children: React.ReactNode;
|
||||||
className?: string; // Additional custom classes for styling
|
className?: string; // Additional custom classes for styling
|
||||||
desc?: string | React.ReactNode; // Description text
|
desc?: string | React.ReactNode; // Description text
|
||||||
|
headerContent?: React.ReactNode; // Additional content to display in header (e.g., actions, navigation)
|
||||||
}
|
}
|
||||||
|
|
||||||
const ComponentCard: React.FC<ComponentCardProps> = ({
|
const ComponentCard: React.FC<ComponentCardProps> = ({
|
||||||
@@ -10,21 +11,29 @@ const ComponentCard: React.FC<ComponentCardProps> = ({
|
|||||||
children,
|
children,
|
||||||
className = "",
|
className = "",
|
||||||
desc = "",
|
desc = "",
|
||||||
|
headerContent,
|
||||||
}) => {
|
}) => {
|
||||||
return (
|
return (
|
||||||
<div
|
<div
|
||||||
className={`rounded-2xl border border-gray-200 bg-white dark:border-gray-800 dark:bg-white/[0.03] overflow-visible ${className}`}
|
className={`rounded-2xl border border-gray-200 bg-white dark:border-gray-800 dark:bg-white/[0.03] overflow-visible ${className}`}
|
||||||
>
|
>
|
||||||
{/* Card Header (render only when title or desc provided) */}
|
{/* Card Header (render only when title or desc provided) */}
|
||||||
{(title || desc) && (
|
{(title || desc || headerContent) && (
|
||||||
<div className="px-6 py-5 relative z-0">
|
<div className="px-6 py-5 relative z-0 flex items-start justify-between gap-4">
|
||||||
<h3 className="text-base font-medium text-gray-800 dark:text-white/90">
|
<div className="flex-1">
|
||||||
{title}
|
<h3 className="text-base font-medium text-gray-800 dark:text-white/90">
|
||||||
</h3>
|
{title}
|
||||||
{desc && (
|
</h3>
|
||||||
<p className="mt-1 text-sm text-gray-500 dark:text-gray-400">
|
{desc && (
|
||||||
{desc}
|
<p className="mt-1 text-sm text-gray-500 dark:text-gray-400">
|
||||||
</p>
|
{desc}
|
||||||
|
</p>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
{headerContent && (
|
||||||
|
<div className="flex-shrink-0">
|
||||||
|
{headerContent}
|
||||||
|
</div>
|
||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
|
|||||||
@@ -56,13 +56,21 @@ export default function SingleSiteSelector() {
|
|||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
const handleSiteSelect = async (siteId: number) => {
|
const handleSiteSelect = async (newSiteId: number) => {
|
||||||
try {
|
try {
|
||||||
await apiSetActiveSite(siteId);
|
await apiSetActiveSite(newSiteId);
|
||||||
const selectedSite = sites.find(s => s.id === siteId);
|
const selectedSite = sites.find(s => s.id === newSiteId);
|
||||||
if (selectedSite) {
|
if (selectedSite) {
|
||||||
setActiveSite(selectedSite);
|
setActiveSite(selectedSite);
|
||||||
toast.success(`Switched to "${selectedSite.name}"`);
|
toast.success(`Switched to "${selectedSite.name}"`);
|
||||||
|
|
||||||
|
// If we're on a site-specific page (/sites/:id/...), navigate to same subpage for new site
|
||||||
|
const path = window.location.pathname;
|
||||||
|
const sitePageMatch = path.match(/^\/sites\/(\d+)(\/.*)?$/);
|
||||||
|
if (sitePageMatch) {
|
||||||
|
const subPath = sitePageMatch[2] || ''; // e.g., '/settings', '/content', ''
|
||||||
|
navigate(`/sites/${newSiteId}${subPath}`);
|
||||||
|
}
|
||||||
}
|
}
|
||||||
setSitesOpen(false);
|
setSitesOpen(false);
|
||||||
} catch (error: any) {
|
} catch (error: any) {
|
||||||
|
|||||||
133
frontend/src/components/common/SiteInfoBar.tsx
Normal file
133
frontend/src/components/common/SiteInfoBar.tsx
Normal file
@@ -0,0 +1,133 @@
|
|||||||
|
/**
|
||||||
|
* SiteInfoBar - Reusable site info bar for site-specific pages
|
||||||
|
* Shows site name, URL, badges, and action buttons in a single row
|
||||||
|
*/
|
||||||
|
import { useNavigate } from 'react-router-dom';
|
||||||
|
import Button from '../ui/button/Button';
|
||||||
|
import {
|
||||||
|
GridIcon,
|
||||||
|
FileIcon,
|
||||||
|
GlobeIcon,
|
||||||
|
PlusIcon,
|
||||||
|
} from '../../icons';
|
||||||
|
|
||||||
|
interface SiteInfoBarProps {
|
||||||
|
site: {
|
||||||
|
id: number;
|
||||||
|
name: string;
|
||||||
|
domain?: string;
|
||||||
|
url?: string;
|
||||||
|
site_type?: string;
|
||||||
|
hosting_type?: string;
|
||||||
|
is_active?: boolean;
|
||||||
|
} | null;
|
||||||
|
/** Current page - determines which buttons to show */
|
||||||
|
currentPage: 'dashboard' | 'settings' | 'content';
|
||||||
|
/** Optional: total items count for content page */
|
||||||
|
itemsCount?: number;
|
||||||
|
/** Optional: show New Post button */
|
||||||
|
showNewPostButton?: boolean;
|
||||||
|
}
|
||||||
|
|
||||||
|
export default function SiteInfoBar({
|
||||||
|
site,
|
||||||
|
currentPage,
|
||||||
|
itemsCount,
|
||||||
|
showNewPostButton = false,
|
||||||
|
}: SiteInfoBarProps) {
|
||||||
|
const navigate = useNavigate();
|
||||||
|
|
||||||
|
if (!site) return null;
|
||||||
|
|
||||||
|
const siteUrl = site.domain || site.url;
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="mb-6 px-4 py-3 rounded-xl bg-white dark:bg-gray-900 border border-gray-200 dark:border-gray-800 shadow-sm">
|
||||||
|
<div className="flex items-center justify-between gap-4 flex-wrap">
|
||||||
|
{/* Left: Badges */}
|
||||||
|
<div className="flex items-center gap-2">
|
||||||
|
<span className="inline-flex items-center px-2.5 py-1 rounded-md text-xs font-medium bg-brand-50 text-brand-700 dark:bg-brand-900/30 dark:text-brand-300 border border-brand-200 dark:border-brand-800">
|
||||||
|
{site.site_type || 'marketing'}
|
||||||
|
</span>
|
||||||
|
<span className="inline-flex items-center px-2.5 py-1 rounded-md text-xs font-medium bg-purple-50 text-purple-700 dark:bg-purple-900/30 dark:text-purple-300 border border-purple-200 dark:border-purple-800">
|
||||||
|
{site.hosting_type || 'igny8_sites'}
|
||||||
|
</span>
|
||||||
|
<span className={`inline-flex items-center px-2.5 py-1 rounded-md text-xs font-medium ${
|
||||||
|
site.is_active !== false
|
||||||
|
? 'bg-success-50 text-success-700 dark:bg-success-900/30 dark:text-success-300 border border-success-200 dark:border-success-800'
|
||||||
|
: 'bg-gray-50 text-gray-700 dark:bg-gray-800 dark:text-gray-300 border border-gray-200 dark:border-gray-700'
|
||||||
|
}`}>
|
||||||
|
{site.is_active !== false ? '● Active' : '○ Inactive'}
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Center: Site Name and URL */}
|
||||||
|
<div className="flex-1 text-center min-w-0">
|
||||||
|
<h2 className="text-base font-semibold text-gray-900 dark:text-white truncate">{site.name}</h2>
|
||||||
|
{siteUrl && (
|
||||||
|
<a
|
||||||
|
href={siteUrl.startsWith('http') ? siteUrl : `https://${siteUrl}`}
|
||||||
|
target="_blank"
|
||||||
|
rel="noopener noreferrer"
|
||||||
|
className="text-brand-600 hover:text-brand-700 dark:text-brand-400 text-sm transition truncate inline-block max-w-full"
|
||||||
|
>
|
||||||
|
{siteUrl}
|
||||||
|
</a>
|
||||||
|
)}
|
||||||
|
{itemsCount !== undefined && (
|
||||||
|
<span className="text-sm text-gray-500 dark:text-gray-400 ml-2">
|
||||||
|
({itemsCount} items)
|
||||||
|
</span>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Right: Action Buttons */}
|
||||||
|
<div className="flex items-center gap-2">
|
||||||
|
{currentPage !== 'dashboard' && (
|
||||||
|
<Button
|
||||||
|
onClick={() => navigate(`/sites/${site.id}`)}
|
||||||
|
variant="outline"
|
||||||
|
size="sm"
|
||||||
|
className="border-brand-300 text-brand-700 hover:bg-brand-50 dark:border-brand-600 dark:text-brand-400 dark:hover:bg-brand-900/20"
|
||||||
|
startIcon={<GlobeIcon className="w-4 h-4" />}
|
||||||
|
>
|
||||||
|
Dashboard
|
||||||
|
</Button>
|
||||||
|
)}
|
||||||
|
{currentPage !== 'settings' && (
|
||||||
|
<Button
|
||||||
|
onClick={() => navigate(`/sites/${site.id}/settings`)}
|
||||||
|
variant="outline"
|
||||||
|
size="sm"
|
||||||
|
className="border-purple-300 text-purple-700 hover:bg-purple-50 dark:border-purple-600 dark:text-purple-400 dark:hover:bg-purple-900/20"
|
||||||
|
startIcon={<GridIcon className="w-4 h-4" />}
|
||||||
|
>
|
||||||
|
Settings
|
||||||
|
</Button>
|
||||||
|
)}
|
||||||
|
{currentPage !== 'content' && (
|
||||||
|
<Button
|
||||||
|
onClick={() => navigate(`/sites/${site.id}/content`)}
|
||||||
|
variant="outline"
|
||||||
|
size="sm"
|
||||||
|
className="border-success-300 text-success-700 hover:bg-success-50 dark:border-success-600 dark:text-success-400 dark:hover:bg-success-900/20"
|
||||||
|
startIcon={<FileIcon className="w-4 h-4" />}
|
||||||
|
>
|
||||||
|
Content
|
||||||
|
</Button>
|
||||||
|
)}
|
||||||
|
{showNewPostButton && (
|
||||||
|
<Button
|
||||||
|
onClick={() => navigate(`/sites/${site.id}/posts/new`)}
|
||||||
|
variant="primary"
|
||||||
|
size="sm"
|
||||||
|
startIcon={<PlusIcon className="w-4 h-4" />}
|
||||||
|
>
|
||||||
|
New Post
|
||||||
|
</Button>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -170,3 +170,4 @@ export { AlertIcon as BellIcon }; // Bell notification alias
|
|||||||
export { PlugInIcon as TestTubeIcon }; // Test tube alias
|
export { PlugInIcon as TestTubeIcon }; // Test tube alias
|
||||||
export { MailIcon as Mail }; // Mail without Icon suffix
|
export { MailIcon as Mail }; // Mail without Icon suffix
|
||||||
export { BoxIcon as PackageIcon }; // Package alias
|
export { BoxIcon as PackageIcon }; // Package alias
|
||||||
|
export { GridIcon as LayersIcon }; // Layers alias
|
||||||
|
|||||||
@@ -24,6 +24,7 @@ const SINGLE_SITE_ROUTES = [
|
|||||||
'/automation',
|
'/automation',
|
||||||
'/publisher', // Content Calendar page
|
'/publisher', // Content Calendar page
|
||||||
'/account/content-settings', // Content settings and sub-pages
|
'/account/content-settings', // Content settings and sub-pages
|
||||||
|
'/sites', // Site dashboard and site settings pages (matches /sites/21, /sites/21/settings, /sites/21/content)
|
||||||
];
|
];
|
||||||
|
|
||||||
const SITE_WITH_ALL_SITES_ROUTES = [
|
const SITE_WITH_ALL_SITES_ROUTES = [
|
||||||
|
|||||||
@@ -54,17 +54,32 @@ const AppSidebar: React.FC = () => {
|
|||||||
);
|
);
|
||||||
const subMenuRefs = useRef<Record<string, HTMLDivElement | null>>({});
|
const subMenuRefs = useRef<Record<string, HTMLDivElement | null>>({});
|
||||||
|
|
||||||
|
// Check if a path is active - exact match only for menu items
|
||||||
|
// Prefix matching is only used for parent menus to determine if submenu should be open
|
||||||
const isActive = useCallback(
|
const isActive = useCallback(
|
||||||
(path: string) => {
|
(path: string, exactOnly: boolean = false) => {
|
||||||
// Exact match
|
// Exact match always works
|
||||||
if (location.pathname === path) return true;
|
if (location.pathname === path) return true;
|
||||||
// For sub-pages, match if pathname starts with the path (except for root)
|
|
||||||
if (path !== '/' && location.pathname.startsWith(path + '/')) return true;
|
// For prefix matching (used by parent menus to check if any child is active)
|
||||||
|
// Skip if exactOnly is requested (for submenu items)
|
||||||
|
if (!exactOnly && path !== '/' && location.pathname.startsWith(path + '/')) {
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
|
||||||
return false;
|
return false;
|
||||||
},
|
},
|
||||||
[location.pathname]
|
[location.pathname]
|
||||||
);
|
);
|
||||||
|
|
||||||
|
// Check if a submenu item path is active - uses exact match only
|
||||||
|
const isSubItemActive = useCallback(
|
||||||
|
(path: string) => {
|
||||||
|
return location.pathname === path;
|
||||||
|
},
|
||||||
|
[location.pathname]
|
||||||
|
);
|
||||||
|
|
||||||
// Define menu sections with useMemo to prevent recreation on every render
|
// Define menu sections with useMemo to prevent recreation on every render
|
||||||
// New structure: Dashboard (standalone) → SETUP → WORKFLOW → SETTINGS
|
// New structure: Dashboard (standalone) → SETUP → WORKFLOW → SETTINGS
|
||||||
// Module visibility is controlled by GlobalModuleSettings (Django Admin only)
|
// Module visibility is controlled by GlobalModuleSettings (Django Admin only)
|
||||||
@@ -242,15 +257,12 @@ const AppSidebar: React.FC = () => {
|
|||||||
const currentPath = location.pathname;
|
const currentPath = location.pathname;
|
||||||
let foundMatch = false;
|
let foundMatch = false;
|
||||||
|
|
||||||
// Find the matching submenu for the current path
|
// Find the matching submenu for the current path - use exact match only for subitems
|
||||||
allSections.forEach((section, sectionIndex) => {
|
allSections.forEach((section, sectionIndex) => {
|
||||||
section.items.forEach((nav, itemIndex) => {
|
section.items.forEach((nav, itemIndex) => {
|
||||||
if (nav.subItems && !foundMatch) {
|
if (nav.subItems && !foundMatch) {
|
||||||
const shouldOpen = nav.subItems.some((subItem) => {
|
// Only use exact match for submenu items to prevent multiple active states
|
||||||
if (currentPath === subItem.path) return true;
|
const shouldOpen = nav.subItems.some((subItem) => currentPath === subItem.path);
|
||||||
if (subItem.path !== '/' && currentPath.startsWith(subItem.path + '/')) return true;
|
|
||||||
return false;
|
|
||||||
});
|
|
||||||
|
|
||||||
if (shouldOpen) {
|
if (shouldOpen) {
|
||||||
setOpenSubmenu((prev) => {
|
setOpenSubmenu((prev) => {
|
||||||
@@ -326,8 +338,8 @@ const AppSidebar: React.FC = () => {
|
|||||||
return true;
|
return true;
|
||||||
})
|
})
|
||||||
.map((nav, itemIndex) => {
|
.map((nav, itemIndex) => {
|
||||||
// Check if any subitem is active to determine parent active state
|
// Check if any subitem is active to determine parent active state (uses exact match for subitems)
|
||||||
const hasActiveSubItem = nav.subItems?.some(subItem => isActive(subItem.path)) ?? false;
|
const hasActiveSubItem = nav.subItems?.some(subItem => isSubItemActive(subItem.path)) ?? false;
|
||||||
const isSubmenuOpen = openSubmenu?.sectionIndex === sectionIndex && openSubmenu?.itemIndex === itemIndex;
|
const isSubmenuOpen = openSubmenu?.sectionIndex === sectionIndex && openSubmenu?.itemIndex === itemIndex;
|
||||||
|
|
||||||
return (
|
return (
|
||||||
@@ -408,7 +420,7 @@ const AppSidebar: React.FC = () => {
|
|||||||
<Link
|
<Link
|
||||||
to={subItem.path}
|
to={subItem.path}
|
||||||
className={`menu-dropdown-item ${
|
className={`menu-dropdown-item ${
|
||||||
isActive(subItem.path)
|
isSubItemActive(subItem.path)
|
||||||
? "menu-dropdown-item-active"
|
? "menu-dropdown-item-active"
|
||||||
: "menu-dropdown-item-inactive"
|
: "menu-dropdown-item-inactive"
|
||||||
}`}
|
}`}
|
||||||
@@ -418,7 +430,7 @@ const AppSidebar: React.FC = () => {
|
|||||||
{subItem.new && (
|
{subItem.new && (
|
||||||
<span
|
<span
|
||||||
className={`ml-auto ${
|
className={`ml-auto ${
|
||||||
isActive(subItem.path)
|
isSubItemActive(subItem.path)
|
||||||
? "menu-dropdown-badge-active"
|
? "menu-dropdown-badge-active"
|
||||||
: "menu-dropdown-badge-inactive"
|
: "menu-dropdown-badge-inactive"
|
||||||
} menu-dropdown-badge`}
|
} menu-dropdown-badge`}
|
||||||
@@ -429,7 +441,7 @@ const AppSidebar: React.FC = () => {
|
|||||||
{subItem.pro && (
|
{subItem.pro && (
|
||||||
<span
|
<span
|
||||||
className={`ml-auto ${
|
className={`ml-auto ${
|
||||||
isActive(subItem.path)
|
isSubItemActive(subItem.path)
|
||||||
? "menu-dropdown-badge-active"
|
? "menu-dropdown-badge-active"
|
||||||
: "menu-dropdown-badge-inactive"
|
: "menu-dropdown-badge-inactive"
|
||||||
} menu-dropdown-badge`}
|
} menu-dropdown-badge`}
|
||||||
|
|||||||
@@ -448,7 +448,7 @@ const AutomationPage: React.FC = () => {
|
|||||||
|
|
||||||
{/* Compact Schedule & Controls Panel */}
|
{/* Compact Schedule & Controls Panel */}
|
||||||
{config && (
|
{config && (
|
||||||
<ComponentCard className="border-0 overflow-hidden rounded-2xl bg-gradient-to-br from-brand-600 to-brand-700 [&>div]:!py-3 [&>div]:!px-4">
|
<ComponentCard className="mt-[10px] border-0 overflow-hidden rounded-2xl bg-gradient-to-br from-brand-600 to-brand-700 [&>div]:!py-3 [&>div]:!px-4">
|
||||||
<div className="flex items-center justify-between gap-4">
|
<div className="flex items-center justify-between gap-4">
|
||||||
<div className="flex items-center gap-4 flex-wrap">
|
<div className="flex items-center gap-4 flex-wrap">
|
||||||
<div className="flex items-center gap-2">
|
<div className="flex items-center gap-2">
|
||||||
@@ -554,20 +554,20 @@ const AutomationPage: React.FC = () => {
|
|||||||
{/* Metrics Summary Cards */}
|
{/* Metrics Summary Cards */}
|
||||||
<div className="grid grid-cols-2 md:grid-cols-5 gap-4">
|
<div className="grid grid-cols-2 md:grid-cols-5 gap-4">
|
||||||
{/* Keywords */}
|
{/* Keywords */}
|
||||||
<div className="bg-gradient-to-br from-brand-50 to-brand-100 dark:from-brand-900/20 dark:to-brand-800/20 rounded-xl p-4 border-2 border-brand-200 dark:border-brand-800">
|
<div className="bg-white dark:bg-gray-900 rounded-xl p-4">
|
||||||
<div className="flex items-center justify-between mb-3">
|
<div className="flex items-center justify-between mb-3">
|
||||||
<div className="flex items-center gap-2">
|
<div className="flex items-center gap-2">
|
||||||
<div className="size-8 rounded-lg bg-gradient-to-br from-brand-500 to-brand-600 flex items-center justify-center">
|
<div className="size-8 rounded-lg bg-brand-100 dark:bg-brand-900/30 flex items-center justify-center">
|
||||||
<ListIcon className="size-4 text-white" />
|
<ListIcon className="size-4 text-brand-600 dark:text-brand-400" />
|
||||||
</div>
|
</div>
|
||||||
<div className="text-base font-bold text-brand-900 dark:text-brand-100">Keywords</div>
|
<div className="text-base font-bold text-gray-900 dark:text-white">Keywords</div>
|
||||||
</div>
|
</div>
|
||||||
{(() => {
|
{(() => {
|
||||||
const res = getStageResult(1);
|
const res = getStageResult(1);
|
||||||
const total = res?.total ?? pipelineOverview[0]?.counts?.total ?? metrics?.keywords?.total ?? pipelineOverview[0]?.pending ?? 0;
|
const total = res?.total ?? pipelineOverview[0]?.counts?.total ?? metrics?.keywords?.total ?? pipelineOverview[0]?.pending ?? 0;
|
||||||
return (
|
return (
|
||||||
<div className="text-right">
|
<div className="text-right">
|
||||||
<div className="text-3xl font-bold text-brand-900">{total}</div>
|
<div className="text-3xl font-bold text-brand-600">{total}</div>
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
})()}
|
})()}
|
||||||
@@ -578,28 +578,28 @@ const AutomationPage: React.FC = () => {
|
|||||||
const mapped = res?.mapped ?? pipelineOverview[0]?.counts?.mapped ?? metrics?.keywords?.mapped ?? 0;
|
const mapped = res?.mapped ?? pipelineOverview[0]?.counts?.mapped ?? metrics?.keywords?.mapped ?? 0;
|
||||||
return (
|
return (
|
||||||
renderMetricRow([
|
renderMetricRow([
|
||||||
{ label: 'New:', value: newCount, colorCls: 'text-brand-700' },
|
{ label: 'New:', value: newCount, colorCls: 'text-brand-600' },
|
||||||
{ label: 'Mapped:', value: mapped, colorCls: 'text-brand-700' },
|
{ label: 'Mapped:', value: mapped, colorCls: 'text-brand-600' },
|
||||||
])
|
])
|
||||||
);
|
);
|
||||||
})()}
|
})()}
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
{/* Clusters */}
|
{/* Clusters */}
|
||||||
<div className="bg-gradient-to-br from-purple-50 to-purple-100 dark:from-purple-900/20 dark:to-purple-800/20 rounded-xl p-4 border-2 border-purple-200 dark:border-purple-800">
|
<div className="bg-white dark:bg-gray-900 rounded-xl p-4">
|
||||||
<div className="flex items-center justify-between mb-3">
|
<div className="flex items-center justify-between mb-3">
|
||||||
<div className="flex items-center gap-2">
|
<div className="flex items-center gap-2">
|
||||||
<div className="size-8 rounded-lg bg-gradient-to-br from-purple-500 to-purple-600 flex items-center justify-center">
|
<div className="size-8 rounded-lg bg-purple-100 dark:bg-purple-900/30 flex items-center justify-center">
|
||||||
<GroupIcon className="size-4 text-white" />
|
<GroupIcon className="size-4 text-purple-600 dark:text-purple-400" />
|
||||||
</div>
|
</div>
|
||||||
<div className="text-base font-bold text-purple-900 dark:text-purple-100">Clusters</div>
|
<div className="text-base font-bold text-gray-900 dark:text-white">Clusters</div>
|
||||||
</div>
|
</div>
|
||||||
{(() => {
|
{(() => {
|
||||||
const res = getStageResult(2);
|
const res = getStageResult(2);
|
||||||
const total = res?.total ?? pipelineOverview[1]?.counts?.total ?? metrics?.clusters?.total ?? pipelineOverview[1]?.pending ?? 0;
|
const total = res?.total ?? pipelineOverview[1]?.counts?.total ?? metrics?.clusters?.total ?? pipelineOverview[1]?.pending ?? 0;
|
||||||
return (
|
return (
|
||||||
<div className="text-right">
|
<div className="text-right">
|
||||||
<div className="text-3xl font-bold text-purple-900">{total}</div>
|
<div className="text-3xl font-bold text-purple-600">{total}</div>
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
})()}
|
})()}
|
||||||
@@ -610,28 +610,28 @@ const AutomationPage: React.FC = () => {
|
|||||||
const mapped = res?.mapped ?? pipelineOverview[1]?.counts?.mapped ?? metrics?.clusters?.mapped ?? 0;
|
const mapped = res?.mapped ?? pipelineOverview[1]?.counts?.mapped ?? metrics?.clusters?.mapped ?? 0;
|
||||||
return (
|
return (
|
||||||
renderMetricRow([
|
renderMetricRow([
|
||||||
{ label: 'New:', value: newCount, colorCls: 'text-purple-700' },
|
{ label: 'New:', value: newCount, colorCls: 'text-purple-600' },
|
||||||
{ label: 'Mapped:', value: mapped, colorCls: 'text-purple-700' },
|
{ label: 'Mapped:', value: mapped, colorCls: 'text-purple-600' },
|
||||||
])
|
])
|
||||||
);
|
);
|
||||||
})()}
|
})()}
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
{/* Ideas */}
|
{/* Ideas */}
|
||||||
<div className="bg-gradient-to-br from-warning-50 to-warning-100 dark:from-warning-900/20 dark:to-warning-800/20 rounded-xl p-4 border-2 border-warning-200 dark:border-warning-800">
|
<div className="bg-white dark:bg-gray-900 rounded-xl p-4">
|
||||||
<div className="flex items-center justify-between mb-3">
|
<div className="flex items-center justify-between mb-3">
|
||||||
<div className="flex items-center gap-2">
|
<div className="flex items-center gap-2">
|
||||||
<div className="size-8 rounded-lg bg-gradient-to-br from-warning-500 to-warning-600 flex items-center justify-center">
|
<div className="size-8 rounded-lg bg-warning-100 dark:bg-warning-900/30 flex items-center justify-center">
|
||||||
<BoltIcon className="size-4 text-white" />
|
<BoltIcon className="size-4 text-warning-600 dark:text-warning-400" />
|
||||||
</div>
|
</div>
|
||||||
<div className="text-base font-bold text-warning-900 dark:text-warning-100">Ideas</div>
|
<div className="text-base font-bold text-gray-900 dark:text-white">Ideas</div>
|
||||||
</div>
|
</div>
|
||||||
{(() => {
|
{(() => {
|
||||||
const res = getStageResult(3);
|
const res = getStageResult(3);
|
||||||
const total = res?.total ?? pipelineOverview[2]?.counts?.total ?? metrics?.ideas?.total ?? pipelineOverview[2]?.pending ?? 0;
|
const total = res?.total ?? pipelineOverview[2]?.counts?.total ?? metrics?.ideas?.total ?? pipelineOverview[2]?.pending ?? 0;
|
||||||
return (
|
return (
|
||||||
<div className="text-right">
|
<div className="text-right">
|
||||||
<div className="text-3xl font-bold text-warning-900">{total}</div>
|
<div className="text-3xl font-bold text-warning-600">{total}</div>
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
})()}
|
})()}
|
||||||
@@ -643,29 +643,29 @@ const AutomationPage: React.FC = () => {
|
|||||||
const completed = res?.completed ?? pipelineOverview[2]?.counts?.completed ?? metrics?.ideas?.completed ?? 0;
|
const completed = res?.completed ?? pipelineOverview[2]?.counts?.completed ?? metrics?.ideas?.completed ?? 0;
|
||||||
return (
|
return (
|
||||||
renderMetricRow([
|
renderMetricRow([
|
||||||
{ label: 'New:', value: newCount, colorCls: 'text-warning-700' },
|
{ label: 'New:', value: newCount, colorCls: 'text-warning-600' },
|
||||||
{ label: 'Queued:', value: queued, colorCls: 'text-warning-700' },
|
{ label: 'Queued:', value: queued, colorCls: 'text-warning-600' },
|
||||||
{ label: 'Completed:', value: completed, colorCls: 'text-warning-700' },
|
{ label: 'Completed:', value: completed, colorCls: 'text-warning-600' },
|
||||||
])
|
])
|
||||||
);
|
);
|
||||||
})()}
|
})()}
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
{/* Content */}
|
{/* Content */}
|
||||||
<div className="bg-gradient-to-br from-success-50 to-success-100 dark:from-success-900/20 dark:to-success-800/20 rounded-xl p-4 border-2 border-success-200 dark:border-success-800">
|
<div className="bg-white dark:bg-gray-900 rounded-xl p-4">
|
||||||
<div className="flex items-center justify-between mb-3">
|
<div className="flex items-center justify-between mb-3">
|
||||||
<div className="flex items-center gap-2">
|
<div className="flex items-center gap-2">
|
||||||
<div className="size-8 rounded-lg bg-gradient-to-br from-success-500 to-success-600 flex items-center justify-center">
|
<div className="size-8 rounded-lg bg-success-100 dark:bg-success-900/30 flex items-center justify-center">
|
||||||
<FileTextIcon className="size-4 text-white" />
|
<FileTextIcon className="size-4 text-success-600 dark:text-success-400" />
|
||||||
</div>
|
</div>
|
||||||
<div className="text-base font-bold text-success-900 dark:text-success-100">Content</div>
|
<div className="text-base font-bold text-gray-900 dark:text-white">Content</div>
|
||||||
</div>
|
</div>
|
||||||
{(() => {
|
{(() => {
|
||||||
const res = getStageResult(4);
|
const res = getStageResult(4);
|
||||||
const total = res?.total ?? pipelineOverview[3]?.counts?.total ?? metrics?.content?.total ?? pipelineOverview[3]?.pending ?? 0;
|
const total = res?.total ?? pipelineOverview[3]?.counts?.total ?? metrics?.content?.total ?? pipelineOverview[3]?.pending ?? 0;
|
||||||
return (
|
return (
|
||||||
<div className="text-right">
|
<div className="text-right">
|
||||||
<div className="text-3xl font-bold text-success-900">{total}</div>
|
<div className="text-3xl font-bold text-success-600">{total}</div>
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
})()}
|
})()}
|
||||||
@@ -677,29 +677,29 @@ const AutomationPage: React.FC = () => {
|
|||||||
const publish = res?.published ?? res?.publish ?? pipelineOverview[3]?.counts?.published ?? metrics?.content?.published ?? 0;
|
const publish = res?.published ?? res?.publish ?? pipelineOverview[3]?.counts?.published ?? metrics?.content?.published ?? 0;
|
||||||
return (
|
return (
|
||||||
renderMetricRow([
|
renderMetricRow([
|
||||||
{ label: 'Draft:', value: draft, colorCls: 'text-success-700' },
|
{ label: 'Draft:', value: draft, colorCls: 'text-success-600' },
|
||||||
{ label: 'Review:', value: review, colorCls: 'text-success-700' },
|
{ label: 'Review:', value: review, colorCls: 'text-success-600' },
|
||||||
{ label: 'Publish:', value: publish, colorCls: 'text-success-700' },
|
{ label: 'Publish:', value: publish, colorCls: 'text-success-600' },
|
||||||
])
|
])
|
||||||
);
|
);
|
||||||
})()}
|
})()}
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
{/* Images */}
|
{/* Images */}
|
||||||
<div className="bg-gradient-to-br from-purple-50 to-purple-100 dark:from-purple-900/20 dark:to-purple-800/20 rounded-xl p-4 border-2 border-purple-200 dark:border-purple-800">
|
<div className="bg-white dark:bg-gray-900 rounded-xl p-4">
|
||||||
<div className="flex items-center justify-between mb-3">
|
<div className="flex items-center justify-between mb-3">
|
||||||
<div className="flex items-center gap-2">
|
<div className="flex items-center gap-2">
|
||||||
<div className="size-8 rounded-lg bg-gradient-to-br from-purple-500 to-purple-600 flex items-center justify-center">
|
<div className="size-8 rounded-lg bg-info-100 dark:bg-info-900/30 flex items-center justify-center">
|
||||||
<FileIcon className="size-4 text-white" />
|
<FileIcon className="size-4 text-info-600 dark:text-info-400" />
|
||||||
</div>
|
</div>
|
||||||
<div className="text-base font-bold text-purple-900 dark:text-purple-100">Images</div>
|
<div className="text-base font-bold text-gray-900 dark:text-white">Images</div>
|
||||||
</div>
|
</div>
|
||||||
{(() => {
|
{(() => {
|
||||||
const res = getStageResult(6);
|
const res = getStageResult(6);
|
||||||
const total = res?.total ?? pipelineOverview[5]?.counts?.total ?? metrics?.images?.total ?? pipelineOverview[5]?.pending ?? 0;
|
const total = res?.total ?? pipelineOverview[5]?.counts?.total ?? metrics?.images?.total ?? pipelineOverview[5]?.pending ?? 0;
|
||||||
return (
|
return (
|
||||||
<div className="text-right">
|
<div className="text-right">
|
||||||
<div className="text-3xl font-bold text-purple-900">{total}</div>
|
<div className="text-3xl font-bold text-info-600">{total}</div>
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
})()}
|
})()}
|
||||||
@@ -708,17 +708,17 @@ const AutomationPage: React.FC = () => {
|
|||||||
const res = getStageResult(6); // stage 6 is Image Prompts -> Images
|
const res = getStageResult(6); // stage 6 is Image Prompts -> Images
|
||||||
if (res && typeof res === 'object') {
|
if (res && typeof res === 'object') {
|
||||||
const entries = Object.entries(res);
|
const entries = Object.entries(res);
|
||||||
const items = entries.slice(0,3).map(([k, v]) => ({ label: `${k.replace(/_/g, ' ')}:`, value: Number(v) || 0, colorCls: 'text-purple-700' }));
|
const items = entries.slice(0,3).map(([k, v]) => ({ label: `${k.replace(/_/g, ' ')}:`, value: Number(v) || 0, colorCls: 'text-info-600' }));
|
||||||
return renderMetricRow(items);
|
return renderMetricRow(items);
|
||||||
}
|
}
|
||||||
const counts = pipelineOverview[5]?.counts ?? metrics?.images ?? null;
|
const counts = pipelineOverview[5]?.counts ?? metrics?.images ?? null;
|
||||||
if (counts && typeof counts === 'object') {
|
if (counts && typeof counts === 'object') {
|
||||||
const entries = Object.entries(counts);
|
const entries = Object.entries(counts);
|
||||||
const items = entries.slice(0,3).map(([k, v]) => ({ label: `${k.replace(/_/g, ' ')}:`, value: Number(v) || 0, colorCls: 'text-purple-700' }));
|
const items = entries.slice(0,3).map(([k, v]) => ({ label: `${k.replace(/_/g, ' ')}:`, value: Number(v) || 0, colorCls: 'text-info-600' }));
|
||||||
return renderMetricRow(items);
|
return renderMetricRow(items);
|
||||||
}
|
}
|
||||||
return renderMetricRow([
|
return renderMetricRow([
|
||||||
{ label: 'Pending:', value: pipelineOverview[5]?.pending ?? metrics?.images?.pending ?? 0, colorCls: 'text-purple-700' },
|
{ label: 'Pending:', value: pipelineOverview[5]?.pending ?? metrics?.images?.pending ?? 0, colorCls: 'text-info-600' },
|
||||||
]);
|
]);
|
||||||
})()}
|
})()}
|
||||||
</div>
|
</div>
|
||||||
@@ -795,18 +795,24 @@ const AutomationPage: React.FC = () => {
|
|||||||
|
|
||||||
const progressPercent = total > 0 ? Math.min(Math.round((processed / total) * 100), 100) : 0;
|
const progressPercent = total > 0 ? Math.min(Math.round((processed / total) * 100), 100) : 0;
|
||||||
|
|
||||||
|
// Determine the left border color based on stage
|
||||||
|
const stageBorderColors = ['border-l-brand-500', 'border-l-purple-500', 'border-l-warning-500', 'border-l-gray-600'];
|
||||||
|
const stageBorderColor = stageBorderColors[index] || 'border-l-brand-500';
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div
|
<div
|
||||||
key={stage.number}
|
key={stage.number}
|
||||||
className={`
|
className={`
|
||||||
relative rounded-2xl border-2 p-4 transition-all
|
relative rounded-xl border border-gray-200 dark:border-gray-800 p-4 transition-all bg-white dark:bg-gray-900
|
||||||
|
border-l-[5px] ${stageBorderColor}
|
||||||
${isActive
|
${isActive
|
||||||
? 'border-brand-500 bg-brand-50 dark:bg-brand-500/10 shadow-lg'
|
? 'shadow-lg ring-2 ring-brand-200 dark:ring-brand-800'
|
||||||
: isComplete
|
: isComplete
|
||||||
? 'border-success-500 bg-success-50 dark:bg-success-500/10'
|
? ''
|
||||||
: stage.pending > 0
|
: stage.pending > 0
|
||||||
? `border-gray-200 bg-white dark:bg-white/[0.03] dark:border-gray-800 ${stageConfig.hoverColor} hover:shadow-lg`
|
? `${stageConfig.hoverColor} hover:shadow-lg`
|
||||||
: 'border-gray-200 bg-gray-50 dark:bg-white/[0.02] dark:border-gray-800'
|
: ''
|
||||||
|
}
|
||||||
}
|
}
|
||||||
`}
|
`}
|
||||||
>
|
>
|
||||||
@@ -919,18 +925,23 @@ const AutomationPage: React.FC = () => {
|
|||||||
|
|
||||||
const progressPercent = total > 0 ? Math.min(Math.round((processed / total) * 100), 100) : 0;
|
const progressPercent = total > 0 ? Math.min(Math.round((processed / total) * 100), 100) : 0;
|
||||||
|
|
||||||
|
// Determine the left border color based on stage (5=brand, 6=purple)
|
||||||
|
const stageBorderColors56 = ['border-l-brand-500', 'border-l-purple-500'];
|
||||||
|
const stageBorderColor = stageBorderColors56[index] || 'border-l-brand-500';
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div
|
<div
|
||||||
key={stage.number}
|
key={stage.number}
|
||||||
className={`
|
className={`
|
||||||
relative rounded-2xl border-2 p-4 transition-all
|
relative rounded-xl border border-gray-200 dark:border-gray-800 p-4 transition-all bg-white dark:bg-gray-900
|
||||||
|
border-l-[5px] ${stageBorderColor}
|
||||||
${isActive
|
${isActive
|
||||||
? 'border-brand-500 bg-brand-50 dark:bg-brand-500/10 shadow-lg'
|
? 'shadow-lg ring-2 ring-brand-200 dark:ring-brand-800'
|
||||||
: isComplete
|
: isComplete
|
||||||
? 'border-success-500 bg-success-50 dark:bg-success-500/10'
|
? ''
|
||||||
: stage.pending > 0
|
: stage.pending > 0
|
||||||
? `border-gray-200 bg-white dark:bg-white/[0.03] dark:border-gray-800 ${stageConfig.hoverColor} hover:shadow-lg`
|
? `${stageConfig.hoverColor} hover:shadow-lg`
|
||||||
: 'border-gray-200 bg-gray-50 dark:bg-white/[0.02] dark:border-gray-800'
|
: ''
|
||||||
}
|
}
|
||||||
`}
|
`}
|
||||||
>
|
>
|
||||||
@@ -1030,14 +1041,15 @@ const AutomationPage: React.FC = () => {
|
|||||||
return (
|
return (
|
||||||
<div
|
<div
|
||||||
className={`
|
className={`
|
||||||
relative rounded-2xl border-2 p-4 transition-all
|
relative rounded-xl border border-gray-200 dark:border-gray-800 p-4 transition-all bg-white dark:bg-gray-900
|
||||||
|
border-l-[5px] border-l-success-500
|
||||||
${isActive
|
${isActive
|
||||||
? 'border-success-500 bg-success-50 dark:bg-success-500/10 shadow-lg'
|
? 'shadow-lg ring-2 ring-success-200 dark:ring-success-800'
|
||||||
: isComplete
|
: isComplete
|
||||||
? 'border-success-500 bg-success-50 dark:bg-success-500/10'
|
? ''
|
||||||
: pendingReview > 0
|
: pendingReview > 0
|
||||||
? 'border-success-300 bg-success-50 dark:bg-success-900/20 dark:border-success-700'
|
? 'hover:border-success-500 hover:shadow-lg'
|
||||||
: 'border-gray-200 bg-gray-50 dark:bg-white/[0.02] dark:border-gray-800'
|
: ''
|
||||||
}
|
}
|
||||||
`}
|
`}
|
||||||
>
|
>
|
||||||
|
|||||||
@@ -35,16 +35,23 @@ import {
|
|||||||
|
|
||||||
type ViewMode = 'list' | 'calendar';
|
type ViewMode = 'list' | 'calendar';
|
||||||
|
|
||||||
// API function to schedule content
|
// Type for schedule API response (partial content data)
|
||||||
async function scheduleContent(contentId: number, scheduledDate: string): Promise<Content> {
|
interface ScheduleResponse {
|
||||||
|
content_id: number;
|
||||||
|
site_status: string;
|
||||||
|
scheduled_publish_at: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
// API function to schedule content - returns partial data, not full Content
|
||||||
|
async function scheduleContent(contentId: number, scheduledDate: string): Promise<ScheduleResponse> {
|
||||||
return fetchAPI(`/v1/writer/content/${contentId}/schedule/`, {
|
return fetchAPI(`/v1/writer/content/${contentId}/schedule/`, {
|
||||||
method: 'POST',
|
method: 'POST',
|
||||||
body: JSON.stringify({ scheduled_publish_at: scheduledDate }),
|
body: JSON.stringify({ scheduled_publish_at: scheduledDate }),
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
// API function to unschedule content
|
// API function to unschedule content - returns partial data
|
||||||
async function unscheduleContent(contentId: number): Promise<Content> {
|
async function unscheduleContent(contentId: number): Promise<{ content_id: number; site_status: string }> {
|
||||||
return fetchAPI(`/v1/writer/content/${contentId}/unschedule/`, {
|
return fetchAPI(`/v1/writer/content/${contentId}/unschedule/`, {
|
||||||
method: 'POST',
|
method: 'POST',
|
||||||
});
|
});
|
||||||
@@ -59,11 +66,15 @@ export default function ContentCalendar() {
|
|||||||
const [allContent, setAllContent] = useState<Content[]>([]);
|
const [allContent, setAllContent] = useState<Content[]>([]);
|
||||||
const [viewMode, setViewMode] = useState<ViewMode>('calendar'); // Default to calendar view
|
const [viewMode, setViewMode] = useState<ViewMode>('calendar'); // Default to calendar view
|
||||||
const [draggedItem, setDraggedItem] = useState<Content | null>(null);
|
const [draggedItem, setDraggedItem] = useState<Content | null>(null);
|
||||||
|
const [currentMonth, setCurrentMonth] = useState(new Date()); // Track current month for calendar
|
||||||
|
|
||||||
// Derived state: Queue items (scheduled or publishing - future dates only for queue)
|
// Derived state: Queue items (scheduled or publishing - exclude already published)
|
||||||
const queueItems = useMemo(() => {
|
const queueItems = useMemo(() => {
|
||||||
return allContent
|
return allContent
|
||||||
.filter((c: Content) => c.site_status === 'scheduled' || c.site_status === 'publishing')
|
.filter((c: Content) =>
|
||||||
|
(c.site_status === 'scheduled' || c.site_status === 'publishing') &&
|
||||||
|
(!c.external_id || c.external_id === '') // Exclude already published items
|
||||||
|
)
|
||||||
.sort((a: Content, b: Content) => {
|
.sort((a: Content, b: Content) => {
|
||||||
const dateA = a.scheduled_publish_at ? new Date(a.scheduled_publish_at).getTime() : 0;
|
const dateA = a.scheduled_publish_at ? new Date(a.scheduled_publish_at).getTime() : 0;
|
||||||
const dateB = b.scheduled_publish_at ? new Date(b.scheduled_publish_at).getTime() : 0;
|
const dateB = b.scheduled_publish_at ? new Date(b.scheduled_publish_at).getTime() : 0;
|
||||||
@@ -71,19 +82,19 @@ export default function ContentCalendar() {
|
|||||||
});
|
});
|
||||||
}, [allContent]);
|
}, [allContent]);
|
||||||
|
|
||||||
// Derived state: Published items (site_status = 'published')
|
// Derived state: Published items (have external_id - same logic as Content Approved page)
|
||||||
const publishedItems = useMemo(() => {
|
const publishedItems = useMemo(() => {
|
||||||
return allContent.filter((c: Content) => c.site_status === 'published');
|
return allContent.filter((c: Content) => c.external_id && c.external_id !== '');
|
||||||
}, [allContent]);
|
}, [allContent]);
|
||||||
|
|
||||||
// Derived state: Approved items for sidebar (approved but not scheduled/publishing/published)
|
// Derived state: Approved items for sidebar (approved but not published to site)
|
||||||
const approvedItems = useMemo(() => {
|
const approvedItems = useMemo(() => {
|
||||||
return allContent.filter(
|
return allContent.filter(
|
||||||
(c: Content) =>
|
(c: Content) =>
|
||||||
c.status === 'approved' &&
|
c.status === 'approved' &&
|
||||||
|
(!c.external_id || c.external_id === '') && // Not published to site
|
||||||
c.site_status !== 'scheduled' &&
|
c.site_status !== 'scheduled' &&
|
||||||
c.site_status !== 'publishing' &&
|
c.site_status !== 'publishing'
|
||||||
c.site_status !== 'published'
|
|
||||||
);
|
);
|
||||||
}, [allContent]);
|
}, [allContent]);
|
||||||
|
|
||||||
@@ -93,26 +104,31 @@ export default function ContentCalendar() {
|
|||||||
const thirtyDaysAgo = new Date(now.getTime() - 30 * 24 * 60 * 60 * 1000);
|
const thirtyDaysAgo = new Date(now.getTime() - 30 * 24 * 60 * 60 * 1000);
|
||||||
const thirtyDaysFromNow = new Date(now.getTime() + 30 * 24 * 60 * 60 * 1000);
|
const thirtyDaysFromNow = new Date(now.getTime() + 30 * 24 * 60 * 60 * 1000);
|
||||||
|
|
||||||
// Published in last 30 days
|
// Published in last 30 days (items with external_id)
|
||||||
const publishedLast30Days = allContent.filter((c: Content) => {
|
const publishedLast30Days = allContent.filter((c: Content) => {
|
||||||
if (c.site_status !== 'published') return false;
|
if (!c.external_id || c.external_id === '') return false;
|
||||||
const publishDate = c.site_status_updated_at ? new Date(c.site_status_updated_at) : null;
|
// Use updated_at as publish date since site_status_updated_at may not be set
|
||||||
|
const publishDate = c.updated_at ? new Date(c.updated_at) : null;
|
||||||
return publishDate && publishDate >= thirtyDaysAgo;
|
return publishDate && publishDate >= thirtyDaysAgo;
|
||||||
}).length;
|
}).length;
|
||||||
|
|
||||||
// Scheduled in next 30 days
|
// Scheduled in next 30 days (exclude already published items with external_id)
|
||||||
const scheduledNext30Days = allContent.filter((c: Content) => {
|
const scheduledNext30Days = allContent.filter((c: Content) => {
|
||||||
if (c.site_status !== 'scheduled') return false;
|
if (c.site_status !== 'scheduled') return false;
|
||||||
|
if (c.external_id && c.external_id !== '') return false; // Exclude already published
|
||||||
const schedDate = c.scheduled_publish_at ? new Date(c.scheduled_publish_at) : null;
|
const schedDate = c.scheduled_publish_at ? new Date(c.scheduled_publish_at) : null;
|
||||||
return schedDate && schedDate >= now && schedDate <= thirtyDaysFromNow;
|
return schedDate && schedDate >= now && schedDate <= thirtyDaysFromNow;
|
||||||
}).length;
|
}).length;
|
||||||
|
|
||||||
return {
|
return {
|
||||||
scheduled: allContent.filter((c: Content) => c.site_status === 'scheduled').length,
|
// Scheduled count excludes items that are already published (have external_id)
|
||||||
|
scheduled: allContent.filter((c: Content) =>
|
||||||
|
c.site_status === 'scheduled' && (!c.external_id || c.external_id === '')
|
||||||
|
).length,
|
||||||
publishing: allContent.filter((c: Content) => c.site_status === 'publishing').length,
|
publishing: allContent.filter((c: Content) => c.site_status === 'publishing').length,
|
||||||
published: allContent.filter((c: Content) => c.site_status === 'published').length,
|
published: allContent.filter((c: Content) => c.external_id && c.external_id !== '').length,
|
||||||
review: allContent.filter((c: Content) => c.status === 'review').length,
|
review: allContent.filter((c: Content) => c.status === 'review').length,
|
||||||
approved: allContent.filter((c: Content) => c.status === 'approved' && c.site_status !== 'published').length,
|
approved: allContent.filter((c: Content) => c.status === 'approved' && (!c.external_id || c.external_id === '')).length,
|
||||||
publishedLast30Days,
|
publishedLast30Days,
|
||||||
scheduledNext30Days,
|
scheduledNext30Days,
|
||||||
};
|
};
|
||||||
@@ -130,6 +146,20 @@ export default function ContentCalendar() {
|
|||||||
page_size: 200,
|
page_size: 200,
|
||||||
});
|
});
|
||||||
|
|
||||||
|
// Debug: Log content with external_id (published status)
|
||||||
|
console.log('[ContentCalendar] Total content items:', response.results?.length);
|
||||||
|
console.log('[ContentCalendar] Published items (with external_id):', response.results?.filter(c => c.external_id && c.external_id !== '').length);
|
||||||
|
console.log('[ContentCalendar] Scheduled items:', response.results?.filter(c => c.site_status === 'scheduled').length);
|
||||||
|
console.log('[ContentCalendar] Sample content:', response.results?.slice(0, 3).map(c => ({
|
||||||
|
id: c.id,
|
||||||
|
title: c.title,
|
||||||
|
status: c.status,
|
||||||
|
site_status: c.site_status,
|
||||||
|
external_id: c.external_id,
|
||||||
|
scheduled_publish_at: c.scheduled_publish_at,
|
||||||
|
updated_at: c.updated_at
|
||||||
|
})));
|
||||||
|
|
||||||
setAllContent(response.results || []);
|
setAllContent(response.results || []);
|
||||||
} catch (error: any) {
|
} catch (error: any) {
|
||||||
toast.error(`Failed to load content: ${error.message}`);
|
toast.error(`Failed to load content: ${error.message}`);
|
||||||
@@ -142,7 +172,7 @@ export default function ContentCalendar() {
|
|||||||
if (activeSite?.id) {
|
if (activeSite?.id) {
|
||||||
loadQueue();
|
loadQueue();
|
||||||
}
|
}
|
||||||
}, [activeSite?.id, loadQueue]);
|
}, [activeSite?.id]); // Removed loadQueue from dependencies to prevent reload loops
|
||||||
|
|
||||||
// Drag and drop handlers for list view
|
// Drag and drop handlers for list view
|
||||||
const handleDragStart = (e: React.DragEvent, item: Content, source: 'queue' | 'approved') => {
|
const handleDragStart = (e: React.DragEvent, item: Content, source: 'queue' | 'approved') => {
|
||||||
@@ -170,9 +200,17 @@ export default function ContentCalendar() {
|
|||||||
tomorrow.setDate(tomorrow.getDate() + 1);
|
tomorrow.setDate(tomorrow.getDate() + 1);
|
||||||
tomorrow.setHours(9, 0, 0, 0);
|
tomorrow.setHours(9, 0, 0, 0);
|
||||||
|
|
||||||
await scheduleContent(draggedItem.id, tomorrow.toISOString());
|
const response = await scheduleContent(draggedItem.id, tomorrow.toISOString());
|
||||||
toast.success(`Scheduled for ${tomorrow.toLocaleDateString()}`);
|
toast.success(`Scheduled for ${tomorrow.toLocaleDateString()}`);
|
||||||
loadQueue(); // Reload to get updated data
|
|
||||||
|
// Merge API response with existing content - API returns partial data
|
||||||
|
setAllContent(prevContent => [
|
||||||
|
...prevContent.map(c => c.id === draggedItem.id ? {
|
||||||
|
...c,
|
||||||
|
site_status: response.site_status,
|
||||||
|
scheduled_publish_at: response.scheduled_publish_at,
|
||||||
|
} : c)
|
||||||
|
]);
|
||||||
} catch (error: any) {
|
} catch (error: any) {
|
||||||
toast.error(`Failed to schedule: ${error.message}`);
|
toast.error(`Failed to schedule: ${error.message}`);
|
||||||
}
|
}
|
||||||
@@ -191,9 +229,17 @@ export default function ContentCalendar() {
|
|||||||
newDate.setHours(9, 0, 0, 0);
|
newDate.setHours(9, 0, 0, 0);
|
||||||
|
|
||||||
try {
|
try {
|
||||||
await scheduleContent(draggedItem.id, newDate.toISOString());
|
const response = await scheduleContent(draggedItem.id, newDate.toISOString());
|
||||||
toast.success(`Scheduled for ${newDate.toLocaleDateString()}`);
|
toast.success(`Scheduled for ${newDate.toLocaleDateString()}`);
|
||||||
loadQueue(); // Reload to get updated data from server
|
|
||||||
|
// Merge API response with existing content - API returns partial data
|
||||||
|
setAllContent(prevContent => [
|
||||||
|
...prevContent.map(c => c.id === draggedItem.id ? {
|
||||||
|
...c,
|
||||||
|
site_status: response.site_status,
|
||||||
|
scheduled_publish_at: response.scheduled_publish_at,
|
||||||
|
} : c)
|
||||||
|
]);
|
||||||
} catch (error: any) {
|
} catch (error: any) {
|
||||||
toast.error(`Failed to schedule: ${error.message}`);
|
toast.error(`Failed to schedule: ${error.message}`);
|
||||||
}
|
}
|
||||||
@@ -209,7 +255,15 @@ export default function ContentCalendar() {
|
|||||||
try {
|
try {
|
||||||
await unscheduleContent(item.id);
|
await unscheduleContent(item.id);
|
||||||
toast.success('Removed from queue');
|
toast.success('Removed from queue');
|
||||||
loadQueue(); // Reload to get updated data
|
|
||||||
|
// Update state directly - set site_status to not_published and clear scheduled_publish_at
|
||||||
|
setAllContent(prevContent => [
|
||||||
|
...prevContent.map(c => c.id === item.id ? {
|
||||||
|
...c,
|
||||||
|
site_status: 'not_published',
|
||||||
|
scheduled_publish_at: null,
|
||||||
|
} : c)
|
||||||
|
]);
|
||||||
} catch (error: any) {
|
} catch (error: any) {
|
||||||
toast.error(`Failed to remove: ${error.message}`);
|
toast.error(`Failed to remove: ${error.message}`);
|
||||||
}
|
}
|
||||||
@@ -251,24 +305,56 @@ export default function ContentCalendar() {
|
|||||||
|
|
||||||
// Calendar view helpers
|
// Calendar view helpers
|
||||||
const getCalendarDays = () => {
|
const getCalendarDays = () => {
|
||||||
const today = new Date();
|
|
||||||
const days = [];
|
const days = [];
|
||||||
// Start from beginning of current week
|
// Get first day of the month
|
||||||
const startOfWeek = new Date(today);
|
const firstDayOfMonth = new Date(currentMonth.getFullYear(), currentMonth.getMonth(), 1);
|
||||||
startOfWeek.setDate(today.getDate() - today.getDay());
|
// Get last day of the month
|
||||||
|
const lastDayOfMonth = new Date(currentMonth.getFullYear(), currentMonth.getMonth() + 1, 0);
|
||||||
|
|
||||||
// Show 4 weeks
|
// Start from the Sunday before or on the first day of month
|
||||||
for (let i = 0; i < 28; i++) {
|
const startDate = new Date(firstDayOfMonth);
|
||||||
const date = new Date(startOfWeek);
|
startDate.setDate(startDate.getDate() - startDate.getDay());
|
||||||
date.setDate(startOfWeek.getDate() + i);
|
|
||||||
|
// Calculate how many days to show (must be multiple of 7)
|
||||||
|
const daysInMonth = lastDayOfMonth.getDate();
|
||||||
|
const startDayOffset = firstDayOfMonth.getDay();
|
||||||
|
const totalCells = Math.ceil((daysInMonth + startDayOffset) / 7) * 7;
|
||||||
|
|
||||||
|
// Generate all days for the calendar grid
|
||||||
|
for (let i = 0; i < totalCells; i++) {
|
||||||
|
const date = new Date(startDate);
|
||||||
|
date.setDate(startDate.getDate() + i);
|
||||||
days.push(date);
|
days.push(date);
|
||||||
}
|
}
|
||||||
return days;
|
return days;
|
||||||
};
|
};
|
||||||
|
|
||||||
// Get scheduled items for a specific date
|
// Navigation functions
|
||||||
|
const goToPreviousMonth = () => {
|
||||||
|
setCurrentMonth(new Date(currentMonth.getFullYear(), currentMonth.getMonth() - 1, 1));
|
||||||
|
};
|
||||||
|
|
||||||
|
const goToNextMonth = () => {
|
||||||
|
setCurrentMonth(new Date(currentMonth.getFullYear(), currentMonth.getMonth() + 1, 1));
|
||||||
|
};
|
||||||
|
|
||||||
|
const goToToday = () => {
|
||||||
|
setCurrentMonth(new Date());
|
||||||
|
};
|
||||||
|
|
||||||
|
// Truncate title to max words
|
||||||
|
const truncateTitle = (title: string | undefined, maxWords: number = 7) => {
|
||||||
|
if (!title) return '';
|
||||||
|
const words = title.split(' ');
|
||||||
|
if (words.length <= maxWords) return title;
|
||||||
|
return words.slice(0, maxWords).join(' ') + '...';
|
||||||
|
};
|
||||||
|
|
||||||
|
// Get scheduled items for a specific date (exclude already published)
|
||||||
const getScheduledItemsForDate = (date: Date) => {
|
const getScheduledItemsForDate = (date: Date) => {
|
||||||
return queueItems.filter(item => {
|
return queueItems.filter(item => {
|
||||||
|
// Skip if already published (has external_id)
|
||||||
|
if (item.external_id && item.external_id !== '') return false;
|
||||||
if (!item.scheduled_publish_at) return false;
|
if (!item.scheduled_publish_at) return false;
|
||||||
const itemDate = new Date(item.scheduled_publish_at);
|
const itemDate = new Date(item.scheduled_publish_at);
|
||||||
return (
|
return (
|
||||||
@@ -282,8 +368,8 @@ export default function ContentCalendar() {
|
|||||||
// Get published items for a specific date
|
// Get published items for a specific date
|
||||||
const getPublishedItemsForDate = (date: Date) => {
|
const getPublishedItemsForDate = (date: Date) => {
|
||||||
return publishedItems.filter(item => {
|
return publishedItems.filter(item => {
|
||||||
// Use site_status_updated_at as publish date
|
// Use updated_at as publish date (when external_id was set)
|
||||||
const publishDate = item.site_status_updated_at || item.updated_at;
|
const publishDate = item.updated_at;
|
||||||
if (!publishDate) return false;
|
if (!publishDate) return false;
|
||||||
const itemDate = new Date(publishDate);
|
const itemDate = new Date(publishDate);
|
||||||
return (
|
return (
|
||||||
@@ -321,14 +407,11 @@ export default function ContentCalendar() {
|
|||||||
<div className="p-6">
|
<div className="p-6">
|
||||||
<PageMeta title="Content Calendar" description="View and manage scheduled content" />
|
<PageMeta title="Content Calendar" description="View and manage scheduled content" />
|
||||||
|
|
||||||
{/* Header - Site selector is in app header */}
|
<PageHeader
|
||||||
<div className="flex flex-col sm:flex-row sm:items-center sm:justify-between gap-4 mb-6">
|
title="Content Calendar"
|
||||||
<PageHeader
|
badge={{ icon: <CalendarIcon />, color: 'amber' }}
|
||||||
title="Content Calendar"
|
hideSiteSector
|
||||||
badge={{ icon: <CalendarIcon />, color: 'amber' }}
|
/>
|
||||||
hideSiteSector
|
|
||||||
/>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
{/* Stats Overview - New layout with count on right, bigger labels, descriptions */}
|
{/* Stats Overview - New layout with count on right, bigger labels, descriptions */}
|
||||||
<div className="grid grid-cols-2 md:grid-cols-4 gap-4 mb-6">
|
<div className="grid grid-cols-2 md:grid-cols-4 gap-4 mb-6">
|
||||||
@@ -464,9 +547,11 @@ export default function ContentCalendar() {
|
|||||||
tomorrow.setDate(tomorrow.getDate() + 1);
|
tomorrow.setDate(tomorrow.getDate() + 1);
|
||||||
tomorrow.setHours(9, 0, 0, 0);
|
tomorrow.setHours(9, 0, 0, 0);
|
||||||
scheduleContent(draggedItem.id, tomorrow.toISOString())
|
scheduleContent(draggedItem.id, tomorrow.toISOString())
|
||||||
.then(() => {
|
.then((updatedContent) => {
|
||||||
toast.success(`Scheduled for ${tomorrow.toLocaleDateString()}`);
|
toast.success(`Scheduled for ${tomorrow.toLocaleDateString()}`);
|
||||||
loadQueue();
|
setAllContent(prevContent => [
|
||||||
|
...prevContent.map(c => c.id === draggedItem.id ? updatedContent : c)
|
||||||
|
]);
|
||||||
})
|
})
|
||||||
.catch((err) => toast.error(`Failed to schedule: ${err.message}`));
|
.catch((err) => toast.error(`Failed to schedule: ${err.message}`));
|
||||||
}
|
}
|
||||||
@@ -532,7 +617,40 @@ export default function ContentCalendar() {
|
|||||||
</ComponentCard>
|
</ComponentCard>
|
||||||
) : (
|
) : (
|
||||||
/* Calendar View with drag-drop */
|
/* Calendar View with drag-drop */
|
||||||
<ComponentCard title="Calendar View" desc="Drag content from sidebar to schedule. Published items shown with glass effect.">
|
<ComponentCard
|
||||||
|
title="Calendar View"
|
||||||
|
desc="Drag content from sidebar to schedule."
|
||||||
|
headerContent={
|
||||||
|
<div className="flex items-center gap-2">
|
||||||
|
<Button
|
||||||
|
variant="ghost"
|
||||||
|
size="sm"
|
||||||
|
onClick={goToToday}
|
||||||
|
>
|
||||||
|
Today
|
||||||
|
</Button>
|
||||||
|
<IconButton
|
||||||
|
icon={<span className="text-lg">‹</span>}
|
||||||
|
variant="ghost"
|
||||||
|
tone="neutral"
|
||||||
|
size="sm"
|
||||||
|
onClick={goToPreviousMonth}
|
||||||
|
title="Previous month"
|
||||||
|
/>
|
||||||
|
<h3 className="text-sm font-semibold text-gray-900 dark:text-white px-2">
|
||||||
|
{currentMonth.toLocaleString('default', { month: 'long', year: 'numeric' })}
|
||||||
|
</h3>
|
||||||
|
<IconButton
|
||||||
|
icon={<span className="text-lg">›</span>}
|
||||||
|
variant="ghost"
|
||||||
|
tone="neutral"
|
||||||
|
size="sm"
|
||||||
|
onClick={goToNextMonth}
|
||||||
|
title="Next month"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
}
|
||||||
|
>
|
||||||
<div className="grid grid-cols-7 gap-2">
|
<div className="grid grid-cols-7 gap-2">
|
||||||
{/* Day headers */}
|
{/* Day headers */}
|
||||||
{['Sun', 'Mon', 'Tue', 'Wed', 'Thu', 'Fri', 'Sat'].map(day => (
|
{['Sun', 'Mon', 'Tue', 'Wed', 'Thu', 'Fri', 'Sat'].map(day => (
|
||||||
@@ -547,6 +665,9 @@ export default function ContentCalendar() {
|
|||||||
const publishedOnDate = getPublishedItemsForDate(date);
|
const publishedOnDate = getPublishedItemsForDate(date);
|
||||||
const isToday = date.toDateString() === new Date().toDateString();
|
const isToday = date.toDateString() === new Date().toDateString();
|
||||||
const isPast = date < new Date(new Date().setHours(0, 0, 0, 0));
|
const isPast = date < new Date(new Date().setHours(0, 0, 0, 0));
|
||||||
|
const isCurrentMonth = date.getMonth() === currentMonth.getMonth();
|
||||||
|
const totalItems = scheduledItems.length + publishedOnDate.length;
|
||||||
|
const hasMoreThan5 = totalItems > 5;
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div
|
<div
|
||||||
@@ -554,13 +675,14 @@ export default function ContentCalendar() {
|
|||||||
onDragOver={!isPast ? handleDragOver : undefined}
|
onDragOver={!isPast ? handleDragOver : undefined}
|
||||||
onDrop={!isPast ? (e) => handleDropOnCalendarDate(e, date) : undefined}
|
onDrop={!isPast ? (e) => handleDropOnCalendarDate(e, date) : undefined}
|
||||||
className={`
|
className={`
|
||||||
min-h-[100px] p-2 rounded-lg border transition-colors
|
p-2 rounded-lg border transition-colors
|
||||||
${isToday
|
${isToday
|
||||||
? 'border-brand-500 bg-brand-50 dark:bg-brand-900/20'
|
? 'border-brand-500 bg-brand-50 dark:bg-brand-900/20'
|
||||||
: isPast
|
: isPast
|
||||||
? 'border-gray-100 dark:border-gray-800 bg-gray-50/50 dark:bg-gray-900/30'
|
? 'border-gray-100 dark:border-gray-800 bg-gray-50/50 dark:bg-gray-900/30'
|
||||||
: 'border-gray-200 dark:border-gray-700 bg-white dark:bg-gray-800'
|
: 'border-gray-200 dark:border-gray-700 bg-white dark:bg-gray-800'
|
||||||
}
|
}
|
||||||
|
${!isCurrentMonth ? 'opacity-40' : ''}
|
||||||
${!isPast && draggedItem ? 'border-dashed border-brand-300 dark:border-brand-600' : ''}
|
${!isPast && draggedItem ? 'border-dashed border-brand-300 dark:border-brand-600' : ''}
|
||||||
`}
|
`}
|
||||||
>
|
>
|
||||||
@@ -574,26 +696,26 @@ export default function ContentCalendar() {
|
|||||||
</div>
|
</div>
|
||||||
<div className="space-y-1">
|
<div className="space-y-1">
|
||||||
{/* Published items with glass effect */}
|
{/* Published items with glass effect */}
|
||||||
{publishedOnDate.slice(0, 2).map(item => (
|
{publishedOnDate.slice(0, 5).map(item => (
|
||||||
<CalendarItemTooltip
|
<CalendarItemTooltip
|
||||||
key={item.id}
|
key={item.id}
|
||||||
title={item.title}
|
title={item.title}
|
||||||
status="published"
|
status="published"
|
||||||
contentType={item.content_type || 'Article'}
|
contentType={item.content_type || 'Article'}
|
||||||
date={item.site_status_updated_at}
|
date={item.updated_at}
|
||||||
dateLabel="Published"
|
dateLabel="Published"
|
||||||
placement="top"
|
placement="top"
|
||||||
>
|
>
|
||||||
<div
|
<div
|
||||||
onClick={() => handleViewContent(item)}
|
onClick={() => handleViewContent(item)}
|
||||||
className="text-xs p-1.5 bg-success-100/60 dark:bg-success-900/20 text-success-700 dark:text-success-300 rounded truncate cursor-pointer backdrop-blur-sm border border-success-200/50 dark:border-success-800/50"
|
className="text-xs p-1.5 bg-success-100/60 dark:bg-success-900/20 text-success-700 dark:text-success-300 rounded cursor-pointer backdrop-blur-sm border border-success-200/50 dark:border-success-800/50 break-words"
|
||||||
>
|
>
|
||||||
✓ {item.title}
|
✓ {truncateTitle(item.title, 7)}
|
||||||
</div>
|
</div>
|
||||||
</CalendarItemTooltip>
|
</CalendarItemTooltip>
|
||||||
))}
|
))}
|
||||||
{/* Scheduled items */}
|
{/* Scheduled items */}
|
||||||
{scheduledItems.slice(0, 3 - publishedOnDate.length).map(item => (
|
{scheduledItems.slice(0, Math.max(0, 5 - publishedOnDate.length)).map(item => (
|
||||||
<CalendarItemTooltip
|
<CalendarItemTooltip
|
||||||
key={item.id}
|
key={item.id}
|
||||||
title={item.title}
|
title={item.title}
|
||||||
@@ -608,20 +730,23 @@ export default function ContentCalendar() {
|
|||||||
onDragStart={!isPast ? (e) => handleDragStart(e, item, 'queue') : undefined}
|
onDragStart={!isPast ? (e) => handleDragStart(e, item, 'queue') : undefined}
|
||||||
onDragEnd={handleDragEnd}
|
onDragEnd={handleDragEnd}
|
||||||
onClick={() => handleViewContent(item)}
|
onClick={() => handleViewContent(item)}
|
||||||
className={`text-xs p-1.5 rounded truncate transition-colors ${
|
className={`text-xs p-1.5 rounded transition-colors break-words ${
|
||||||
isPast
|
isPast
|
||||||
? 'bg-gray-100/60 dark:bg-gray-800/40 text-gray-500 dark:text-gray-400 backdrop-blur-sm cursor-default'
|
? 'bg-gray-100/60 dark:bg-gray-800/40 text-gray-500 dark:text-gray-400 backdrop-blur-sm cursor-default'
|
||||||
: 'bg-warning-100 dark:bg-warning-900/30 text-warning-800 dark:text-warning-200 cursor-move hover:bg-warning-200 dark:hover:bg-warning-900/50'
|
: 'bg-warning-100 dark:bg-warning-900/30 text-warning-800 dark:text-warning-200 cursor-move hover:bg-warning-200 dark:hover:bg-warning-900/50'
|
||||||
}`}
|
}`}
|
||||||
>
|
>
|
||||||
{item.title}
|
{truncateTitle(item.title, 7)}
|
||||||
</div>
|
</div>
|
||||||
</CalendarItemTooltip>
|
</CalendarItemTooltip>
|
||||||
))}
|
))}
|
||||||
{(scheduledItems.length + publishedOnDate.length) > 3 && (
|
{hasMoreThan5 && (
|
||||||
<div className="text-xs text-gray-500 dark:text-gray-400">
|
<button
|
||||||
+{(scheduledItems.length + publishedOnDate.length) - 3} more
|
onClick={() => setViewMode('list')}
|
||||||
</div>
|
className="text-xs p-1.5 rounded bg-gray-100 dark:bg-gray-700 text-gray-600 dark:text-gray-300 hover:bg-gray-200 dark:hover:bg-gray-600 transition-colors w-full text-center font-medium"
|
||||||
|
>
|
||||||
|
View {totalItems - 5} more
|
||||||
|
</button>
|
||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
@@ -12,7 +12,8 @@ import Button from '../../components/ui/button/Button';
|
|||||||
import InputField from '../../components/form/input/InputField';
|
import InputField from '../../components/form/input/InputField';
|
||||||
import Select from '../../components/form/Select';
|
import Select from '../../components/form/Select';
|
||||||
import { useToast } from '../../components/ui/toast/ToastContainer';
|
import { useToast } from '../../components/ui/toast/ToastContainer';
|
||||||
import { fetchAPI } from '../../services/api';
|
import { fetchAPI, setActiveSite as apiSetActiveSite } from '../../services/api';
|
||||||
|
import { useSiteStore } from '../../store/siteStore';
|
||||||
import { SearchIcon } from '../../icons';
|
import { SearchIcon } from '../../icons';
|
||||||
import {
|
import {
|
||||||
PencilIcon,
|
PencilIcon,
|
||||||
@@ -20,8 +21,10 @@ import {
|
|||||||
TrashBinIcon,
|
TrashBinIcon,
|
||||||
PlusIcon,
|
PlusIcon,
|
||||||
FileIcon,
|
FileIcon,
|
||||||
GridIcon
|
GridIcon,
|
||||||
|
GlobeIcon
|
||||||
} from '../../icons';
|
} from '../../icons';
|
||||||
|
import SiteInfoBar from '../../components/common/SiteInfoBar';
|
||||||
|
|
||||||
interface ContentItem {
|
interface ContentItem {
|
||||||
id: number;
|
id: number;
|
||||||
@@ -40,6 +43,7 @@ export default function SiteContentManager() {
|
|||||||
const { id: siteId } = useParams<{ id: string }>();
|
const { id: siteId } = useParams<{ id: string }>();
|
||||||
const navigate = useNavigate();
|
const navigate = useNavigate();
|
||||||
const toast = useToast();
|
const toast = useToast();
|
||||||
|
const { setActiveSite } = useSiteStore();
|
||||||
const [content, setContent] = useState<ContentItem[]>([]);
|
const [content, setContent] = useState<ContentItem[]>([]);
|
||||||
const [loading, setLoading] = useState(true);
|
const [loading, setLoading] = useState(true);
|
||||||
const [searchTerm, setSearchTerm] = useState('');
|
const [searchTerm, setSearchTerm] = useState('');
|
||||||
@@ -50,13 +54,36 @@ export default function SiteContentManager() {
|
|||||||
const [currentPage, setCurrentPage] = useState(1);
|
const [currentPage, setCurrentPage] = useState(1);
|
||||||
const [totalPages, setTotalPages] = useState(1);
|
const [totalPages, setTotalPages] = useState(1);
|
||||||
const [totalCount, setTotalCount] = useState(0);
|
const [totalCount, setTotalCount] = useState(0);
|
||||||
|
const [site, setSite] = useState<any>(null);
|
||||||
const pageSize = 20;
|
const pageSize = 20;
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
if (siteId) {
|
||||||
|
loadSiteAndContent();
|
||||||
|
}
|
||||||
|
}, [siteId]);
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
if (siteId) {
|
if (siteId) {
|
||||||
loadContent();
|
loadContent();
|
||||||
}
|
}
|
||||||
}, [siteId, currentPage, statusFilter, sourceFilter, searchTerm, sortBy, sortDirection]);
|
}, [currentPage, statusFilter, sourceFilter, searchTerm, sortBy, sortDirection]);
|
||||||
|
|
||||||
|
const loadSiteAndContent = async () => {
|
||||||
|
try {
|
||||||
|
// Load site data and sync with store
|
||||||
|
const siteData = await fetchAPI(`/v1/auth/sites/${siteId}/`);
|
||||||
|
if (siteData) {
|
||||||
|
setSite(siteData);
|
||||||
|
setActiveSite(siteData);
|
||||||
|
await apiSetActiveSite(siteData.id).catch(() => {});
|
||||||
|
}
|
||||||
|
// Then load content
|
||||||
|
await loadContent();
|
||||||
|
} catch (error: any) {
|
||||||
|
console.error('Failed to load site:', error);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
const loadContent = async () => {
|
const loadContent = async () => {
|
||||||
try {
|
try {
|
||||||
@@ -127,15 +154,13 @@ export default function SiteContentManager() {
|
|||||||
<PageMeta title="Site Content Manager - IGNY8" />
|
<PageMeta title="Site Content Manager - IGNY8" />
|
||||||
|
|
||||||
<PageHeader
|
<PageHeader
|
||||||
title={`Content Manager (${totalCount} items)`}
|
title="Content Manager"
|
||||||
badge={{ icon: <FileIcon />, color: 'blue' }}
|
badge={{ icon: <FileIcon />, color: 'green' }}
|
||||||
hideSiteSector
|
hideSiteSector
|
||||||
/>
|
/>
|
||||||
<div className="mb-6 flex justify-end">
|
|
||||||
<Button onClick={() => navigate(`/sites/${siteId}/posts/new`)} variant="primary" startIcon={<PlusIcon className="w-4 h-4" />}>
|
{/* Site Info Bar */}
|
||||||
New Post
|
<SiteInfoBar site={site} currentPage="content" itemsCount={totalCount} showNewPostButton />
|
||||||
</Button>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
{/* Filters */}
|
{/* Filters */}
|
||||||
<Card className="p-4 mb-6">
|
<Card className="p-4 mb-6">
|
||||||
|
|||||||
@@ -8,16 +8,19 @@ import { useParams, useNavigate } from 'react-router-dom';
|
|||||||
import PageMeta from '../../components/common/PageMeta';
|
import PageMeta from '../../components/common/PageMeta';
|
||||||
import PageHeader from '../../components/common/PageHeader';
|
import PageHeader from '../../components/common/PageHeader';
|
||||||
import ComponentCard from '../../components/common/ComponentCard';
|
import ComponentCard from '../../components/common/ComponentCard';
|
||||||
|
import SiteInfoBar from '../../components/common/SiteInfoBar';
|
||||||
import { Card } from '../../components/ui/card';
|
import { Card } from '../../components/ui/card';
|
||||||
import Button from '../../components/ui/button/Button';
|
import Button from '../../components/ui/button/Button';
|
||||||
import { useToast } from '../../components/ui/toast/ToastContainer';
|
import { useToast } from '../../components/ui/toast/ToastContainer';
|
||||||
import { fetchAPI, fetchSiteSectors } from '../../services/api';
|
import { fetchAPI, fetchSiteSectors, setActiveSite as apiSetActiveSite } from '../../services/api';
|
||||||
|
import { getDashboardStats } from '../../services/billing.api';
|
||||||
import SiteSetupChecklist from '../../components/sites/SiteSetupChecklist';
|
import SiteSetupChecklist from '../../components/sites/SiteSetupChecklist';
|
||||||
import { integrationApi } from '../../services/integration.api';
|
import { integrationApi } from '../../services/integration.api';
|
||||||
import SiteConfigWidget from '../../components/dashboard/SiteConfigWidget';
|
import SiteConfigWidget from '../../components/dashboard/SiteConfigWidget';
|
||||||
import OperationsCostsWidget from '../../components/dashboard/OperationsCostsWidget';
|
import OperationsCostsWidget from '../../components/dashboard/OperationsCostsWidget';
|
||||||
import CreditAvailabilityWidget from '../../components/dashboard/CreditAvailabilityWidget';
|
import CreditAvailabilityWidget from '../../components/dashboard/CreditAvailabilityWidget';
|
||||||
import { useBillingStore } from '../../store/billingStore';
|
import { useBillingStore } from '../../store/billingStore';
|
||||||
|
import { useSiteStore } from '../../store/siteStore';
|
||||||
import {
|
import {
|
||||||
FileIcon,
|
FileIcon,
|
||||||
PlugInIcon,
|
PlugInIcon,
|
||||||
@@ -27,6 +30,7 @@ import {
|
|||||||
ArrowRightIcon,
|
ArrowRightIcon,
|
||||||
ArrowUpIcon,
|
ArrowUpIcon,
|
||||||
ClockIcon,
|
ClockIcon,
|
||||||
|
ChevronRightIcon,
|
||||||
} from '../../icons';
|
} from '../../icons';
|
||||||
|
|
||||||
interface Site {
|
interface Site {
|
||||||
@@ -67,6 +71,7 @@ export default function SiteDashboard() {
|
|||||||
const navigate = useNavigate();
|
const navigate = useNavigate();
|
||||||
const toast = useToast();
|
const toast = useToast();
|
||||||
const { balance, loadBalance } = useBillingStore();
|
const { balance, loadBalance } = useBillingStore();
|
||||||
|
const { setActiveSite } = useSiteStore();
|
||||||
const [site, setSite] = useState<Site | null>(null);
|
const [site, setSite] = useState<Site | null>(null);
|
||||||
const [setupState, setSetupState] = useState<SiteSetupState>({
|
const [setupState, setSetupState] = useState<SiteSetupState>({
|
||||||
hasIndustry: false,
|
hasIndustry: false,
|
||||||
@@ -83,19 +88,33 @@ export default function SiteDashboard() {
|
|||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
if (siteId) {
|
if (siteId) {
|
||||||
loadSiteData();
|
// Create a local copy of siteId to use in async operations
|
||||||
loadBalance();
|
const currentSiteId = siteId;
|
||||||
}
|
|
||||||
}, [siteId, loadBalance]);
|
|
||||||
|
|
||||||
const loadSiteData = async () => {
|
// Reset state when site changes
|
||||||
try {
|
setOperations([]);
|
||||||
|
setSite(null);
|
||||||
setLoading(true);
|
setLoading(true);
|
||||||
|
|
||||||
|
// Load data for this specific siteId
|
||||||
|
loadSiteData(currentSiteId);
|
||||||
|
loadBalance();
|
||||||
|
}
|
||||||
|
}, [siteId]);
|
||||||
|
|
||||||
|
const loadSiteData = async (currentSiteId: string) => {
|
||||||
|
try {
|
||||||
// Load site data
|
// Load site data
|
||||||
const siteData = await fetchAPI(`/v1/auth/sites/${siteId}/`);
|
const siteData = await fetchAPI(`/v1/auth/sites/${currentSiteId}/`);
|
||||||
|
|
||||||
|
// CRITICAL: Verify we're still on the same site before updating state
|
||||||
|
// This prevents race conditions when user rapidly switches sites
|
||||||
if (siteData) {
|
if (siteData) {
|
||||||
setSite(siteData);
|
setSite(siteData);
|
||||||
|
// Update global site store so site selector shows correct site
|
||||||
|
setActiveSite(siteData);
|
||||||
|
// Also set as active site in backend
|
||||||
|
await apiSetActiveSite(siteData.id).catch(() => {});
|
||||||
|
|
||||||
// Check setup state
|
// Check setup state
|
||||||
const hasIndustry = !!siteData.industry || !!siteData.industry_name;
|
const hasIndustry = !!siteData.industry || !!siteData.industry_name;
|
||||||
@@ -104,7 +123,7 @@ export default function SiteDashboard() {
|
|||||||
let hasSectors = false;
|
let hasSectors = false;
|
||||||
let sectorsCount = 0;
|
let sectorsCount = 0;
|
||||||
try {
|
try {
|
||||||
const sectors = await fetchSiteSectors(Number(siteId));
|
const sectors = await fetchSiteSectors(Number(currentSiteId));
|
||||||
hasSectors = sectors && sectors.length > 0;
|
hasSectors = sectors && sectors.length > 0;
|
||||||
sectorsCount = sectors?.length || 0;
|
sectorsCount = sectors?.length || 0;
|
||||||
} catch (err) {
|
} catch (err) {
|
||||||
@@ -114,7 +133,7 @@ export default function SiteDashboard() {
|
|||||||
// Check WordPress integration
|
// Check WordPress integration
|
||||||
let hasWordPressIntegration = false;
|
let hasWordPressIntegration = false;
|
||||||
try {
|
try {
|
||||||
const wpIntegration = await integrationApi.getWordPressIntegration(Number(siteId));
|
const wpIntegration = await integrationApi.getWordPressIntegration(Number(currentSiteId));
|
||||||
hasWordPressIntegration = !!wpIntegration;
|
hasWordPressIntegration = !!wpIntegration;
|
||||||
} catch (err) {
|
} catch (err) {
|
||||||
// No integration is fine
|
// No integration is fine
|
||||||
@@ -125,7 +144,7 @@ export default function SiteDashboard() {
|
|||||||
let keywordsCount = 0;
|
let keywordsCount = 0;
|
||||||
try {
|
try {
|
||||||
const { fetchKeywords } = await import('../../services/api');
|
const { fetchKeywords } = await import('../../services/api');
|
||||||
const keywordsData = await fetchKeywords({ site_id: Number(siteId), page_size: 1 });
|
const keywordsData = await fetchKeywords({ site_id: Number(currentSiteId), page_size: 1 });
|
||||||
hasKeywords = keywordsData?.results?.length > 0 || keywordsData?.count > 0;
|
hasKeywords = keywordsData?.results?.length > 0 || keywordsData?.count > 0;
|
||||||
keywordsCount = keywordsData?.count || 0;
|
keywordsCount = keywordsData?.count || 0;
|
||||||
} catch (err) {
|
} catch (err) {
|
||||||
@@ -136,7 +155,7 @@ export default function SiteDashboard() {
|
|||||||
let hasAuthorProfiles = false;
|
let hasAuthorProfiles = false;
|
||||||
let authorProfilesCount = 0;
|
let authorProfilesCount = 0;
|
||||||
try {
|
try {
|
||||||
const authorsData = await fetchAPI(`/v1/thinker/author-profiles/?site_id=${siteId}&page_size=1`);
|
const authorsData = await fetchAPI(`/v1/thinker/author-profiles/?site_id=${currentSiteId}&page_size=1`);
|
||||||
hasAuthorProfiles = authorsData?.count > 0;
|
hasAuthorProfiles = authorsData?.count > 0;
|
||||||
authorProfilesCount = authorsData?.count || 0;
|
authorProfilesCount = authorsData?.count || 0;
|
||||||
} catch (err) {
|
} catch (err) {
|
||||||
@@ -154,15 +173,54 @@ export default function SiteDashboard() {
|
|||||||
authorProfilesCount,
|
authorProfilesCount,
|
||||||
});
|
});
|
||||||
|
|
||||||
// Load operation stats (mock data for now - would come from backend)
|
// Load operation stats from real API data
|
||||||
// In real implementation, fetch from /api/v1/dashboard/site/{siteId}/operations/
|
try {
|
||||||
const mockOperations: OperationStat[] = [
|
const stats = await getDashboardStats({ site_id: Number(currentSiteId), days: 7 });
|
||||||
{ type: 'clustering', count: 8, creditsUsed: 80, avgCreditsPerOp: 10 },
|
|
||||||
{ type: 'ideas', count: 12, creditsUsed: 24, avgCreditsPerOp: 2 },
|
// Map operation types from API to display types
|
||||||
{ type: 'content', count: 28, creditsUsed: 1400, avgCreditsPerOp: 50 },
|
const operationTypeMap: Record<string, 'clustering' | 'ideas' | 'content' | 'images'> = {
|
||||||
{ type: 'images', count: 45, creditsUsed: 225, avgCreditsPerOp: 5 },
|
'clustering': 'clustering',
|
||||||
];
|
'idea_generation': 'ideas',
|
||||||
setOperations(mockOperations);
|
'content_generation': 'content',
|
||||||
|
'image_generation': 'images',
|
||||||
|
'image_prompt_extraction': 'images',
|
||||||
|
};
|
||||||
|
|
||||||
|
const mappedOperations: OperationStat[] = [];
|
||||||
|
const expectedTypes: Array<'clustering' | 'ideas' | 'content' | 'images'> = ['clustering', 'ideas', 'content', 'images'];
|
||||||
|
|
||||||
|
// Initialize with zeros
|
||||||
|
const opTotals: Record<string, { count: number; credits: number }> = {};
|
||||||
|
expectedTypes.forEach(t => { opTotals[t] = { count: 0, credits: 0 }; });
|
||||||
|
|
||||||
|
// Sum up operations by mapped type
|
||||||
|
if (stats.ai_operations?.operations) {
|
||||||
|
stats.ai_operations.operations.forEach(op => {
|
||||||
|
const mappedType = operationTypeMap[op.type] || op.type;
|
||||||
|
if (opTotals[mappedType]) {
|
||||||
|
opTotals[mappedType].count += op.count;
|
||||||
|
opTotals[mappedType].credits += op.credits;
|
||||||
|
}
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
// Convert to array with avgCreditsPerOp
|
||||||
|
expectedTypes.forEach(type => {
|
||||||
|
const data = opTotals[type];
|
||||||
|
mappedOperations.push({
|
||||||
|
type,
|
||||||
|
count: data.count,
|
||||||
|
creditsUsed: data.credits,
|
||||||
|
avgCreditsPerOp: data.count > 0 ? data.credits / data.count : 0,
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
setOperations(mappedOperations);
|
||||||
|
} catch (err) {
|
||||||
|
console.log('Could not load operations stats:', err);
|
||||||
|
// Set empty operations if API fails
|
||||||
|
setOperations([]);
|
||||||
|
}
|
||||||
}
|
}
|
||||||
} catch (error: any) {
|
} catch (error: any) {
|
||||||
toast.error(`Failed to load site data: ${error.message}`);
|
toast.error(`Failed to load site data: ${error.message}`);
|
||||||
@@ -200,33 +258,17 @@ export default function SiteDashboard() {
|
|||||||
<div className="p-6">
|
<div className="p-6">
|
||||||
<PageMeta title={`${site.name} - Dashboard`} />
|
<PageMeta title={`${site.name} - Dashboard`} />
|
||||||
<PageHeader
|
<PageHeader
|
||||||
title={site.name}
|
title="Site Dashboard"
|
||||||
badge={{ icon: <GridIcon />, color: 'blue' }}
|
badge={{ icon: <GridIcon />, color: 'blue' }}
|
||||||
breadcrumb="Sites / Dashboard"
|
hideSiteSector
|
||||||
/>
|
/>
|
||||||
|
|
||||||
{/* Site Info */}
|
{/* Site Info Bar */}
|
||||||
<div className="mb-6">
|
<SiteInfoBar site={site} currentPage="dashboard" />
|
||||||
<p className="text-sm text-gray-600 dark:text-gray-400">
|
|
||||||
{site.slug} • {site.site_type} • {site.hosting_type}
|
|
||||||
</p>
|
|
||||||
{site.domain && (
|
|
||||||
<p className="text-sm text-gray-500 dark:text-gray-500 mt-1">
|
|
||||||
{site.domain}
|
|
||||||
</p>
|
|
||||||
)}
|
|
||||||
<div className="flex gap-2 mt-4">
|
|
||||||
<Button
|
|
||||||
variant="primary"
|
|
||||||
onClick={() => navigate(`/sites/${siteId}/settings`)}
|
|
||||||
>
|
|
||||||
Settings
|
|
||||||
</Button>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
{/* Site Setup Checklist */}
|
{/* Site Setup Progress + Quick Actions - Side by Side */}
|
||||||
<div className="mb-6">
|
<div className="grid grid-cols-1 lg:grid-cols-2 gap-6 mb-6">
|
||||||
|
{/* Site Setup Checklist - Left Half */}
|
||||||
<SiteSetupChecklist
|
<SiteSetupChecklist
|
||||||
siteId={Number(siteId)}
|
siteId={Number(siteId)}
|
||||||
siteName={site.name}
|
siteName={site.name}
|
||||||
@@ -235,22 +277,95 @@ export default function SiteDashboard() {
|
|||||||
hasWordPressIntegration={setupState.hasWordPressIntegration}
|
hasWordPressIntegration={setupState.hasWordPressIntegration}
|
||||||
hasKeywords={setupState.hasKeywords}
|
hasKeywords={setupState.hasKeywords}
|
||||||
/>
|
/>
|
||||||
|
|
||||||
|
{/* Quick Actions - Right Half */}
|
||||||
|
<ComponentCard title="Quick Actions" desc="Common site management tasks">
|
||||||
|
<div className="grid grid-cols-2 gap-3">
|
||||||
|
{/* Manage Pages */}
|
||||||
|
<button
|
||||||
|
onClick={() => navigate(`/sites/${siteId}/pages`)}
|
||||||
|
className="flex items-center gap-3 px-4 py-3 rounded-lg border border-gray-200 dark:border-gray-700 bg-white dark:bg-gray-900 hover:border-brand-300 hover:bg-brand-50 dark:hover:bg-brand-900/10 transition-all group"
|
||||||
|
>
|
||||||
|
<div className="size-8 rounded-lg bg-brand-100 dark:bg-brand-900/30 flex items-center justify-center flex-shrink-0">
|
||||||
|
<PageIcon className="h-4 w-4 text-brand-600 dark:text-brand-400" />
|
||||||
|
</div>
|
||||||
|
<span className="flex-1 text-sm font-medium text-gray-700 dark:text-gray-300 text-left">Manage Pages</span>
|
||||||
|
</button>
|
||||||
|
|
||||||
|
{/* Manage Content */}
|
||||||
|
<button
|
||||||
|
onClick={() => navigate(`/sites/${siteId}/content`)}
|
||||||
|
className="flex items-center gap-3 px-4 py-3 rounded-lg border border-gray-200 dark:border-gray-700 bg-white dark:bg-gray-900 hover:border-success-300 hover:bg-success-50 dark:hover:bg-success-900/10 transition-all group"
|
||||||
|
>
|
||||||
|
<div className="size-8 rounded-lg bg-success-100 dark:bg-success-900/30 flex items-center justify-center flex-shrink-0">
|
||||||
|
<FileIcon className="h-4 w-4 text-success-600 dark:text-success-400" />
|
||||||
|
</div>
|
||||||
|
<span className="flex-1 text-sm font-medium text-gray-700 dark:text-gray-300 text-left">Manage Content</span>
|
||||||
|
</button>
|
||||||
|
|
||||||
|
{/* Integrations */}
|
||||||
|
<button
|
||||||
|
onClick={() => navigate(`/sites/${siteId}/settings?tab=integrations`)}
|
||||||
|
className="flex items-center gap-3 px-4 py-3 rounded-lg border border-gray-200 dark:border-gray-700 bg-white dark:bg-gray-900 hover:border-purple-300 hover:bg-purple-50 dark:hover:bg-purple-900/10 transition-all group"
|
||||||
|
>
|
||||||
|
<div className="size-8 rounded-lg bg-purple-100 dark:bg-purple-900/30 flex items-center justify-center flex-shrink-0">
|
||||||
|
<PlugInIcon className="h-4 w-4 text-purple-600 dark:text-purple-400" />
|
||||||
|
</div>
|
||||||
|
<span className="flex-1 text-sm font-medium text-gray-700 dark:text-gray-300 text-left">Integrations</span>
|
||||||
|
</button>
|
||||||
|
|
||||||
|
{/* Sync Dashboard */}
|
||||||
|
<button
|
||||||
|
onClick={() => navigate(`/sites/${siteId}/sync`)}
|
||||||
|
className="flex items-center gap-3 px-4 py-3 rounded-lg border border-gray-200 dark:border-gray-700 bg-white dark:bg-gray-900 hover:border-warning-300 hover:bg-warning-50 dark:hover:bg-warning-900/10 transition-all group"
|
||||||
|
>
|
||||||
|
<div className="size-8 rounded-lg bg-warning-100 dark:bg-warning-900/30 flex items-center justify-center flex-shrink-0">
|
||||||
|
<BoltIcon className="h-4 w-4 text-warning-600 dark:text-warning-400" />
|
||||||
|
</div>
|
||||||
|
<span className="flex-1 text-sm font-medium text-gray-700 dark:text-gray-300 text-left">Sync Dashboard</span>
|
||||||
|
</button>
|
||||||
|
|
||||||
|
{/* Deploy Site */}
|
||||||
|
<button
|
||||||
|
onClick={() => navigate(`/sites/${siteId}/deploy`)}
|
||||||
|
className="flex items-center gap-3 px-4 py-3 rounded-lg border border-gray-200 dark:border-gray-700 bg-white dark:bg-gray-900 hover:border-info-300 hover:bg-info-50 dark:hover:bg-info-900/10 transition-all group"
|
||||||
|
>
|
||||||
|
<div className="size-8 rounded-lg bg-info-100 dark:bg-info-900/30 flex items-center justify-center flex-shrink-0">
|
||||||
|
<ArrowUpIcon className="h-4 w-4 text-info-600 dark:text-info-400" />
|
||||||
|
</div>
|
||||||
|
<span className="flex-1 text-sm font-medium text-gray-700 dark:text-gray-300 text-left">Deploy Site</span>
|
||||||
|
</button>
|
||||||
|
|
||||||
|
{/* Content Calendar */}
|
||||||
|
<button
|
||||||
|
onClick={() => navigate(`/publisher/content-calendar`)}
|
||||||
|
className="flex items-center gap-3 px-4 py-3 rounded-lg border border-gray-200 dark:border-gray-700 bg-white dark:bg-gray-900 hover:border-gray-300 hover:bg-gray-50 dark:hover:bg-gray-800/50 transition-all group"
|
||||||
|
>
|
||||||
|
<div className="size-8 rounded-lg bg-gray-100 dark:bg-gray-800 flex items-center justify-center flex-shrink-0">
|
||||||
|
<ClockIcon className="h-4 w-4 text-gray-600 dark:text-gray-400" />
|
||||||
|
</div>
|
||||||
|
<span className="flex-1 text-sm font-medium text-gray-700 dark:text-gray-300 text-left">Content Calendar</span>
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</ComponentCard>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
{/* Site Insights - 3 Column Grid */}
|
{/* Site Insights - 3 Column Grid */}
|
||||||
<div className="grid grid-cols-1 lg:grid-cols-3 gap-6 mb-6">
|
<div className="grid grid-cols-1 lg:grid-cols-3 gap-6 mb-6">
|
||||||
<SiteConfigWidget
|
<SiteConfigWidget
|
||||||
setupState={{
|
|
||||||
hasIndustry: setupState.hasIndustry,
|
|
||||||
sectorsCount: setupState.sectorsCount,
|
|
||||||
hasWordPressIntegration: setupState.hasWordPressIntegration,
|
|
||||||
keywordsCount: setupState.keywordsCount,
|
|
||||||
authorProfilesCount: setupState.authorProfilesCount
|
|
||||||
}}
|
|
||||||
siteId={Number(siteId)}
|
siteId={Number(siteId)}
|
||||||
|
siteName={site.name}
|
||||||
|
hasIndustry={setupState.hasIndustry}
|
||||||
|
hasSectors={setupState.hasSectors}
|
||||||
|
sectorsCount={setupState.sectorsCount}
|
||||||
|
hasWordPress={setupState.hasWordPressIntegration}
|
||||||
|
hasKeywords={setupState.hasKeywords}
|
||||||
|
keywordsCount={setupState.keywordsCount}
|
||||||
|
hasAuthorProfiles={setupState.hasAuthorProfiles}
|
||||||
|
authorProfilesCount={setupState.authorProfilesCount}
|
||||||
/>
|
/>
|
||||||
|
|
||||||
<OperationsCostsWidget operations={operations} siteId={Number(siteId)} />
|
<OperationsCostsWidget operations={operations} />
|
||||||
|
|
||||||
<CreditAvailabilityWidget
|
<CreditAvailabilityWidget
|
||||||
availableCredits={balance?.credits_remaining ?? 0}
|
availableCredits={balance?.credits_remaining ?? 0}
|
||||||
@@ -260,109 +375,8 @@ export default function SiteDashboard() {
|
|||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
{/* Quick Actions */}
|
|
||||||
<ComponentCard title="Quick Actions" desc="Common site management tasks">
|
|
||||||
<div className="grid grid-cols-1 md:grid-cols-2 lg:grid-cols-3 gap-4">
|
|
||||||
<Button
|
|
||||||
onClick={() => navigate(`/sites/${siteId}/pages`)}
|
|
||||||
variant="ghost"
|
|
||||||
tone="neutral"
|
|
||||||
className="flex items-center gap-4 p-6 rounded-xl border-2 border-gray-200 bg-white hover:border-[var(--color-primary)] hover:shadow-lg transition-all group h-auto justify-start"
|
|
||||||
>
|
|
||||||
<div className="size-12 rounded-xl bg-gradient-to-br from-[var(--color-primary)] to-[var(--color-primary-dark)] flex items-center justify-center text-white shadow-lg">
|
|
||||||
<PageIcon className="h-6 w-6" />
|
|
||||||
</div>
|
|
||||||
<div className="flex-1 text-left">
|
|
||||||
<h4 className="font-semibold text-gray-900 mb-1">Manage Pages</h4>
|
|
||||||
<p className="text-sm text-gray-600">View and edit pages</p>
|
|
||||||
</div>
|
|
||||||
<ArrowRightIcon className="h-5 w-5 text-gray-400 group-hover:text-[var(--color-primary)] transition" />
|
|
||||||
</Button>
|
|
||||||
|
|
||||||
<Button
|
|
||||||
onClick={() => navigate(`/sites/${siteId}/content`)}
|
|
||||||
variant="ghost"
|
|
||||||
tone="neutral"
|
|
||||||
className="flex items-center gap-4 p-6 rounded-xl border-2 border-gray-200 bg-white hover:border-[var(--color-success)] hover:shadow-lg transition-all group h-auto justify-start"
|
|
||||||
>
|
|
||||||
<div className="size-12 rounded-xl bg-gradient-to-br from-[var(--color-success)] to-[var(--color-success-dark)] flex items-center justify-center text-white shadow-lg">
|
|
||||||
<FileIcon className="h-6 w-6" />
|
|
||||||
</div>
|
|
||||||
<div className="flex-1 text-left">
|
|
||||||
<h4 className="font-semibold text-gray-900 mb-1">Manage Content</h4>
|
|
||||||
<p className="text-sm text-gray-600">View and edit content</p>
|
|
||||||
</div>
|
|
||||||
<ArrowRightIcon className="h-5 w-5 text-gray-400 group-hover:text-[var(--color-success)] transition" />
|
|
||||||
</Button>
|
|
||||||
|
|
||||||
<Button
|
|
||||||
onClick={() => navigate(`/sites/${siteId}/settings?tab=integrations`)}
|
|
||||||
variant="ghost"
|
|
||||||
tone="neutral"
|
|
||||||
className="flex items-center gap-4 p-6 rounded-xl border-2 border-gray-200 bg-white hover:border-[var(--color-purple)] hover:shadow-lg transition-all group h-auto justify-start"
|
|
||||||
>
|
|
||||||
<div className="size-12 rounded-xl bg-gradient-to-br from-[var(--color-purple)] to-[var(--color-purple-dark)] flex items-center justify-center text-white shadow-lg">
|
|
||||||
<PlugInIcon className="h-6 w-6" />
|
|
||||||
</div>
|
|
||||||
<div className="flex-1 text-left">
|
|
||||||
<h4 className="font-semibold text-gray-900 mb-1">Integrations</h4>
|
|
||||||
<p className="text-sm text-gray-600">Manage connections</p>
|
|
||||||
</div>
|
|
||||||
<ArrowRightIcon className="h-5 w-5 text-gray-400 group-hover:text-[var(--color-purple)] transition" />
|
|
||||||
</Button>
|
|
||||||
|
|
||||||
<Button
|
|
||||||
onClick={() => navigate(`/sites/${siteId}/sync`)}
|
|
||||||
variant="ghost"
|
|
||||||
tone="neutral"
|
|
||||||
className="flex items-center gap-4 p-6 rounded-xl border-2 border-gray-200 bg-white hover:border-[var(--color-warning)] hover:shadow-lg transition-all group h-auto justify-start"
|
|
||||||
>
|
|
||||||
<div className="size-12 rounded-xl bg-gradient-to-br from-[var(--color-warning)] to-[var(--color-warning-dark)] flex items-center justify-center text-white shadow-lg">
|
|
||||||
<BoltIcon className="h-6 w-6" />
|
|
||||||
</div>
|
|
||||||
<div className="flex-1 text-left">
|
|
||||||
<h4 className="font-semibold text-gray-900 mb-1">Sync Dashboard</h4>
|
|
||||||
<p className="text-sm text-gray-600">View sync status</p>
|
|
||||||
</div>
|
|
||||||
<ArrowRightIcon className="h-5 w-5 text-gray-400 group-hover:text-[var(--color-warning)] transition" />
|
|
||||||
</Button>
|
|
||||||
|
|
||||||
<Button
|
|
||||||
onClick={() => navigate(`/sites/${siteId}/deploy`)}
|
|
||||||
variant="ghost"
|
|
||||||
tone="neutral"
|
|
||||||
className="flex items-center gap-4 p-6 rounded-xl border-2 border-gray-200 bg-white hover:border-[var(--color-primary)] hover:shadow-lg transition-all group h-auto justify-start"
|
|
||||||
>
|
|
||||||
<div className="size-12 rounded-xl bg-gradient-to-br from-[var(--color-primary)] to-[var(--color-primary-dark)] flex items-center justify-center text-white shadow-lg">
|
|
||||||
<ArrowUpIcon className="h-6 w-6" />
|
|
||||||
</div>
|
|
||||||
<div className="flex-1 text-left">
|
|
||||||
<h4 className="font-semibold text-gray-900 mb-1">Deploy Site</h4>
|
|
||||||
<p className="text-sm text-gray-600">Deploy to production</p>
|
|
||||||
</div>
|
|
||||||
<ArrowRightIcon className="h-5 w-5 text-gray-400 group-hover:text-[var(--color-primary)] transition" />
|
|
||||||
</Button>
|
|
||||||
|
|
||||||
<Button
|
|
||||||
onClick={() => navigate(`/publisher/content-calendar`)}
|
|
||||||
variant="ghost"
|
|
||||||
tone="neutral"
|
|
||||||
className="flex items-center gap-4 p-6 rounded-xl border-2 border-gray-200 bg-white hover:border-amber-500 hover:shadow-lg transition-all group h-auto justify-start"
|
|
||||||
>
|
|
||||||
<div className="size-12 rounded-xl bg-gradient-to-br from-amber-500 to-amber-600 flex items-center justify-center text-white shadow-lg">
|
|
||||||
<ClockIcon className="h-6 w-6" />
|
|
||||||
</div>
|
|
||||||
<div className="flex-1 text-left">
|
|
||||||
<h4 className="font-semibold text-gray-900 mb-1">Content Calendar</h4>
|
|
||||||
<p className="text-sm text-gray-600">Schedule and manage content publishing</p>
|
|
||||||
</div>
|
|
||||||
<ArrowRightIcon className="h-5 w-5 text-gray-400 group-hover:text-warning-500 transition" />
|
|
||||||
</Button>
|
|
||||||
</div>
|
|
||||||
</ComponentCard>
|
|
||||||
|
|
||||||
{/* Recent Activity - Placeholder */}
|
{/* Recent Activity - Placeholder */}
|
||||||
<Card className="p-6 mt-6">
|
<Card className="p-6">
|
||||||
<h2 className="text-lg font-semibold text-gray-900 dark:text-white mb-4">
|
<h2 className="text-lg font-semibold text-gray-900 dark:text-white mb-4">
|
||||||
Recent Activity
|
Recent Activity
|
||||||
</h2>
|
</h2>
|
||||||
|
|||||||
@@ -24,19 +24,23 @@ import {
|
|||||||
fetchIndustries,
|
fetchIndustries,
|
||||||
Site,
|
Site,
|
||||||
Industry,
|
Industry,
|
||||||
|
setActiveSite as apiSetActiveSite,
|
||||||
} from '../../services/api';
|
} from '../../services/api';
|
||||||
|
import { useSiteStore } from '../../store/siteStore';
|
||||||
import WordPressIntegrationForm from '../../components/sites/WordPressIntegrationForm';
|
import WordPressIntegrationForm from '../../components/sites/WordPressIntegrationForm';
|
||||||
import { integrationApi, SiteIntegration } from '../../services/integration.api';
|
import { integrationApi, SiteIntegration } from '../../services/integration.api';
|
||||||
import { GridIcon, PlugInIcon, PaperPlaneIcon, DocsIcon, BoltIcon, FileIcon, ChevronDownIcon, CloseIcon, PlusIcon, RefreshCwIcon, FileTextIcon, ImageIcon, SaveIcon, Loader2Icon } from '../../icons';
|
import { GridIcon, PlugInIcon, PaperPlaneIcon, DocsIcon, BoltIcon, FileIcon, ChevronDownIcon, CloseIcon, PlusIcon, RefreshCwIcon, FileTextIcon, ImageIcon, SaveIcon, Loader2Icon, ArrowRightIcon, SettingsIcon, GlobeIcon, LayersIcon } from '../../icons';
|
||||||
import Badge from '../../components/ui/badge/Badge';
|
import Badge from '../../components/ui/badge/Badge';
|
||||||
import { Dropdown } from '../../components/ui/dropdown/Dropdown';
|
import { Dropdown } from '../../components/ui/dropdown/Dropdown';
|
||||||
import { DropdownItem } from '../../components/ui/dropdown/DropdownItem';
|
import { DropdownItem } from '../../components/ui/dropdown/DropdownItem';
|
||||||
|
import SiteInfoBar from '../../components/common/SiteInfoBar';
|
||||||
|
|
||||||
export default function SiteSettings() {
|
export default function SiteSettings() {
|
||||||
const { id: siteId } = useParams<{ id: string }>();
|
const { id: siteId } = useParams<{ id: string }>();
|
||||||
const navigate = useNavigate();
|
const navigate = useNavigate();
|
||||||
const [searchParams] = useSearchParams();
|
const [searchParams] = useSearchParams();
|
||||||
const toast = useToast();
|
const toast = useToast();
|
||||||
|
const { setActiveSite } = useSiteStore();
|
||||||
const [loading, setLoading] = useState(true);
|
const [loading, setLoading] = useState(true);
|
||||||
const [saving, setSaving] = useState(false);
|
const [saving, setSaving] = useState(false);
|
||||||
const [site, setSite] = useState<any>(null);
|
const [site, setSite] = useState<any>(null);
|
||||||
@@ -55,6 +59,9 @@ export default function SiteSettings() {
|
|||||||
const [contentTypes, setContentTypes] = useState<any>(null);
|
const [contentTypes, setContentTypes] = useState<any>(null);
|
||||||
const [contentTypesLoading, setContentTypesLoading] = useState(false);
|
const [contentTypesLoading, setContentTypesLoading] = useState(false);
|
||||||
|
|
||||||
|
// Advanced Settings toggle
|
||||||
|
const [showAdvancedSettings, setShowAdvancedSettings] = useState(false);
|
||||||
|
|
||||||
// Publishing settings state
|
// Publishing settings state
|
||||||
const [publishingSettings, setPublishingSettings] = useState<any>(null);
|
const [publishingSettings, setPublishingSettings] = useState<any>(null);
|
||||||
const [publishingSettingsLoading, setPublishingSettingsLoading] = useState(false);
|
const [publishingSettingsLoading, setPublishingSettingsLoading] = useState(false);
|
||||||
@@ -281,6 +288,10 @@ export default function SiteSettings() {
|
|||||||
const data = await fetchAPI(`/v1/auth/sites/${siteId}/`);
|
const data = await fetchAPI(`/v1/auth/sites/${siteId}/`);
|
||||||
if (data) {
|
if (data) {
|
||||||
setSite(data);
|
setSite(data);
|
||||||
|
// Update global site store so site selector shows correct site
|
||||||
|
setActiveSite(data);
|
||||||
|
// Also set as active site in backend
|
||||||
|
await apiSetActiveSite(data.id).catch(() => {});
|
||||||
const seoData = data.seo_metadata || data.metadata || {};
|
const seoData = data.seo_metadata || data.metadata || {};
|
||||||
setFormData({
|
setFormData({
|
||||||
name: data.name || '',
|
name: data.name || '',
|
||||||
@@ -746,116 +757,46 @@ export default function SiteSettings() {
|
|||||||
return (
|
return (
|
||||||
<div className="p-6">
|
<div className="p-6">
|
||||||
<PageMeta title="Site Settings - IGNY8" />
|
<PageMeta title="Site Settings - IGNY8" />
|
||||||
|
<PageHeader
|
||||||
|
title="Site Settings"
|
||||||
|
badge={{ icon: <GridIcon />, color: 'blue' }}
|
||||||
|
hideSiteSector
|
||||||
|
/>
|
||||||
|
|
||||||
<div className="flex items-center justify-between gap-4 mb-6">
|
{/* Site Info Bar */}
|
||||||
<div className="flex items-center gap-4">
|
<SiteInfoBar site={site} currentPage="settings" />
|
||||||
<PageHeader
|
|
||||||
title={site?.name ? `${site.name} — Site Settings` : 'Site Settings'}
|
|
||||||
badge={{ icon: <GridIcon />, color: 'blue' }}
|
|
||||||
hideSiteSector
|
|
||||||
/>
|
|
||||||
{/* Integration status indicator */}
|
|
||||||
<div className="flex items-center gap-3 ml-2">
|
|
||||||
<span
|
|
||||||
className={`inline-block w-6 h-6 rounded-full ${
|
|
||||||
integrationStatus === 'connected' ? 'bg-success-500' :
|
|
||||||
integrationStatus === 'configured' ? 'bg-brand-500' : 'bg-gray-300'
|
|
||||||
}`}
|
|
||||||
title={`Integration status: ${
|
|
||||||
integrationStatus === 'connected' ? 'Connected & Authenticated' :
|
|
||||||
integrationStatus === 'configured' ? 'Configured (testing...)' : 'Not configured'
|
|
||||||
}`}
|
|
||||||
/>
|
|
||||||
<span className="text-sm text-gray-600 dark:text-gray-300">
|
|
||||||
{integrationStatus === 'connected' && 'Connected'}
|
|
||||||
{integrationStatus === 'configured' && (testingAuth ? 'Testing...' : 'Configured')}
|
|
||||||
{integrationStatus === 'not_configured' && 'Not configured'}
|
|
||||||
</span>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
{/* Site Selector - Only show if more than 1 site */}
|
|
||||||
{!sitesLoading && sites.length > 1 && (
|
|
||||||
<div className="relative inline-block">
|
|
||||||
<Button
|
|
||||||
ref={siteSelectorRef}
|
|
||||||
onClick={() => setIsSiteSelectorOpen(!isSiteSelectorOpen)}
|
|
||||||
variant="outline"
|
|
||||||
className="flex items-center gap-2"
|
|
||||||
aria-label="Switch site"
|
|
||||||
>
|
|
||||||
<span className="flex items-center gap-2">
|
|
||||||
<GridIcon className="w-4 h-4 text-brand-500 dark:text-brand-400" />
|
|
||||||
<span className="max-w-[150px] truncate">{site?.name || 'Select Site'}</span>
|
|
||||||
</span>
|
|
||||||
<ChevronDownIcon className={`w-4 h-4 text-brand-500 dark:text-brand-400 transition-transform ${isSiteSelectorOpen ? 'rotate-180' : ''}`} />
|
|
||||||
</Button>
|
|
||||||
<Dropdown
|
|
||||||
isOpen={isSiteSelectorOpen}
|
|
||||||
onClose={() => setIsSiteSelectorOpen(false)}
|
|
||||||
anchorRef={siteSelectorRef}
|
|
||||||
>
|
|
||||||
{sites.map((s) => (
|
|
||||||
<DropdownItem
|
|
||||||
key={s.id}
|
|
||||||
onItemClick={() => handleSiteSelect(s.id)}
|
|
||||||
className={`flex items-center gap-3 px-3 py-2 font-medium rounded-lg text-sm text-left ${
|
|
||||||
site?.id === s.id
|
|
||||||
? "bg-brand-50 text-brand-700 dark:bg-brand-500/20 dark:text-brand-300"
|
|
||||||
: "text-gray-700 hover:bg-gray-100 hover:text-gray-700 dark:text-gray-400 dark:hover:bg-white/5 dark:hover:text-gray-300"
|
|
||||||
}`}
|
|
||||||
>
|
|
||||||
<span className="flex-1">{s.name}</span>
|
|
||||||
{site?.id === s.id && (
|
|
||||||
<svg
|
|
||||||
className="w-4 h-4 text-brand-600 dark:text-brand-400"
|
|
||||||
fill="currentColor"
|
|
||||||
viewBox="0 0 20 20"
|
|
||||||
>
|
|
||||||
<path
|
|
||||||
fillRule="evenodd"
|
|
||||||
d="M16.707 5.293a1 1 0 010 1.414l-8 8a1 1 0 01-1.414 0l-4-4a1 1 0 011.414-1.414L8 12.586l7.293-7.293a1 1 0 011.414 0z"
|
|
||||||
clipRule="evenodd"
|
|
||||||
/>
|
|
||||||
</svg>
|
|
||||||
)}
|
|
||||||
</DropdownItem>
|
|
||||||
))}
|
|
||||||
</Dropdown>
|
|
||||||
</div>
|
|
||||||
)}
|
|
||||||
</div>
|
|
||||||
|
|
||||||
{/* Tabs */}
|
{/* Tabs */}
|
||||||
<div className="mb-6 border-b border-gray-200 dark:border-gray-700">
|
<div className="mb-6 border-b border-gray-200 dark:border-gray-700">
|
||||||
<div className="flex gap-4 overflow-x-auto">
|
<div className="flex items-center justify-between gap-4">
|
||||||
<Button
|
<div className="flex gap-4 overflow-x-auto">
|
||||||
variant="ghost"
|
<Button
|
||||||
onClick={() => {
|
variant="ghost"
|
||||||
setActiveTab('general');
|
onClick={() => {
|
||||||
navigate(`/sites/${siteId}/settings`, { replace: true });
|
setActiveTab('general');
|
||||||
}}
|
navigate(`/sites/${siteId}/settings`, { replace: true });
|
||||||
className={`px-4 py-2 font-medium border-b-2 rounded-none transition-colors whitespace-nowrap ${
|
}}
|
||||||
activeTab === 'general'
|
className={`px-4 py-2 font-medium border-b-2 rounded-none transition-colors whitespace-nowrap ${
|
||||||
? 'border-brand-500 text-brand-600 dark:text-brand-400'
|
activeTab === 'general'
|
||||||
: 'border-transparent text-gray-500 hover:text-gray-700 dark:text-gray-400 dark:hover:text-gray-300'
|
? 'border-brand-500 text-brand-600 dark:text-brand-400'
|
||||||
}`}
|
: 'border-transparent text-gray-500 hover:text-gray-700 dark:text-gray-400 dark:hover:text-gray-300'
|
||||||
startIcon={<GridIcon className="w-4 h-4" />}
|
}`}
|
||||||
>
|
startIcon={<GridIcon className={`w-4 h-4 ${activeTab === 'general' ? 'text-brand-500' : ''}`} />}
|
||||||
General
|
>
|
||||||
</Button>
|
General
|
||||||
<Button
|
</Button>
|
||||||
variant="ghost"
|
<Button
|
||||||
onClick={() => {
|
variant="ghost"
|
||||||
setActiveTab('content-generation');
|
onClick={() => {
|
||||||
|
setActiveTab('content-generation');
|
||||||
navigate(`/sites/${siteId}/settings?tab=content-generation`, { replace: true });
|
navigate(`/sites/${siteId}/settings?tab=content-generation`, { replace: true });
|
||||||
}}
|
}}
|
||||||
className={`px-4 py-2 font-medium border-b-2 rounded-none transition-colors whitespace-nowrap ${
|
className={`px-4 py-2 font-medium border-b-2 rounded-none transition-colors whitespace-nowrap ${
|
||||||
activeTab === 'content-generation'
|
activeTab === 'content-generation'
|
||||||
? 'border-brand-500 text-brand-600 dark:text-brand-400'
|
? 'border-success-500 text-success-600 dark:text-success-400'
|
||||||
: 'border-transparent text-gray-500 hover:text-gray-700 dark:text-gray-400 dark:hover:text-gray-300'
|
: 'border-transparent text-gray-500 hover:text-gray-700 dark:text-gray-400 dark:hover:text-gray-300'
|
||||||
}`}
|
}`}
|
||||||
startIcon={<FileTextIcon className="w-4 h-4" />}
|
startIcon={<FileTextIcon className={`w-4 h-4 ${activeTab === 'content-generation' ? 'text-success-500' : ''}`} />}
|
||||||
>
|
>
|
||||||
Content
|
Content
|
||||||
</Button>
|
</Button>
|
||||||
@@ -867,10 +808,10 @@ export default function SiteSettings() {
|
|||||||
}}
|
}}
|
||||||
className={`px-4 py-2 font-medium border-b-2 rounded-none transition-colors whitespace-nowrap ${
|
className={`px-4 py-2 font-medium border-b-2 rounded-none transition-colors whitespace-nowrap ${
|
||||||
activeTab === 'image-settings'
|
activeTab === 'image-settings'
|
||||||
? 'border-brand-500 text-brand-600 dark:text-brand-400'
|
? 'border-purple-500 text-purple-600 dark:text-purple-400'
|
||||||
: 'border-transparent text-gray-500 hover:text-gray-700 dark:text-gray-400 dark:hover:text-gray-300'
|
: 'border-transparent text-gray-500 hover:text-gray-700 dark:text-gray-400 dark:hover:text-gray-300'
|
||||||
}`}
|
}`}
|
||||||
startIcon={<ImageIcon className="w-4 h-4" />}
|
startIcon={<ImageIcon className={`w-4 h-4 ${activeTab === 'image-settings' ? 'text-purple-500' : ''}`} />}
|
||||||
>
|
>
|
||||||
Images
|
Images
|
||||||
</Button>
|
</Button>
|
||||||
@@ -882,10 +823,10 @@ export default function SiteSettings() {
|
|||||||
}}
|
}}
|
||||||
className={`px-4 py-2 font-medium border-b-2 rounded-none transition-colors whitespace-nowrap ${
|
className={`px-4 py-2 font-medium border-b-2 rounded-none transition-colors whitespace-nowrap ${
|
||||||
activeTab === 'integrations'
|
activeTab === 'integrations'
|
||||||
? 'border-brand-500 text-brand-600 dark:text-brand-400'
|
? 'border-warning-500 text-warning-600 dark:text-warning-400'
|
||||||
: 'border-transparent text-gray-500 hover:text-gray-700 dark:text-gray-400 dark:hover:text-gray-300'
|
: 'border-transparent text-gray-500 hover:text-gray-700 dark:text-gray-400 dark:hover:text-gray-300'
|
||||||
}`}
|
}`}
|
||||||
startIcon={<PlugInIcon className="w-4 h-4" />}
|
startIcon={<PlugInIcon className={`w-4 h-4 ${activeTab === 'integrations' ? 'text-warning-500' : ''}`} />}
|
||||||
>
|
>
|
||||||
Integrations
|
Integrations
|
||||||
</Button>
|
</Button>
|
||||||
@@ -897,10 +838,10 @@ export default function SiteSettings() {
|
|||||||
}}
|
}}
|
||||||
className={`px-4 py-2 font-medium border-b-2 rounded-none transition-colors whitespace-nowrap ${
|
className={`px-4 py-2 font-medium border-b-2 rounded-none transition-colors whitespace-nowrap ${
|
||||||
activeTab === 'publishing'
|
activeTab === 'publishing'
|
||||||
? 'border-brand-500 text-brand-600 dark:text-brand-400'
|
? 'border-info-500 text-info-600 dark:text-info-400'
|
||||||
: 'border-transparent text-gray-500 hover:text-gray-700 dark:text-gray-400 dark:hover:text-gray-300'
|
: 'border-transparent text-gray-500 hover:text-gray-700 dark:text-gray-400 dark:hover:text-gray-300'
|
||||||
}`}
|
}`}
|
||||||
startIcon={<PaperPlaneIcon className="w-4 h-4" />}
|
startIcon={<PaperPlaneIcon className={`w-4 h-4 ${activeTab === 'publishing' ? 'text-info-500' : ''}`} />}
|
||||||
>
|
>
|
||||||
Publishing
|
Publishing
|
||||||
</Button>
|
</Button>
|
||||||
@@ -913,21 +854,37 @@ export default function SiteSettings() {
|
|||||||
}}
|
}}
|
||||||
className={`px-4 py-2 font-medium border-b-2 rounded-none transition-colors whitespace-nowrap ${
|
className={`px-4 py-2 font-medium border-b-2 rounded-none transition-colors whitespace-nowrap ${
|
||||||
activeTab === 'content-types'
|
activeTab === 'content-types'
|
||||||
? 'border-brand-500 text-brand-600 dark:text-brand-400'
|
? 'border-error-500 text-error-600 dark:text-error-400'
|
||||||
: 'border-transparent text-gray-500 hover:text-gray-700 dark:text-gray-400 dark:hover:text-gray-300'
|
: 'border-transparent text-gray-500 hover:text-gray-700 dark:text-gray-400 dark:hover:text-gray-300'
|
||||||
}`}
|
}`}
|
||||||
startIcon={<FileIcon className="w-4 h-4" />}
|
startIcon={<FileIcon className={`w-4 h-4 ${activeTab === 'content-types' ? 'text-error-500' : ''}`} />}
|
||||||
>
|
>
|
||||||
Content Types
|
Content Types
|
||||||
</Button>
|
</Button>
|
||||||
)}
|
)}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Integration Status Indicator - Larger */}
|
||||||
|
<div className="flex items-center gap-3 px-4 py-2 rounded-lg bg-gray-50 dark:bg-gray-800 flex-shrink-0">
|
||||||
|
<span
|
||||||
|
className={`inline-block w-4 h-4 rounded-full ${
|
||||||
|
integrationStatus === 'connected' ? 'bg-success-500' :
|
||||||
|
integrationStatus === 'configured' ? 'bg-brand-500' : 'bg-gray-300'
|
||||||
|
}`}
|
||||||
|
/>
|
||||||
|
<span className="text-sm font-medium text-gray-700 dark:text-gray-200">
|
||||||
|
{integrationStatus === 'connected' && 'Connected'}
|
||||||
|
{integrationStatus === 'configured' && (testingAuth ? 'Testing...' : 'Configured')}
|
||||||
|
{integrationStatus === 'not_configured' && 'Not Configured'}
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
{/* Content Generation Tab */}
|
{/* Content Generation Tab */}
|
||||||
{activeTab === 'content-generation' && (
|
{activeTab === 'content-generation' && (
|
||||||
<div className="space-y-6 max-w-4xl">
|
<div className="space-y-6 max-w-4xl">
|
||||||
<Card className="p-6">
|
<Card className="p-6 border-l-4 border-l-brand-500">
|
||||||
<div className="flex items-center gap-3 mb-6">
|
<div className="flex items-center gap-3 mb-6">
|
||||||
<div className="p-2 bg-brand-100 dark:bg-brand-900/30 rounded-lg">
|
<div className="p-2 bg-brand-100 dark:bg-brand-900/30 rounded-lg">
|
||||||
<FileTextIcon className="w-5 h-5 text-brand-600 dark:text-brand-400" />
|
<FileTextIcon className="w-5 h-5 text-brand-600 dark:text-brand-400" />
|
||||||
@@ -1010,7 +967,7 @@ export default function SiteSettings() {
|
|||||||
{/* Image Settings Tab */}
|
{/* Image Settings Tab */}
|
||||||
{activeTab === 'image-settings' && (
|
{activeTab === 'image-settings' && (
|
||||||
<div className="space-y-6 max-w-4xl">
|
<div className="space-y-6 max-w-4xl">
|
||||||
<Card className="p-6">
|
<Card className="p-6 border-l-4 border-l-purple-500">
|
||||||
<div className="flex items-center gap-3 mb-6">
|
<div className="flex items-center gap-3 mb-6">
|
||||||
<div className="p-2 bg-purple-100 dark:bg-purple-900/30 rounded-lg">
|
<div className="p-2 bg-purple-100 dark:bg-purple-900/30 rounded-lg">
|
||||||
<ImageIcon className="w-5 h-5 text-purple-600 dark:text-purple-400" />
|
<ImageIcon className="w-5 h-5 text-purple-600 dark:text-purple-400" />
|
||||||
@@ -1533,10 +1490,10 @@ export default function SiteSettings() {
|
|||||||
{/* General Tab */}
|
{/* General Tab */}
|
||||||
{activeTab === 'general' && (
|
{activeTab === 'general' && (
|
||||||
<>
|
<>
|
||||||
{/* 4-Card Layout for Basic Settings, SEO, Open Graph, and Schema */}
|
{/* Row 1: Basic Settings and Industry/Sectors side by side */}
|
||||||
<div className="grid grid-cols-1 md:grid-cols-2 gap-6 mb-6">
|
<div className="grid grid-cols-1 md:grid-cols-2 gap-6 mb-6">
|
||||||
{/* Card 1: Basic Site Settings */}
|
{/* Card 1: Basic Site Settings */}
|
||||||
<Card className="p-6">
|
<Card className="p-6 border-l-4 border-l-brand-500">
|
||||||
<h3 className="text-lg font-semibold mb-4 flex items-center gap-2">
|
<h3 className="text-lg font-semibold mb-4 flex items-center gap-2">
|
||||||
<GridIcon className="w-5 h-5 text-brand-500" />
|
<GridIcon className="w-5 h-5 text-brand-500" />
|
||||||
Basic Settings
|
Basic Settings
|
||||||
@@ -1598,289 +1555,316 @@ export default function SiteSettings() {
|
|||||||
</div>
|
</div>
|
||||||
</Card>
|
</Card>
|
||||||
|
|
||||||
{/* Card 2: SEO Meta Tags */}
|
{/* Card 2: Industry & Sectors Configuration */}
|
||||||
<Card className="p-6">
|
<Card className="p-6 border-l-4 border-l-info-500">
|
||||||
<h3 className="text-lg font-semibold mb-4 flex items-center gap-2">
|
<h3 className="text-lg font-semibold mb-4 flex items-center gap-2">
|
||||||
<DocsIcon className="w-5 h-5 text-brand-500" />
|
<LayersIcon className="w-5 h-5 text-info-500" />
|
||||||
SEO Meta Tags
|
Industry & Sectors
|
||||||
</h3>
|
</h3>
|
||||||
|
<p className="text-sm text-gray-600 dark:text-gray-400 mb-4">
|
||||||
|
Configure up to 5 sectors for content targeting.
|
||||||
|
</p>
|
||||||
|
|
||||||
<div className="space-y-4">
|
<div className="space-y-4">
|
||||||
<div>
|
<div>
|
||||||
<Label>Meta Title</Label>
|
<label className="block text-sm font-medium text-gray-700 dark:text-gray-300 mb-2">
|
||||||
<InputField
|
Select Industry
|
||||||
type="text"
|
</label>
|
||||||
value={formData.meta_title}
|
<Select
|
||||||
onChange={(e) => setFormData({ ...formData, meta_title: e.target.value })}
|
options={industries.map((industry) => ({
|
||||||
placeholder="SEO title (recommended: 50-60 characters)"
|
value: industry.slug,
|
||||||
max="60"
|
label: industry.name,
|
||||||
|
}))}
|
||||||
|
placeholder="Select an industry..."
|
||||||
|
defaultValue={selectedIndustry}
|
||||||
|
onChange={(value) => {
|
||||||
|
setSelectedIndustry(value);
|
||||||
|
setSelectedSectors([]);
|
||||||
|
}}
|
||||||
/>
|
/>
|
||||||
<p className="mt-1 text-xs text-gray-500 dark:text-gray-400">
|
{selectedIndustry && (
|
||||||
{formData.meta_title.length}/60 characters
|
<p className="mt-1 text-xs text-gray-500 dark:text-gray-400">
|
||||||
</p>
|
{industries.find(i => i.slug === selectedIndustry)?.description}
|
||||||
|
</p>
|
||||||
|
)}
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div>
|
{selectedIndustry && (
|
||||||
<Label>Meta Description</Label>
|
<div>
|
||||||
<TextArea
|
<label className="block text-sm font-medium text-gray-700 dark:text-gray-300 mb-2">
|
||||||
value={formData.meta_description}
|
Select Sectors (max 5)
|
||||||
onChange={(value) => setFormData({ ...formData, meta_description: value })}
|
</label>
|
||||||
rows={4}
|
<div className="space-y-2 max-h-48 overflow-y-auto border border-gray-200 rounded-lg p-3 dark:border-gray-700">
|
||||||
placeholder="SEO description (recommended: 150-160 characters)"
|
{getIndustrySectors().map((sector) => (
|
||||||
maxLength={160}
|
<div
|
||||||
className="mt-1"
|
key={sector.slug}
|
||||||
/>
|
className="flex items-start space-x-3 p-2 rounded-lg hover:bg-gray-50 dark:hover:bg-gray-800"
|
||||||
<p className="mt-1 text-xs text-gray-500 dark:text-gray-400">
|
>
|
||||||
{formData.meta_description.length}/160 characters
|
<Checkbox
|
||||||
</p>
|
checked={selectedSectors.includes(sector.slug)}
|
||||||
</div>
|
onChange={(checked) => {
|
||||||
|
if (checked) {
|
||||||
|
if (selectedSectors.length >= 5) {
|
||||||
|
toast.error('Maximum 5 sectors allowed per site');
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
setSelectedSectors([...selectedSectors, sector.slug]);
|
||||||
|
} else {
|
||||||
|
setSelectedSectors(selectedSectors.filter(s => s !== sector.slug));
|
||||||
|
}
|
||||||
|
}}
|
||||||
|
/>
|
||||||
|
<div className="flex-1">
|
||||||
|
<div className="font-medium text-sm text-gray-900 dark:text-white">
|
||||||
|
{sector.name}
|
||||||
|
</div>
|
||||||
|
<div className="text-xs text-gray-500 dark:text-gray-400 mt-0.5 line-clamp-1">
|
||||||
|
{sector.description}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
<p className="mt-2 text-xs text-gray-500 dark:text-gray-400">
|
||||||
|
Selected: {selectedSectors.length} / 5 sectors
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
<div>
|
{selectedIndustry && selectedSectors.length > 0 && (
|
||||||
<Label>Meta Keywords (comma-separated)</Label>
|
<div className="flex justify-end">
|
||||||
<InputField
|
<Button
|
||||||
type="text"
|
onClick={handleSelectSectors}
|
||||||
value={formData.meta_keywords}
|
variant="primary"
|
||||||
onChange={(e) => setFormData({ ...formData, meta_keywords: e.target.value })}
|
size="sm"
|
||||||
placeholder="keyword1, keyword2, keyword3"
|
disabled={isSelectingSectors}
|
||||||
/>
|
>
|
||||||
<p className="mt-1 text-xs text-gray-500 dark:text-gray-400">
|
{isSelectingSectors ? 'Saving...' : 'Save Sectors'}
|
||||||
Separate keywords with commas
|
</Button>
|
||||||
</p>
|
</div>
|
||||||
</div>
|
)}
|
||||||
</div>
|
|
||||||
</Card>
|
|
||||||
|
|
||||||
{/* Card 3: Open Graph */}
|
|
||||||
<Card className="p-6">
|
|
||||||
<h3 className="text-lg font-semibold mb-4 flex items-center gap-2">
|
|
||||||
<PaperPlaneIcon className="w-5 h-5 text-brand-500" />
|
|
||||||
Open Graph
|
|
||||||
</h3>
|
|
||||||
<div className="space-y-4">
|
|
||||||
<div>
|
|
||||||
<Label>OG Title</Label>
|
|
||||||
<InputField
|
|
||||||
type="text"
|
|
||||||
value={formData.og_title}
|
|
||||||
onChange={(e) => setFormData({ ...formData, og_title: e.target.value })}
|
|
||||||
placeholder="Open Graph title"
|
|
||||||
/>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<div>
|
|
||||||
<Label>OG Description</Label>
|
|
||||||
<TextArea
|
|
||||||
value={formData.og_description}
|
|
||||||
onChange={(value) => setFormData({ ...formData, og_description: value })}
|
|
||||||
rows={3}
|
|
||||||
placeholder="Open Graph description"
|
|
||||||
className="mt-1"
|
|
||||||
/>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<div>
|
|
||||||
<Label>OG Image URL</Label>
|
|
||||||
<InputField
|
|
||||||
type="url"
|
|
||||||
value={formData.og_image}
|
|
||||||
onChange={(e) => setFormData({ ...formData, og_image: e.target.value })}
|
|
||||||
placeholder="https://example.com/image.jpg"
|
|
||||||
/>
|
|
||||||
<p className="mt-1 text-xs text-gray-500 dark:text-gray-400">
|
|
||||||
Recommended: 1200x630px image
|
|
||||||
</p>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<div>
|
|
||||||
<Label>OG Type</Label>
|
|
||||||
<SelectDropdown
|
|
||||||
options={[
|
|
||||||
{ value: 'website', label: 'Website' },
|
|
||||||
{ value: 'article', label: 'Article' },
|
|
||||||
{ value: 'business.business', label: 'Business' },
|
|
||||||
{ value: 'product', label: 'Product' },
|
|
||||||
]}
|
|
||||||
value={formData.og_type}
|
|
||||||
onChange={(value) => setFormData({ ...formData, og_type: value })}
|
|
||||||
/>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<div>
|
|
||||||
<Label>OG Site Name</Label>
|
|
||||||
<InputField
|
|
||||||
type="text"
|
|
||||||
value={formData.og_site_name}
|
|
||||||
onChange={(e) => setFormData({ ...formData, og_site_name: e.target.value })}
|
|
||||||
placeholder="Site name for social sharing"
|
|
||||||
/>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</Card>
|
|
||||||
|
|
||||||
{/* Card 4: Schema.org */}
|
|
||||||
<Card className="p-6">
|
|
||||||
<h3 className="text-lg font-semibold mb-4 flex items-center gap-2">
|
|
||||||
<BoltIcon className="w-5 h-5 text-brand-500" />
|
|
||||||
Schema.org
|
|
||||||
</h3>
|
|
||||||
<div className="space-y-4">
|
|
||||||
<div>
|
|
||||||
<Label>Schema Type</Label>
|
|
||||||
<SelectDropdown
|
|
||||||
options={[
|
|
||||||
{ value: 'Organization', label: 'Organization' },
|
|
||||||
{ value: 'LocalBusiness', label: 'Local Business' },
|
|
||||||
{ value: 'WebSite', label: 'Website' },
|
|
||||||
{ value: 'Corporation', label: 'Corporation' },
|
|
||||||
{ value: 'NGO', label: 'NGO' },
|
|
||||||
]}
|
|
||||||
value={formData.schema_type}
|
|
||||||
onChange={(value) => setFormData({ ...formData, schema_type: value })}
|
|
||||||
/>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<div>
|
|
||||||
<Label>Schema Name</Label>
|
|
||||||
<InputField
|
|
||||||
type="text"
|
|
||||||
value={formData.schema_name}
|
|
||||||
onChange={(e) => setFormData({ ...formData, schema_name: e.target.value })}
|
|
||||||
placeholder="Organization name"
|
|
||||||
/>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<div>
|
|
||||||
<Label>Schema Description</Label>
|
|
||||||
<TextArea
|
|
||||||
value={formData.schema_description}
|
|
||||||
onChange={(value) => setFormData({ ...formData, schema_description: value })}
|
|
||||||
rows={3}
|
|
||||||
placeholder="Organization description"
|
|
||||||
className="mt-1"
|
|
||||||
/>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<div>
|
|
||||||
<Label>Schema URL</Label>
|
|
||||||
<InputField
|
|
||||||
type="url"
|
|
||||||
value={formData.schema_url}
|
|
||||||
onChange={(e) => setFormData({ ...formData, schema_url: e.target.value })}
|
|
||||||
placeholder="https://example.com"
|
|
||||||
/>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<div>
|
|
||||||
<Label>Schema Logo URL</Label>
|
|
||||||
<InputField
|
|
||||||
type="url"
|
|
||||||
value={formData.schema_logo}
|
|
||||||
onChange={(e) => setFormData({ ...formData, schema_logo: e.target.value })}
|
|
||||||
placeholder="https://example.com/logo.png"
|
|
||||||
/>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<div>
|
|
||||||
<Label>Same As URLs (comma-separated)</Label>
|
|
||||||
<InputField
|
|
||||||
type="text"
|
|
||||||
value={formData.schema_same_as}
|
|
||||||
onChange={(e) => setFormData({ ...formData, schema_same_as: e.target.value })}
|
|
||||||
placeholder="https://facebook.com/page, https://twitter.com/page"
|
|
||||||
/>
|
|
||||||
<p className="mt-1 text-xs text-gray-500 dark:text-gray-400">
|
|
||||||
Social media profiles and other related URLs
|
|
||||||
</p>
|
|
||||||
</div>
|
|
||||||
</div>
|
</div>
|
||||||
</Card>
|
</Card>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
{/* Sectors Configuration Section */}
|
{/* Advanced Settings Toggle */}
|
||||||
<Card className="p-6">
|
<div className="mb-6">
|
||||||
<h3 className="text-lg font-semibold mb-4">Industry & Sectors Configuration</h3>
|
<Button
|
||||||
<p className="text-sm text-gray-600 dark:text-gray-400 mb-6">
|
variant="outline"
|
||||||
Configure up to 5 sectors from your selected industry. Keywords and clusters are automatically associated with sectors.
|
onClick={() => setShowAdvancedSettings(!showAdvancedSettings)}
|
||||||
</p>
|
className="w-full justify-between"
|
||||||
|
endIcon={<ChevronDownIcon className={`w-4 h-4 transition-transform ${showAdvancedSettings ? 'rotate-180' : ''}`} />}
|
||||||
|
>
|
||||||
|
<span className="flex items-center gap-2">
|
||||||
|
<SettingsIcon className="w-4 h-4" />
|
||||||
|
Advanced Settings (SEO, Open Graph, Schema)
|
||||||
|
</span>
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
|
|
||||||
<div className="space-y-6">
|
{/* Advanced Settings - 3 Column Grid */}
|
||||||
<div>
|
{showAdvancedSettings && (
|
||||||
<label className="block text-sm font-medium text-gray-700 dark:text-gray-300 mb-2">
|
<div className="grid grid-cols-1 md:grid-cols-3 gap-6 mb-6">
|
||||||
Select Industry
|
{/* SEO Meta Tags */}
|
||||||
</label>
|
<Card className="p-6 border-l-4 border-l-success-500">
|
||||||
<Select
|
<h3 className="text-lg font-semibold mb-4 flex items-center gap-2">
|
||||||
options={industries.map((industry) => ({
|
<DocsIcon className="w-5 h-5 text-success-500" />
|
||||||
value: industry.slug,
|
SEO Meta Tags
|
||||||
label: industry.name,
|
</h3>
|
||||||
}))}
|
<div className="space-y-4">
|
||||||
placeholder="Select an industry..."
|
<div>
|
||||||
defaultValue={selectedIndustry}
|
<Label>Meta Title</Label>
|
||||||
onChange={(value) => {
|
<InputField
|
||||||
setSelectedIndustry(value);
|
type="text"
|
||||||
setSelectedSectors([]);
|
value={formData.meta_title}
|
||||||
}}
|
onChange={(e) => setFormData({ ...formData, meta_title: e.target.value })}
|
||||||
/>
|
placeholder="SEO title (50-60 chars)"
|
||||||
{selectedIndustry && (
|
max="60"
|
||||||
<p className="mt-1 text-xs text-gray-500 dark:text-gray-400">
|
/>
|
||||||
{industries.find(i => i.slug === selectedIndustry)?.description}
|
<p className="mt-1 text-xs text-gray-500 dark:text-gray-400">
|
||||||
</p>
|
{formData.meta_title.length}/60 characters
|
||||||
)}
|
</p>
|
||||||
</div>
|
|
||||||
|
|
||||||
{selectedIndustry && (
|
|
||||||
<div>
|
|
||||||
<label className="block text-sm font-medium text-gray-700 dark:text-gray-300 mb-2">
|
|
||||||
Select Sectors (max 5)
|
|
||||||
</label>
|
|
||||||
<div className="space-y-2 max-h-64 overflow-y-auto border border-gray-200 rounded-lg p-4 dark:border-gray-700">
|
|
||||||
{getIndustrySectors().map((sector) => (
|
|
||||||
<div
|
|
||||||
key={sector.slug}
|
|
||||||
className="flex items-start space-x-3 p-3 rounded-lg hover:bg-gray-50 dark:hover:bg-gray-800"
|
|
||||||
>
|
|
||||||
<Checkbox
|
|
||||||
checked={selectedSectors.includes(sector.slug)}
|
|
||||||
onChange={(checked) => {
|
|
||||||
if (checked) {
|
|
||||||
if (selectedSectors.length >= 5) {
|
|
||||||
toast.error('Maximum 5 sectors allowed per site');
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
setSelectedSectors([...selectedSectors, sector.slug]);
|
|
||||||
} else {
|
|
||||||
setSelectedSectors(selectedSectors.filter(s => s !== sector.slug));
|
|
||||||
}
|
|
||||||
}}
|
|
||||||
/>
|
|
||||||
<div className="flex-1">
|
|
||||||
<div className="font-medium text-sm text-gray-900 dark:text-white">
|
|
||||||
{sector.name}
|
|
||||||
</div>
|
|
||||||
<div className="text-xs text-gray-500 dark:text-gray-400 mt-1">
|
|
||||||
{sector.description}
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
))}
|
|
||||||
</div>
|
</div>
|
||||||
<p className="mt-2 text-xs text-gray-500 dark:text-gray-400">
|
|
||||||
Selected: {selectedSectors.length} / 5 sectors
|
|
||||||
</p>
|
|
||||||
</div>
|
|
||||||
)}
|
|
||||||
|
|
||||||
{selectedIndustry && selectedSectors.length > 0 && (
|
<div>
|
||||||
<div className="flex justify-end">
|
<Label>Meta Description</Label>
|
||||||
<Button
|
<TextArea
|
||||||
onClick={handleSelectSectors}
|
value={formData.meta_description}
|
||||||
variant="primary"
|
onChange={(value) => setFormData({ ...formData, meta_description: value })}
|
||||||
disabled={isSelectingSectors}
|
rows={3}
|
||||||
>
|
placeholder="SEO description (150-160 chars)"
|
||||||
{isSelectingSectors ? 'Saving Sectors...' : 'Save Sectors'}
|
maxLength={160}
|
||||||
</Button>
|
className="mt-1"
|
||||||
|
/>
|
||||||
|
<p className="mt-1 text-xs text-gray-500 dark:text-gray-400">
|
||||||
|
{formData.meta_description.length}/160 characters
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div>
|
||||||
|
<Label>Meta Keywords</Label>
|
||||||
|
<InputField
|
||||||
|
type="text"
|
||||||
|
value={formData.meta_keywords}
|
||||||
|
onChange={(e) => setFormData({ ...formData, meta_keywords: e.target.value })}
|
||||||
|
placeholder="keyword1, keyword2"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
</div>
|
</div>
|
||||||
)}
|
</Card>
|
||||||
|
|
||||||
|
{/* Open Graph */}
|
||||||
|
<Card className="p-6 border-l-4 border-l-purple-500">
|
||||||
|
<h3 className="text-lg font-semibold mb-4 flex items-center gap-2">
|
||||||
|
<PaperPlaneIcon className="w-5 h-5 text-purple-500" />
|
||||||
|
Open Graph
|
||||||
|
</h3>
|
||||||
|
<div className="space-y-4">
|
||||||
|
<div>
|
||||||
|
<Label>OG Title</Label>
|
||||||
|
<InputField
|
||||||
|
type="text"
|
||||||
|
value={formData.og_title}
|
||||||
|
onChange={(e) => setFormData({ ...formData, og_title: e.target.value })}
|
||||||
|
placeholder="Open Graph title"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div>
|
||||||
|
<Label>OG Description</Label>
|
||||||
|
<TextArea
|
||||||
|
value={formData.og_description}
|
||||||
|
onChange={(value) => setFormData({ ...formData, og_description: value })}
|
||||||
|
rows={3}
|
||||||
|
placeholder="Open Graph description"
|
||||||
|
className="mt-1"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div>
|
||||||
|
<Label>OG Image URL</Label>
|
||||||
|
<InputField
|
||||||
|
type="url"
|
||||||
|
value={formData.og_image}
|
||||||
|
onChange={(e) => setFormData({ ...formData, og_image: e.target.value })}
|
||||||
|
placeholder="https://example.com/image.jpg"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div>
|
||||||
|
<Label>OG Type</Label>
|
||||||
|
<SelectDropdown
|
||||||
|
options={[
|
||||||
|
{ value: 'website', label: 'Website' },
|
||||||
|
{ value: 'article', label: 'Article' },
|
||||||
|
{ value: 'business.business', label: 'Business' },
|
||||||
|
{ value: 'product', label: 'Product' },
|
||||||
|
]}
|
||||||
|
value={formData.og_type}
|
||||||
|
onChange={(value) => setFormData({ ...formData, og_type: value })}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div>
|
||||||
|
<Label>OG Site Name</Label>
|
||||||
|
<InputField
|
||||||
|
type="text"
|
||||||
|
value={formData.og_site_name}
|
||||||
|
onChange={(e) => setFormData({ ...formData, og_site_name: e.target.value })}
|
||||||
|
placeholder="Site name"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</Card>
|
||||||
|
|
||||||
|
{/* Schema.org */}
|
||||||
|
<Card className="p-6 border-l-4 border-l-warning-500">
|
||||||
|
<h3 className="text-lg font-semibold mb-4 flex items-center gap-2">
|
||||||
|
<BoltIcon className="w-5 h-5 text-warning-500" />
|
||||||
|
Schema.org
|
||||||
|
</h3>
|
||||||
|
<div className="space-y-4">
|
||||||
|
<div>
|
||||||
|
<Label>Schema Type</Label>
|
||||||
|
<SelectDropdown
|
||||||
|
options={[
|
||||||
|
{ value: 'Organization', label: 'Organization' },
|
||||||
|
{ value: 'LocalBusiness', label: 'Local Business' },
|
||||||
|
{ value: 'WebSite', label: 'Website' },
|
||||||
|
{ value: 'Corporation', label: 'Corporation' },
|
||||||
|
{ value: 'NGO', label: 'NGO' },
|
||||||
|
]}
|
||||||
|
value={formData.schema_type}
|
||||||
|
onChange={(value) => setFormData({ ...formData, schema_type: value })}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div>
|
||||||
|
<Label>Schema Name</Label>
|
||||||
|
<InputField
|
||||||
|
type="text"
|
||||||
|
value={formData.schema_name}
|
||||||
|
onChange={(e) => setFormData({ ...formData, schema_name: e.target.value })}
|
||||||
|
placeholder="Organization name"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div>
|
||||||
|
<Label>Schema Description</Label>
|
||||||
|
<TextArea
|
||||||
|
value={formData.schema_description}
|
||||||
|
onChange={(value) => setFormData({ ...formData, schema_description: value })}
|
||||||
|
rows={3}
|
||||||
|
placeholder="Description"
|
||||||
|
className="mt-1"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div>
|
||||||
|
<Label>Schema URL</Label>
|
||||||
|
<InputField
|
||||||
|
type="url"
|
||||||
|
value={formData.schema_url}
|
||||||
|
onChange={(e) => setFormData({ ...formData, schema_url: e.target.value })}
|
||||||
|
placeholder="https://example.com"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div>
|
||||||
|
<Label>Schema Logo URL</Label>
|
||||||
|
<InputField
|
||||||
|
type="url"
|
||||||
|
value={formData.schema_logo}
|
||||||
|
onChange={(e) => setFormData({ ...formData, schema_logo: e.target.value })}
|
||||||
|
placeholder="https://example.com/logo.png"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div>
|
||||||
|
<Label>Same As URLs</Label>
|
||||||
|
<InputField
|
||||||
|
type="text"
|
||||||
|
value={formData.schema_same_as}
|
||||||
|
onChange={(e) => setFormData({ ...formData, schema_same_as: e.target.value })}
|
||||||
|
placeholder="Social profiles (comma-separated)"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</Card>
|
||||||
</div>
|
</div>
|
||||||
</Card>
|
)}
|
||||||
|
|
||||||
|
{/* Save Button */}
|
||||||
|
<div className="flex justify-end">
|
||||||
|
<Button
|
||||||
|
onClick={handleSave}
|
||||||
|
variant="primary"
|
||||||
|
disabled={saving}
|
||||||
|
startIcon={saving ? <Loader2Icon className="w-4 h-4 animate-spin" /> : <SaveIcon className="w-4 h-4" />}
|
||||||
|
>
|
||||||
|
{saving ? 'Saving...' : 'Save Settings'}
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
</>
|
</>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
@@ -2084,15 +2068,6 @@ export default function SiteSettings() {
|
|||||||
onIntegrationUpdate={handleIntegrationUpdate}
|
onIntegrationUpdate={handleIntegrationUpdate}
|
||||||
/>
|
/>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
{/* Save Button */}
|
|
||||||
{activeTab === 'general' && (
|
|
||||||
<div className="flex justify-end">
|
|
||||||
<Button onClick={handleSave} variant="primary" disabled={saving}>
|
|
||||||
{saving ? 'Saving...' : 'Save Settings'}
|
|
||||||
</Button>
|
|
||||||
</div>
|
|
||||||
)}
|
|
||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
|
|||||||
@@ -17,6 +17,8 @@ import Select from '../../components/form/Select';
|
|||||||
import Checkbox from '../../components/form/input/Checkbox';
|
import Checkbox from '../../components/form/input/Checkbox';
|
||||||
import PageMeta from '../../components/common/PageMeta';
|
import PageMeta from '../../components/common/PageMeta';
|
||||||
import PageHeader from '../../components/common/PageHeader';
|
import PageHeader from '../../components/common/PageHeader';
|
||||||
|
import ComponentCard from '../../components/common/ComponentCard';
|
||||||
|
import { Modal } from '../../components/ui/modal';
|
||||||
import { useToast } from '../../components/ui/toast/ToastContainer';
|
import { useToast } from '../../components/ui/toast/ToastContainer';
|
||||||
import { useAuthStore } from '../../store/authStore';
|
import { useAuthStore } from '../../store/authStore';
|
||||||
import {
|
import {
|
||||||
@@ -322,7 +324,7 @@ export default function AccountSettingsPage() {
|
|||||||
|
|
||||||
<form onSubmit={handleAccountSubmit} className="space-y-6">
|
<form onSubmit={handleAccountSubmit} className="space-y-6">
|
||||||
{/* Account Information */}
|
{/* Account Information */}
|
||||||
<Card className="p-6">
|
<Card className="p-6 border-l-4 border-l-brand-500">
|
||||||
<h2 className="text-lg font-semibold mb-4 text-gray-900 dark:text-white">Account Information</h2>
|
<h2 className="text-lg font-semibold mb-4 text-gray-900 dark:text-white">Account Information</h2>
|
||||||
<div className="grid grid-cols-1 md:grid-cols-2 gap-4">
|
<div className="grid grid-cols-1 md:grid-cols-2 gap-4">
|
||||||
<div>
|
<div>
|
||||||
@@ -355,7 +357,7 @@ export default function AccountSettingsPage() {
|
|||||||
</Card>
|
</Card>
|
||||||
|
|
||||||
{/* Billing Address */}
|
{/* Billing Address */}
|
||||||
<Card className="p-6">
|
<Card className="p-6 border-l-4 border-l-purple-500">
|
||||||
<h2 className="text-lg font-semibold mb-4 text-gray-900 dark:text-white">Billing Address</h2>
|
<h2 className="text-lg font-semibold mb-4 text-gray-900 dark:text-white">Billing Address</h2>
|
||||||
<div className="space-y-4">
|
<div className="space-y-4">
|
||||||
<div>
|
<div>
|
||||||
@@ -419,7 +421,7 @@ export default function AccountSettingsPage() {
|
|||||||
</Card>
|
</Card>
|
||||||
|
|
||||||
{/* Tax Information */}
|
{/* Tax Information */}
|
||||||
<Card className="p-6">
|
<Card className="p-6 border-l-4 border-l-success-500">
|
||||||
<h2 className="text-lg font-semibold mb-4 text-gray-900 dark:text-white">Tax Information</h2>
|
<h2 className="text-lg font-semibold mb-4 text-gray-900 dark:text-white">Tax Information</h2>
|
||||||
<div>
|
<div>
|
||||||
<InputField
|
<InputField
|
||||||
@@ -453,7 +455,7 @@ export default function AccountSettingsPage() {
|
|||||||
{activeTab === 'profile' && (
|
{activeTab === 'profile' && (
|
||||||
<div className="space-y-6 max-w-4xl">
|
<div className="space-y-6 max-w-4xl">
|
||||||
<form onSubmit={handleProfileSubmit} className="space-y-6">
|
<form onSubmit={handleProfileSubmit} className="space-y-6">
|
||||||
<Card className="p-6">
|
<Card className="p-6 border-l-4 border-l-brand-500">
|
||||||
<h2 className="text-lg font-semibold mb-4 text-gray-900 dark:text-white">About You</h2>
|
<h2 className="text-lg font-semibold mb-4 text-gray-900 dark:text-white">About You</h2>
|
||||||
<div className="grid grid-cols-1 md:grid-cols-2 gap-4">
|
<div className="grid grid-cols-1 md:grid-cols-2 gap-4">
|
||||||
<div>
|
<div>
|
||||||
@@ -491,7 +493,7 @@ export default function AccountSettingsPage() {
|
|||||||
</div>
|
</div>
|
||||||
</Card>
|
</Card>
|
||||||
|
|
||||||
<Card className="p-6">
|
<Card className="p-6 border-l-4 border-l-purple-500">
|
||||||
<h2 className="text-lg font-semibold mb-4 text-gray-900 dark:text-white">Preferences</h2>
|
<h2 className="text-lg font-semibold mb-4 text-gray-900 dark:text-white">Preferences</h2>
|
||||||
<div className="grid grid-cols-1 md:grid-cols-2 gap-4">
|
<div className="grid grid-cols-1 md:grid-cols-2 gap-4">
|
||||||
<div>
|
<div>
|
||||||
@@ -529,7 +531,7 @@ export default function AccountSettingsPage() {
|
|||||||
</div>
|
</div>
|
||||||
</Card>
|
</Card>
|
||||||
|
|
||||||
<Card className="p-6">
|
<Card className="p-6 border-l-4 border-l-success-500">
|
||||||
<h2 className="text-lg font-semibold mb-4 text-gray-900 dark:text-white">Notifications</h2>
|
<h2 className="text-lg font-semibold mb-4 text-gray-900 dark:text-white">Notifications</h2>
|
||||||
<p className="text-sm text-gray-600 dark:text-gray-400 mb-4">
|
<p className="text-sm text-gray-600 dark:text-gray-400 mb-4">
|
||||||
Choose what emails you want to receive:
|
Choose what emails you want to receive:
|
||||||
@@ -562,7 +564,7 @@ export default function AccountSettingsPage() {
|
|||||||
</div>
|
</div>
|
||||||
</Card>
|
</Card>
|
||||||
|
|
||||||
<Card className="p-6">
|
<Card className="p-6 border-l-4 border-l-warning-500">
|
||||||
<h2 className="text-lg font-semibold mb-4 text-gray-900 dark:text-white flex items-center gap-2">
|
<h2 className="text-lg font-semibold mb-4 text-gray-900 dark:text-white flex items-center gap-2">
|
||||||
<LockIcon className="w-5 h-5" />
|
<LockIcon className="w-5 h-5" />
|
||||||
Security
|
Security
|
||||||
@@ -595,100 +597,101 @@ export default function AccountSettingsPage() {
|
|||||||
{/* Team Tab */}
|
{/* Team Tab */}
|
||||||
{activeTab === 'team' && (
|
{activeTab === 'team' && (
|
||||||
<div className="space-y-6">
|
<div className="space-y-6">
|
||||||
<div className="flex justify-between items-center">
|
{/* Team Members Section */}
|
||||||
<div>
|
<ComponentCard
|
||||||
<h2 className="text-lg font-semibold text-gray-900 dark:text-white">Team Members</h2>
|
title="Team Members"
|
||||||
<p className="text-sm text-gray-600 dark:text-gray-400">
|
desc="Manage who can access your account"
|
||||||
Manage who can access your account
|
headerContent={
|
||||||
</p>
|
<Button
|
||||||
</div>
|
variant="primary"
|
||||||
<Button
|
tone="brand"
|
||||||
variant="primary"
|
size="sm"
|
||||||
tone="brand"
|
startIcon={<UserPlusIcon className="w-4 h-4" />}
|
||||||
startIcon={<UserPlusIcon className="w-4 h-4" />}
|
onClick={() => setShowInviteModal(true)}
|
||||||
onClick={() => setShowInviteModal(true)}
|
>
|
||||||
>
|
Invite Someone
|
||||||
Invite Someone
|
</Button>
|
||||||
</Button>
|
}
|
||||||
</div>
|
>
|
||||||
|
|
||||||
{teamLoading ? (
|
{teamLoading ? (
|
||||||
<div className="flex items-center justify-center h-32">
|
<div className="flex items-center justify-center h-32">
|
||||||
<Loader2Icon className="w-6 h-6 animate-spin text-[var(--color-brand-500)]" />
|
<Loader2Icon className="w-6 h-6 animate-spin text-[var(--color-brand-500)]" />
|
||||||
</div>
|
</div>
|
||||||
) : (
|
) : (
|
||||||
<Card className="overflow-hidden">
|
<div className="overflow-x-auto -mx-6 -mb-6">
|
||||||
<div className="overflow-x-auto">
|
<table className="w-full">
|
||||||
<table className="w-full">
|
<thead className="bg-gray-50 dark:bg-gray-800 border-b border-gray-200 dark:border-gray-700">
|
||||||
<thead className="bg-gray-50 dark:bg-gray-800 border-b border-gray-200 dark:border-gray-700">
|
<tr>
|
||||||
<tr>
|
<th className="text-left py-3 px-6 text-sm font-medium text-gray-700 dark:text-gray-300">Name</th>
|
||||||
<th className="text-left py-3 px-4 text-sm font-medium text-gray-700 dark:text-gray-300">Name</th>
|
<th className="text-left py-3 px-4 text-sm font-medium text-gray-700 dark:text-gray-300">Email</th>
|
||||||
<th className="text-left py-3 px-4 text-sm font-medium text-gray-700 dark:text-gray-300">Email</th>
|
<th className="text-left py-3 px-4 text-sm font-medium text-gray-700 dark:text-gray-300">Status</th>
|
||||||
<th className="text-left py-3 px-4 text-sm font-medium text-gray-700 dark:text-gray-300">Status</th>
|
<th className="text-left py-3 px-4 text-sm font-medium text-gray-700 dark:text-gray-300">Role</th>
|
||||||
<th className="text-left py-3 px-4 text-sm font-medium text-gray-700 dark:text-gray-300">Role</th>
|
<th className="text-left py-3 px-4 text-sm font-medium text-gray-700 dark:text-gray-300">Joined</th>
|
||||||
<th className="text-left py-3 px-4 text-sm font-medium text-gray-700 dark:text-gray-300">Joined</th>
|
<th className="text-right py-3 px-6 text-sm font-medium text-gray-700 dark:text-gray-300">Actions</th>
|
||||||
<th className="text-right py-3 px-4 text-sm font-medium text-gray-700 dark:text-gray-300">Actions</th>
|
</tr>
|
||||||
|
</thead>
|
||||||
|
<tbody className="divide-y divide-gray-200 dark:divide-gray-700">
|
||||||
|
{members.map((member) => (
|
||||||
|
<tr key={member.id} className="hover:bg-gray-50 dark:hover:bg-gray-800">
|
||||||
|
<td className="py-3 px-6 text-sm text-gray-900 dark:text-white">
|
||||||
|
{member.first_name || member.last_name
|
||||||
|
? `${member.first_name} ${member.last_name}`.trim()
|
||||||
|
: '-'}
|
||||||
|
</td>
|
||||||
|
<td className="py-3 px-4 text-sm text-gray-900 dark:text-white">
|
||||||
|
{member.email}
|
||||||
|
</td>
|
||||||
|
<td className="py-3 px-4">
|
||||||
|
<Badge
|
||||||
|
variant="light"
|
||||||
|
color={member.is_active ? 'success' : 'error'}
|
||||||
|
>
|
||||||
|
{member.is_active ? 'Active' : 'Inactive'}
|
||||||
|
</Badge>
|
||||||
|
</td>
|
||||||
|
<td className="py-3 px-4 text-sm text-gray-600 dark:text-gray-400">
|
||||||
|
{member.is_staff ? 'Admin' : 'Member'}
|
||||||
|
</td>
|
||||||
|
<td className="py-3 px-4 text-sm text-gray-600 dark:text-gray-400">
|
||||||
|
{member.date_joined ? new Date(member.date_joined).toLocaleDateString() : 'N/A'}
|
||||||
|
</td>
|
||||||
|
<td className="py-3 px-6 text-right">
|
||||||
|
<Button
|
||||||
|
variant="outline"
|
||||||
|
tone="neutral"
|
||||||
|
size="sm"
|
||||||
|
onClick={() => handleRemoveMember(member.id, member.email)}
|
||||||
|
>
|
||||||
|
Remove
|
||||||
|
</Button>
|
||||||
|
</td>
|
||||||
</tr>
|
</tr>
|
||||||
</thead>
|
))}
|
||||||
<tbody className="divide-y divide-gray-200 dark:divide-gray-700">
|
{members.length === 0 && (
|
||||||
{members.map((member) => (
|
<tr>
|
||||||
<tr key={member.id} className="hover:bg-gray-50 dark:hover:bg-gray-800">
|
<td colSpan={6} className="py-12 text-center text-gray-500 dark:text-gray-400">
|
||||||
<td className="py-3 px-4 text-sm text-gray-900 dark:text-white">
|
<UsersIcon className="w-12 h-12 mx-auto mb-3 text-gray-300 dark:text-gray-600" />
|
||||||
{member.first_name || member.last_name
|
No team members yet. Invite your first team member!
|
||||||
? `${member.first_name} ${member.last_name}`.trim()
|
</td>
|
||||||
: '-'}
|
</tr>
|
||||||
</td>
|
)}
|
||||||
<td className="py-3 px-4 text-sm text-gray-900 dark:text-white">
|
</tbody>
|
||||||
{member.email}
|
</table>
|
||||||
</td>
|
</div>
|
||||||
<td className="py-3 px-4">
|
|
||||||
<Badge
|
|
||||||
variant="light"
|
|
||||||
color={member.is_active ? 'success' : 'error'}
|
|
||||||
>
|
|
||||||
{member.is_active ? 'Active' : 'Inactive'}
|
|
||||||
</Badge>
|
|
||||||
</td>
|
|
||||||
<td className="py-3 px-4 text-sm text-gray-600 dark:text-gray-400">
|
|
||||||
{member.is_staff ? 'Admin' : 'Member'}
|
|
||||||
</td>
|
|
||||||
<td className="py-3 px-4 text-sm text-gray-600 dark:text-gray-400">
|
|
||||||
{member.date_joined ? new Date(member.date_joined).toLocaleDateString() : 'N/A'}
|
|
||||||
</td>
|
|
||||||
<td className="py-3 px-4 text-right">
|
|
||||||
<Button
|
|
||||||
variant="outline"
|
|
||||||
tone="neutral"
|
|
||||||
size="sm"
|
|
||||||
onClick={() => handleRemoveMember(member.id, member.email)}
|
|
||||||
>
|
|
||||||
Remove
|
|
||||||
</Button>
|
|
||||||
</td>
|
|
||||||
</tr>
|
|
||||||
))}
|
|
||||||
{members.length === 0 && (
|
|
||||||
<tr>
|
|
||||||
<td colSpan={6} className="py-12 text-center text-gray-500 dark:text-gray-400">
|
|
||||||
<UsersIcon className="w-12 h-12 mx-auto mb-3 text-gray-300 dark:text-gray-600" />
|
|
||||||
No team members yet. Invite your first team member!
|
|
||||||
</td>
|
|
||||||
</tr>
|
|
||||||
)}
|
|
||||||
</tbody>
|
|
||||||
</table>
|
|
||||||
</div>
|
|
||||||
</Card>
|
|
||||||
)}
|
)}
|
||||||
|
</ComponentCard>
|
||||||
|
|
||||||
{/* Role Permissions Info */}
|
{/* Role Permissions Info */}
|
||||||
<Card className="p-6">
|
<ComponentCard
|
||||||
<h3 className="text-lg font-semibold mb-4 text-gray-900 dark:text-white flex items-center gap-2">
|
title={
|
||||||
<ShieldIcon className="w-5 h-5" />
|
<span className="flex items-center gap-2">
|
||||||
Role Permissions
|
<ShieldIcon className="w-5 h-5" />
|
||||||
</h3>
|
Role Permissions
|
||||||
|
</span>
|
||||||
|
}
|
||||||
|
>
|
||||||
<div className="grid grid-cols-1 md:grid-cols-2 gap-4">
|
<div className="grid grid-cols-1 md:grid-cols-2 gap-4">
|
||||||
<div className="p-4 border border-gray-200 dark:border-gray-700 rounded-lg">
|
<div className="p-4 border-l-4 border-brand-500 bg-brand-50/50 dark:bg-brand-900/10 rounded-r-lg">
|
||||||
<div className="flex items-center justify-between mb-2">
|
<div className="flex items-center justify-between mb-2">
|
||||||
<h4 className="font-semibold text-gray-900 dark:text-white">Admin</h4>
|
<h4 className="font-semibold text-gray-900 dark:text-white">Admin</h4>
|
||||||
<Badge variant="light" color="primary">High Access</Badge>
|
<Badge variant="light" color="primary">High Access</Badge>
|
||||||
@@ -699,7 +702,7 @@ export default function AccountSettingsPage() {
|
|||||||
<li>✗ Cannot manage billing</li>
|
<li>✗ Cannot manage billing</li>
|
||||||
</ul>
|
</ul>
|
||||||
</div>
|
</div>
|
||||||
<div className="p-4 border border-gray-200 dark:border-gray-700 rounded-lg">
|
<div className="p-4 border-l-4 border-info-500 bg-info-50/50 dark:bg-info-900/10 rounded-r-lg">
|
||||||
<div className="flex items-center justify-between mb-2">
|
<div className="flex items-center justify-between mb-2">
|
||||||
<h4 className="font-semibold text-gray-900 dark:text-white">Member</h4>
|
<h4 className="font-semibold text-gray-900 dark:text-white">Member</h4>
|
||||||
<Badge variant="light" color="info">Standard Access</Badge>
|
<Badge variant="light" color="info">Standard Access</Badge>
|
||||||
@@ -711,17 +714,22 @@ export default function AccountSettingsPage() {
|
|||||||
</ul>
|
</ul>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</Card>
|
</ComponentCard>
|
||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
{/* Invite Modal */}
|
{/* Invite Modal */}
|
||||||
{showInviteModal && (
|
<Modal
|
||||||
<div className="fixed inset-0 bg-black bg-opacity-50 flex items-center justify-center z-50">
|
isOpen={showInviteModal}
|
||||||
<Card className="p-6 w-full max-w-md mx-4">
|
onClose={() => {
|
||||||
<h2 className="text-xl font-bold text-gray-900 dark:text-white mb-4">
|
setShowInviteModal(false);
|
||||||
Invite Team Member
|
setInviteForm({ email: '', first_name: '', last_name: '' });
|
||||||
</h2>
|
}}
|
||||||
|
>
|
||||||
|
<div className="p-6">
|
||||||
|
<h2 className="text-xl font-bold text-gray-900 dark:text-white mb-4">
|
||||||
|
Invite Team Member
|
||||||
|
</h2>
|
||||||
|
|
||||||
<div className="space-y-4">
|
<div className="space-y-4">
|
||||||
<div>
|
<div>
|
||||||
@@ -774,85 +782,76 @@ export default function AccountSettingsPage() {
|
|||||||
{inviting ? 'Inviting...' : 'Send Invitation'}
|
{inviting ? 'Inviting...' : 'Send Invitation'}
|
||||||
</Button>
|
</Button>
|
||||||
</div>
|
</div>
|
||||||
</Card>
|
</div>
|
||||||
</div>
|
</Modal>
|
||||||
)}
|
|
||||||
|
|
||||||
{/* Password Change Modal */}
|
{/* Password Change Modal */}
|
||||||
{showPasswordModal && (
|
<Modal
|
||||||
<div className="fixed inset-0 z-50 flex items-center justify-center">
|
isOpen={showPasswordModal}
|
||||||
<div
|
onClose={() => {
|
||||||
className="fixed inset-0 bg-black/50"
|
setShowPasswordModal(false);
|
||||||
onClick={() => setShowPasswordModal(false)}
|
setPasswordForm({ currentPassword: '', newPassword: '', confirmPassword: '' });
|
||||||
/>
|
}}
|
||||||
<Card className="relative z-10 w-full max-w-md p-6 m-4">
|
>
|
||||||
<div className="flex items-center justify-between mb-4">
|
<div className="p-6">
|
||||||
<h2 className="text-lg font-semibold text-gray-900 dark:text-white flex items-center gap-2">
|
<h2 className="text-lg font-semibold text-gray-900 dark:text-white flex items-center gap-2 mb-4">
|
||||||
<LockIcon className="w-5 h-5" />
|
<LockIcon className="w-5 h-5" />
|
||||||
Change Password
|
Change Password
|
||||||
</h2>
|
</h2>
|
||||||
<button
|
|
||||||
onClick={() => setShowPasswordModal(false)}
|
|
||||||
className="p-1 text-gray-400 hover:text-gray-600 dark:hover:text-gray-300"
|
|
||||||
>
|
|
||||||
<XIcon className="w-5 h-5" />
|
|
||||||
</button>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<div className="space-y-4">
|
<div className="space-y-4">
|
||||||
<div>
|
<div>
|
||||||
<InputField
|
<InputField
|
||||||
type="password"
|
type="password"
|
||||||
label="Current Password"
|
label="Current Password"
|
||||||
value={passwordForm.currentPassword}
|
value={passwordForm.currentPassword}
|
||||||
onChange={(e) => setPasswordForm(prev => ({ ...prev, currentPassword: e.target.value }))}
|
onChange={(e) => setPasswordForm(prev => ({ ...prev, currentPassword: e.target.value }))}
|
||||||
placeholder="Enter current password"
|
placeholder="Enter current password"
|
||||||
/>
|
/>
|
||||||
</div>
|
|
||||||
<div>
|
|
||||||
<InputField
|
|
||||||
type="password"
|
|
||||||
label="New Password"
|
|
||||||
value={passwordForm.newPassword}
|
|
||||||
onChange={(e) => setPasswordForm(prev => ({ ...prev, newPassword: e.target.value }))}
|
|
||||||
placeholder="Enter new password"
|
|
||||||
/>
|
|
||||||
</div>
|
|
||||||
<div>
|
|
||||||
<InputField
|
|
||||||
type="password"
|
|
||||||
label="Confirm New Password"
|
|
||||||
value={passwordForm.confirmPassword}
|
|
||||||
onChange={(e) => setPasswordForm(prev => ({ ...prev, confirmPassword: e.target.value }))}
|
|
||||||
placeholder="Confirm new password"
|
|
||||||
/>
|
|
||||||
</div>
|
|
||||||
</div>
|
</div>
|
||||||
|
<div>
|
||||||
|
<InputField
|
||||||
|
type="password"
|
||||||
|
label="New Password"
|
||||||
|
value={passwordForm.newPassword}
|
||||||
|
onChange={(e) => setPasswordForm(prev => ({ ...prev, newPassword: e.target.value }))}
|
||||||
|
placeholder="Enter new password"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
<div>
|
||||||
|
<InputField
|
||||||
|
type="password"
|
||||||
|
label="Confirm New Password"
|
||||||
|
value={passwordForm.confirmPassword}
|
||||||
|
onChange={(e) => setPasswordForm(prev => ({ ...prev, confirmPassword: e.target.value }))}
|
||||||
|
placeholder="Confirm new password"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
<div className="mt-6 flex justify-end gap-3">
|
<div className="mt-6 flex justify-end gap-3">
|
||||||
<Button
|
<Button
|
||||||
variant="outline"
|
variant="outline"
|
||||||
tone="neutral"
|
tone="neutral"
|
||||||
onClick={() => {
|
onClick={() => {
|
||||||
setShowPasswordModal(false);
|
setShowPasswordModal(false);
|
||||||
setPasswordForm({ currentPassword: '', newPassword: '', confirmPassword: '' });
|
setPasswordForm({ currentPassword: '', newPassword: '', confirmPassword: '' });
|
||||||
}}
|
}}
|
||||||
disabled={changingPassword}
|
disabled={changingPassword}
|
||||||
>
|
>
|
||||||
Cancel
|
Cancel
|
||||||
</Button>
|
</Button>
|
||||||
<Button
|
<Button
|
||||||
variant="primary"
|
variant="primary"
|
||||||
tone="brand"
|
tone="brand"
|
||||||
onClick={handlePasswordChange}
|
onClick={handlePasswordChange}
|
||||||
disabled={changingPassword}
|
disabled={changingPassword}
|
||||||
>
|
>
|
||||||
{changingPassword ? 'Changing...' : 'Change Password'}
|
{changingPassword ? 'Changing...' : 'Change Password'}
|
||||||
</Button>
|
</Button>
|
||||||
</div>
|
</div>
|
||||||
</Card>
|
|
||||||
</div>
|
</div>
|
||||||
)}
|
</Modal>
|
||||||
</div>
|
</div>
|
||||||
</>
|
</>
|
||||||
);
|
);
|
||||||
|
|||||||
@@ -344,7 +344,7 @@ export default function ContentSettingsPage() {
|
|||||||
{/* Content Generation Tab */}
|
{/* Content Generation Tab */}
|
||||||
{activeTab === 'content' && (
|
{activeTab === 'content' && (
|
||||||
<div className="space-y-6 max-w-4xl">
|
<div className="space-y-6 max-w-4xl">
|
||||||
<Card className="p-6">
|
<Card className="p-6 border-l-4 border-l-brand-500">
|
||||||
<div className="flex items-center gap-3 mb-6">
|
<div className="flex items-center gap-3 mb-6">
|
||||||
<div className="p-2 bg-brand-100 dark:bg-brand-900/30 rounded-lg">
|
<div className="p-2 bg-brand-100 dark:bg-brand-900/30 rounded-lg">
|
||||||
<FileTextIcon className="w-5 h-5 text-brand-600 dark:text-brand-400" />
|
<FileTextIcon className="w-5 h-5 text-brand-600 dark:text-brand-400" />
|
||||||
@@ -422,7 +422,7 @@ export default function ContentSettingsPage() {
|
|||||||
{/* Publishing Tab */}
|
{/* Publishing Tab */}
|
||||||
{activeTab === 'publishing' && (
|
{activeTab === 'publishing' && (
|
||||||
<div className="space-y-6 max-w-4xl">
|
<div className="space-y-6 max-w-4xl">
|
||||||
<Card className="p-6">
|
<Card className="p-6 border-l-4 border-l-success-500">
|
||||||
<div className="flex items-center gap-3 mb-6">
|
<div className="flex items-center gap-3 mb-6">
|
||||||
<div className="p-2 bg-success-100 dark:bg-success-900/30 rounded-lg">
|
<div className="p-2 bg-success-100 dark:bg-success-900/30 rounded-lg">
|
||||||
<PaperPlaneIcon className="w-5 h-5 text-success-600 dark:text-success-400" />
|
<PaperPlaneIcon className="w-5 h-5 text-success-600 dark:text-success-400" />
|
||||||
@@ -498,7 +498,7 @@ export default function ContentSettingsPage() {
|
|||||||
{/* Image Settings Tab */}
|
{/* Image Settings Tab */}
|
||||||
{activeTab === 'images' && (
|
{activeTab === 'images' && (
|
||||||
<div className="space-y-6 max-w-4xl">
|
<div className="space-y-6 max-w-4xl">
|
||||||
<Card className="p-6">
|
<Card className="p-6 border-l-4 border-l-purple-500">
|
||||||
<div className="flex items-center gap-3 mb-6">
|
<div className="flex items-center gap-3 mb-6">
|
||||||
<div className="p-2 bg-purple-100 dark:bg-purple-900/30 rounded-lg">
|
<div className="p-2 bg-purple-100 dark:bg-purple-900/30 rounded-lg">
|
||||||
<ImageIcon className="w-5 h-5 text-purple-600 dark:text-purple-400" />
|
<ImageIcon className="w-5 h-5 text-purple-600 dark:text-purple-400" />
|
||||||
|
|||||||
@@ -542,27 +542,27 @@ export default function PlansAndBillingPage() {
|
|||||||
</Button>
|
</Button>
|
||||||
</div>
|
</div>
|
||||||
<div className="grid grid-cols-2 md:grid-cols-4 gap-4">
|
<div className="grid grid-cols-2 md:grid-cols-4 gap-4">
|
||||||
<div className="p-4 bg-gray-50 dark:bg-gray-800 rounded-lg">
|
<div className="p-4 bg-white dark:bg-gray-900 rounded-lg border border-gray-200 dark:border-gray-800 border-l-4 border-l-brand-500">
|
||||||
<div className="flex items-center gap-2 text-sm text-gray-600 dark:text-gray-400 mb-2">
|
<div className="flex items-center gap-2 text-sm text-gray-600 dark:text-gray-400 mb-2">
|
||||||
<GlobeIcon className="w-4 h-4" />
|
<GlobeIcon className="w-4 h-4 text-brand-500" />
|
||||||
Sites
|
Sites
|
||||||
</div>
|
</div>
|
||||||
<div className="text-2xl font-bold text-gray-900 dark:text-white">
|
<div className="text-2xl font-bold text-gray-900 dark:text-white">
|
||||||
{currentPlan?.max_sites === 9999 ? '∞' : currentPlan?.max_sites || 0}
|
{currentPlan?.max_sites === 9999 ? '∞' : currentPlan?.max_sites || 0}
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
<div className="p-4 bg-gray-50 dark:bg-gray-800 rounded-lg">
|
<div className="p-4 bg-white dark:bg-gray-900 rounded-lg border border-gray-200 dark:border-gray-800 border-l-4 border-l-purple-500">
|
||||||
<div className="flex items-center gap-2 text-sm text-gray-600 dark:text-gray-400 mb-2">
|
<div className="flex items-center gap-2 text-sm text-gray-600 dark:text-gray-400 mb-2">
|
||||||
<UsersIcon className="w-4 h-4" />
|
<UsersIcon className="w-4 h-4 text-purple-500" />
|
||||||
Team Members
|
Team Members
|
||||||
</div>
|
</div>
|
||||||
<div className="text-2xl font-bold text-gray-900 dark:text-white">
|
<div className="text-2xl font-bold text-gray-900 dark:text-white">
|
||||||
{currentPlan?.max_users === 9999 ? '∞' : currentPlan?.max_users || 0}
|
{currentPlan?.max_users === 9999 ? '∞' : currentPlan?.max_users || 0}
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
<div className="p-4 bg-gray-50 dark:bg-gray-800 rounded-lg">
|
<div className="p-4 bg-white dark:bg-gray-900 rounded-lg border border-gray-200 dark:border-gray-800 border-l-4 border-l-success-500">
|
||||||
<div className="flex items-center gap-2 text-sm text-gray-600 dark:text-gray-400 mb-2">
|
<div className="flex items-center gap-2 text-sm text-gray-600 dark:text-gray-400 mb-2">
|
||||||
<FileTextIcon className="w-4 h-4" />
|
<FileTextIcon className="w-4 h-4 text-success-500" />
|
||||||
Content Words/mo
|
Content Words/mo
|
||||||
</div>
|
</div>
|
||||||
<div className="text-2xl font-bold text-gray-900 dark:text-white">
|
<div className="text-2xl font-bold text-gray-900 dark:text-white">
|
||||||
@@ -573,9 +573,9 @@ export default function PlansAndBillingPage() {
|
|||||||
: 0}
|
: 0}
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
<div className="p-4 bg-gray-50 dark:bg-gray-800 rounded-lg">
|
<div className="p-4 bg-white dark:bg-gray-900 rounded-lg border border-gray-200 dark:border-gray-800 border-l-4 border-l-warning-500">
|
||||||
<div className="flex items-center gap-2 text-sm text-gray-600 dark:text-gray-400 mb-2">
|
<div className="flex items-center gap-2 text-sm text-gray-600 dark:text-gray-400 mb-2">
|
||||||
<ZapIcon className="w-4 h-4" />
|
<ZapIcon className="w-4 h-4 text-warning-500" />
|
||||||
Monthly Credits
|
Monthly Credits
|
||||||
</div>
|
</div>
|
||||||
<div className="text-2xl font-bold text-gray-900 dark:text-white">
|
<div className="text-2xl font-bold text-gray-900 dark:text-white">
|
||||||
|
|||||||
@@ -101,59 +101,59 @@ export default function UsageAnalyticsPage() {
|
|||||||
{/* Quick Stats Overview */}
|
{/* Quick Stats Overview */}
|
||||||
{creditBalance && (
|
{creditBalance && (
|
||||||
<div className="grid grid-cols-1 md:grid-cols-4 gap-4 mb-6">
|
<div className="grid grid-cols-1 md:grid-cols-4 gap-4 mb-6">
|
||||||
<Card className="p-4 bg-gradient-to-br from-brand-50 to-brand-100 dark:from-brand-900/20 dark:to-brand-800/10 border-brand-200 dark:border-brand-700">
|
<Card className="p-4 bg-white dark:bg-gray-900 border border-gray-200 dark:border-gray-800 border-l-4 border-l-brand-500">
|
||||||
<div className="flex items-center gap-3">
|
<div className="flex items-center gap-3">
|
||||||
<div className="p-2 bg-brand-500 rounded-lg">
|
<div className="p-2 bg-brand-100 dark:bg-brand-900/30 rounded-lg">
|
||||||
<ZapIcon className="w-5 h-5 text-white" />
|
<ZapIcon className="w-5 h-5 text-brand-600 dark:text-brand-400" />
|
||||||
</div>
|
</div>
|
||||||
<div>
|
<div>
|
||||||
<div className="text-xs text-brand-700 dark:text-brand-300">Credits Left</div>
|
<div className="text-xs text-gray-600 dark:text-gray-400">Credits Left</div>
|
||||||
<div className="text-2xl font-bold text-brand-600 dark:text-brand-400">
|
<div className="text-2xl font-bold text-brand-600 dark:text-brand-400">
|
||||||
{creditBalance.credits.toLocaleString()}
|
{creditBalance.credits.toLocaleString()}
|
||||||
</div>
|
</div>
|
||||||
<div className="text-xs text-brand-600 dark:text-brand-400 mt-1">available</div>
|
<div className="text-xs text-gray-500 dark:text-gray-400 mt-1">available</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</Card>
|
</Card>
|
||||||
|
|
||||||
<Card className="p-4 bg-gradient-to-br from-purple-50 to-purple-100 dark:from-purple-900/20 dark:to-purple-800/10 border-purple-200 dark:border-purple-700">
|
<Card className="p-4 bg-white dark:bg-gray-900 border border-gray-200 dark:border-gray-800 border-l-4 border-l-purple-500">
|
||||||
<div className="flex items-center gap-3">
|
<div className="flex items-center gap-3">
|
||||||
<div className="p-2 bg-purple-500 rounded-lg">
|
<div className="p-2 bg-purple-100 dark:bg-purple-900/30 rounded-lg">
|
||||||
<TrendingUpIcon className="w-5 h-5 text-white" />
|
<TrendingUpIcon className="w-5 h-5 text-purple-600 dark:text-purple-400" />
|
||||||
</div>
|
</div>
|
||||||
<div>
|
<div>
|
||||||
<div className="text-xs text-purple-700 dark:text-purple-300">Credits Used This Month</div>
|
<div className="text-xs text-gray-600 dark:text-gray-400">Credits Used This Month</div>
|
||||||
<div className="text-2xl font-bold text-purple-600 dark:text-purple-400">
|
<div className="text-2xl font-bold text-purple-600 dark:text-purple-400">
|
||||||
{creditBalance.credits_used_this_month.toLocaleString()}
|
{creditBalance.credits_used_this_month.toLocaleString()}
|
||||||
</div>
|
</div>
|
||||||
<div className="text-xs text-purple-600 dark:text-purple-400 mt-1">spent so far</div>
|
<div className="text-xs text-gray-500 dark:text-gray-400 mt-1">spent so far</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</Card>
|
</Card>
|
||||||
|
|
||||||
<Card className="p-4 bg-gradient-to-br from-success-50 to-success-100 dark:from-success-900/20 dark:to-success-800/10 border-success-200 dark:border-success-700">
|
<Card className="p-4 bg-white dark:bg-gray-900 border border-gray-200 dark:border-gray-800 border-l-4 border-l-success-500">
|
||||||
<div className="flex items-center gap-3">
|
<div className="flex items-center gap-3">
|
||||||
<div className="p-2 bg-success-500 rounded-lg">
|
<div className="p-2 bg-success-100 dark:bg-success-900/30 rounded-lg">
|
||||||
<BarChart3Icon className="w-5 h-5 text-white" />
|
<BarChart3Icon className="w-5 h-5 text-success-600 dark:text-success-400" />
|
||||||
</div>
|
</div>
|
||||||
<div>
|
<div>
|
||||||
<div className="text-xs text-success-700 dark:text-success-300">Your Monthly Limit</div>
|
<div className="text-xs text-gray-600 dark:text-gray-400">Your Monthly Limit</div>
|
||||||
<div className="text-2xl font-bold text-success-600 dark:text-success-400">
|
<div className="text-2xl font-bold text-success-600 dark:text-success-400">
|
||||||
{creditBalance.plan_credits_per_month.toLocaleString()}
|
{creditBalance.plan_credits_per_month.toLocaleString()}
|
||||||
</div>
|
</div>
|
||||||
<div className="text-xs text-success-600 dark:text-success-400 mt-1">total each month</div>
|
<div className="text-xs text-gray-500 dark:text-gray-400 mt-1">total each month</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</Card>
|
</Card>
|
||||||
|
|
||||||
<Card className="p-4 bg-gradient-to-br from-purple-50 to-purple-100 dark:from-purple-900/20 dark:to-purple-800/10 border-purple-200 dark:border-purple-700">
|
<Card className="p-4 bg-white dark:bg-gray-900 border border-gray-200 dark:border-gray-800 border-l-4 border-l-warning-500">
|
||||||
<div className="flex items-center gap-3">
|
<div className="flex items-center gap-3">
|
||||||
<div className="p-2 bg-purple-500 rounded-lg">
|
<div className="p-2 bg-warning-100 dark:bg-warning-900/30 rounded-lg">
|
||||||
<CalendarIcon className="w-5 h-5 text-white" />
|
<CalendarIcon className="w-5 h-5 text-warning-600 dark:text-warning-400" />
|
||||||
</div>
|
</div>
|
||||||
<div>
|
<div>
|
||||||
<div className="text-xs text-purple-700 dark:text-purple-300">Usage %</div>
|
<div className="text-xs text-gray-600 dark:text-gray-400">Usage %</div>
|
||||||
<div className="text-2xl font-bold text-purple-600 dark:text-purple-400">
|
<div className="text-2xl font-bold text-warning-600 dark:text-warning-400">
|
||||||
{creditBalance.plan_credits_per_month > 0
|
{creditBalance.plan_credits_per_month > 0
|
||||||
? Math.round((creditBalance.credits_used_this_month / creditBalance.plan_credits_per_month) * 100)
|
? Math.round((creditBalance.credits_used_this_month / creditBalance.plan_credits_per_month) * 100)
|
||||||
: 0}%
|
: 0}%
|
||||||
|
|||||||
Reference in New Issue
Block a user