Files
cannaiq/cannaiq/src/pages/admin/seo/PagesTab.tsx
Kelly 249d3c1b7f fix: Build args format for version info + schema-tolerant routes
CI/CD:
- Fix build_args format in woodpecker CI (comma-separated, not YAML list)
- This fixes "unknown" SHA/version showing on remote deployments

Backend schema-tolerant fixes (graceful fallbacks when tables missing):
- users.ts: Check which columns exist before querying
- worker-registry.ts: Return empty result if table doesn't exist
- task-service.ts: Add tableExists() helper, handle missing tables/views
- proxies.ts: Return totalProxies in test-all response

Frontend fixes:
- Proxies: Use total from response for accurate progress display
- SEO PagesTab: Dim Generate button when no AI provider active

🤖 Generated with [Claude Code](https://claude.com/claude-code)

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
2025-12-10 09:53:21 -07:00

241 lines
10 KiB
TypeScript

/**
* PagesTab - SEO Pages List
*
* Lists all SEO pages with filtering and state metrics.
* Token-safe: Component is small and focused.
*/
import { useState, useEffect } from 'react';
import { api } from '../../../lib/api';
import { Building2, Tag, Globe, Target, FileText, RefreshCw, Sparkles, Loader2, AlertCircle } from 'lucide-react';
interface SeoPage {
id: number;
type: string;
slug: string;
pageKey: string;
primaryKeyword: string | null;
status: string;
lastGeneratedAt: string | null;
lastReviewedAt: string | null;
metrics?: {
dispensaryCount: number;
productCount: number;
brandCount: number;
};
}
const TYPE_ICONS: Record<string, React.ReactNode> = {
state: <Globe className="w-4 h-4" />,
brand: <Tag className="w-4 h-4" />,
competitor_alternative: <Target className="w-4 h-4" />,
high_intent: <FileText className="w-4 h-4" />,
insight_post: <FileText className="w-4 h-4" />
};
const STATUS_COLORS: Record<string, string> = {
live: 'bg-green-100 text-green-800',
pending_generation: 'bg-yellow-100 text-yellow-800',
draft: 'bg-gray-100 text-gray-800',
stale: 'bg-red-100 text-red-800'
};
export function PagesTab() {
const [pages, setPages] = useState<SeoPage[]>([]);
const [loading, setLoading] = useState(true);
const [typeFilter, setTypeFilter] = useState('all');
const [search, setSearch] = useState('');
const [syncing, setSyncing] = useState(false);
const [generatingId, setGeneratingId] = useState<number | null>(null);
const [hasActiveAiProvider, setHasActiveAiProvider] = useState<boolean | null>(null);
useEffect(() => {
loadPages();
checkAiProvider();
}, [typeFilter, search]);
async function checkAiProvider() {
try {
const data = await api.getSettings();
const settings = data.settings || [];
// Check if either Anthropic or OpenAI is configured with an API key AND enabled
const anthropicKey = settings.find((s: any) => s.key === 'anthropic_api_key')?.value;
const anthropicEnabled = settings.find((s: any) => s.key === 'anthropic_enabled')?.value === 'true';
const openaiKey = settings.find((s: any) => s.key === 'openai_api_key')?.value;
const openaiEnabled = settings.find((s: any) => s.key === 'openai_enabled')?.value === 'true';
const hasProvider = (anthropicKey && anthropicEnabled) || (openaiKey && openaiEnabled);
setHasActiveAiProvider(!!hasProvider);
} catch (error) {
console.error('Failed to check AI provider:', error);
setHasActiveAiProvider(false);
}
}
async function loadPages() {
setLoading(true);
try {
const result = await api.getSeoPages({
type: typeFilter !== 'all' ? typeFilter : undefined,
search: search || undefined
});
setPages(result.pages);
} catch (error) {
console.error('Failed to load SEO pages:', error);
} finally {
setLoading(false);
}
}
async function handleSync() {
setSyncing(true);
try {
await api.syncStatePages();
await loadPages();
} catch (error) {
console.error('Failed to sync state pages:', error);
} finally {
setSyncing(false);
}
}
async function handleGenerate(pageId: number) {
setGeneratingId(pageId);
try {
await api.generateSeoPage(pageId);
await loadPages();
} catch (error) {
console.error('Failed to generate SEO page:', error);
} finally {
setGeneratingId(null);
}
}
return (
<div className="space-y-4">
{/* Filters - responsive wrap */}
<div className="flex flex-col sm:flex-row sm:items-center sm:justify-between gap-3">
<div className="flex flex-wrap items-center gap-2">
<select
value={typeFilter}
onChange={(e) => setTypeFilter(e.target.value)}
className="px-3 py-2 border rounded-lg text-sm"
>
<option value="all">All Types</option>
<option value="state">State</option>
<option value="brand">Brand</option>
<option value="competitor_alternative">Competitor</option>
<option value="high_intent">High Intent</option>
<option value="insight_post">Insight</option>
</select>
<input
type="text"
placeholder="Search..."
value={search}
onChange={(e) => setSearch(e.target.value)}
className="px-3 py-2 border rounded-lg text-sm w-full sm:w-48 lg:w-64"
/>
</div>
<button
onClick={handleSync}
disabled={syncing}
className="flex items-center justify-center gap-2 px-4 py-2 bg-emerald-600 text-white rounded-lg text-sm hover:bg-emerald-700 disabled:opacity-50 whitespace-nowrap"
>
<RefreshCw className={`w-4 h-4 ${syncing ? 'animate-spin' : ''}`} />
<span className="hidden sm:inline">Sync State Pages</span>
<span className="sm:hidden">Sync</span>
</button>
</div>
{/* Table - responsive with horizontal scroll */}
<div className="bg-white rounded-lg border overflow-hidden">
<div className="overflow-x-auto">
<table className="min-w-full divide-y divide-gray-200">
<thead className="bg-gray-50">
<tr>
<th className="px-3 sm:px-4 py-3 text-left text-xs font-medium text-gray-500 uppercase">Type</th>
<th className="px-3 sm:px-4 py-3 text-left text-xs font-medium text-gray-500 uppercase">Slug</th>
<th className="hidden md:table-cell px-4 py-3 text-left text-xs font-medium text-gray-500 uppercase">Keyword</th>
<th className="px-3 sm:px-4 py-3 text-left text-xs font-medium text-gray-500 uppercase">Status</th>
<th className="hidden lg:table-cell px-4 py-3 text-left text-xs font-medium text-gray-500 uppercase">Metrics</th>
<th className="hidden sm:table-cell px-4 py-3 text-left text-xs font-medium text-gray-500 uppercase">Generated</th>
<th className="px-3 sm:px-4 py-3 text-left text-xs font-medium text-gray-500 uppercase">Actions</th>
</tr>
</thead>
<tbody className="divide-y divide-gray-200">
{loading ? (
<tr>
<td colSpan={7} className="px-4 py-8 text-center text-gray-500">Loading...</td>
</tr>
) : pages.length === 0 ? (
<tr>
<td colSpan={7} className="px-4 py-8 text-center text-gray-500">
No pages found. Click "Sync State Pages" to create state pages.
</td>
</tr>
) : (
pages.map((page) => (
<tr key={page.id} className="hover:bg-gray-50">
<td className="px-3 sm:px-4 py-3">
<div className="flex items-center gap-1.5 sm:gap-2">
{TYPE_ICONS[page.type] || <FileText className="w-4 h-4" />}
<span className="text-xs sm:text-sm capitalize whitespace-nowrap">{page.type.replace('_', ' ')}</span>
</div>
</td>
<td className="px-3 sm:px-4 py-3">
<code className="text-xs sm:text-sm text-gray-700 break-all">{page.slug}</code>
</td>
<td className="hidden md:table-cell px-4 py-3 text-sm text-gray-600">
{page.primaryKeyword || '-'}
</td>
<td className="px-3 sm:px-4 py-3">
<span className={`px-2 py-1 rounded-full text-xs whitespace-nowrap ${STATUS_COLORS[page.status] || 'bg-gray-100'}`}>
{page.status.replace('_', ' ')}
</span>
</td>
<td className="hidden lg:table-cell px-4 py-3 text-sm">
{page.metrics ? (
<div className="flex items-center gap-3 text-gray-600 text-xs">
<span title="Dispensaries"><Building2 className="w-3 h-3 inline" /> {page.metrics.dispensaryCount}</span>
<span title="Products">{page.metrics.productCount.toLocaleString()}</span>
<span title="Brands">{page.metrics.brandCount}</span>
</div>
) : '-'}
</td>
<td className="hidden sm:table-cell px-4 py-3 text-sm text-gray-500 whitespace-nowrap">
{page.lastGeneratedAt ? new Date(page.lastGeneratedAt).toLocaleDateString() : 'Never'}
</td>
<td className="px-3 sm:px-4 py-3">
<button
onClick={() => handleGenerate(page.id)}
disabled={generatingId === page.id || hasActiveAiProvider === false}
className={`flex items-center gap-1 px-2 sm:px-3 py-1.5 text-xs font-medium rounded-lg disabled:cursor-not-allowed ${
hasActiveAiProvider === false
? 'bg-gray-100 text-gray-400'
: 'bg-purple-50 text-purple-700 hover:bg-purple-100 disabled:opacity-50'
}`}
title={hasActiveAiProvider === false ? 'No Active AI Provider' : 'Generate content'}
>
{generatingId === page.id ? (
<Loader2 className="w-3.5 h-3.5 animate-spin" />
) : hasActiveAiProvider === false ? (
<AlertCircle className="w-3.5 h-3.5" />
) : (
<Sparkles className="w-3.5 h-3.5" />
)}
<span className="hidden sm:inline">{generatingId === page.id ? 'Generating...' : 'Generate'}</span>
</button>
</td>
</tr>
))
)}
</tbody>
</table>
</div>
</div>
</div>
);
}
export default PagesTab;