refactor phase 7-8

This commit is contained in:
alorig
2025-11-20 22:40:18 +05:00
parent 45dc0d1fa2
commit 3e142afc7a
11 changed files with 695 additions and 74 deletions

View File

@@ -275,13 +275,8 @@ export default function App() {
} />
{/* Thinker Module */}
<Route path="/thinker" element={
<Suspense fallback={null}>
<ModuleGuard module="thinker">
<ThinkerDashboard />
</ModuleGuard>
</Suspense>
} />
{/* Thinker Module - Redirect dashboard to prompts */}
<Route path="/thinker" element={<Navigate to="/thinker/prompts" replace />} />
<Route path="/thinker/prompts" element={
<Suspense fallback={null}>
<ModuleGuard module="thinker">

View File

@@ -1,13 +1,15 @@
/**
* WorkflowGuide Component
* Inline welcome/guide screen for new users
* Shows complete workflow explainer with visual flow maps
* Shows complete workflow explainer with visual flow maps and progress tracking
*/
import React from 'react';
import React, { useState, useEffect } from 'react';
import { useNavigate } from 'react-router-dom';
import { Card } from '../ui/card';
import Button from '../ui/button/Button';
import Badge from '../ui/badge/Badge';
import { ProgressBar } from '../ui/progress';
import Checkbox from '../form/input/Checkbox';
import {
CloseIcon,
ArrowRightIcon,
@@ -22,15 +24,93 @@ import {
} from '../../icons';
import { useOnboardingStore } from '../../store/onboardingStore';
import { useSiteStore } from '../../store/siteStore';
import { fetchSites, fetchKeywords, fetchClusters, fetchContent } from '../../services/api';
import { useAuthStore } from '../../store/authStore';
interface WorkflowProgress {
hasSite: boolean;
keywordsCount: number;
clustersCount: number;
contentCount: number;
publishedCount: number;
completionPercentage: number;
}
export default function WorkflowGuide() {
const navigate = useNavigate();
const { isGuideVisible, dismissGuide } = useOnboardingStore();
const { isGuideVisible, dismissGuide, loadFromBackend } = useOnboardingStore();
const { activeSite } = useSiteStore();
const { isAuthenticated } = useAuthStore();
const [progress, setProgress] = useState<WorkflowProgress>({
hasSite: false,
keywordsCount: 0,
clustersCount: 0,
contentCount: 0,
publishedCount: 0,
completionPercentage: 0,
});
const [loadingProgress, setLoadingProgress] = useState(true);
const [dontShowAgain, setDontShowAgain] = useState(false);
// Load dismissal state from backend on mount
useEffect(() => {
if (isAuthenticated) {
loadFromBackend().catch(() => {
// Silently fail - local state will be used
});
}
}, [isAuthenticated, loadFromBackend]);
// Load progress data
useEffect(() => {
if (!isAuthenticated || !isGuideVisible) return;
const loadProgress = async () => {
try {
setLoadingProgress(true);
const [sitesRes, keywordsRes, clustersRes, contentRes] = await Promise.all([
fetchSites().catch(() => ({ results: [], count: 0 })),
fetchKeywords({ page_size: 1 }).catch(() => ({ count: 0 })),
fetchClusters({ page_size: 1 }).catch(() => ({ count: 0 })),
fetchContent({ page_size: 1 }).catch(() => ({ count: 0 })),
]);
const sitesCount = sitesRes.results?.filter((s: any) => s.is_active).length || 0;
const keywordsCount = keywordsRes.count || 0;
const clustersCount = clustersRes.count || 0;
const contentCount = contentRes.count || 0;
const publishedCount = 0; // TODO: Add published content count when API is available
// Calculate completion percentage
// Milestones: Site (20%), Keywords (20%), Clusters (20%), Content (20%), Published (20%)
let completion = 0;
if (sitesCount > 0) completion += 20;
if (keywordsCount > 0) completion += 20;
if (clustersCount > 0) completion += 20;
if (contentCount > 0) completion += 20;
if (publishedCount > 0) completion += 20;
setProgress({
hasSite: sitesCount > 0,
keywordsCount,
clustersCount,
contentCount,
publishedCount,
completionPercentage: completion,
});
} catch (error) {
console.error('Failed to load progress:', error);
} finally {
setLoadingProgress(false);
}
};
loadProgress();
}, [isAuthenticated, isGuideVisible]);
if (!isGuideVisible) return null;
const hasSite = !!activeSite;
const { hasSite, keywordsCount, clustersCount, contentCount, publishedCount, completionPercentage } = progress;
return (
<div className="mb-8">
@@ -55,7 +135,7 @@ export default function WorkflowGuide() {
<Button
variant="ghost"
size="sm"
onClick={dismissGuide}
onClick={async () => await dismissGuide()}
className="text-gray-400 hover:text-gray-600 dark:hover:text-gray-300"
>
<CloseIcon className="w-5 h-5" />
@@ -81,9 +161,9 @@ export default function WorkflowGuide() {
</div>
<div className="space-y-3">
<button
onClick={() => {
onClick={async () => {
navigate('/sites/builder?type=wordpress');
dismissGuide();
await dismissGuide();
}}
className="w-full flex items-center justify-between p-4 rounded-lg border-2 border-gray-200 dark:border-gray-700 hover:border-blue-400 dark:hover:border-blue-600 bg-white dark:bg-gray-800 transition-colors group"
>
@@ -101,9 +181,9 @@ export default function WorkflowGuide() {
<ArrowRightIcon className="w-5 h-5 text-gray-400 group-hover:text-blue-600 dark:group-hover:text-blue-400 transition-colors" />
</button>
<button
onClick={() => {
onClick={async () => {
navigate('/sites/builder?type=igny8');
dismissGuide();
await dismissGuide();
}}
className="w-full flex items-center justify-between p-4 rounded-lg border-2 border-gray-200 dark:border-gray-700 hover:border-blue-400 dark:hover:border-blue-600 bg-white dark:bg-gray-800 transition-colors group"
>
@@ -140,9 +220,9 @@ export default function WorkflowGuide() {
</div>
<div className="space-y-3">
<button
onClick={() => {
onClick={async () => {
navigate('/sites?action=integrate&platform=wordpress');
dismissGuide();
await dismissGuide();
}}
className="w-full flex items-center justify-between p-4 rounded-lg border-2 border-gray-200 dark:border-gray-700 hover:border-green-400 dark:hover:border-green-600 bg-white dark:bg-gray-800 transition-colors group"
>
@@ -160,9 +240,9 @@ export default function WorkflowGuide() {
<ArrowRightIcon className="w-5 h-5 text-gray-400 group-hover:text-green-600 dark:group-hover:text-green-400 transition-colors" />
</button>
<button
onClick={() => {
onClick={async () => {
navigate('/sites?action=integrate&platform=custom');
dismissGuide();
await dismissGuide();
}}
className="w-full flex items-center justify-between p-4 rounded-lg border-2 border-gray-200 dark:border-gray-700 hover:border-green-400 dark:hover:border-green-600 bg-white dark:bg-gray-800 transition-colors group"
>
@@ -213,60 +293,261 @@ export default function WorkflowGuide() {
</div>
</div>
{/* Progress Indicator (if user has started) */}
{hasSite && (
<div className="flex items-center gap-2 p-4 rounded-lg bg-blue-50 dark:bg-blue-950/20 border border-blue-200 dark:border-blue-800">
<CheckCircleIcon className="w-5 h-5 text-blue-600 dark:text-blue-400 flex-shrink-0" />
{/* Progress Tracking */}
<div className="mb-6">
<div className="flex items-center justify-between mb-3">
<h3 className="text-lg font-semibold text-gray-900 dark:text-white">
Your Progress
</h3>
<span className="text-sm font-medium text-gray-600 dark:text-gray-400">
{completionPercentage}% Complete
</span>
</div>
<ProgressBar
value={completionPercentage}
className="mb-4"
/>
{/* Milestones */}
<div className="grid grid-cols-2 md:grid-cols-5 gap-3">
{[
{
label: 'Site Created',
completed: hasSite,
count: hasSite ? 1 : 0,
path: '/sites',
icon: <GridIcon className="w-4 h-4" />
},
{
label: 'Keywords',
completed: keywordsCount > 0,
count: keywordsCount,
path: '/planner/keywords',
icon: <ListIcon className="w-4 h-4" />
},
{
label: 'Clusters',
completed: clustersCount > 0,
count: clustersCount,
path: '/planner/clusters',
icon: <GroupIcon className="w-4 h-4" />
},
{
label: 'Content',
completed: contentCount > 0,
count: contentCount,
path: '/writer/content',
icon: <FileTextIcon className="w-4 h-4" />
},
{
label: 'Published',
completed: publishedCount > 0,
count: publishedCount,
path: '/writer/published',
icon: <CheckCircleIcon className="w-4 h-4" />
},
].map((milestone, index) => (
<button
key={index}
onClick={() => {
navigate(milestone.path);
dismissGuide();
}}
className={`flex flex-col items-center gap-2 p-3 rounded-lg border-2 transition-colors ${
milestone.completed
? 'border-green-300 dark:border-green-700 bg-green-50 dark:bg-green-950/20'
: 'border-gray-200 dark:border-gray-700 bg-white dark:bg-gray-800 hover:border-orange-400 dark:hover:border-orange-600'
}`}
>
<div className={`flex items-center justify-center w-8 h-8 rounded-lg ${
milestone.completed
? 'bg-green-500 text-white'
: 'bg-gray-200 dark:bg-gray-700 text-gray-500 dark:text-gray-400'
}`}>
{milestone.completed ? (
<CheckCircleIcon className="w-5 h-5" />
) : (
milestone.icon
)}
</div>
<div className="text-center">
<div className={`text-xs font-semibold ${
milestone.completed
? 'text-green-700 dark:text-green-300'
: 'text-gray-600 dark:text-gray-400'
}`}>
{milestone.label}
</div>
<div className={`text-xs ${
milestone.completed
? 'text-green-600 dark:text-green-400'
: 'text-gray-500 dark:text-gray-500'
}`}>
{milestone.count}
</div>
</div>
</button>
))}
</div>
</div>
{/* Contextual CTA based on progress */}
{completionPercentage === 0 && (
<div className="flex items-center gap-2 p-4 rounded-lg bg-blue-50 dark:bg-blue-950/20 border border-blue-200 dark:border-blue-800 mb-6">
<BoltIcon className="w-5 h-5 text-blue-600 dark:text-blue-400 flex-shrink-0" />
<div className="flex-1">
<div className="font-semibold text-sm text-blue-900 dark:text-blue-100">
Great! You've created your first site
Get started by creating your first site
</div>
<div className="text-xs text-blue-700 dark:text-blue-300 mt-1">
Continue with keyword research and content planning
Choose one of the options above to begin
</div>
</div>
</div>
)}
{hasSite && keywordsCount === 0 && (
<div className="flex items-center gap-2 p-4 rounded-lg bg-purple-50 dark:bg-purple-950/20 border border-purple-200 dark:border-purple-800 mb-6">
<CheckCircleIcon className="w-5 h-5 text-purple-600 dark:text-purple-400 flex-shrink-0" />
<div className="flex-1">
<div className="font-semibold text-sm text-purple-900 dark:text-purple-100">
Great! You've created your first site
</div>
<div className="text-xs text-purple-700 dark:text-purple-300 mt-1">
Now discover keywords to start your content planning
</div>
</div>
<Button
variant="outline"
size="sm"
onClick={() => {
onClick={async () => {
navigate('/planner/keywords');
dismissGuide();
await dismissGuide();
}}
>
Get Started
Discover Keywords
<ArrowRightIcon className="w-4 h-4 ml-2" />
</Button>
</div>
)}
{keywordsCount > 0 && clustersCount === 0 && (
<div className="flex items-center gap-2 p-4 rounded-lg bg-indigo-50 dark:bg-indigo-950/20 border border-indigo-200 dark:border-indigo-800 mb-6">
<CheckCircleIcon className="w-5 h-5 text-indigo-600 dark:text-indigo-400 flex-shrink-0" />
<div className="flex-1">
<div className="font-semibold text-sm text-indigo-900 dark:text-indigo-100">
{keywordsCount} keywords added!
</div>
<div className="text-xs text-indigo-700 dark:text-indigo-300 mt-1">
Group them into clusters to organize your content strategy
</div>
</div>
<Button
variant="outline"
size="sm"
onClick={async () => {
navigate('/planner/clusters');
await dismissGuide();
}}
>
Create Clusters
<ArrowRightIcon className="w-4 h-4 ml-2" />
</Button>
</div>
)}
{clustersCount > 0 && contentCount === 0 && (
<div className="flex items-center gap-2 p-4 rounded-lg bg-orange-50 dark:bg-orange-950/20 border border-orange-200 dark:border-orange-800 mb-6">
<CheckCircleIcon className="w-5 h-5 text-orange-600 dark:text-orange-400 flex-shrink-0" />
<div className="flex-1">
<div className="font-semibold text-sm text-orange-900 dark:text-orange-100">
{clustersCount} clusters ready!
</div>
<div className="text-xs text-orange-700 dark:text-orange-300 mt-1">
Generate content ideas and start writing
</div>
</div>
<Button
variant="outline"
size="sm"
onClick={async () => {
navigate('/writer/tasks');
await dismissGuide();
}}
>
Create Content
<ArrowRightIcon className="w-4 h-4 ml-2" />
</Button>
</div>
)}
{contentCount > 0 && publishedCount === 0 && (
<div className="flex items-center gap-2 p-4 rounded-lg bg-green-50 dark:bg-green-950/20 border border-green-200 dark:border-green-800 mb-6">
<CheckCircleIcon className="w-5 h-5 text-green-600 dark:text-green-400 flex-shrink-0" />
<div className="flex-1">
<div className="font-semibold text-sm text-green-900 dark:text-green-100">
{contentCount} content pieces created!
</div>
<div className="text-xs text-green-700 dark:text-green-300 mt-1">
Review and publish your content to go live
</div>
</div>
<Button
variant="outline"
size="sm"
onClick={async () => {
navigate('/writer/content');
await dismissGuide();
}}
>
Review Content
<ArrowRightIcon className="w-4 h-4 ml-2" />
</Button>
</div>
)}
{/* Footer Actions */}
<div className="flex items-center justify-between pt-4 border-t border-gray-200 dark:border-gray-700">
<p className="text-sm text-gray-600 dark:text-gray-400">
You can always access this guide from the header
</p>
<div className="flex gap-2">
<Button
variant="outline"
size="sm"
onClick={() => {
navigate('/sites');
dismissGuide();
}}
>
View All Sites
</Button>
<Button
variant="primary"
size="sm"
onClick={() => {
navigate('/planner/keywords');
dismissGuide();
}}
>
Start Planning
<ArrowRightIcon className="w-4 h-4 ml-2" />
</Button>
<div className="pt-4 border-t border-gray-200 dark:border-gray-700">
<div className="flex items-center justify-between mb-4">
<div className="flex items-center gap-2">
<Checkbox
checked={dontShowAgain}
onChange={setDontShowAgain}
label="Don't show this again"
id="dont-show-guide"
/>
</div>
<div className="flex gap-2">
<Button
variant="outline"
size="sm"
onClick={async () => {
if (dontShowAgain) {
await dismissGuide();
}
navigate('/sites');
}}
>
View All Sites
</Button>
<Button
variant="primary"
size="sm"
onClick={async () => {
if (dontShowAgain) {
await dismissGuide();
}
navigate('/planner/keywords');
}}
>
Start Planning
<ArrowRightIcon className="w-4 h-4 ml-2" />
</Button>
</div>
</div>
<p className="text-xs text-gray-500 dark:text-gray-400">
You can always access this guide from the orange "Show Guide" button in the header
</p>
</div>
</Card>
</div>

View File

@@ -117,12 +117,19 @@ export default function Home() {
const toast = useToast();
const { activeSite } = useSiteStore();
const { activeSector } = useSectorStore();
const { isGuideDismissed, showGuide } = useOnboardingStore();
const { isGuideDismissed, showGuide, loadFromBackend } = useOnboardingStore();
const [insights, setInsights] = useState<AppInsights | null>(null);
const [loading, setLoading] = useState(true);
const [lastUpdated, setLastUpdated] = useState<Date>(new Date());
// Load guide state from backend on mount
useEffect(() => {
loadFromBackend().catch(() => {
// Silently fail - local state will be used
});
}, [loadFromBackend]);
// Show guide on first visit if not dismissed
useEffect(() => {
if (!isGuideDismissed) {

View File

@@ -2,12 +2,14 @@ import { useState, useEffect, useCallback } from 'react';
import { useNavigate } from 'react-router';
import PageMeta from '../../components/common/PageMeta';
import PageHeader from '../../components/common/PageHeader';
import ModuleNavigationTabs from '../../components/navigation/ModuleNavigationTabs';
import ModuleMetricsFooter, { MetricItem, ProgressMetric } from '../../components/dashboard/ModuleMetricsFooter';
import { linkerApi } from '../../api/linker.api';
import { fetchContent, Content as ContentType } from '../../services/api';
import { useToast } from '../../components/ui/toast/ToastContainer';
import { SourceBadge, ContentSource } from '../../components/content/SourceBadge';
import { LinkResults } from '../../components/linker/LinkResults';
import { PlugInIcon } from '../../icons';
import { PlugInIcon, CheckCircleIcon, FileIcon } from '../../icons';
import { useSectorStore } from '../../store/sectorStore';
import { usePageSizeStore } from '../../store/pageSizeStore';
@@ -103,6 +105,9 @@ export default function LinkerContentList() {
title="Link Content"
description="Add internal links to your content"
/>
<ModuleNavigationTabs tabs={[
{ label: 'Content', path: '/linker/content', icon: <FileIcon /> },
]} />
{loading ? (
<div className="text-center py-12">
@@ -240,6 +245,41 @@ export default function LinkerContentList() {
)}
</div>
)}
{/* Module Metrics Footer */}
<ModuleMetricsFooter
metrics={[
{
title: 'Total Content',
value: totalCount.toLocaleString(),
subtitle: `${content.filter(c => (c.internal_links?.length || 0) > 0).length} with links`,
icon: <FileIcon className="w-5 h-5" />,
accentColor: 'blue',
href: '/linker/content',
},
{
title: 'Links Added',
value: content.reduce((sum, c) => sum + (c.internal_links?.length || 0), 0).toLocaleString(),
subtitle: `${Object.keys(linkResults).length} processed`,
icon: <PlugInIcon className="w-5 h-5" />,
accentColor: 'purple',
},
{
title: 'Avg Links/Content',
value: content.length > 0
? (content.reduce((sum, c) => sum + (c.internal_links?.length || 0), 0) / content.length).toFixed(1)
: '0',
subtitle: `${content.filter(c => c.linker_version && c.linker_version > 0).length} optimized`,
icon: <CheckCircleIcon className="w-5 h-5" />,
accentColor: 'green',
},
]}
progress={{
label: 'Content Linking Progress',
value: totalCount > 0 ? Math.round((content.filter(c => (c.internal_links?.length || 0) > 0).length / totalCount) * 100) : 0,
color: 'primary',
}}
/>
</div>
</>
);

View File

@@ -2,6 +2,8 @@ import { useState, useEffect, useCallback } from 'react';
import { useNavigate } from 'react-router';
import PageMeta from '../../components/common/PageMeta';
import PageHeader from '../../components/common/PageHeader';
import ModuleNavigationTabs from '../../components/navigation/ModuleNavigationTabs';
import ModuleMetricsFooter, { MetricItem, ProgressMetric } from '../../components/dashboard/ModuleMetricsFooter';
import { optimizerApi, EntryPoint } from '../../api/optimizer.api';
import { fetchContent, Content as ContentType } from '../../services/api';
import { useToast } from '../../components/ui/toast/ToastContainer';
@@ -9,7 +11,7 @@ import { SourceBadge, ContentSource } from '../../components/content/SourceBadge
import { SyncStatusBadge, SyncStatus } from '../../components/content/SyncStatusBadge';
import { ContentFilter, FilterState } from '../../components/content/ContentFilter';
import { OptimizationScores } from '../../components/optimizer/OptimizationScores';
import { BoltIcon, CheckCircleIcon } from '../../icons';
import { BoltIcon, CheckCircleIcon, FileIcon } from '../../icons';
import { useSectorStore } from '../../store/sectorStore';
import { usePageSizeStore } from '../../store/pageSizeStore';
@@ -146,15 +148,18 @@ export default function OptimizerContentSelector() {
<PageMeta title="Optimize Content" description="Select and optimize content for SEO and engagement" />
<div className="space-y-6">
<PageHeader
title="Optimize Content"
lastUpdated={new Date()}
badge={{
icon: <BoltIcon />,
color: 'orange',
}}
/>
<ModuleNavigationTabs tabs={[
{ label: 'Content', path: '/optimizer/content', icon: <FileIcon /> },
]} />
<div className="flex items-center justify-between mb-6">
<PageHeader
title="Optimize Content"
lastUpdated={new Date()}
badge={{
icon: <BoltIcon />,
color: 'orange',
}}
/>
<div className="flex items-center gap-4">
<select
value={entryPoint}
@@ -326,6 +331,47 @@ export default function OptimizerContentSelector() {
)}
</div>
)}
{/* Module Metrics Footer */}
<ModuleMetricsFooter
metrics={[
{
title: 'Total Content',
value: totalCount.toLocaleString(),
subtitle: `${filteredContent.length} filtered`,
icon: <FileIcon className="w-5 h-5" />,
accentColor: 'blue',
href: '/optimizer/content',
},
{
title: 'Optimized',
value: content.filter(c => c.optimizer_version && c.optimizer_version > 0).length.toLocaleString(),
subtitle: `${processing.length} processing`,
icon: <BoltIcon className="w-5 h-5" />,
accentColor: 'orange',
},
{
title: 'Avg Score',
value: content.length > 0 && content.some(c => c.optimization_scores?.overall_score)
? (content
.filter(c => c.optimization_scores?.overall_score)
.reduce((sum, c) => sum + (c.optimization_scores?.overall_score || 0), 0) /
content.filter(c => c.optimization_scores?.overall_score).length
).toFixed(1)
: '-',
subtitle: `${content.filter(c => c.optimization_scores?.overall_score && c.optimization_scores.overall_score >= 80).length} high score`,
icon: <CheckCircleIcon className="w-5 h-5" />,
accentColor: 'green',
},
]}
progress={{
label: 'Content Optimization Progress',
value: totalCount > 0
? Math.round((content.filter(c => c.optimizer_version && c.optimizer_version > 0).length / totalCount) * 100)
: 0,
color: 'warning',
}}
/>
</div>
</>
);

View File

@@ -28,6 +28,7 @@ import { useSectorStore } from '../../store/sectorStore';
import { usePageSizeStore } from '../../store/pageSizeStore';
import PageHeader from '../../components/common/PageHeader';
import ModuleNavigationTabs from '../../components/navigation/ModuleNavigationTabs';
import ModuleMetricsFooter, { MetricItem, ProgressMetric } from '../../components/dashboard/ModuleMetricsFooter';
import { getDifficultyLabelFromNumber, getDifficultyRange } from '../../utils/difficulty';
import FormModal from '../../components/common/FormModal';
import ProgressModal from '../../components/common/ProgressModal';
@@ -881,6 +882,40 @@ export default function Keywords() {
}}
/>
{/* Module Metrics Footer */}
<ModuleMetricsFooter
metrics={[
{
title: 'Total Keywords',
value: totalCount.toLocaleString(),
subtitle: `${clusters.length} clusters`,
icon: <ListIcon className="w-5 h-5" />,
accentColor: 'blue',
href: '/planner/keywords',
},
{
title: 'Clustered',
value: keywords.filter(k => k.cluster_id).length.toLocaleString(),
subtitle: `${Math.round((keywords.filter(k => k.cluster_id).length / Math.max(totalCount, 1)) * 100)}% coverage`,
icon: <GroupIcon className="w-5 h-5" />,
accentColor: 'purple',
href: '/planner/clusters',
},
{
title: 'Active',
value: keywords.filter(k => k.status === 'active').length.toLocaleString(),
subtitle: `${keywords.filter(k => k.status === 'pending').length} pending`,
icon: <BoltIcon className="w-5 h-5" />,
accentColor: 'green',
},
]}
progress={{
label: 'Keyword Clustering Progress',
value: totalCount > 0 ? Math.round((keywords.filter(k => k.cluster_id).length / totalCount) * 100) : 0,
color: 'primary',
}}
/>
{/* Create/Edit Modal */}
<FormModal
key={`keyword-form-${isEditMode ? editingKeyword?.id : 'new'}-${formData.seed_keyword_id}-${formData.status}`}

View File

@@ -205,6 +205,38 @@ function BusinessDetailsStepStage1({
metadata?: SiteBuilderMetadata;
selectedSectors?: Array<{ id: number; name: string }>;
}) {
const [userPreferences, setUserPreferences] = useState<{
selectedIndustry?: string;
selectedSectors?: string[];
} | null>(null);
const [loadingPreferences, setLoadingPreferences] = useState(true);
// Load user preferences from account settings
useEffect(() => {
const loadPreferences = async () => {
try {
const { fetchAccountSetting } = await import('../../../../services/api');
const setting = await fetchAccountSetting('user_preferences');
const preferences = setting.config as { selectedIndustry?: string; selectedSectors?: string[] } | undefined;
if (preferences) {
setUserPreferences(preferences);
// Pre-populate industry if available and not already set
if (preferences.selectedIndustry && !data.industry) {
onChange('industry', preferences.selectedIndustry);
}
}
} catch (error: any) {
// 404 means preferences don't exist yet - that's fine
if (error.status !== 404) {
console.warn('Failed to load user preferences:', error);
}
} finally {
setLoadingPreferences(false);
}
};
loadPreferences();
}, []);
return (
<Card variant="surface" padding="lg" className="space-y-6">
<div>
@@ -216,7 +248,12 @@ function BusinessDetailsStepStage1({
Business details
</h3>
<p className="text-sm text-gray-500 dark:text-gray-400 max-w-2xl">
These inputs help the AI understand what were building. You can refine them later in the builder or site settings.
These inputs help the AI understand what we're building. You can refine them later in the builder or site settings.
{userPreferences?.selectedIndustry && (
<span className="block mt-1 text-xs text-green-600 dark:text-green-400">
✓ Using your pre-selected industry and sectors from setup
</span>
)}
</p>
</div>
@@ -268,9 +305,8 @@ function BusinessDetailsStepStage1({
<Input
value={data.industry}
onChange={(e) => onChange('industry', e.target.value)}
placeholder="Supply chain automation"
placeholder={userPreferences?.selectedIndustry || "Supply chain automation"}
/>
</div>
<div className="space-y-3 rounded-2xl border border-gray-100 bg-white p-4 shadow-theme-sm dark:border-white/5 dark:bg-white/[0.02]">
<label className="text-sm font-semibold text-gray-900 dark:text-white">
Hosting preference

View File

@@ -72,6 +72,10 @@ export default function SiteList() {
const [selectedIndustry, setSelectedIndustry] = useState<string>('');
const [selectedSectors, setSelectedSectors] = useState<string[]>([]);
const [isSelectingSectors, setIsSelectingSectors] = useState(false);
const [userPreferences, setUserPreferences] = useState<{
selectedIndustry?: string;
selectedSectors?: string[];
} | null>(null);
// Form state for site creation/editing
const [formData, setFormData] = useState({
@@ -91,8 +95,25 @@ export default function SiteList() {
useEffect(() => {
loadSites();
loadIndustries();
loadUserPreferences();
}, []);
const loadUserPreferences = async () => {
try {
const { fetchAccountSetting } = await import('../../services/api');
const setting = await fetchAccountSetting('user_preferences');
const preferences = setting.config as { selectedIndustry?: string; selectedSectors?: string[] } | undefined;
if (preferences) {
setUserPreferences(preferences);
}
} catch (error: any) {
// 404 means preferences don't exist yet - that's fine
if (error.status !== 404) {
console.warn('Failed to load user preferences:', error);
}
}
};
useEffect(() => {
applyFilters();
}, [sites, searchTerm, siteTypeFilter, hostingTypeFilter, statusFilter, integrationFilter]);
@@ -132,7 +153,26 @@ export default function SiteList() {
const loadIndustries = async () => {
try {
const response = await fetchIndustries();
setIndustries(response.industries || []);
let allIndustries = response.industries || [];
// Filter to show only user's pre-selected industries/sectors from account preferences
try {
const { fetchAccountSetting } = await import('../../services/api');
const setting = await fetchAccountSetting('user_preferences');
const preferences = setting.config as { selectedIndustry?: string; selectedSectors?: string[] } | undefined;
if (preferences?.selectedIndustry) {
// Filter industries to only show the user's pre-selected industry
allIndustries = allIndustries.filter(i => i.slug === preferences.selectedIndustry);
}
} catch (error: any) {
// 404 means preferences don't exist yet - show all industries
if (error.status !== 404) {
console.warn('Failed to load user preferences for filtering:', error);
}
}
setIndustries(allIndustries);
} catch (error: any) {
console.error('Failed to load industries:', error);
}
@@ -387,7 +427,14 @@ export default function SiteList() {
const getIndustrySectors = () => {
if (!selectedIndustry) return [];
const industry = industries.find(i => i.slug === selectedIndustry);
return industry?.sectors || [];
let sectors = industry?.sectors || [];
// Filter to show only user's pre-selected sectors from account preferences
if (userPreferences?.selectedSectors && userPreferences.selectedSectors.length > 0) {
sectors = sectors.filter(s => userPreferences.selectedSectors!.includes(s.slug));
}
return sectors;
};
const clearFilters = () => {

View File

@@ -22,6 +22,7 @@ import ProgressModal from '../../components/common/ProgressModal';
import { useProgressModal } from '../../hooks/useProgressModal';
import PageHeader from '../../components/common/PageHeader';
import ModuleNavigationTabs from '../../components/navigation/ModuleNavigationTabs';
import ModuleMetricsFooter, { MetricItem, ProgressMetric } from '../../components/dashboard/ModuleMetricsFooter';
export default function Content() {
const toast = useToast();
@@ -259,6 +260,39 @@ export default function Content() {
getItemDisplayName={(row: ContentType) => row.meta_title || row.title || `Content #${row.id}`}
/>
{/* Module Metrics Footer */}
<ModuleMetricsFooter
metrics={[
{
title: 'Total Content',
value: totalCount.toLocaleString(),
subtitle: `${content.filter(c => c.status === 'published').length} published`,
icon: <FileIcon className="w-5 h-5" />,
accentColor: 'green',
href: '/writer/content',
},
{
title: 'Draft',
value: content.filter(c => c.status === 'draft').length.toLocaleString(),
subtitle: `${content.filter(c => c.status === 'review').length} in review`,
icon: <TaskIcon className="w-5 h-5" />,
accentColor: 'blue',
},
{
title: 'Synced',
value: content.filter(c => c.sync_status === 'synced').length.toLocaleString(),
subtitle: `${content.filter(c => c.sync_status === 'pending').length} pending`,
icon: <CheckCircleIcon className="w-5 h-5" />,
accentColor: 'purple',
},
]}
progress={{
label: 'Content Publishing Progress',
value: totalCount > 0 ? Math.round((content.filter(c => c.status === 'published').length / totalCount) * 100) : 0,
color: 'success',
}}
/>
{/* Progress Modal for AI Functions */}
<ProgressModal
isOpen={progressModal.isOpen}

View File

@@ -1580,6 +1580,50 @@ export async function deleteAccountSetting(key: string): Promise<void> {
}
}
// User Settings API functions
export interface UserSetting {
id: number;
key: string;
value: Record<string, any>;
created_at: string;
updated_at: string;
}
export interface UserSettingsResponse {
count: number;
next: string | null;
previous: string | null;
results: UserSetting[];
}
export async function fetchUserSettings(): Promise<UserSettingsResponse> {
return fetchAPI('/v1/system/settings/user/');
}
export async function fetchUserSetting(key: string): Promise<UserSetting> {
return fetchAPI(`/v1/system/settings/user/${key}/`);
}
export async function createUserSetting(data: { key: string; value: Record<string, any> }): Promise<UserSetting> {
return fetchAPI('/v1/system/settings/user/', {
method: 'POST',
body: JSON.stringify(data),
});
}
export async function updateUserSetting(key: string, data: { value: Record<string, any> }): Promise<UserSetting> {
return fetchAPI(`/v1/system/settings/user/${key}/`, {
method: 'PUT',
body: JSON.stringify(data),
});
}
export async function deleteUserSetting(key: string): Promise<void> {
await fetchAPI(`/v1/system/settings/user/${key}/`, {
method: 'DELETE',
});
}
// Module Settings
export interface ModuleEnableSettings {
id: number;

View File

@@ -1,28 +1,84 @@
/**
* Onboarding Store (Zustand)
* Manages welcome/guide screen state and dismissal
* Syncs with backend UserSettings for cross-device persistence
*/
import { create } from 'zustand';
import { persist } from 'zustand/middleware';
import { fetchUserSetting, createUserSetting, updateUserSetting } from '../services/api';
interface OnboardingState {
isGuideDismissed: boolean;
isGuideVisible: boolean;
isLoading: boolean;
lastSyncedAt: Date | null;
// Actions
dismissGuide: () => void;
dismissGuide: () => Promise<void>;
showGuide: () => void;
toggleGuide: () => void;
loadFromBackend: () => Promise<void>;
syncToBackend: (dismissed: boolean) => Promise<void>;
}
const GUIDE_SETTING_KEY = 'workflow_guide_dismissed';
export const useOnboardingStore = create<OnboardingState>()(
persist<OnboardingState>(
(set) => ({
(set, get) => ({
isGuideDismissed: false,
isGuideVisible: false,
isLoading: false,
lastSyncedAt: null,
loadFromBackend: async () => {
set({ isLoading: true });
try {
const setting = await fetchUserSetting(GUIDE_SETTING_KEY);
const dismissed = setting.value?.dismissed === true;
set({
isGuideDismissed: dismissed,
isGuideVisible: !dismissed,
lastSyncedAt: new Date(),
isLoading: false
});
} catch (error: any) {
// 404 means setting doesn't exist yet - that's fine, use local state
if (error.status !== 404) {
console.warn('Failed to load guide dismissal from backend:', error);
}
set({ isLoading: false });
}
},
syncToBackend: async (dismissed: boolean) => {
try {
const data = { value: { dismissed, dismissed_at: new Date().toISOString() } };
try {
await updateUserSetting(GUIDE_SETTING_KEY, data);
} catch (error: any) {
// If setting doesn't exist, create it
if (error.status === 404) {
await createUserSetting({ key: GUIDE_SETTING_KEY, value: data.value });
} else {
throw error;
}
}
set({ lastSyncedAt: new Date() });
} catch (error) {
console.warn('Failed to sync guide dismissal to backend:', error);
// Don't throw - local state is still updated
}
},
dismissGuide: async () => {
set({ isGuideDismissed: true, isGuideVisible: false });
// Sync to backend asynchronously
await get().syncToBackend(true);
},
dismissGuide: () => set({ isGuideDismissed: true, isGuideVisible: false }),
showGuide: () => set({ isGuideVisible: true }),
toggleGuide: () => set((state) => ({ isGuideVisible: !state.isGuideVisible })),
}),
{