finalizing app adn fixes
This commit is contained in:
@@ -4,6 +4,7 @@ import { HelmetProvider } from "react-helmet-async";
|
||||
import AppLayout from "./layout/AppLayout";
|
||||
import { ScrollToTop } from "./components/common/ScrollToTop";
|
||||
import ProtectedRoute from "./components/auth/ProtectedRoute";
|
||||
import AdminRoute from "./components/auth/AdminRoute";
|
||||
import GlobalErrorDisplay from "./components/common/GlobalErrorDisplay";
|
||||
import LoadingStateMonitor from "./components/common/LoadingStateMonitor";
|
||||
import { useAuthStore } from "./store/authStore";
|
||||
@@ -64,8 +65,9 @@ const CreditsAndBilling = lazy(() => import("./pages/Settings/CreditsAndBilling"
|
||||
const PurchaseCreditsPage = lazy(() => import("./pages/account/PurchaseCreditsPage"));
|
||||
const PlansAndBillingPage = lazy(() => import("./pages/account/PlansAndBillingPage"));
|
||||
const AccountSettingsPage = lazy(() => import("./pages/account/AccountSettingsPage"));
|
||||
const TeamManagementPage = lazy(() => import("./pages/account/TeamManagementPage"));
|
||||
// TeamManagementPage - Now integrated as tab in AccountSettingsPage
|
||||
const UsageAnalyticsPage = lazy(() => import("./pages/account/UsageAnalyticsPage"));
|
||||
const ContentSettingsPage = lazy(() => import("./pages/account/ContentSettingsPage"));
|
||||
|
||||
// Reference Data - Lazy loaded
|
||||
const SeedKeywords = lazy(() => import("./pages/Reference/SeedKeywords"));
|
||||
@@ -76,7 +78,7 @@ const IndustriesSectorsKeywords = lazy(() => import("./pages/Setup/IndustriesSec
|
||||
|
||||
// Settings - Lazy loaded
|
||||
const GeneralSettings = lazy(() => import("./pages/Settings/General"));
|
||||
const ProfileSettingsPage = lazy(() => import("./pages/settings/ProfileSettingsPage"));
|
||||
// ProfileSettingsPage - Now integrated as tab in AccountSettingsPage
|
||||
const Users = lazy(() => import("./pages/Settings/Users"));
|
||||
const Subscriptions = lazy(() => import("./pages/Settings/Subscriptions"));
|
||||
const SystemSettings = lazy(() => import("./pages/Settings/System"));
|
||||
@@ -87,7 +89,7 @@ const Industries = lazy(() => import("./pages/Settings/Industries"));
|
||||
const Integration = lazy(() => import("./pages/Settings/Integration"));
|
||||
const Publishing = lazy(() => import("./pages/Settings/Publishing"));
|
||||
const Sites = lazy(() => import("./pages/Settings/Sites"));
|
||||
const ImportExport = lazy(() => import("./pages/Settings/ImportExport"));
|
||||
// ImportExport - Removed from UI, individual pages have their own import/export
|
||||
|
||||
// Sites - Lazy loaded
|
||||
const SiteList = lazy(() => import("./pages/Sites/List"));
|
||||
@@ -176,13 +178,13 @@ export default function App() {
|
||||
<Route path="/optimizer/content" element={<OptimizerContentSelector />} />
|
||||
<Route path="/optimizer/analyze/:id" element={<AnalysisPreview />} />
|
||||
|
||||
{/* Thinker Module - Redirect dashboard to prompts */}
|
||||
<Route path="/thinker" element={<Navigate to="/thinker/prompts" replace />} />
|
||||
<Route path="/thinker/prompts" element={<Prompts />} />
|
||||
<Route path="/thinker/author-profiles" element={<AuthorProfiles />} />
|
||||
<Route path="/thinker/profile" element={<ThinkerProfile />} />
|
||||
<Route path="/thinker/strategies" element={<Strategies />} />
|
||||
<Route path="/thinker/image-testing" element={<ImageTesting />} />
|
||||
{/* Thinker Module - Admin Only (Prompts & AI Configuration) */}
|
||||
<Route path="/thinker" element={<AdminRoute><Navigate to="/thinker/prompts" replace /></AdminRoute>} />
|
||||
<Route path="/thinker/prompts" element={<AdminRoute><Prompts /></AdminRoute>} />
|
||||
<Route path="/thinker/author-profiles" element={<AdminRoute><AuthorProfiles /></AdminRoute>} />
|
||||
<Route path="/thinker/profile" element={<AdminRoute><ThinkerProfile /></AdminRoute>} />
|
||||
<Route path="/thinker/strategies" element={<AdminRoute><Strategies /></AdminRoute>} />
|
||||
<Route path="/thinker/image-testing" element={<AdminRoute><ImageTesting /></AdminRoute>} />
|
||||
|
||||
{/* Billing Module */}
|
||||
<Route path="/billing" element={<Navigate to="/billing/overview" replace />} />
|
||||
@@ -195,8 +197,10 @@ export default function App() {
|
||||
<Route path="/account/plans" element={<PlansAndBillingPage />} />
|
||||
<Route path="/account/purchase-credits" element={<PurchaseCreditsPage />} />
|
||||
<Route path="/account/settings" element={<AccountSettingsPage />} />
|
||||
<Route path="/account/team" element={<TeamManagementPage />} />
|
||||
{/* Legacy redirect - Team is now a tab in Account Settings */}
|
||||
<Route path="/account/team" element={<Navigate to="/account/settings" replace />} />
|
||||
<Route path="/account/usage" element={<UsageAnalyticsPage />} />
|
||||
<Route path="/account/content-settings" element={<ContentSettingsPage />} />
|
||||
|
||||
{/* Reference Data */}
|
||||
<Route path="/reference/seed-keywords" element={<SeedKeywords />} />
|
||||
@@ -209,7 +213,8 @@ export default function App() {
|
||||
<Route path="/setup/industries-sectors-keywords" element={<Navigate to="/setup/add-keywords" replace />} />
|
||||
|
||||
{/* Settings */}
|
||||
<Route path="/settings/profile" element={<ProfileSettingsPage />} />
|
||||
{/* Legacy redirect - Profile is now a tab in Account Settings */}
|
||||
<Route path="/settings/profile" element={<Navigate to="/account/settings" replace />} />
|
||||
<Route path="/settings" element={<GeneralSettings />} />
|
||||
<Route path="/settings/users" element={<Users />} />
|
||||
<Route path="/settings/subscriptions" element={<Subscriptions />} />
|
||||
@@ -218,10 +223,12 @@ export default function App() {
|
||||
<Route path="/settings/ai" element={<AISettings />} />
|
||||
<Route path="/settings/plans" element={<Plans />} />
|
||||
<Route path="/settings/industries" element={<Industries />} />
|
||||
<Route path="/settings/integration" element={<Integration />} />
|
||||
{/* AI Models Settings - Admin Only */}
|
||||
<Route path="/settings/integration" element={<AdminRoute><Integration /></AdminRoute>} />
|
||||
<Route path="/settings/publishing" element={<Publishing />} />
|
||||
<Route path="/settings/sites" element={<Sites />} />
|
||||
<Route path="/settings/import-export" element={<ImportExport />} />
|
||||
{/* Legacy redirect - Import/Export removed, redirect to dashboard */}
|
||||
<Route path="/settings/import-export" element={<Navigate to="/" replace />} />
|
||||
|
||||
{/* Sites Management */}
|
||||
<Route path="/sites" element={<SiteList />} />
|
||||
|
||||
32
frontend/src/components/auth/AdminRoute.tsx
Normal file
32
frontend/src/components/auth/AdminRoute.tsx
Normal file
@@ -0,0 +1,32 @@
|
||||
import { ReactNode } from "react";
|
||||
import { Navigate, useLocation } from "react-router-dom";
|
||||
import { useAuthStore } from "../../store/authStore";
|
||||
|
||||
interface AdminRouteProps {
|
||||
children: ReactNode;
|
||||
}
|
||||
|
||||
/**
|
||||
* AdminRoute component - guards routes requiring admin or staff privileges
|
||||
* Redirects to dashboard if user is not admin/staff
|
||||
*/
|
||||
export default function AdminRoute({ children }: AdminRouteProps) {
|
||||
const { user, isAuthenticated } = useAuthStore();
|
||||
const location = useLocation();
|
||||
|
||||
// If not authenticated, ProtectedRoute will handle redirect
|
||||
if (!isAuthenticated) {
|
||||
return null;
|
||||
}
|
||||
|
||||
// Check if user is admin or staff
|
||||
const isAdmin = user?.role === 'admin' || user?.is_staff === true;
|
||||
|
||||
if (!isAdmin) {
|
||||
// Redirect non-admin users to dashboard
|
||||
console.log('AdminRoute: User is not admin/staff, redirecting to dashboard');
|
||||
return <Navigate to="/" state={{ from: location }} replace />;
|
||||
}
|
||||
|
||||
return <>{children}</>;
|
||||
}
|
||||
@@ -13,6 +13,7 @@ import { WorkflowInsights, WorkflowInsight } from './WorkflowInsights';
|
||||
|
||||
interface PageHeaderProps {
|
||||
title: string;
|
||||
description?: string; // Optional page description shown below title
|
||||
lastUpdated?: Date;
|
||||
showRefresh?: boolean;
|
||||
onRefresh?: () => void;
|
||||
@@ -28,6 +29,7 @@ interface PageHeaderProps {
|
||||
|
||||
export default function PageHeader({
|
||||
title,
|
||||
description,
|
||||
lastUpdated,
|
||||
showRefresh = false,
|
||||
onRefresh,
|
||||
@@ -116,6 +118,9 @@ export default function PageHeader({
|
||||
)}
|
||||
<h2 className="text-2xl font-bold text-gray-800 dark:text-white/90">{title}</h2>
|
||||
</div>
|
||||
{description && (
|
||||
<p className="text-sm text-gray-500 dark:text-gray-400 mt-1 max-w-xl">{description}</p>
|
||||
)}
|
||||
{!hideSiteSector && (
|
||||
<div className="flex items-center gap-3 mt-1">
|
||||
{lastUpdated && (
|
||||
|
||||
@@ -19,8 +19,6 @@ import {
|
||||
UserCircleIcon,
|
||||
} from "../icons";
|
||||
import { useSidebar } from "../context/SidebarContext";
|
||||
import SidebarWidget from "./SidebarWidget";
|
||||
import { APP_VERSION } from "../config/version";
|
||||
import { useAuthStore } from "../store/authStore";
|
||||
import { useSettingsStore } from "../store/settingsStore";
|
||||
import { useModuleStore } from "../store/moduleStore";
|
||||
@@ -66,26 +64,32 @@ const AppSidebar: React.FC = () => {
|
||||
const setupItems: NavItem[] = [
|
||||
{
|
||||
icon: <DocsIcon />,
|
||||
name: "Find Keywords",
|
||||
name: "Add Keywords",
|
||||
path: "/setup/add-keywords",
|
||||
},
|
||||
{
|
||||
icon: <PageIcon />,
|
||||
name: "Content Settings",
|
||||
path: "/account/content-settings",
|
||||
},
|
||||
];
|
||||
|
||||
// Add Sites (Site Builder) if enabled
|
||||
if (isModuleEnabled('site_builder')) {
|
||||
setupItems.push({
|
||||
icon: <GridIcon />,
|
||||
name: "Your Websites",
|
||||
name: "Sites",
|
||||
path: "/sites", // Submenus shown as in-page navigation
|
||||
});
|
||||
}
|
||||
|
||||
// Add Thinker if enabled
|
||||
// Add Thinker if enabled (admin only - prompts and AI settings)
|
||||
if (isModuleEnabled('thinker')) {
|
||||
setupItems.push({
|
||||
icon: <BoltIcon />,
|
||||
name: "AI Writer Setup",
|
||||
name: "Thinker",
|
||||
path: "/thinker/prompts", // Default to prompts, submenus shown as in-page navigation
|
||||
adminOnly: true, // Only visible to admin/staff users
|
||||
});
|
||||
}
|
||||
|
||||
@@ -96,7 +100,7 @@ const AppSidebar: React.FC = () => {
|
||||
if (isModuleEnabled('planner')) {
|
||||
workflowItems.push({
|
||||
icon: <ListIcon />,
|
||||
name: "Organize Keywords",
|
||||
name: "Planner",
|
||||
path: "/planner/keywords", // Default to keywords, submenus shown as in-page navigation
|
||||
});
|
||||
}
|
||||
@@ -105,7 +109,7 @@ const AppSidebar: React.FC = () => {
|
||||
if (isModuleEnabled('writer')) {
|
||||
workflowItems.push({
|
||||
icon: <TaskIcon />,
|
||||
name: "Write Articles",
|
||||
name: "Writer",
|
||||
path: "/writer/tasks", // Default to tasks, submenus shown as in-page navigation
|
||||
});
|
||||
}
|
||||
@@ -114,7 +118,7 @@ const AppSidebar: React.FC = () => {
|
||||
if (isModuleEnabled('automation')) {
|
||||
workflowItems.push({
|
||||
icon: <BoltIcon />,
|
||||
name: "Automate Everything",
|
||||
name: "Automation",
|
||||
path: "/automation",
|
||||
});
|
||||
}
|
||||
@@ -150,26 +154,21 @@ const AppSidebar: React.FC = () => {
|
||||
],
|
||||
},
|
||||
{
|
||||
label: "GET STARTED",
|
||||
label: "SETUP",
|
||||
items: setupItems,
|
||||
},
|
||||
{
|
||||
label: "CREATE CONTENT",
|
||||
label: "WORKFLOW",
|
||||
items: workflowItems,
|
||||
},
|
||||
{
|
||||
label: "MANAGE ACCOUNT",
|
||||
label: "ACCOUNT",
|
||||
items: [
|
||||
{
|
||||
icon: <UserCircleIcon />,
|
||||
name: "Account Settings",
|
||||
path: "/account/settings",
|
||||
},
|
||||
{
|
||||
icon: <UserIcon />,
|
||||
name: "Team Management",
|
||||
path: "/account/team",
|
||||
},
|
||||
{
|
||||
icon: <DollarLineIcon />,
|
||||
name: "Plans & Billing",
|
||||
@@ -177,42 +176,23 @@ const AppSidebar: React.FC = () => {
|
||||
},
|
||||
{
|
||||
icon: <PieChartIcon />,
|
||||
name: "Usage & Analytics",
|
||||
name: "Usage",
|
||||
path: "/account/usage",
|
||||
},
|
||||
],
|
||||
},
|
||||
{
|
||||
label: "CONFIGURATION",
|
||||
items: [
|
||||
{
|
||||
icon: <UserCircleIcon />,
|
||||
name: "Profile Settings",
|
||||
path: "/settings/profile",
|
||||
},
|
||||
{
|
||||
icon: <PlugInIcon />,
|
||||
name: "AI Model Settings",
|
||||
name: "AI Models",
|
||||
path: "/settings/integration",
|
||||
},
|
||||
{
|
||||
icon: <PageIcon />,
|
||||
name: "Publishing",
|
||||
path: "/settings/publishing",
|
||||
},
|
||||
{
|
||||
icon: <FileIcon />,
|
||||
name: "Import / Export",
|
||||
path: "/settings/import-export",
|
||||
adminOnly: true, // Only visible to admin/staff users
|
||||
},
|
||||
],
|
||||
},
|
||||
{
|
||||
label: "HELP & LEARNING",
|
||||
label: "HELP",
|
||||
items: [
|
||||
{
|
||||
icon: <DocsIcon />,
|
||||
name: "Help Center",
|
||||
name: "Help & Docs",
|
||||
path: "/help",
|
||||
},
|
||||
],
|
||||
@@ -303,7 +283,7 @@ const AppSidebar: React.FC = () => {
|
||||
};
|
||||
|
||||
const renderMenuItems = (items: NavItem[], sectionIndex: number) => (
|
||||
<ul className="flex flex-col gap-2">
|
||||
<ul className="flex flex-col gap-0.5">
|
||||
{items
|
||||
.filter((nav) => {
|
||||
// Filter out admin-only items for non-admin users
|
||||
@@ -450,9 +430,7 @@ const AppSidebar: React.FC = () => {
|
||||
onMouseEnter={() => !isExpanded && setIsHovered(true)}
|
||||
onMouseLeave={() => setIsHovered(false)}
|
||||
>
|
||||
<div
|
||||
className={`py-8 flex flex-col justify-center items-center gap-3`}
|
||||
>
|
||||
<div className="py-4 flex justify-center items-center">
|
||||
<Link to="/" className="flex justify-center items-center">
|
||||
{isExpanded || isHovered || isMobileOpen ? (
|
||||
<>
|
||||
@@ -480,23 +458,15 @@ const AppSidebar: React.FC = () => {
|
||||
/>
|
||||
)}
|
||||
</Link>
|
||||
{/* Version Badge - Only show when sidebar is expanded */}
|
||||
{(isExpanded || isHovered || isMobileOpen) && (
|
||||
<div className="flex justify-center items-center">
|
||||
<span className="inline-flex items-center px-2.5 py-0.5 rounded-full text-xs font-medium bg-gray-900 dark:bg-gray-700 text-gray-100 dark:text-gray-300">
|
||||
v{APP_VERSION}
|
||||
</span>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
<div className="flex flex-col overflow-y-auto duration-300 ease-linear no-scrollbar">
|
||||
<nav className="mb-6">
|
||||
<div className="flex flex-col gap-2">
|
||||
<nav>
|
||||
<div className="flex flex-col gap-1">
|
||||
{allSections.map((section, sectionIndex) => (
|
||||
<div key={section.label || `section-${sectionIndex}`}>
|
||||
<div key={section.label || `section-${sectionIndex}`} className={section.label ? "mt-4" : ""}>
|
||||
{section.label && (
|
||||
<h2
|
||||
className={`mb-4 text-xs uppercase flex leading-[20px] text-gray-400 ${
|
||||
className={`mb-2 text-xs font-medium uppercase flex leading-[20px] text-gray-500 dark:text-gray-400 ${
|
||||
!isExpanded && !isHovered
|
||||
? "lg:justify-center"
|
||||
: "justify-start"
|
||||
@@ -514,7 +484,6 @@ const AppSidebar: React.FC = () => {
|
||||
))}
|
||||
</div>
|
||||
</nav>
|
||||
{isExpanded || isHovered || isMobileOpen ? <SidebarWidget /> : null}
|
||||
</div>
|
||||
</aside>
|
||||
);
|
||||
|
||||
@@ -37,13 +37,13 @@ import {
|
||||
} from '../../icons';
|
||||
|
||||
const STAGE_CONFIG = [
|
||||
{ icon: ListIcon, color: 'from-blue-500 to-blue-600', textColor: 'text-blue-600', hoverColor: 'hover:border-blue-500', name: 'Organize Keywords', description: 'Group related search terms into topic clusters' },
|
||||
{ icon: GroupIcon, color: 'from-purple-500 to-purple-600', textColor: 'text-purple-600', hoverColor: 'hover:border-purple-500', name: 'Create Article Ideas', description: 'Generate article titles and outlines for each cluster' },
|
||||
{ icon: CheckCircleIcon, color: 'from-indigo-500 to-indigo-600', textColor: 'text-indigo-600', hoverColor: 'hover:border-indigo-500', name: 'Prepare Writing Jobs', description: 'Convert ideas into tasks for the AI writer' },
|
||||
{ icon: PencilIcon, color: 'from-green-500 to-green-600', textColor: 'text-green-600', hoverColor: 'hover:border-green-500', name: 'Write Articles', description: 'AI generates full, complete articles' },
|
||||
{ icon: FileIcon, color: 'from-amber-500 to-amber-600', textColor: 'text-amber-600', hoverColor: 'hover:border-amber-500', name: 'Create Image Descriptions', description: 'Generate descriptions for AI to create images' },
|
||||
{ icon: FileTextIcon, color: 'from-pink-500 to-pink-600', textColor: 'text-pink-600', hoverColor: 'hover:border-pink-500', name: 'Generate Images', description: 'AI creates custom images for your articles' },
|
||||
{ icon: PaperPlaneIcon, color: 'from-teal-500 to-teal-600', textColor: 'text-teal-600', hoverColor: 'hover:border-teal-500', name: 'Review & Publish ⚠️', description: 'Review articles before they go live (manual approval needed)' },
|
||||
{ icon: ListIcon, color: 'from-blue-500 to-blue-600', textColor: 'text-blue-600', hoverColor: 'hover:border-blue-500', name: 'Keywords → Clusters' },
|
||||
{ icon: GroupIcon, color: 'from-purple-500 to-purple-600', textColor: 'text-purple-600', hoverColor: 'hover:border-purple-500', name: 'Clusters → Ideas' },
|
||||
{ icon: CheckCircleIcon, color: 'from-indigo-500 to-indigo-600', textColor: 'text-indigo-600', hoverColor: 'hover:border-indigo-500', name: 'Ideas → Tasks' },
|
||||
{ icon: PencilIcon, color: 'from-green-500 to-green-600', textColor: 'text-green-600', hoverColor: 'hover:border-green-500', name: 'Tasks → Content' },
|
||||
{ icon: FileIcon, color: 'from-amber-500 to-amber-600', textColor: 'text-amber-600', hoverColor: 'hover:border-amber-500', name: 'Content → Image Prompts' },
|
||||
{ icon: FileTextIcon, color: 'from-pink-500 to-pink-600', textColor: 'text-pink-600', hoverColor: 'hover:border-pink-500', name: 'Image Prompts → Images' },
|
||||
{ icon: PaperPlaneIcon, color: 'from-teal-500 to-teal-600', textColor: 'text-teal-600', hoverColor: 'hover:border-teal-500', name: 'Review Gate' },
|
||||
];
|
||||
|
||||
const AutomationPage: React.FC = () => {
|
||||
@@ -389,7 +389,7 @@ const AutomationPage: React.FC = () => {
|
||||
<BoltIcon className="text-white size-5" />
|
||||
</div>
|
||||
<div>
|
||||
<h2 className="text-2xl font-bold text-gray-800 dark:text-white/90">Automate Everything</h2>
|
||||
<h2 className="text-2xl font-bold text-gray-800 dark:text-white/90">Automation</h2>
|
||||
{activeSite && (
|
||||
<p className="text-sm text-gray-500 dark:text-gray-400 mt-0.5">
|
||||
Site: <span className="font-medium text-brand-600 dark:text-brand-400">{activeSite.name}</span>
|
||||
@@ -411,7 +411,7 @@ const AutomationPage: React.FC = () => {
|
||||
<div className="text-sm font-semibold text-slate-900 dark:text-white truncate">
|
||||
{currentRun?.status === 'running' && `Running - Stage ${currentRun.current_stage}/7`}
|
||||
{currentRun?.status === 'paused' && 'Paused'}
|
||||
{!currentRun && totalPending > 0 && 'Ready to Go!'}
|
||||
{!currentRun && totalPending > 0 && 'Ready to Run'}
|
||||
{!currentRun && totalPending === 0 && 'No Items Pending'}
|
||||
</div>
|
||||
<div className="text-xs text-slate-600 dark:text-gray-400 truncate">
|
||||
|
||||
@@ -595,10 +595,10 @@ export default function Home() {
|
||||
{/* Custom Header with Site Selector and Refresh */}
|
||||
<div className="flex flex-col sm:flex-row items-start sm:items-center justify-between gap-4 mb-6">
|
||||
<div className="flex-1">
|
||||
<h2 className="text-2xl font-bold text-gray-800 dark:text-white/90">Your Content Creation Dashboard</h2>
|
||||
<h2 className="text-2xl font-bold text-gray-800 dark:text-white/90">Dashboard</h2>
|
||||
{lastUpdated && (
|
||||
<p className="text-sm text-gray-500 dark:text-gray-400 mt-1">
|
||||
Last checked: {lastUpdated.toLocaleTimeString()}
|
||||
Last updated: {lastUpdated.toLocaleTimeString()}
|
||||
</p>
|
||||
)}
|
||||
</div>
|
||||
@@ -629,12 +629,9 @@ export default function Home() {
|
||||
<h1 className="text-3xl md:text-4xl font-bold mb-2">
|
||||
AI-Powered Content Creation Workflow
|
||||
</h1>
|
||||
<p className="text-lg text-white/90 mb-1">
|
||||
<p className="text-lg text-white/90">
|
||||
Transform keywords into published content with intelligent automation.
|
||||
</p>
|
||||
<p className="text-sm text-white/80">
|
||||
Your complete toolkit for finding topics, creating content, and publishing it to your site - all automated
|
||||
</p>
|
||||
</div>
|
||||
{/* Add Site Button and Site Count in Single Row - Right Side */}
|
||||
<div className="flex items-center gap-4">
|
||||
@@ -642,7 +639,7 @@ export default function Home() {
|
||||
<div className="text-right">
|
||||
{sites.length > 1 ? (
|
||||
<div className="text-3xl font-bold text-white">
|
||||
{sites.length} of {maxSites || '∞'} Sites Active
|
||||
{sites.length}/{maxSites || '∞'} Sites
|
||||
</div>
|
||||
) : (
|
||||
<div className="text-xl font-semibold text-white/90">
|
||||
@@ -661,7 +658,7 @@ export default function Home() {
|
||||
startIcon={<PlusIcon className="w-6 h-6 fill-current" />}
|
||||
className="!bg-white !text-brand-600 hover:!bg-gray-50 font-bold text-base px-8 py-4 shadow-2xl hover:shadow-[0_25px_50px_-12px_rgba(0,0,0,0.4)] hover:scale-105 active:scale-100 transition-all duration-200"
|
||||
>
|
||||
+ Add Another Website
|
||||
Add Site
|
||||
</Button>
|
||||
)}
|
||||
{!canAddMoreSites && sites.length > 0 && maxSites > 0 && (
|
||||
@@ -685,7 +682,7 @@ export default function Home() {
|
||||
|
||||
<div className="space-y-6">
|
||||
{/* Progress Flow - Circular Design with Progress Bar */}
|
||||
<ComponentCard title="Your Content Journey" desc="Track your content creation progress from ideas to published articles">
|
||||
<ComponentCard title="Workflow Progress" desc="Track your content creation pipeline">
|
||||
{/* Percentage and Progress Bar */}
|
||||
<div className="mb-8">
|
||||
<div className="flex items-center justify-between mb-3">
|
||||
@@ -702,9 +699,6 @@ export default function Home() {
|
||||
color="primary"
|
||||
className="h-4"
|
||||
/>
|
||||
<p className="text-xs text-gray-500 dark:text-gray-400 mt-2">
|
||||
(This shows your progress from keywords through to published content)
|
||||
</p>
|
||||
</div>
|
||||
|
||||
{/* Icon-based Progress Flow */}
|
||||
@@ -717,7 +711,7 @@ export default function Home() {
|
||||
<div className="text-center">
|
||||
<div className="text-sm font-semibold text-gray-800 dark:text-white">Site & Sectors</div>
|
||||
<div className="text-lg font-bold text-gray-900 dark:text-white">{sites.filter(s => s.active_sectors_count > 0).length}</div>
|
||||
<div className="text-xs text-gray-500 dark:text-gray-400">Niches you're targeting - Industry & sectors set up</div>
|
||||
<div className="text-xs text-gray-500 dark:text-gray-400">Industry & sectors configured</div>
|
||||
</div>
|
||||
</Link>
|
||||
|
||||
@@ -728,7 +722,7 @@ export default function Home() {
|
||||
<div className="text-center">
|
||||
<div className="text-sm font-semibold text-gray-800">Keywords</div>
|
||||
<div className="text-lg font-bold text-blue-600">{progress.keywordsCount}</div>
|
||||
<div className="text-xs text-gray-500">Search terms to target - Keywords added from research</div>
|
||||
<div className="text-xs text-gray-500">Added from opportunities</div>
|
||||
</div>
|
||||
</Link>
|
||||
|
||||
@@ -739,7 +733,7 @@ export default function Home() {
|
||||
<div className="text-center">
|
||||
<div className="text-sm font-semibold text-gray-800">Clusters</div>
|
||||
<div className="text-lg font-bold text-purple-600">{progress.clustersCount}</div>
|
||||
<div className="text-xs text-gray-500">Topic groups - Keywords organized by theme</div>
|
||||
<div className="text-xs text-gray-500">Keywords grouped by topic</div>
|
||||
</div>
|
||||
</Link>
|
||||
|
||||
@@ -750,7 +744,7 @@ export default function Home() {
|
||||
<div className="text-center">
|
||||
<div className="text-sm font-semibold text-gray-800">Ideas</div>
|
||||
<div className="text-lg font-bold text-orange-600">{progress.ideasCount}</div>
|
||||
<div className="text-xs text-gray-500">Article outlines ready - Ideas and outlines created</div>
|
||||
<div className="text-xs text-gray-500">Content ideas and outlines</div>
|
||||
</div>
|
||||
</Link>
|
||||
|
||||
@@ -761,7 +755,7 @@ export default function Home() {
|
||||
<div className="text-center">
|
||||
<div className="text-sm font-semibold text-gray-800">Content</div>
|
||||
<div className="text-lg font-bold text-green-600">{progress.contentCount}</div>
|
||||
<div className="text-xs text-gray-500">Articles created - Written content + images ready</div>
|
||||
<div className="text-xs text-gray-500">Articles ready to publish</div>
|
||||
</div>
|
||||
</Link>
|
||||
|
||||
@@ -772,7 +766,7 @@ export default function Home() {
|
||||
<div className="text-center">
|
||||
<div className="text-sm font-semibold text-gray-800">Published</div>
|
||||
<div className="text-lg font-bold text-indigo-600">{progress.publishedCount}</div>
|
||||
<div className="text-xs text-gray-500">Live on your site - Articles published and active</div>
|
||||
<div className="text-xs text-gray-500">Live on your site</div>
|
||||
</div>
|
||||
</Link>
|
||||
</div>
|
||||
@@ -859,7 +853,7 @@ export default function Home() {
|
||||
<EnhancedMetricCard
|
||||
title="Your Keywords"
|
||||
value={insights?.totalKeywords.toLocaleString() || "0"}
|
||||
subtitle={`Organized into ${insights?.totalClusters || 0} topic groups with ${insights?.totalIdeas || 0} content ideas`}
|
||||
subtitle={`Organized into ${insights?.totalClusters || 0} clusters with ${insights?.totalIdeas || 0} content ideas`}
|
||||
icon={<ListIcon className="size-6" />}
|
||||
accentColor="blue"
|
||||
trend={0}
|
||||
|
||||
@@ -444,15 +444,16 @@ export default function Clusters() {
|
||||
|
||||
// Planner navigation tabs
|
||||
const plannerTabs = [
|
||||
{ label: 'Keywords (individual terms)', path: '/planner/keywords', icon: <ListIcon /> },
|
||||
{ label: 'Topics (keyword groups)', path: '/planner/clusters', icon: <GroupIcon /> },
|
||||
{ label: 'Keywords', path: '/planner/keywords', icon: <ListIcon /> },
|
||||
{ label: 'Clusters', path: '/planner/clusters', icon: <GroupIcon /> },
|
||||
{ label: 'Ideas', path: '/planner/ideas', icon: <BoltIcon /> },
|
||||
];
|
||||
|
||||
return (
|
||||
<>
|
||||
<PageHeader
|
||||
title="Topic Clusters"
|
||||
title="Clusters"
|
||||
description="Keyword groups organized by topic. Generate content ideas from clusters to build topical authority."
|
||||
badge={{ icon: <GroupIcon />, color: 'purple' }}
|
||||
navigation={<ModuleNavigationTabs tabs={plannerTabs} />}
|
||||
workflowInsights={workflowInsights}
|
||||
|
||||
@@ -350,15 +350,16 @@ export default function Ideas() {
|
||||
|
||||
// Planner navigation tabs
|
||||
const plannerTabs = [
|
||||
{ label: 'Keywords (individual terms)', path: '/planner/keywords', icon: <ListIcon /> },
|
||||
{ label: 'Topics (keyword groups)', path: '/planner/clusters', icon: <GroupIcon /> },
|
||||
{ label: 'Keywords', path: '/planner/keywords', icon: <ListIcon /> },
|
||||
{ label: 'Clusters', path: '/planner/clusters', icon: <GroupIcon /> },
|
||||
{ label: 'Ideas', path: '/planner/ideas', icon: <BoltIcon /> },
|
||||
];
|
||||
|
||||
return (
|
||||
<>
|
||||
<PageHeader
|
||||
title="Article Ideas"
|
||||
title="Ideas"
|
||||
description="AI-generated content ideas with titles, outlines, and target keywords. Queue ideas to start content generation."
|
||||
badge={{ icon: <BoltIcon />, color: 'orange' }}
|
||||
navigation={<ModuleNavigationTabs tabs={plannerTabs} />}
|
||||
workflowInsights={workflowInsights}
|
||||
@@ -474,7 +475,7 @@ export default function Ideas() {
|
||||
{
|
||||
title: 'Clusters',
|
||||
value: clusters.length.toLocaleString(),
|
||||
subtitle: 'topic groups',
|
||||
subtitle: 'keyword groups',
|
||||
icon: <GroupIcon className="w-5 h-5" />,
|
||||
accentColor: 'purple',
|
||||
href: '/planner/clusters',
|
||||
|
||||
@@ -611,15 +611,16 @@ export default function Keywords() {
|
||||
|
||||
// Planner navigation tabs
|
||||
const plannerTabs = [
|
||||
{ label: 'Keywords (individual terms)', path: '/planner/keywords', icon: <ListIcon /> },
|
||||
{ label: 'Topics (keyword groups)', path: '/planner/clusters', icon: <GroupIcon /> },
|
||||
{ label: 'Keywords', path: '/planner/keywords', icon: <ListIcon /> },
|
||||
{ label: 'Clusters', path: '/planner/clusters', icon: <GroupIcon /> },
|
||||
{ label: 'Ideas', path: '/planner/ideas', icon: <BoltIcon /> },
|
||||
];
|
||||
|
||||
return (
|
||||
<>
|
||||
<PageHeader
|
||||
title="Your Keywords"
|
||||
title="Keywords"
|
||||
description="Your target search terms organized for content creation. Import, cluster, and transform into content ideas."
|
||||
badge={{ icon: <ListIcon />, color: 'green' }}
|
||||
navigation={<ModuleNavigationTabs tabs={plannerTabs} />}
|
||||
workflowInsights={workflowInsights}
|
||||
|
||||
@@ -280,17 +280,18 @@ export default function Content() {
|
||||
|
||||
// Writer navigation tabs
|
||||
const writerTabs = [
|
||||
{ label: 'Ready to Write', path: '/writer/tasks', icon: <TaskIcon /> },
|
||||
{ label: 'Finished Drafts', path: '/writer/content', icon: <FileIcon /> },
|
||||
{ label: 'Article Images', path: '/writer/images', icon: <ImageIcon /> },
|
||||
{ label: 'Review Before Publishing', path: '/writer/review', icon: <CheckCircleIcon /> },
|
||||
{ label: 'Queue', path: '/writer/tasks', icon: <TaskIcon /> },
|
||||
{ label: 'Drafts', path: '/writer/content', icon: <FileIcon /> },
|
||||
{ label: 'Images', path: '/writer/images', icon: <ImageIcon /> },
|
||||
{ label: 'Review', path: '/writer/review', icon: <CheckCircleIcon /> },
|
||||
{ label: 'Published', path: '/writer/published', icon: <CheckCircleIcon /> },
|
||||
];
|
||||
|
||||
return (
|
||||
<>
|
||||
<PageHeader
|
||||
title="Your Articles"
|
||||
title="Drafts"
|
||||
description="AI-generated content ready for review. Add images, edit, and publish when ready."
|
||||
badge={{ icon: <FileIcon />, color: 'purple' }}
|
||||
navigation={<ModuleNavigationTabs tabs={writerTabs} />}
|
||||
workflowInsights={workflowInsights}
|
||||
|
||||
@@ -449,17 +449,17 @@ export default function Images() {
|
||||
|
||||
// Writer navigation tabs
|
||||
const writerTabs = [
|
||||
{ label: 'Ready to Write', path: '/writer/tasks', icon: <TaskIcon /> },
|
||||
{ label: 'Finished Drafts', path: '/writer/content', icon: <FileIcon /> },
|
||||
{ label: 'Article Images', path: '/writer/images', icon: <ImageIcon /> },
|
||||
{ label: 'Review Before Publishing', path: '/writer/review', icon: <CheckCircleIcon /> },
|
||||
{ label: 'Queue', path: '/writer/tasks', icon: <TaskIcon /> },
|
||||
{ label: 'Drafts', path: '/writer/content', icon: <FileIcon /> },
|
||||
{ label: 'Images', path: '/writer/images', icon: <ImageIcon /> },
|
||||
{ label: 'Review', path: '/writer/review', icon: <CheckCircleIcon /> },
|
||||
{ label: 'Published', path: '/writer/published', icon: <CheckCircleIcon /> },
|
||||
];
|
||||
|
||||
return (
|
||||
<>
|
||||
<PageHeader
|
||||
title="Article Images"
|
||||
title="Images"
|
||||
badge={{ icon: <FileIcon />, color: 'orange' }}
|
||||
navigation={<ModuleNavigationTabs tabs={writerTabs} />}
|
||||
/>
|
||||
|
||||
@@ -307,17 +307,17 @@ export default function Published() {
|
||||
|
||||
// Writer navigation tabs
|
||||
const writerTabs = [
|
||||
{ label: 'Ready to Write', path: '/writer/tasks', icon: <TaskIcon /> },
|
||||
{ label: 'Finished Drafts', path: '/writer/content', icon: <FileIcon /> },
|
||||
{ label: 'Article Images', path: '/writer/images', icon: <ImageIcon /> },
|
||||
{ label: 'Review Before Publishing', path: '/writer/review', icon: <CheckCircleIcon /> },
|
||||
{ label: 'Queue', path: '/writer/tasks', icon: <TaskIcon /> },
|
||||
{ label: 'Drafts', path: '/writer/content', icon: <FileIcon /> },
|
||||
{ label: 'Images', path: '/writer/images', icon: <ImageIcon /> },
|
||||
{ label: 'Review', path: '/writer/review', icon: <CheckCircleIcon /> },
|
||||
{ label: 'Published', path: '/writer/published', icon: <CheckCircleIcon /> },
|
||||
];
|
||||
|
||||
return (
|
||||
<>
|
||||
<PageHeader
|
||||
title="Published Articles"
|
||||
title="Published"
|
||||
badge={{ icon: <CheckCircleIcon />, color: 'green' }}
|
||||
navigation={<ModuleNavigationTabs tabs={writerTabs} />}
|
||||
/>
|
||||
|
||||
@@ -346,10 +346,10 @@ export default function Review() {
|
||||
|
||||
// Writer navigation tabs
|
||||
const writerTabs = [
|
||||
{ label: 'Ready to Write', path: '/writer/tasks', icon: <TaskIcon /> },
|
||||
{ label: 'Finished Drafts', path: '/writer/content', icon: <FileIcon /> },
|
||||
{ label: 'Article Images', path: '/writer/images', icon: <ImageIcon /> },
|
||||
{ label: 'Review Before Publishing', path: '/writer/review', icon: <CheckCircleIcon /> },
|
||||
{ label: 'Queue', path: '/writer/tasks', icon: <TaskIcon /> },
|
||||
{ label: 'Drafts', path: '/writer/content', icon: <FileIcon /> },
|
||||
{ label: 'Images', path: '/writer/images', icon: <ImageIcon /> },
|
||||
{ label: 'Review', path: '/writer/review', icon: <CheckCircleIcon /> },
|
||||
{ label: 'Published', path: '/writer/published', icon: <CheckCircleIcon /> },
|
||||
];
|
||||
|
||||
|
||||
@@ -424,17 +424,18 @@ export default function Tasks() {
|
||||
|
||||
// Writer navigation tabs
|
||||
const writerTabs = [
|
||||
{ label: 'Ready to Write', path: '/writer/tasks', icon: <TaskIcon /> },
|
||||
{ label: 'Finished Drafts', path: '/writer/content', icon: <FileIcon /> },
|
||||
{ label: 'Article Images', path: '/writer/images', icon: <ImageIcon /> },
|
||||
{ label: 'Review Before Publishing', path: '/writer/review', icon: <CheckCircleIcon /> },
|
||||
{ label: 'Queue', path: '/writer/tasks', icon: <TaskIcon /> },
|
||||
{ label: 'Drafts', path: '/writer/content', icon: <FileIcon /> },
|
||||
{ label: 'Images', path: '/writer/images', icon: <ImageIcon /> },
|
||||
{ label: 'Review', path: '/writer/review', icon: <CheckCircleIcon /> },
|
||||
{ label: 'Published', path: '/writer/published', icon: <CheckCircleIcon /> },
|
||||
];
|
||||
|
||||
return (
|
||||
<>
|
||||
<PageHeader
|
||||
title="Writing Tasks"
|
||||
title="Content Queue"
|
||||
description="Manage content tasks waiting for AI generation. Queue ideas here to create articles automatically."
|
||||
badge={{ icon: <TaskIcon />, color: 'indigo' }}
|
||||
navigation={<ModuleNavigationTabs tabs={writerTabs} />}
|
||||
workflowInsights={workflowInsights}
|
||||
|
||||
@@ -1,25 +1,41 @@
|
||||
/**
|
||||
* Account Settings Page
|
||||
* Manage account information and billing address
|
||||
* Account Settings Page - Consolidated Settings
|
||||
* Tabs: Account, Profile, Team
|
||||
* Consistent with system page structure (like Plans & Usage pages)
|
||||
*/
|
||||
|
||||
import { useState, useEffect } from 'react';
|
||||
import { Save, Loader2 } from 'lucide-react';
|
||||
import {
|
||||
Save, Loader2, Settings, User, Users, UserPlus, Shield, Lock
|
||||
} from 'lucide-react';
|
||||
import { Card } from '../../components/ui/card';
|
||||
import Button from '../../components/ui/button/Button';
|
||||
import Badge from '../../components/ui/badge/Badge';
|
||||
import PageMeta from '../../components/common/PageMeta';
|
||||
import { useToast } from '../../components/ui/toast/ToastContainer';
|
||||
import {
|
||||
getAccountSettings,
|
||||
updateAccountSettings,
|
||||
getTeamMembers,
|
||||
inviteTeamMember,
|
||||
removeTeamMember,
|
||||
type AccountSettings,
|
||||
type TeamMember,
|
||||
} from '../../services/billing.api';
|
||||
|
||||
type TabType = 'account' | 'profile' | 'team';
|
||||
|
||||
export default function AccountSettingsPage() {
|
||||
const [settings, setSettings] = useState<AccountSettings | null>(null);
|
||||
const toast = useToast();
|
||||
const [activeTab, setActiveTab] = useState<TabType>('account');
|
||||
const [loading, setLoading] = useState(true);
|
||||
const [saving, setSaving] = useState(false);
|
||||
const [error, setError] = useState<string>('');
|
||||
const [success, setSuccess] = useState<string>('');
|
||||
|
||||
const [formData, setFormData] = useState({
|
||||
// Account settings state
|
||||
const [settings, setSettings] = useState<AccountSettings | null>(null);
|
||||
const [accountForm, setAccountForm] = useState({
|
||||
name: '',
|
||||
billing_address_line1: '',
|
||||
billing_address_line2: '',
|
||||
@@ -31,252 +47,730 @@ export default function AccountSettingsPage() {
|
||||
billing_email: '',
|
||||
});
|
||||
|
||||
// Profile settings state
|
||||
const [profileForm, setProfileForm] = useState({
|
||||
firstName: '',
|
||||
lastName: '',
|
||||
email: '',
|
||||
phone: '',
|
||||
timezone: 'America/New_York',
|
||||
language: 'en',
|
||||
emailNotifications: true,
|
||||
marketingEmails: false,
|
||||
});
|
||||
|
||||
// Team state
|
||||
const [members, setMembers] = useState<TeamMember[]>([]);
|
||||
const [teamLoading, setTeamLoading] = useState(false);
|
||||
const [showInviteModal, setShowInviteModal] = useState(false);
|
||||
const [inviting, setInviting] = useState(false);
|
||||
const [inviteForm, setInviteForm] = useState({
|
||||
email: '',
|
||||
first_name: '',
|
||||
last_name: '',
|
||||
});
|
||||
|
||||
useEffect(() => {
|
||||
loadSettings();
|
||||
loadData();
|
||||
}, []);
|
||||
|
||||
const loadSettings = async () => {
|
||||
const loadData = async () => {
|
||||
try {
|
||||
setLoading(true);
|
||||
const data = await getAccountSettings();
|
||||
setSettings(data);
|
||||
setFormData({
|
||||
name: data.name || '',
|
||||
billing_address_line1: data.billing_address_line1 || '',
|
||||
billing_address_line2: data.billing_address_line2 || '',
|
||||
billing_city: data.billing_city || '',
|
||||
billing_state: data.billing_state || '',
|
||||
billing_postal_code: data.billing_postal_code || '',
|
||||
billing_country: data.billing_country || '',
|
||||
tax_id: data.tax_id || '',
|
||||
billing_email: data.billing_email || '',
|
||||
const accountData = await getAccountSettings();
|
||||
setSettings(accountData);
|
||||
setAccountForm({
|
||||
name: accountData.name || '',
|
||||
billing_address_line1: accountData.billing_address_line1 || '',
|
||||
billing_address_line2: accountData.billing_address_line2 || '',
|
||||
billing_city: accountData.billing_city || '',
|
||||
billing_state: accountData.billing_state || '',
|
||||
billing_postal_code: accountData.billing_postal_code || '',
|
||||
billing_country: accountData.billing_country || '',
|
||||
tax_id: accountData.tax_id || '',
|
||||
billing_email: accountData.billing_email || '',
|
||||
});
|
||||
} catch (err: any) {
|
||||
setError(err.message || 'Failed to load account settings');
|
||||
console.error('Account settings load error:', err);
|
||||
setError(err.message || 'Failed to load settings');
|
||||
} finally {
|
||||
setLoading(false);
|
||||
}
|
||||
};
|
||||
|
||||
const handleSubmit = async (e: React.FormEvent) => {
|
||||
const loadTeamMembers = async () => {
|
||||
try {
|
||||
setTeamLoading(true);
|
||||
const data = await getTeamMembers();
|
||||
setMembers(data.results || []);
|
||||
} catch (error: any) {
|
||||
toast.error(`Failed to load team members: ${error.message}`);
|
||||
} finally {
|
||||
setTeamLoading(false);
|
||||
}
|
||||
};
|
||||
|
||||
// Load team members when team tab is selected
|
||||
useEffect(() => {
|
||||
if (activeTab === 'team' && members.length === 0) {
|
||||
loadTeamMembers();
|
||||
}
|
||||
}, [activeTab]);
|
||||
|
||||
const handleAccountSubmit = async (e: React.FormEvent) => {
|
||||
e.preventDefault();
|
||||
try {
|
||||
setSaving(true);
|
||||
setError('');
|
||||
setSuccess('');
|
||||
|
||||
await updateAccountSettings(formData);
|
||||
await updateAccountSettings(accountForm);
|
||||
setSuccess('Account settings updated successfully');
|
||||
await loadSettings();
|
||||
toast.success('Account settings saved');
|
||||
await loadData();
|
||||
} catch (err: any) {
|
||||
setError(err.message || 'Failed to update account settings');
|
||||
toast.error(err.message || 'Failed to save settings');
|
||||
} finally {
|
||||
setSaving(false);
|
||||
}
|
||||
};
|
||||
|
||||
const handleChange = (e: React.ChangeEvent<HTMLInputElement>) => {
|
||||
setFormData(prev => ({
|
||||
const handleProfileSubmit = async (e: React.FormEvent) => {
|
||||
e.preventDefault();
|
||||
try {
|
||||
setSaving(true);
|
||||
// TODO: Connect to profile API when available
|
||||
await new Promise(resolve => setTimeout(resolve, 500));
|
||||
toast.success('Profile settings saved');
|
||||
} catch (err: any) {
|
||||
toast.error(err.message || 'Failed to save profile');
|
||||
} finally {
|
||||
setSaving(false);
|
||||
}
|
||||
};
|
||||
|
||||
const handleInvite = async () => {
|
||||
if (!inviteForm.email) {
|
||||
toast.error('Email is required');
|
||||
return;
|
||||
}
|
||||
|
||||
try {
|
||||
setInviting(true);
|
||||
const result = await inviteTeamMember(inviteForm);
|
||||
toast.success(result.message || 'Team member invited successfully');
|
||||
setShowInviteModal(false);
|
||||
setInviteForm({ email: '', first_name: '', last_name: '' });
|
||||
await loadTeamMembers();
|
||||
} catch (error: any) {
|
||||
toast.error(`Failed to invite team member: ${error.message}`);
|
||||
} finally {
|
||||
setInviting(false);
|
||||
}
|
||||
};
|
||||
|
||||
const handleRemoveMember = async (userId: number, email: string) => {
|
||||
if (!confirm(`Are you sure you want to remove ${email} from the team?`)) {
|
||||
return;
|
||||
}
|
||||
|
||||
try {
|
||||
const result = await removeTeamMember(userId);
|
||||
toast.success(result.message || 'Team member removed successfully');
|
||||
await loadTeamMembers();
|
||||
} catch (error: any) {
|
||||
toast.error(`Failed to remove team member: ${error.message}`);
|
||||
}
|
||||
};
|
||||
|
||||
const handleAccountChange = (e: React.ChangeEvent<HTMLInputElement>) => {
|
||||
setAccountForm(prev => ({
|
||||
...prev,
|
||||
[e.target.name]: e.target.value
|
||||
}));
|
||||
};
|
||||
|
||||
const tabs = [
|
||||
{ id: 'account' as TabType, label: 'Account', icon: <Settings className="w-4 h-4" /> },
|
||||
{ id: 'profile' as TabType, label: 'Profile', icon: <User className="w-4 h-4" /> },
|
||||
{ id: 'team' as TabType, label: 'Team', icon: <Users className="w-4 h-4" /> },
|
||||
];
|
||||
|
||||
if (loading) {
|
||||
return (
|
||||
<div className="flex items-center justify-center min-h-screen">
|
||||
<Loader2 className="w-8 h-8 animate-spin text-[var(--color-brand-500)]" />
|
||||
<div className="p-6">
|
||||
<PageMeta title="Account Settings" description="Manage your account, profile, and team" />
|
||||
<div className="flex items-center justify-center h-64">
|
||||
<div className="flex flex-col items-center gap-3">
|
||||
<Loader2 className="w-8 h-8 animate-spin text-[var(--color-brand-500)]" />
|
||||
<div className="text-gray-500 dark:text-gray-400">Loading settings...</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="p-6 max-w-4xl mx-auto">
|
||||
<div className="p-6">
|
||||
<PageMeta title="Account Settings" description="Manage your account, profile, and team" />
|
||||
|
||||
{/* Page Header */}
|
||||
<div className="mb-6">
|
||||
<h1 className="text-2xl font-bold text-gray-900 dark:text-white">Account Settings</h1>
|
||||
<p className="text-gray-600 dark:text-gray-400 mt-1">
|
||||
Manage your account information and billing details
|
||||
Manage your account information, profile settings, and team members
|
||||
</p>
|
||||
</div>
|
||||
|
||||
{error && (
|
||||
<div className="mb-6 p-4 bg-red-50 dark:bg-red-900/20 border border-red-200 dark:border-red-800 rounded-lg">
|
||||
<p className="text-red-800 dark:text-red-200">{error}</p>
|
||||
</div>
|
||||
)}
|
||||
{/* Tabs */}
|
||||
<div className="mb-6 border-b border-gray-200 dark:border-gray-700">
|
||||
<nav className="-mb-px flex space-x-8">
|
||||
{tabs.map((tab) => (
|
||||
<button
|
||||
type="button"
|
||||
key={tab.id}
|
||||
onClick={() => setActiveTab(tab.id)}
|
||||
className={`
|
||||
flex items-center gap-2 py-4 px-1 border-b-2 font-medium text-sm
|
||||
${activeTab === tab.id
|
||||
? 'border-[var(--color-brand-500)] text-[var(--color-brand-600)] dark:text-[var(--color-brand-400)]'
|
||||
: 'border-transparent text-gray-500 hover:text-gray-700 hover:border-gray-300 dark:text-gray-400 dark:hover:text-gray-300'
|
||||
}
|
||||
`}
|
||||
>
|
||||
{tab.icon}
|
||||
{tab.label}
|
||||
</button>
|
||||
))}
|
||||
</nav>
|
||||
</div>
|
||||
|
||||
{success && (
|
||||
<div className="mb-6 p-4 bg-green-50 dark:bg-green-900/20 border border-green-200 dark:border-green-800 rounded-lg">
|
||||
<p className="text-green-800 dark:text-green-200">{success}</p>
|
||||
</div>
|
||||
)}
|
||||
|
||||
<form onSubmit={handleSubmit} className="space-y-6">
|
||||
{/* Account Information */}
|
||||
<Card className="p-6">
|
||||
<h2 className="text-lg font-semibold mb-4">Account Information</h2>
|
||||
<div className="grid grid-cols-1 md:grid-cols-2 gap-4">
|
||||
<div>
|
||||
<label className="block text-sm font-medium text-gray-700 dark:text-gray-300 mb-1">
|
||||
Account Name
|
||||
</label>
|
||||
<input
|
||||
type="text"
|
||||
name="name"
|
||||
value={formData.name}
|
||||
onChange={handleChange}
|
||||
className="w-full px-3 py-2 border border-gray-300 dark:border-gray-600 rounded-lg focus:ring-2 focus:ring-blue-500 dark:bg-gray-800"
|
||||
required
|
||||
/>
|
||||
</div>
|
||||
<div>
|
||||
<label className="block text-sm font-medium text-gray-700 dark:text-gray-300 mb-1">
|
||||
Account Slug
|
||||
</label>
|
||||
<input
|
||||
type="text"
|
||||
value={settings?.slug || ''}
|
||||
className="w-full px-3 py-2 border border-gray-300 dark:border-gray-600 rounded-lg bg-gray-100 dark:bg-gray-700"
|
||||
disabled
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
<div className="mt-4">
|
||||
<label className="block text-sm font-medium text-gray-700 dark:text-gray-300 mb-1">
|
||||
Billing Email
|
||||
</label>
|
||||
<input
|
||||
type="email"
|
||||
name="billing_email"
|
||||
value={formData.billing_email}
|
||||
onChange={handleChange}
|
||||
className="w-full px-3 py-2 border border-gray-300 dark:border-gray-600 rounded-lg focus:ring-2 focus:ring-blue-500 dark:bg-gray-800"
|
||||
/>
|
||||
</div>
|
||||
</Card>
|
||||
|
||||
{/* Billing Address */}
|
||||
<Card className="p-6">
|
||||
<h2 className="text-lg font-semibold mb-4">Billing Address</h2>
|
||||
<div className="space-y-4">
|
||||
<div>
|
||||
<label className="block text-sm font-medium text-gray-700 dark:text-gray-300 mb-1">
|
||||
Address Line 1
|
||||
</label>
|
||||
<input
|
||||
type="text"
|
||||
name="billing_address_line1"
|
||||
value={formData.billing_address_line1}
|
||||
onChange={handleChange}
|
||||
className="w-full px-3 py-2 border border-gray-300 dark:border-gray-600 rounded-lg focus:ring-2 focus:ring-blue-500 dark:bg-gray-800"
|
||||
/>
|
||||
</div>
|
||||
<div>
|
||||
<label className="block text-sm font-medium text-gray-700 dark:text-gray-300 mb-1">
|
||||
Address Line 2
|
||||
</label>
|
||||
<input
|
||||
type="text"
|
||||
name="billing_address_line2"
|
||||
value={formData.billing_address_line2}
|
||||
onChange={handleChange}
|
||||
className="w-full px-3 py-2 border border-gray-300 dark:border-gray-600 rounded-lg focus:ring-2 focus:ring-blue-500 dark:bg-gray-800"
|
||||
/>
|
||||
</div>
|
||||
<div className="grid grid-cols-1 md:grid-cols-3 gap-4">
|
||||
<div>
|
||||
<label className="block text-sm font-medium text-gray-700 dark:text-gray-300 mb-1">
|
||||
City
|
||||
</label>
|
||||
<input
|
||||
type="text"
|
||||
name="billing_city"
|
||||
value={formData.billing_city}
|
||||
onChange={handleChange}
|
||||
className="w-full px-3 py-2 border border-gray-300 dark:border-gray-600 rounded-lg focus:ring-2 focus:ring-blue-500 dark:bg-gray-800"
|
||||
/>
|
||||
{/* Tab Content */}
|
||||
<div className="mt-6">
|
||||
{/* Account Tab */}
|
||||
{activeTab === 'account' && (
|
||||
<div className="space-y-6 max-w-4xl">
|
||||
{error && (
|
||||
<div className="p-4 bg-red-50 dark:bg-red-900/20 border border-red-200 dark:border-red-800 rounded-lg">
|
||||
<p className="text-red-800 dark:text-red-200">{error}</p>
|
||||
</div>
|
||||
<div>
|
||||
<label className="block text-sm font-medium text-gray-700 dark:text-gray-300 mb-1">
|
||||
State/Province
|
||||
</label>
|
||||
<input
|
||||
type="text"
|
||||
name="billing_state"
|
||||
value={formData.billing_state}
|
||||
onChange={handleChange}
|
||||
className="w-full px-3 py-2 border border-gray-300 dark:border-gray-600 rounded-lg focus:ring-2 focus:ring-blue-500 dark:bg-gray-800"
|
||||
/>
|
||||
</div>
|
||||
<div>
|
||||
<label className="block text-sm font-medium text-gray-700 dark:text-gray-300 mb-1">
|
||||
Postal Code
|
||||
</label>
|
||||
<input
|
||||
type="text"
|
||||
name="billing_postal_code"
|
||||
value={formData.billing_postal_code}
|
||||
onChange={handleChange}
|
||||
className="w-full px-3 py-2 border border-gray-300 dark:border-gray-600 rounded-lg focus:ring-2 focus:ring-blue-500 dark:bg-gray-800"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
<div>
|
||||
<label className="block text-sm font-medium text-gray-700 dark:text-gray-300 mb-1">
|
||||
Country
|
||||
</label>
|
||||
<input
|
||||
type="text"
|
||||
name="billing_country"
|
||||
value={formData.billing_country}
|
||||
onChange={handleChange}
|
||||
className="w-full px-3 py-2 border border-gray-300 dark:border-gray-600 rounded-lg focus:ring-2 focus:ring-blue-500 dark:bg-gray-800"
|
||||
placeholder="US, GB, IN, etc."
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
</Card>
|
||||
|
||||
{/* Tax Information */}
|
||||
<Card className="p-6">
|
||||
<h2 className="text-lg font-semibold mb-4">Tax Information</h2>
|
||||
<div>
|
||||
<label className="block text-sm font-medium text-gray-700 dark:text-gray-300 mb-1">
|
||||
Tax ID / VAT Number
|
||||
</label>
|
||||
<input
|
||||
type="text"
|
||||
name="tax_id"
|
||||
value={formData.tax_id}
|
||||
onChange={handleChange}
|
||||
className="w-full px-3 py-2 border border-gray-300 dark:border-gray-600 rounded-lg focus:ring-2 focus:ring-blue-500 dark:bg-gray-800"
|
||||
placeholder="Optional"
|
||||
/>
|
||||
</div>
|
||||
</Card>
|
||||
|
||||
{/* Submit Button */}
|
||||
<div className="flex justify-end">
|
||||
<button
|
||||
type="submit"
|
||||
disabled={saving}
|
||||
className="flex items-center gap-2 px-6 py-2 bg-[var(--color-brand-500)] text-white rounded-lg hover:bg-[var(--color-brand-600)] disabled:opacity-50 disabled:cursor-not-allowed transition-colors"
|
||||
>
|
||||
{saving ? (
|
||||
<>
|
||||
<Loader2 className="w-4 h-4 animate-spin" />
|
||||
Saving...
|
||||
</>
|
||||
) : (
|
||||
<>
|
||||
<Save className="w-4 h-4" />
|
||||
Save Changes
|
||||
</>
|
||||
)}
|
||||
</button>
|
||||
|
||||
{success && (
|
||||
<div className="p-4 bg-green-50 dark:bg-green-900/20 border border-green-200 dark:border-green-800 rounded-lg">
|
||||
<p className="text-green-800 dark:text-green-200">{success}</p>
|
||||
</div>
|
||||
)}
|
||||
|
||||
<form onSubmit={handleAccountSubmit} className="space-y-6">
|
||||
{/* Account Information */}
|
||||
<Card className="p-6">
|
||||
<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>
|
||||
<label className="block text-sm font-medium text-gray-700 dark:text-gray-300 mb-1">
|
||||
Account Name
|
||||
</label>
|
||||
<input
|
||||
type="text"
|
||||
name="name"
|
||||
value={accountForm.name}
|
||||
onChange={handleAccountChange}
|
||||
className="w-full px-3 py-2 border border-gray-300 dark:border-gray-600 rounded-lg focus:ring-2 focus:ring-[var(--color-brand-500)] dark:bg-gray-800"
|
||||
required
|
||||
/>
|
||||
</div>
|
||||
<div>
|
||||
<label className="block text-sm font-medium text-gray-700 dark:text-gray-300 mb-1">
|
||||
Account Slug
|
||||
</label>
|
||||
<input
|
||||
type="text"
|
||||
value={settings?.slug || ''}
|
||||
className="w-full px-3 py-2 border border-gray-300 dark:border-gray-600 rounded-lg bg-gray-100 dark:bg-gray-700"
|
||||
disabled
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
<div className="mt-4">
|
||||
<label className="block text-sm font-medium text-gray-700 dark:text-gray-300 mb-1">
|
||||
Billing Email
|
||||
</label>
|
||||
<input
|
||||
type="email"
|
||||
name="billing_email"
|
||||
value={accountForm.billing_email}
|
||||
onChange={handleAccountChange}
|
||||
className="w-full px-3 py-2 border border-gray-300 dark:border-gray-600 rounded-lg focus:ring-2 focus:ring-[var(--color-brand-500)] dark:bg-gray-800"
|
||||
/>
|
||||
</div>
|
||||
</Card>
|
||||
|
||||
{/* Billing Address */}
|
||||
<Card className="p-6">
|
||||
<h2 className="text-lg font-semibold mb-4 text-gray-900 dark:text-white">Billing Address</h2>
|
||||
<div className="space-y-4">
|
||||
<div>
|
||||
<label className="block text-sm font-medium text-gray-700 dark:text-gray-300 mb-1">
|
||||
Address Line 1
|
||||
</label>
|
||||
<input
|
||||
type="text"
|
||||
name="billing_address_line1"
|
||||
value={accountForm.billing_address_line1}
|
||||
onChange={handleAccountChange}
|
||||
className="w-full px-3 py-2 border border-gray-300 dark:border-gray-600 rounded-lg focus:ring-2 focus:ring-[var(--color-brand-500)] dark:bg-gray-800"
|
||||
/>
|
||||
</div>
|
||||
<div>
|
||||
<label className="block text-sm font-medium text-gray-700 dark:text-gray-300 mb-1">
|
||||
Address Line 2
|
||||
</label>
|
||||
<input
|
||||
type="text"
|
||||
name="billing_address_line2"
|
||||
value={accountForm.billing_address_line2}
|
||||
onChange={handleAccountChange}
|
||||
className="w-full px-3 py-2 border border-gray-300 dark:border-gray-600 rounded-lg focus:ring-2 focus:ring-[var(--color-brand-500)] dark:bg-gray-800"
|
||||
/>
|
||||
</div>
|
||||
<div className="grid grid-cols-1 md:grid-cols-3 gap-4">
|
||||
<div>
|
||||
<label className="block text-sm font-medium text-gray-700 dark:text-gray-300 mb-1">
|
||||
City
|
||||
</label>
|
||||
<input
|
||||
type="text"
|
||||
name="billing_city"
|
||||
value={accountForm.billing_city}
|
||||
onChange={handleAccountChange}
|
||||
className="w-full px-3 py-2 border border-gray-300 dark:border-gray-600 rounded-lg focus:ring-2 focus:ring-[var(--color-brand-500)] dark:bg-gray-800"
|
||||
/>
|
||||
</div>
|
||||
<div>
|
||||
<label className="block text-sm font-medium text-gray-700 dark:text-gray-300 mb-1">
|
||||
State/Province
|
||||
</label>
|
||||
<input
|
||||
type="text"
|
||||
name="billing_state"
|
||||
value={accountForm.billing_state}
|
||||
onChange={handleAccountChange}
|
||||
className="w-full px-3 py-2 border border-gray-300 dark:border-gray-600 rounded-lg focus:ring-2 focus:ring-[var(--color-brand-500)] dark:bg-gray-800"
|
||||
/>
|
||||
</div>
|
||||
<div>
|
||||
<label className="block text-sm font-medium text-gray-700 dark:text-gray-300 mb-1">
|
||||
Postal Code
|
||||
</label>
|
||||
<input
|
||||
type="text"
|
||||
name="billing_postal_code"
|
||||
value={accountForm.billing_postal_code}
|
||||
onChange={handleAccountChange}
|
||||
className="w-full px-3 py-2 border border-gray-300 dark:border-gray-600 rounded-lg focus:ring-2 focus:ring-[var(--color-brand-500)] dark:bg-gray-800"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
<div>
|
||||
<label className="block text-sm font-medium text-gray-700 dark:text-gray-300 mb-1">
|
||||
Country
|
||||
</label>
|
||||
<input
|
||||
type="text"
|
||||
name="billing_country"
|
||||
value={accountForm.billing_country}
|
||||
onChange={handleAccountChange}
|
||||
className="w-full px-3 py-2 border border-gray-300 dark:border-gray-600 rounded-lg focus:ring-2 focus:ring-[var(--color-brand-500)] dark:bg-gray-800"
|
||||
placeholder="US, GB, IN, etc."
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
</Card>
|
||||
|
||||
{/* Tax Information */}
|
||||
<Card className="p-6">
|
||||
<h2 className="text-lg font-semibold mb-4 text-gray-900 dark:text-white">Tax Information</h2>
|
||||
<div>
|
||||
<label className="block text-sm font-medium text-gray-700 dark:text-gray-300 mb-1">
|
||||
Tax ID / VAT Number
|
||||
</label>
|
||||
<input
|
||||
type="text"
|
||||
name="tax_id"
|
||||
value={accountForm.tax_id}
|
||||
onChange={handleAccountChange}
|
||||
className="w-full px-3 py-2 border border-gray-300 dark:border-gray-600 rounded-lg focus:ring-2 focus:ring-[var(--color-brand-500)] dark:bg-gray-800"
|
||||
placeholder="Optional"
|
||||
/>
|
||||
</div>
|
||||
</Card>
|
||||
|
||||
{/* Submit Button */}
|
||||
<div className="flex justify-end">
|
||||
<Button
|
||||
type="submit"
|
||||
variant="primary"
|
||||
tone="brand"
|
||||
disabled={saving}
|
||||
startIcon={saving ? <Loader2 className="w-4 h-4 animate-spin" /> : <Save className="w-4 h-4" />}
|
||||
>
|
||||
{saving ? 'Saving...' : 'Save Changes'}
|
||||
</Button>
|
||||
</div>
|
||||
</form>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Profile Tab */}
|
||||
{activeTab === 'profile' && (
|
||||
<div className="space-y-6 max-w-4xl">
|
||||
<form onSubmit={handleProfileSubmit} className="space-y-6">
|
||||
<Card className="p-6">
|
||||
<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>
|
||||
<label className="block text-sm font-medium text-gray-700 dark:text-gray-300 mb-2">
|
||||
First Name
|
||||
</label>
|
||||
<input
|
||||
type="text"
|
||||
value={profileForm.firstName}
|
||||
onChange={(e) => setProfileForm({ ...profileForm, firstName: e.target.value })}
|
||||
className="w-full px-4 py-2 border border-gray-300 dark:border-gray-600 rounded-lg focus:ring-2 focus:ring-[var(--color-brand-500)] dark:bg-gray-800"
|
||||
/>
|
||||
</div>
|
||||
<div>
|
||||
<label className="block text-sm font-medium text-gray-700 dark:text-gray-300 mb-2">
|
||||
Last Name
|
||||
</label>
|
||||
<input
|
||||
type="text"
|
||||
value={profileForm.lastName}
|
||||
onChange={(e) => setProfileForm({ ...profileForm, lastName: e.target.value })}
|
||||
className="w-full px-4 py-2 border border-gray-300 dark:border-gray-600 rounded-lg focus:ring-2 focus:ring-[var(--color-brand-500)] dark:bg-gray-800"
|
||||
/>
|
||||
</div>
|
||||
<div>
|
||||
<label className="block text-sm font-medium text-gray-700 dark:text-gray-300 mb-2">
|
||||
Email
|
||||
</label>
|
||||
<input
|
||||
type="email"
|
||||
value={profileForm.email}
|
||||
onChange={(e) => setProfileForm({ ...profileForm, email: e.target.value })}
|
||||
className="w-full px-4 py-2 border border-gray-300 dark:border-gray-600 rounded-lg focus:ring-2 focus:ring-[var(--color-brand-500)] dark:bg-gray-800"
|
||||
/>
|
||||
</div>
|
||||
<div>
|
||||
<label className="block text-sm font-medium text-gray-700 dark:text-gray-300 mb-2">
|
||||
Phone Number (optional)
|
||||
</label>
|
||||
<input
|
||||
type="tel"
|
||||
value={profileForm.phone}
|
||||
onChange={(e) => setProfileForm({ ...profileForm, phone: e.target.value })}
|
||||
className="w-full px-4 py-2 border border-gray-300 dark:border-gray-600 rounded-lg focus:ring-2 focus:ring-[var(--color-brand-500)] dark:bg-gray-800"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
</Card>
|
||||
|
||||
<Card className="p-6">
|
||||
<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>
|
||||
<label className="block text-sm font-medium text-gray-700 dark:text-gray-300 mb-2">
|
||||
Your Timezone
|
||||
</label>
|
||||
<select
|
||||
value={profileForm.timezone}
|
||||
onChange={(e) => setProfileForm({ ...profileForm, timezone: e.target.value })}
|
||||
className="w-full px-4 py-2 border border-gray-300 dark:border-gray-600 rounded-lg focus:ring-2 focus:ring-[var(--color-brand-500)] dark:bg-gray-800"
|
||||
>
|
||||
<option value="America/New_York">Eastern Time</option>
|
||||
<option value="America/Chicago">Central Time</option>
|
||||
<option value="America/Denver">Mountain Time</option>
|
||||
<option value="America/Los_Angeles">Pacific Time</option>
|
||||
<option value="UTC">UTC</option>
|
||||
<option value="Europe/London">London</option>
|
||||
<option value="Asia/Kolkata">India</option>
|
||||
</select>
|
||||
</div>
|
||||
<div>
|
||||
<label className="block text-sm font-medium text-gray-700 dark:text-gray-300 mb-2">
|
||||
Language
|
||||
</label>
|
||||
<select
|
||||
value={profileForm.language}
|
||||
onChange={(e) => setProfileForm({ ...profileForm, language: e.target.value })}
|
||||
className="w-full px-4 py-2 border border-gray-300 dark:border-gray-600 rounded-lg focus:ring-2 focus:ring-[var(--color-brand-500)] dark:bg-gray-800"
|
||||
>
|
||||
<option value="en">English</option>
|
||||
<option value="es">Spanish</option>
|
||||
<option value="fr">French</option>
|
||||
</select>
|
||||
</div>
|
||||
</div>
|
||||
</Card>
|
||||
|
||||
<Card className="p-6">
|
||||
<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">
|
||||
Choose what emails you want to receive:
|
||||
</p>
|
||||
<div className="space-y-4">
|
||||
<div className="flex items-center justify-between">
|
||||
<div>
|
||||
<div className="font-medium text-gray-900 dark:text-white">Important Updates</div>
|
||||
<div className="text-sm text-gray-600 dark:text-gray-400">
|
||||
Get notified about important changes to your account
|
||||
</div>
|
||||
</div>
|
||||
<input
|
||||
type="checkbox"
|
||||
checked={profileForm.emailNotifications}
|
||||
onChange={(e) => setProfileForm({ ...profileForm, emailNotifications: e.target.checked })}
|
||||
className="w-5 h-5 text-[var(--color-brand-500)] rounded focus:ring-[var(--color-brand-500)]"
|
||||
/>
|
||||
</div>
|
||||
<div className="flex items-center justify-between">
|
||||
<div>
|
||||
<div className="font-medium text-gray-900 dark:text-white">Tips & Product Updates</div>
|
||||
<div className="text-sm text-gray-600 dark:text-gray-400">
|
||||
Hear about new features and content tips
|
||||
</div>
|
||||
</div>
|
||||
<input
|
||||
type="checkbox"
|
||||
checked={profileForm.marketingEmails}
|
||||
onChange={(e) => setProfileForm({ ...profileForm, marketingEmails: e.target.checked })}
|
||||
className="w-5 h-5 text-[var(--color-brand-500)] rounded focus:ring-[var(--color-brand-500)]"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
</Card>
|
||||
|
||||
<Card className="p-6">
|
||||
<h2 className="text-lg font-semibold mb-4 text-gray-900 dark:text-white flex items-center gap-2">
|
||||
<Lock className="w-5 h-5" />
|
||||
Security
|
||||
</h2>
|
||||
<Button variant="outline" tone="neutral">
|
||||
Change Password
|
||||
</Button>
|
||||
</Card>
|
||||
|
||||
{/* Submit Button */}
|
||||
<div className="flex justify-end">
|
||||
<Button
|
||||
type="submit"
|
||||
variant="primary"
|
||||
tone="brand"
|
||||
disabled={saving}
|
||||
startIcon={saving ? <Loader2 className="w-4 h-4 animate-spin" /> : <Save className="w-4 h-4" />}
|
||||
>
|
||||
{saving ? 'Saving...' : 'Save Profile'}
|
||||
</Button>
|
||||
</div>
|
||||
</form>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Team Tab */}
|
||||
{activeTab === 'team' && (
|
||||
<div className="space-y-6">
|
||||
<div className="flex justify-between items-center">
|
||||
<div>
|
||||
<h2 className="text-lg font-semibold text-gray-900 dark:text-white">Team Members</h2>
|
||||
<p className="text-sm text-gray-600 dark:text-gray-400">
|
||||
Manage who can access your account
|
||||
</p>
|
||||
</div>
|
||||
<Button
|
||||
variant="primary"
|
||||
tone="brand"
|
||||
startIcon={<UserPlus className="w-4 h-4" />}
|
||||
onClick={() => setShowInviteModal(true)}
|
||||
>
|
||||
Invite Someone
|
||||
</Button>
|
||||
</div>
|
||||
|
||||
{teamLoading ? (
|
||||
<div className="flex items-center justify-center h-32">
|
||||
<Loader2 className="w-6 h-6 animate-spin text-[var(--color-brand-500)]" />
|
||||
</div>
|
||||
) : (
|
||||
<Card className="overflow-hidden">
|
||||
<div className="overflow-x-auto">
|
||||
<table className="w-full">
|
||||
<thead className="bg-gray-50 dark:bg-gray-800 border-b border-gray-200 dark:border-gray-700">
|
||||
<tr>
|
||||
<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">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">Joined</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-4 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-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">
|
||||
<Users 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>
|
||||
)}
|
||||
|
||||
{/* Role Permissions Info */}
|
||||
<Card className="p-6">
|
||||
<h3 className="text-lg font-semibold mb-4 text-gray-900 dark:text-white flex items-center gap-2">
|
||||
<Shield className="w-5 h-5" />
|
||||
Role Permissions
|
||||
</h3>
|
||||
<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="flex items-center justify-between mb-2">
|
||||
<h4 className="font-semibold text-gray-900 dark:text-white">Admin</h4>
|
||||
<Badge variant="light" color="primary">High Access</Badge>
|
||||
</div>
|
||||
<ul className="text-sm text-gray-600 dark:text-gray-400 space-y-1">
|
||||
<li>✓ Manage all sites and content</li>
|
||||
<li>✓ Invite team members</li>
|
||||
<li>✗ Cannot manage billing</li>
|
||||
</ul>
|
||||
</div>
|
||||
<div className="p-4 border border-gray-200 dark:border-gray-700 rounded-lg">
|
||||
<div className="flex items-center justify-between mb-2">
|
||||
<h4 className="font-semibold text-gray-900 dark:text-white">Member</h4>
|
||||
<Badge variant="light" color="info">Standard Access</Badge>
|
||||
</div>
|
||||
<ul className="text-sm text-gray-600 dark:text-gray-400 space-y-1">
|
||||
<li>✓ Create and edit content</li>
|
||||
<li>✓ View analytics</li>
|
||||
<li>✗ Cannot invite users</li>
|
||||
</ul>
|
||||
</div>
|
||||
</div>
|
||||
</Card>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* Invite Modal */}
|
||||
{showInviteModal && (
|
||||
<div className="fixed inset-0 bg-black bg-opacity-50 flex items-center justify-center z-50">
|
||||
<Card className="p-6 w-full max-w-md mx-4">
|
||||
<h2 className="text-xl font-bold text-gray-900 dark:text-white mb-4">
|
||||
Invite Team Member
|
||||
</h2>
|
||||
|
||||
<div className="space-y-4">
|
||||
<div>
|
||||
<label className="block text-sm font-medium text-gray-700 dark:text-gray-300 mb-1">
|
||||
Email *
|
||||
</label>
|
||||
<input
|
||||
type="email"
|
||||
value={inviteForm.email}
|
||||
onChange={(e) => setInviteForm(prev => ({ ...prev, email: e.target.value }))}
|
||||
className="w-full px-3 py-2 border border-gray-300 dark:border-gray-600 rounded-md bg-white dark:bg-gray-800 text-gray-900 dark:text-white"
|
||||
placeholder="user@example.com"
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<label className="block text-sm font-medium text-gray-700 dark:text-gray-300 mb-1">
|
||||
First Name
|
||||
</label>
|
||||
<input
|
||||
type="text"
|
||||
value={inviteForm.first_name}
|
||||
onChange={(e) => setInviteForm(prev => ({ ...prev, first_name: e.target.value }))}
|
||||
className="w-full px-3 py-2 border border-gray-300 dark:border-gray-600 rounded-md bg-white dark:bg-gray-800 text-gray-900 dark:text-white"
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<label className="block text-sm font-medium text-gray-700 dark:text-gray-300 mb-1">
|
||||
Last Name
|
||||
</label>
|
||||
<input
|
||||
type="text"
|
||||
value={inviteForm.last_name}
|
||||
onChange={(e) => setInviteForm(prev => ({ ...prev, last_name: e.target.value }))}
|
||||
className="w-full px-3 py-2 border border-gray-300 dark:border-gray-600 rounded-md bg-white dark:bg-gray-800 text-gray-900 dark:text-white"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="mt-6 flex justify-end gap-3">
|
||||
<Button
|
||||
variant="outline"
|
||||
tone="neutral"
|
||||
onClick={() => {
|
||||
setShowInviteModal(false);
|
||||
setInviteForm({ email: '', first_name: '', last_name: '' });
|
||||
}}
|
||||
disabled={inviting}
|
||||
>
|
||||
Cancel
|
||||
</Button>
|
||||
<Button
|
||||
variant="primary"
|
||||
tone="brand"
|
||||
onClick={handleInvite}
|
||||
disabled={inviting}
|
||||
>
|
||||
{inviting ? 'Inviting...' : 'Send Invitation'}
|
||||
</Button>
|
||||
</div>
|
||||
</Card>
|
||||
</div>
|
||||
</form>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
616
frontend/src/pages/account/ContentSettingsPage.tsx
Normal file
616
frontend/src/pages/account/ContentSettingsPage.tsx
Normal file
@@ -0,0 +1,616 @@
|
||||
/**
|
||||
* Content Settings Page - 3 Tabs
|
||||
* Tabs: Content Generation, Publishing, Image Settings
|
||||
* Consolidated settings for content creation workflow
|
||||
*/
|
||||
|
||||
import { useState, useEffect, useCallback } from 'react';
|
||||
import {
|
||||
Save, Loader2, Image as ImageIcon, FileText, Send, Settings
|
||||
} from 'lucide-react';
|
||||
import { Card } from '../../components/ui/card';
|
||||
import Button from '../../components/ui/button/Button';
|
||||
import { fetchAPI } from '../../services/api';
|
||||
import { useToast } from '../../components/ui/toast/ToastContainer';
|
||||
import SelectDropdown from '../../components/form/SelectDropdown';
|
||||
import Label from '../../components/form/Label';
|
||||
import Checkbox from '../../components/form/input/Checkbox';
|
||||
import PageMeta from '../../components/common/PageMeta';
|
||||
|
||||
type TabType = 'content' | 'publishing' | 'images';
|
||||
|
||||
interface ImageGenerationSettings {
|
||||
enabled: boolean;
|
||||
service: 'openai' | 'runware';
|
||||
provider: string;
|
||||
model: string;
|
||||
runwareModel?: string;
|
||||
image_type: 'realistic' | 'artistic' | 'cartoon';
|
||||
max_in_article_images: number;
|
||||
image_format: 'webp' | 'jpg' | 'png';
|
||||
desktop_enabled: boolean;
|
||||
mobile_enabled: boolean;
|
||||
featured_image_size: string;
|
||||
desktop_image_size: string;
|
||||
}
|
||||
|
||||
interface PublishingSettings {
|
||||
autoPublishEnabled: boolean;
|
||||
autoSyncEnabled: boolean;
|
||||
}
|
||||
|
||||
interface ContentGenerationSettings {
|
||||
appendToPrompt: string;
|
||||
defaultTone: string;
|
||||
defaultLength: string;
|
||||
}
|
||||
|
||||
// Map user-friendly quality to internal service/model configuration
|
||||
const QUALITY_TO_CONFIG: Record<string, { service: 'openai' | 'runware'; model: string }> = {
|
||||
standard: { service: 'openai', model: 'dall-e-2' },
|
||||
premium: { service: 'openai', model: 'dall-e-3' },
|
||||
best: { service: 'runware', model: 'runware:97@1' },
|
||||
};
|
||||
|
||||
// Map internal config back to user-friendly quality
|
||||
const getQualityFromConfig = (service?: string, model?: string): 'standard' | 'premium' | 'best' => {
|
||||
if (service === 'runware') return 'best';
|
||||
if (model === 'dall-e-3') return 'premium';
|
||||
return 'standard';
|
||||
};
|
||||
|
||||
// Get available image sizes based on provider and model
|
||||
const getImageSizes = (provider: string, model: string) => {
|
||||
if (provider === 'runware') {
|
||||
return [
|
||||
{ value: '1280x832', label: '1280×832 pixels' },
|
||||
{ value: '1024x1024', label: '1024×1024 pixels' },
|
||||
{ value: '512x512', label: '512×512 pixels' },
|
||||
];
|
||||
} else if (provider === 'openai') {
|
||||
if (model === 'dall-e-2') {
|
||||
return [
|
||||
{ value: '256x256', label: '256×256 pixels' },
|
||||
{ value: '512x512', label: '512×512 pixels' },
|
||||
{ value: '1024x1024', label: '1024×1024 pixels' },
|
||||
];
|
||||
} else if (model === 'dall-e-3') {
|
||||
return [
|
||||
{ value: '1024x1024', label: '1024×1024 pixels' },
|
||||
];
|
||||
}
|
||||
}
|
||||
return [{ value: '1024x1024', label: '1024×1024 pixels' }];
|
||||
};
|
||||
|
||||
export default function ContentSettingsPage() {
|
||||
const toast = useToast();
|
||||
const [activeTab, setActiveTab] = useState<TabType>('content');
|
||||
const [loading, setLoading] = useState(true);
|
||||
const [saving, setSaving] = useState(false);
|
||||
|
||||
// Content Generation Settings
|
||||
const [contentSettings, setContentSettings] = useState<ContentGenerationSettings>({
|
||||
appendToPrompt: '',
|
||||
defaultTone: 'professional',
|
||||
defaultLength: 'medium',
|
||||
});
|
||||
|
||||
// Publishing Settings
|
||||
const [publishingSettings, setPublishingSettings] = useState<PublishingSettings>({
|
||||
autoPublishEnabled: false,
|
||||
autoSyncEnabled: false,
|
||||
});
|
||||
|
||||
// Image Quality
|
||||
const [imageQuality, setImageQuality] = useState<'standard' | 'premium' | 'best'>('premium');
|
||||
|
||||
// Image Generation Settings
|
||||
const [imageSettings, setImageSettings] = useState<ImageGenerationSettings>({
|
||||
enabled: true,
|
||||
service: 'openai',
|
||||
provider: 'openai',
|
||||
model: 'dall-e-3',
|
||||
image_type: 'realistic',
|
||||
max_in_article_images: 2,
|
||||
image_format: 'webp',
|
||||
desktop_enabled: true,
|
||||
mobile_enabled: true,
|
||||
featured_image_size: '1024x1024',
|
||||
desktop_image_size: '1024x1024',
|
||||
});
|
||||
|
||||
// Get current provider/model from quality setting
|
||||
const getCurrentConfig = useCallback(() => {
|
||||
const config = QUALITY_TO_CONFIG[imageQuality];
|
||||
return {
|
||||
service: config.service,
|
||||
model: config.model,
|
||||
};
|
||||
}, [imageQuality]);
|
||||
|
||||
// Get available sizes for current quality
|
||||
const availableSizes = getImageSizes(
|
||||
getCurrentConfig().service,
|
||||
getCurrentConfig().model
|
||||
);
|
||||
|
||||
useEffect(() => {
|
||||
loadSettings();
|
||||
}, []);
|
||||
|
||||
// Update image sizes when quality changes
|
||||
useEffect(() => {
|
||||
const config = getCurrentConfig();
|
||||
const sizes = getImageSizes(config.service, config.model);
|
||||
const defaultSize = sizes.length > 0 ? sizes[0].value : '1024x1024';
|
||||
|
||||
const validSizes = sizes.map(s => s.value);
|
||||
const needsFeaturedUpdate = !validSizes.includes(imageSettings.featured_image_size);
|
||||
const needsDesktopUpdate = !validSizes.includes(imageSettings.desktop_image_size);
|
||||
|
||||
if (needsFeaturedUpdate || needsDesktopUpdate) {
|
||||
setImageSettings(prev => ({
|
||||
...prev,
|
||||
service: config.service,
|
||||
provider: config.service,
|
||||
model: config.model,
|
||||
featured_image_size: needsFeaturedUpdate ? defaultSize : prev.featured_image_size,
|
||||
desktop_image_size: needsDesktopUpdate ? defaultSize : prev.desktop_image_size,
|
||||
}));
|
||||
} else {
|
||||
setImageSettings(prev => ({
|
||||
...prev,
|
||||
service: config.service,
|
||||
provider: config.service,
|
||||
model: config.model,
|
||||
}));
|
||||
}
|
||||
}, [imageQuality, getCurrentConfig]);
|
||||
|
||||
const loadSettings = async () => {
|
||||
try {
|
||||
setLoading(true);
|
||||
|
||||
// Load image generation settings
|
||||
const imageData = await fetchAPI('/v1/system/settings/integrations/image_generation/');
|
||||
if (imageData) {
|
||||
const quality = getQualityFromConfig(imageData.service || imageData.provider, imageData.model);
|
||||
setImageQuality(quality);
|
||||
|
||||
setImageSettings({
|
||||
enabled: imageData.enabled !== false,
|
||||
service: imageData.service || imageData.provider || 'openai',
|
||||
provider: imageData.provider || imageData.service || 'openai',
|
||||
model: imageData.model || 'dall-e-3',
|
||||
runwareModel: imageData.runwareModel,
|
||||
image_type: imageData.image_type || 'realistic',
|
||||
max_in_article_images: imageData.max_in_article_images || 2,
|
||||
image_format: imageData.image_format || 'webp',
|
||||
desktop_enabled: imageData.desktop_enabled !== false,
|
||||
mobile_enabled: imageData.mobile_enabled !== false,
|
||||
featured_image_size: imageData.featured_image_size || '1024x1024',
|
||||
desktop_image_size: imageData.desktop_image_size || '1024x1024',
|
||||
});
|
||||
}
|
||||
|
||||
// TODO: Load content generation settings when API is available
|
||||
// TODO: Load publishing settings when API is available
|
||||
|
||||
} catch (error: any) {
|
||||
console.error('Error loading content settings:', error);
|
||||
} finally {
|
||||
setLoading(false);
|
||||
}
|
||||
};
|
||||
|
||||
const handleSave = async () => {
|
||||
try {
|
||||
setSaving(true);
|
||||
|
||||
if (activeTab === 'images') {
|
||||
const config = getCurrentConfig();
|
||||
const configToSave = {
|
||||
enabled: imageSettings.enabled,
|
||||
service: config.service,
|
||||
provider: config.service,
|
||||
model: config.model,
|
||||
runwareModel: config.service === 'runware' ? config.model : undefined,
|
||||
image_type: imageSettings.image_type,
|
||||
max_in_article_images: imageSettings.max_in_article_images,
|
||||
image_format: imageSettings.image_format,
|
||||
desktop_enabled: imageSettings.desktop_enabled,
|
||||
mobile_enabled: imageSettings.mobile_enabled,
|
||||
featured_image_size: imageSettings.featured_image_size,
|
||||
desktop_image_size: imageSettings.desktop_image_size,
|
||||
};
|
||||
|
||||
await fetchAPI('/v1/system/settings/integrations/image_generation/save/', {
|
||||
method: 'POST',
|
||||
body: JSON.stringify(configToSave),
|
||||
});
|
||||
}
|
||||
|
||||
// TODO: Save content generation settings when API is available
|
||||
// TODO: Save publishing settings when API is available
|
||||
|
||||
toast.success('Settings saved successfully');
|
||||
} catch (error: any) {
|
||||
console.error('Error saving settings:', error);
|
||||
toast.error(`Failed to save settings: ${error.message}`);
|
||||
} finally {
|
||||
setSaving(false);
|
||||
}
|
||||
};
|
||||
|
||||
const tabs = [
|
||||
{ id: 'content' as TabType, label: 'Content Generation', icon: <FileText className="w-4 h-4" /> },
|
||||
{ id: 'publishing' as TabType, label: 'Publishing', icon: <Send className="w-4 h-4" /> },
|
||||
{ id: 'images' as TabType, label: 'Image Settings', icon: <ImageIcon className="w-4 h-4" /> },
|
||||
];
|
||||
|
||||
if (loading) {
|
||||
return (
|
||||
<div className="p-6">
|
||||
<PageMeta title="Content Settings" description="Configure your content generation settings" />
|
||||
<div className="flex items-center justify-center h-64">
|
||||
<div className="flex flex-col items-center gap-3">
|
||||
<Loader2 className="w-8 h-8 animate-spin text-[var(--color-brand-500)]" />
|
||||
<div className="text-gray-500 dark:text-gray-400">Loading settings...</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="p-6">
|
||||
<PageMeta title="Content Settings" description="Configure your content generation settings" />
|
||||
|
||||
{/* Page Header */}
|
||||
<div className="mb-6">
|
||||
<h1 className="text-2xl font-bold text-gray-900 dark:text-white">Content Settings</h1>
|
||||
<p className="text-gray-600 dark:text-gray-400 mt-1">
|
||||
Configure how your content and images are generated
|
||||
</p>
|
||||
</div>
|
||||
|
||||
{/* Tabs */}
|
||||
<div className="mb-6 border-b border-gray-200 dark:border-gray-700">
|
||||
<nav className="-mb-px flex space-x-8">
|
||||
{tabs.map((tab) => (
|
||||
<button
|
||||
type="button"
|
||||
key={tab.id}
|
||||
onClick={() => setActiveTab(tab.id)}
|
||||
className={`
|
||||
flex items-center gap-2 py-4 px-1 border-b-2 font-medium text-sm
|
||||
${activeTab === tab.id
|
||||
? 'border-[var(--color-brand-500)] text-[var(--color-brand-600)] dark:text-[var(--color-brand-400)]'
|
||||
: 'border-transparent text-gray-500 hover:text-gray-700 hover:border-gray-300 dark:text-gray-400 dark:hover:text-gray-300'
|
||||
}
|
||||
`}
|
||||
>
|
||||
{tab.icon}
|
||||
{tab.label}
|
||||
</button>
|
||||
))}
|
||||
</nav>
|
||||
</div>
|
||||
|
||||
{/* Tab Content */}
|
||||
<div className="mt-6">
|
||||
{/* Content Generation Tab */}
|
||||
{activeTab === 'content' && (
|
||||
<div className="space-y-6 max-w-4xl">
|
||||
<Card className="p-6">
|
||||
<div className="flex items-center gap-3 mb-6">
|
||||
<div className="p-2 bg-blue-100 dark:bg-blue-900/30 rounded-lg">
|
||||
<FileText className="w-5 h-5 text-blue-600 dark:text-blue-400" />
|
||||
</div>
|
||||
<div>
|
||||
<h2 className="text-lg font-semibold text-gray-900 dark:text-white">Content Generation</h2>
|
||||
<p className="text-sm text-gray-500 dark:text-gray-400">Customize how your articles are written</p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="space-y-6">
|
||||
<div>
|
||||
<Label className="mb-2">Append to Every Prompt</Label>
|
||||
<textarea
|
||||
value={contentSettings.appendToPrompt}
|
||||
onChange={(e) => setContentSettings({ ...contentSettings, appendToPrompt: e.target.value })}
|
||||
placeholder="Add custom instructions that will be included with every content generation request..."
|
||||
className="w-full px-4 py-3 border border-gray-300 dark:border-gray-600 rounded-lg focus:ring-2 focus:ring-[var(--color-brand-500)] dark:bg-gray-800 min-h-[120px] resize-y"
|
||||
/>
|
||||
<p className="text-xs text-gray-500 mt-1">
|
||||
This text will be appended to every AI prompt. Use it to enforce brand guidelines, tone, or specific requirements.
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<div className="grid grid-cols-1 md:grid-cols-2 gap-6">
|
||||
<div>
|
||||
<Label className="mb-2">Default Writing Tone</Label>
|
||||
<SelectDropdown
|
||||
options={[
|
||||
{ value: 'professional', label: 'Professional' },
|
||||
{ value: 'conversational', label: 'Conversational' },
|
||||
{ value: 'formal', label: 'Formal' },
|
||||
{ value: 'casual', label: 'Casual' },
|
||||
{ value: 'friendly', label: 'Friendly' },
|
||||
]}
|
||||
value={contentSettings.defaultTone}
|
||||
onChange={(value) => setContentSettings({ ...contentSettings, defaultTone: value })}
|
||||
className="w-full"
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<Label className="mb-2">Default Article Length</Label>
|
||||
<SelectDropdown
|
||||
options={[
|
||||
{ value: 'short', label: 'Short (500-800 words)' },
|
||||
{ value: 'medium', label: 'Medium (1000-1500 words)' },
|
||||
{ value: 'long', label: 'Long (2000-3000 words)' },
|
||||
{ value: 'comprehensive', label: 'Comprehensive (3000+ words)' },
|
||||
]}
|
||||
value={contentSettings.defaultLength}
|
||||
onChange={(value) => setContentSettings({ ...contentSettings, defaultLength: value })}
|
||||
className="w-full"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</Card>
|
||||
|
||||
{/* Save Button */}
|
||||
<div className="flex justify-end">
|
||||
<Button
|
||||
variant="primary"
|
||||
tone="brand"
|
||||
onClick={handleSave}
|
||||
disabled={saving}
|
||||
startIcon={saving ? <Loader2 className="w-4 h-4 animate-spin" /> : <Save className="w-4 h-4" />}
|
||||
>
|
||||
{saving ? 'Saving...' : 'Save Settings'}
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Publishing Tab */}
|
||||
{activeTab === 'publishing' && (
|
||||
<div className="space-y-6 max-w-4xl">
|
||||
<Card className="p-6">
|
||||
<div className="flex items-center gap-3 mb-6">
|
||||
<div className="p-2 bg-green-100 dark:bg-green-900/30 rounded-lg">
|
||||
<Send className="w-5 h-5 text-green-600 dark:text-green-400" />
|
||||
</div>
|
||||
<div>
|
||||
<h2 className="text-lg font-semibold text-gray-900 dark:text-white">WordPress Publishing</h2>
|
||||
<p className="text-sm text-gray-500 dark:text-gray-400">Configure automatic publishing to your WordPress sites</p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="space-y-6">
|
||||
{/* Auto-Publish Setting */}
|
||||
<div className="p-4 rounded-lg border border-gray-200 dark:border-gray-700">
|
||||
<div className="flex items-start gap-3">
|
||||
<Checkbox
|
||||
checked={publishingSettings.autoPublishEnabled}
|
||||
onChange={(checked) => setPublishingSettings({ ...publishingSettings, autoPublishEnabled: checked })}
|
||||
/>
|
||||
<div className="flex-1">
|
||||
<Label className="font-medium text-gray-900 dark:text-white">
|
||||
Automatic Publishing
|
||||
</Label>
|
||||
<p className="text-sm text-gray-500 dark:text-gray-400 mt-1">
|
||||
Automatically publish articles to WordPress when they're finished and reviewed
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{publishingSettings.autoPublishEnabled && (
|
||||
<div className="mt-4 p-3 bg-blue-50 dark:bg-blue-900/20 border border-blue-200 dark:border-blue-800 rounded-lg">
|
||||
<p className="text-sm text-blue-800 dark:text-blue-200">
|
||||
Articles will be published automatically once they pass review. You can still manually review them first if needed.
|
||||
</p>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* Auto-Sync Setting */}
|
||||
<div className="p-4 rounded-lg border border-gray-200 dark:border-gray-700">
|
||||
<div className="flex items-start gap-3">
|
||||
<Checkbox
|
||||
checked={publishingSettings.autoSyncEnabled}
|
||||
onChange={(checked) => setPublishingSettings({ ...publishingSettings, autoSyncEnabled: checked })}
|
||||
/>
|
||||
<div className="flex-1">
|
||||
<Label className="font-medium text-gray-900 dark:text-white">
|
||||
Keep Content Updated
|
||||
</Label>
|
||||
<p className="text-sm text-gray-500 dark:text-gray-400 mt-1">
|
||||
Automatically update articles on WordPress if you make changes here
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</Card>
|
||||
|
||||
{/* Save Button */}
|
||||
<div className="flex justify-end">
|
||||
<Button
|
||||
variant="primary"
|
||||
tone="brand"
|
||||
onClick={handleSave}
|
||||
disabled={saving}
|
||||
startIcon={saving ? <Loader2 className="w-4 h-4 animate-spin" /> : <Save className="w-4 h-4" />}
|
||||
>
|
||||
{saving ? 'Saving...' : 'Save Settings'}
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Image Settings Tab */}
|
||||
{activeTab === 'images' && (
|
||||
<div className="space-y-6 max-w-4xl">
|
||||
<Card className="p-6">
|
||||
<div className="flex items-center gap-3 mb-6">
|
||||
<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" />
|
||||
</div>
|
||||
<div>
|
||||
<h2 className="text-lg font-semibold text-gray-900 dark:text-white">Image Generation</h2>
|
||||
<p className="text-sm text-gray-500 dark:text-gray-400">Configure how images are created for your articles</p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="space-y-6">
|
||||
{/* Row 1: Image Quality & Style */}
|
||||
<div className="grid grid-cols-1 md:grid-cols-2 gap-6">
|
||||
<div>
|
||||
<Label className="mb-2">Image Quality</Label>
|
||||
<SelectDropdown
|
||||
options={[
|
||||
{ value: 'standard', label: 'Standard - Fast & economical (DALL·E 2)' },
|
||||
{ value: 'premium', label: 'Premium - High quality (DALL·E 3)' },
|
||||
{ value: 'best', label: 'Best - Highest quality (Runware)' },
|
||||
]}
|
||||
value={imageQuality}
|
||||
onChange={(value) => setImageQuality(value as 'standard' | 'premium' | 'best')}
|
||||
className="w-full"
|
||||
/>
|
||||
<p className="text-xs text-gray-500 mt-1">
|
||||
Higher quality produces better images
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<Label className="mb-2">Image Style</Label>
|
||||
<SelectDropdown
|
||||
options={[
|
||||
{ value: 'realistic', label: 'Realistic' },
|
||||
{ value: 'artistic', label: 'Artistic' },
|
||||
{ value: 'cartoon', label: 'Cartoon' },
|
||||
]}
|
||||
value={imageSettings.image_type}
|
||||
onChange={(value) => setImageSettings({ ...imageSettings, image_type: value as any })}
|
||||
className="w-full"
|
||||
/>
|
||||
<p className="text-xs text-gray-500 mt-1">
|
||||
Choose the visual style that matches your brand
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Row 2: Featured Image Size */}
|
||||
<div>
|
||||
<Label className="mb-2">Featured Image</Label>
|
||||
<div className="p-4 rounded-lg border border-gray-200 dark:border-gray-700 bg-gradient-to-r from-purple-500 to-blue-500 text-white">
|
||||
<div className="flex items-center justify-between mb-3">
|
||||
<div className="font-medium">Featured Image Size</div>
|
||||
<div className="text-xs bg-white/20 px-2 py-1 rounded">Always Enabled</div>
|
||||
</div>
|
||||
<SelectDropdown
|
||||
options={availableSizes}
|
||||
value={imageSettings.featured_image_size}
|
||||
onChange={(value) => setImageSettings({ ...imageSettings, featured_image_size: value })}
|
||||
className="w-full [&_.igny8-select-styled]:bg-white/10 [&_.igny8-select-styled]:border-white/20 [&_.igny8-select-styled]:text-white"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Row 3: Desktop & Mobile Images */}
|
||||
<div className="grid grid-cols-1 md:grid-cols-2 gap-4">
|
||||
<div className="p-4 rounded-lg border border-gray-200 dark:border-gray-700 space-y-3">
|
||||
<div className="flex items-center gap-3">
|
||||
<Checkbox
|
||||
checked={imageSettings.desktop_enabled}
|
||||
onChange={(checked) => setImageSettings({ ...imageSettings, desktop_enabled: checked })}
|
||||
/>
|
||||
<Label className="font-medium text-gray-700 dark:text-gray-300">
|
||||
Desktop Images
|
||||
</Label>
|
||||
</div>
|
||||
{imageSettings.desktop_enabled && (
|
||||
<SelectDropdown
|
||||
options={availableSizes}
|
||||
value={imageSettings.desktop_image_size}
|
||||
onChange={(value) => setImageSettings({ ...imageSettings, desktop_image_size: value })}
|
||||
className="w-full"
|
||||
/>
|
||||
)}
|
||||
</div>
|
||||
|
||||
<div className="flex items-center gap-3 p-4 rounded-lg border border-gray-200 dark:border-gray-700">
|
||||
<Checkbox
|
||||
checked={imageSettings.mobile_enabled}
|
||||
onChange={(checked) => setImageSettings({ ...imageSettings, mobile_enabled: checked })}
|
||||
/>
|
||||
<div>
|
||||
<Label className="font-medium text-gray-700 dark:text-gray-300">
|
||||
Mobile Images
|
||||
</Label>
|
||||
<div className="text-xs text-gray-500 dark:text-gray-400">
|
||||
512×512 pixels
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Row 4: Max Images & Format */}
|
||||
<div className="grid grid-cols-1 md:grid-cols-2 gap-6">
|
||||
<div>
|
||||
<Label className="mb-2">Max In-Article Images</Label>
|
||||
<SelectDropdown
|
||||
options={[
|
||||
{ value: '1', label: '1 Image' },
|
||||
{ value: '2', label: '2 Images' },
|
||||
{ value: '3', label: '3 Images' },
|
||||
{ value: '4', label: '4 Images' },
|
||||
{ value: '5', label: '5 Images' },
|
||||
]}
|
||||
value={String(imageSettings.max_in_article_images)}
|
||||
onChange={(value) => setImageSettings({ ...imageSettings, max_in_article_images: parseInt(value) })}
|
||||
className="w-full"
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<Label className="mb-2">Image Format</Label>
|
||||
<SelectDropdown
|
||||
options={[
|
||||
{ value: 'webp', label: 'WEBP (recommended)' },
|
||||
{ value: 'jpg', label: 'JPG' },
|
||||
{ value: 'png', label: 'PNG' },
|
||||
]}
|
||||
value={imageSettings.image_format}
|
||||
onChange={(value) => setImageSettings({ ...imageSettings, image_format: value as any })}
|
||||
className="w-full"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</Card>
|
||||
|
||||
{/* Save Button */}
|
||||
<div className="flex justify-end">
|
||||
<Button
|
||||
variant="primary"
|
||||
tone="brand"
|
||||
onClick={handleSave}
|
||||
disabled={saving}
|
||||
startIcon={saving ? <Loader2 className="w-4 h-4 animate-spin" /> : <Save className="w-4 h-4" />}
|
||||
>
|
||||
{saving ? 'Saving...' : 'Save Settings'}
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -1,12 +1,15 @@
|
||||
/**
|
||||
* Plans & Billing Page - Refactored for Better UX
|
||||
* Organized tabs: Current Plan, Plan Limits, Usage, Upgrade Plan, Billing History
|
||||
* Plans & Billing Page - Subscription & Payment Management
|
||||
* Tabs: Current Plan, Upgrade Plan, Billing History
|
||||
*
|
||||
* Note: Usage tracking is consolidated in UsageAnalyticsPage (/account/usage)
|
||||
*/
|
||||
|
||||
import { useState, useEffect, useRef } from 'react';
|
||||
import { Link } from 'react-router-dom';
|
||||
import {
|
||||
CreditCard, Package, TrendingUp, FileText, Wallet, ArrowUpCircle,
|
||||
Loader2, AlertCircle, CheckCircle, Download, BarChart3, Zap, Globe, Users
|
||||
Loader2, AlertCircle, CheckCircle, Download, Zap, Globe, Users
|
||||
} from 'lucide-react';
|
||||
import { Card } from '../../components/ui/card';
|
||||
import Badge from '../../components/ui/badge/Badge';
|
||||
@@ -16,7 +19,7 @@ import { PricingPlan } from '../../components/ui/pricing-table';
|
||||
import PricingTable1 from '../../components/ui/pricing-table/pricing-table-1';
|
||||
import CreditCostBreakdownPanel from '../../components/billing/CreditCostBreakdownPanel';
|
||||
// import CreditCostsPanel from '../../components/billing/CreditCostsPanel'; // Hidden from regular users
|
||||
import UsageLimitsPanel from '../../components/billing/UsageLimitsPanel';
|
||||
// import UsageLimitsPanel from '../../components/billing/UsageLimitsPanel'; // Moved to UsageAnalyticsPage
|
||||
import { convertToPricingPlan } from '../../utils/pricingHelpers';
|
||||
import {
|
||||
getCreditBalance,
|
||||
@@ -44,7 +47,7 @@ import {
|
||||
} from '../../services/billing.api';
|
||||
import { useAuthStore } from '../../store/authStore';
|
||||
|
||||
type TabType = 'plan' | 'limits' | 'credits' | 'upgrade' | 'invoices';
|
||||
type TabType = 'plan' | 'upgrade' | 'invoices';
|
||||
|
||||
export default function PlansAndBillingPage() {
|
||||
const [activeTab, setActiveTab] = useState<TabType>('plan');
|
||||
@@ -345,7 +348,6 @@ export default function PlansAndBillingPage() {
|
||||
|
||||
const tabs = [
|
||||
{ id: 'plan' as TabType, label: 'Current Plan', icon: <Package className="w-4 h-4" /> },
|
||||
{ id: 'limits' as TabType, label: 'Usage', icon: <BarChart3 className="w-4 h-4" /> },
|
||||
{ id: 'upgrade' as TabType, label: 'Upgrade Plan', icon: <Wallet className="w-4 h-4" /> },
|
||||
{ id: 'invoices' as TabType, label: 'History', icon: <FileText className="w-4 h-4" /> },
|
||||
];
|
||||
@@ -481,9 +483,10 @@ export default function PlansAndBillingPage() {
|
||||
<Button
|
||||
variant="outline"
|
||||
tone="neutral"
|
||||
onClick={() => setActiveTab('limits')}
|
||||
as={Link}
|
||||
to="/account/usage"
|
||||
>
|
||||
View Limits
|
||||
View Usage
|
||||
</Button>
|
||||
{hasActivePlan && (
|
||||
<Button
|
||||
@@ -537,9 +540,10 @@ export default function PlansAndBillingPage() {
|
||||
variant="outline"
|
||||
tone="brand"
|
||||
size="sm"
|
||||
onClick={() => setActiveTab('limits')}
|
||||
as={Link}
|
||||
to="/account/usage"
|
||||
>
|
||||
View All Limits
|
||||
View All Usage
|
||||
</Button>
|
||||
</div>
|
||||
<div className="grid grid-cols-2 md:grid-cols-4 gap-4">
|
||||
@@ -589,109 +593,6 @@ export default function PlansAndBillingPage() {
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Plan Limits Tab */}
|
||||
{activeTab === 'limits' && (
|
||||
<div className="space-y-6">
|
||||
<UsageLimitsPanel />
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Usage Overview Tab */}
|
||||
{activeTab === 'credits' && (
|
||||
<div className="space-y-6">
|
||||
{/* Usage Cards */}
|
||||
<div className="grid grid-cols-1 md:grid-cols-3 gap-6">
|
||||
<Card className="p-6 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">
|
||||
<div className="flex items-center gap-3 mb-3">
|
||||
<div className="p-2 bg-brand-500 rounded-lg">
|
||||
<Wallet className="w-5 h-5 text-white" />
|
||||
</div>
|
||||
<div className="text-sm font-medium text-brand-700 dark:text-brand-300">Content Remaining</div>
|
||||
</div>
|
||||
<div className="text-4xl font-bold text-brand-600 dark:text-brand-400">
|
||||
{creditBalance?.credits.toLocaleString() || 0}
|
||||
</div>
|
||||
<div className="text-sm text-brand-600 dark:text-brand-400 mt-2">pieces available</div>
|
||||
</Card>
|
||||
|
||||
<Card className="p-6 bg-gradient-to-br from-red-50 to-red-100 dark:from-red-900/20 dark:to-red-800/10 border-red-200 dark:border-red-700">
|
||||
<div className="flex items-center gap-3 mb-3">
|
||||
<div className="p-2 bg-red-500 rounded-lg">
|
||||
<TrendingUp className="w-5 h-5 text-white" />
|
||||
</div>
|
||||
<div className="text-sm font-medium text-red-700 dark:text-red-300">Used This Month</div>
|
||||
</div>
|
||||
<div className="text-4xl font-bold text-red-600 dark:text-red-400">
|
||||
{creditBalance?.credits_used_this_month.toLocaleString() || 0}
|
||||
</div>
|
||||
<div className="text-sm text-red-600 dark:text-red-400 mt-2">credits consumed</div>
|
||||
</Card>
|
||||
|
||||
<Card className="p-6 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">
|
||||
<div className="flex items-center gap-3 mb-3">
|
||||
<div className="p-2 bg-success-500 rounded-lg">
|
||||
<Package className="w-5 h-5 text-white" />
|
||||
</div>
|
||||
<div className="text-sm font-medium text-success-700 dark:text-success-300">Monthly Included</div>
|
||||
</div>
|
||||
<div className="text-4xl font-bold text-success-600 dark:text-success-400">
|
||||
{creditBalance?.plan_credits_per_month.toLocaleString() || 0}
|
||||
</div>
|
||||
<div className="text-sm text-success-600 dark:text-success-400 mt-2">from your plan</div>
|
||||
</Card>
|
||||
</div>
|
||||
|
||||
{/* Usage Summary with Progress Bar */}
|
||||
<Card className="p-6">
|
||||
<h2 className="text-lg font-semibold mb-4 text-gray-900 dark:text-white">Credit Usage Summary</h2>
|
||||
<div className="space-y-4">
|
||||
<div className="flex justify-between items-center text-sm">
|
||||
<span className="text-gray-700 dark:text-gray-300">Monthly Allocation</span>
|
||||
<span className="font-semibold text-gray-900 dark:text-white">
|
||||
{creditBalance?.plan_credits_per_month.toLocaleString() || 0} credits
|
||||
</span>
|
||||
</div>
|
||||
<div className="flex justify-between items-center text-sm">
|
||||
<span className="text-gray-700 dark:text-gray-300">Used This Month</span>
|
||||
<span className="font-semibold text-red-600 dark:text-red-400">
|
||||
{creditBalance?.credits_used_this_month.toLocaleString() || 0} credits
|
||||
</span>
|
||||
</div>
|
||||
<div className="flex justify-between items-center text-sm">
|
||||
<span className="text-gray-700 dark:text-gray-300">Remaining Balance</span>
|
||||
<span className="font-semibold text-success-600 dark:text-success-400">
|
||||
{creditBalance?.credits_remaining.toLocaleString() || 0} credits
|
||||
</span>
|
||||
</div>
|
||||
|
||||
{/* Progress Bar */}
|
||||
<div className="pt-2">
|
||||
<div className="flex justify-between items-center text-xs text-gray-600 dark:text-gray-400 mb-2">
|
||||
<span>Usage Progress</span>
|
||||
<span>
|
||||
{creditBalance?.credits_used_this_month && creditBalance?.plan_credits_per_month
|
||||
? `${Math.round((creditBalance.credits_used_this_month / creditBalance.plan_credits_per_month) * 100)}%`
|
||||
: '0%'}
|
||||
</span>
|
||||
</div>
|
||||
<div className="w-full bg-gray-200 dark:bg-gray-700 rounded-full h-3">
|
||||
<div
|
||||
className="bg-gradient-to-r from-brand-500 to-brand-600 h-3 rounded-full transition-all duration-300"
|
||||
style={{
|
||||
width: creditBalance?.credits_used_this_month && creditBalance?.plan_credits_per_month
|
||||
? `${Math.min((creditBalance.credits_used_this_month / creditBalance.plan_credits_per_month) * 100, 100)}%`
|
||||
: '0%'
|
||||
}}
|
||||
></div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</Card>
|
||||
|
||||
{/* Usage Analytics - removed detailed credit breakdown to simplify user view */}
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Purchase/Upgrade Tab */}
|
||||
{activeTab === 'upgrade' && (
|
||||
<div className="space-y-6">
|
||||
@@ -752,110 +653,7 @@ export default function PlansAndBillingPage() {
|
||||
</Card>
|
||||
</div>
|
||||
|
||||
{/* Purchase Additional Credits Section - Hidden from regular users
|
||||
<div className="mt-12 pt-8 border-t-2 border-gray-200 dark:border-gray-700">
|
||||
<div className="mb-6">
|
||||
<h2 className="text-xl font-semibold mb-2 text-gray-900 dark:text-white">Purchase Additional Credits</h2>
|
||||
<p className="text-gray-600 dark:text-gray-400">Top up your credit balance with our credit packages</p>
|
||||
</div>
|
||||
|
||||
{/* Current Balance Quick View */}
|
||||
<Card className="p-6 bg-gradient-to-r from-brand-50 to-purple-50 dark:from-brand-900/20 dark:to-purple-900/20 border-brand-200 dark:border-brand-700">
|
||||
<div className="flex items-center justify-between">
|
||||
<div className="flex items-center gap-4">
|
||||
<div className="p-3 bg-brand-500 rounded-lg">
|
||||
<Wallet className="w-6 h-6 text-white" />
|
||||
</div>
|
||||
<div>
|
||||
<div className="text-sm text-gray-600 dark:text-gray-400">Content Remaining</div>
|
||||
<div className="text-3xl font-bold text-gray-900 dark:text-white">
|
||||
{creditBalance?.credits.toLocaleString() || 0}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<div className="text-right">
|
||||
<div className="text-sm text-gray-600 dark:text-gray-400">Monthly Allowance</div>
|
||||
<div className="text-xl font-bold text-brand-600 dark:text-brand-400">
|
||||
{creditBalance?.plan_credits_per_month.toLocaleString() || 0}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</Card>
|
||||
|
||||
{/* Credit Packages Grid - Hidden from regular users */}
|
||||
{/*
|
||||
<div className="grid grid-cols-1 md:grid-cols-2 lg:grid-cols-3 gap-6 mt-6">
|
||||
{packages.map((pkg) => (
|
||||
<Card
|
||||
key={pkg.id}
|
||||
className="p-6 hover:shadow-lg transition-all duration-200 hover:border-brand-300 dark:hover:border-brand-600"
|
||||
>
|
||||
<div className="mb-4">
|
||||
<div className="inline-flex h-12 w-12 items-center justify-center rounded-xl bg-gradient-to-br from-brand-500 to-brand-600 shadow-md">
|
||||
<Zap className="w-6 h-6 text-white" />
|
||||
</div>
|
||||
</div>
|
||||
<h3 className="text-xl font-semibold text-gray-900 dark:text-white mb-2">
|
||||
{pkg.name}
|
||||
</h3>
|
||||
{pkg.description && (
|
||||
<p className="text-sm text-gray-600 dark:text-gray-400 mb-4">
|
||||
{pkg.description}
|
||||
</p>
|
||||
)}
|
||||
<div className="flex items-baseline gap-2 mb-1">
|
||||
<span className="text-4xl font-bold text-brand-600 dark:text-brand-400">
|
||||
{pkg.credits.toLocaleString()}
|
||||
</span>
|
||||
<span className="text-sm text-gray-500 dark:text-gray-400">credits</span>
|
||||
</div>
|
||||
<div className="text-2xl font-semibold text-gray-900 dark:text-white mb-6">
|
||||
${pkg.price}
|
||||
</div>
|
||||
<Button
|
||||
variant="primary"
|
||||
tone="brand"
|
||||
onClick={() => handlePurchase(pkg.id)}
|
||||
fullWidth
|
||||
size="md"
|
||||
disabled={purchaseLoadingId === pkg.id || (!hasPaymentMethods && paymentMethods.length > 0)}
|
||||
startIcon={purchaseLoadingId === pkg.id ? <Loader2 className="w-4 h-4 animate-spin" /> : undefined}
|
||||
>
|
||||
{purchaseLoadingId === pkg.id ? 'Processing...' : 'Purchase Now'}
|
||||
</Button>
|
||||
</Card>
|
||||
))}
|
||||
{packages.length === 0 && (
|
||||
<div className="col-span-3 text-center py-16">
|
||||
<div className="inline-flex h-16 w-16 items-center justify-center rounded-full bg-gray-100 dark:bg-gray-800 mb-4">
|
||||
<Package className="w-8 h-8 text-gray-400" />
|
||||
</div>
|
||||
<h3 className="text-lg font-medium text-gray-900 dark:text-white mb-2">No Packages Available</h3>
|
||||
<p className="text-gray-500 dark:text-gray-400">Credit packages will be available soon</p>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* Payment Methods Info */}
|
||||
{!hasPaymentMethods && paymentMethods.length === 0 && (
|
||||
<Card className="p-6 bg-warning-50 dark:bg-warning-900/20 border-warning-200 dark:border-warning-700 mt-6">
|
||||
<div className="flex items-start gap-3">
|
||||
<div className="p-2 bg-warning-100 dark:bg-warning-800/50 rounded-lg">
|
||||
<AlertCircle className="w-5 h-5 text-warning-600 dark:text-warning-400" />
|
||||
</div>
|
||||
<div>
|
||||
<h3 className="font-semibold text-warning-900 dark:text-warning-100 mb-1">
|
||||
Payment Method Required
|
||||
</h3>
|
||||
<p className="text-sm text-warning-800 dark:text-warning-200">
|
||||
Please contact support to set up a payment method before purchasing credits.
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
</Card>
|
||||
)}
|
||||
</div>
|
||||
*/}
|
||||
{/* Purchase Additional Credits Section - Hidden from regular users - removed for simplification */}
|
||||
</div>
|
||||
)}
|
||||
|
||||
|
||||
Reference in New Issue
Block a user