This commit is contained in:
2026-01-21 08:21:19 +01:00
parent 4733e1a1cc
commit fd6e7c44e1
46 changed files with 3165 additions and 456 deletions

View File

@@ -87,6 +87,7 @@ export default function MonitorsPage() {
const { plan, maxMonitors, minFrequency, canUseKeywords } = usePlan()
const [showAddForm, setShowAddForm] = useState(false)
const [checkingId, setCheckingId] = useState<string | null>(null)
const [checkingSeoId, setCheckingSeoId] = useState<string | null>(null)
const [editingId, setEditingId] = useState<string | null>(null)
const [viewMode, setViewMode] = useState<'grid' | 'list'>('grid')
const [filterStatus, setFilterStatus] = useState<'all' | 'active' | 'error' | 'paused'>('all')
@@ -102,6 +103,8 @@ export default function MonitorsPage() {
threshold?: number
caseSensitive?: boolean
}>,
seoKeywords: [] as string[],
seoInterval: 'off',
})
const [showVisualSelector, setShowVisualSelector] = useState(false)
const [showTemplates, setShowTemplates] = useState(false)
@@ -131,6 +134,10 @@ export default function MonitorsPage() {
if (newMonitor.keywordRules.length > 0) {
payload.keywordRules = newMonitor.keywordRules
}
if (newMonitor.seoKeywords.length > 0) {
payload.seoKeywords = newMonitor.seoKeywords
payload.seoInterval = newMonitor.seoInterval
}
if (editingId) {
await monitorAPI.update(editingId, payload)
@@ -146,7 +153,9 @@ export default function MonitorsPage() {
frequency: 60,
ignoreSelector: '',
selectedPreset: '',
keywordRules: []
keywordRules: [],
seoKeywords: [],
seoInterval: 'off',
})
setShowAddForm(false)
setEditingId(null)
@@ -179,7 +188,9 @@ export default function MonitorsPage() {
frequency: monitor.frequency,
ignoreSelector,
selectedPreset,
keywordRules: monitor.keywordRules || []
keywordRules: monitor.keywordRules || [],
seoKeywords: monitor.seoKeywords || [],
seoInterval: monitor.seoInterval || 'off',
})
setEditingId(monitor.id)
setShowAddForm(true)
@@ -194,7 +205,9 @@ export default function MonitorsPage() {
frequency: 60,
ignoreSelector: '',
selectedPreset: '',
keywordRules: []
keywordRules: [],
seoKeywords: [],
seoInterval: 'off',
})
}
@@ -223,38 +236,55 @@ export default function MonitorsPage() {
frequency: monitorData.frequency,
ignoreSelector,
selectedPreset,
keywordRules: monitorData.keywordRules as any[]
keywordRules: monitorData.keywordRules as any[],
seoKeywords: [],
seoInterval: 'off',
})
setShowTemplates(false)
setShowAddForm(true)
}
const handleCheckNow = async (id: string) => {
// Prevent multiple simultaneous checks
if (checkingId !== null) return
const handleCheckNow = async (id: string, type: 'content' | 'seo' = 'content') => {
// Prevent multiple simultaneous checks of the same type
if (type === 'seo') {
if (checkingSeoId !== null) return
setCheckingSeoId(id)
} else {
if (checkingId !== null) return
setCheckingId(id)
}
setCheckingId(id)
try {
const result = await monitorAPI.check(id)
if (result.snapshot?.errorMessage) {
toast.error(`Check failed: ${result.snapshot.errorMessage}`)
} else if (result.snapshot?.changed) {
toast.success('Changes detected!', {
action: {
label: 'View',
onClick: () => router.push(`/monitors/${id}`)
}
})
const result = await monitorAPI.check(id, type)
if (type === 'seo') {
toast.success('SEO Ranking check completed')
// For SEO check, we might want to refresh rankings specifically if we had a way
} else {
toast.info('No changes detected')
if (result.snapshot?.errorMessage) {
toast.error(`Check failed: ${result.snapshot.errorMessage}`)
} else if (result.snapshot?.changed) {
toast.success('Changes detected!', {
action: {
label: 'View',
onClick: () => router.push(`/monitors/${id}`)
}
})
} else {
toast.info('No changes detected')
}
}
refetch()
} catch (err: any) {
console.error('Failed to trigger check:', err)
toast.error(err.response?.data?.message || 'Failed to check monitor')
toast.error(err.response?.data?.message || `Failed to check ${type === 'seo' ? 'SEO' : 'monitor'}`)
} finally {
setCheckingId(null)
if (type === 'seo') {
setCheckingSeoId(null)
} else {
setCheckingId(null)
}
}
}
@@ -646,6 +676,80 @@ export default function MonitorsPage() {
)}
</div>
{/* SEO Keywords Section */}
<div className="space-y-3 rounded-lg border border-purple-500/20 bg-purple-500/5 p-4">
<div className="flex items-center justify-between">
<div>
<h4 className="font-semibold text-sm">SEO Tracking</h4>
<p className="text-xs text-muted-foreground">Track Google ranking for specific keywords</p>
</div>
<div className="flex gap-2">
<Select
label=""
value={newMonitor.seoInterval}
onChange={(e) => setNewMonitor({ ...newMonitor, seoInterval: e.target.value })}
options={[
{ value: 'off', label: 'Manual Check Only' },
{ value: 'daily', label: 'Check Daily' },
{ value: '2d', label: 'Every 2 Days' },
{ value: 'weekly', label: 'Check Weekly' },
{ value: 'monthly', label: 'Check Monthly' }
]}
className="w-40"
/>
<Button
type="button"
size="sm"
variant="outline"
className="border-purple-200 hover:bg-purple-50 hover:text-purple-700"
onClick={() => {
setNewMonitor({
...newMonitor,
seoKeywords: [...newMonitor.seoKeywords, '']
})
}}
>
+ Add Keyword
</Button>
</div>
</div>
{newMonitor.seoKeywords.length === 0 ? (
<p className="text-xs text-muted-foreground italic">No SEO keywords configured.</p>
) : (
<div className="space-y-2">
{newMonitor.seoKeywords.map((keyword, index) => (
<div key={index} className="flex gap-2">
<Input
type="text"
value={keyword}
onChange={(e) => {
const updated = [...newMonitor.seoKeywords]
updated[index] = e.target.value
setNewMonitor({ ...newMonitor, seoKeywords: updated })
}}
placeholder="e.g. best coffee in austin"
className="flex-1"
/>
<button
type="button"
onClick={() => {
const updated = newMonitor.seoKeywords.filter((_, i) => i !== index)
setNewMonitor({ ...newMonitor, seoKeywords: updated })
}}
className="rounded p-2 text-red-500 hover:bg-red-50"
title="Remove"
>
<svg className="h-4 w-4" fill="none" viewBox="0 0 24 24" stroke="currentColor">
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M6 18L18 6M6 6l12 12" />
</svg>
</button>
</div>
))}
</div>
)}
</div>
<div className="flex gap-3 pt-2">
<Button type="submit">
{editingId ? 'Save Changes' : 'Create Monitor'}
@@ -714,16 +818,41 @@ export default function MonitorsPage() {
</div>
{/* Stats Row */}
{/* SEO Status */}
{monitor.seoInterval && monitor.seoInterval !== 'off' && (
<div className="mb-4 grid grid-cols-2 gap-2 rounded-lg bg-purple-50 p-3 text-center text-xs border border-purple-100">
<div>
<p className="font-semibold text-purple-700">{monitor.seoInterval === '2d' ? 'Every 2 days' : monitor.seoInterval}</p>
<p className="text-purple-600/80">SEO Check</p>
</div>
<div>
{monitor.lastSeoCheckAt ? (
<>
<p className="font-semibold text-purple-700">
{new Date(monitor.lastSeoCheckAt).toLocaleDateString()}
</p>
<p className="text-purple-600/80">Last SEO</p>
</>
) : (
<>
<p className="font-semibold text-purple-700">-</p>
<p className="text-purple-600/80">Last SEO</p>
</>
)}
</div>
</div>
)}
<div className="mb-4 grid grid-cols-2 gap-2 rounded-lg bg-muted/30 p-3 text-center text-xs">
<div>
<p className="font-semibold text-foreground">{monitor.frequency}m</p>
<p className="text-muted-foreground">Frequency</p>
</div>
<div>
{monitor.last_changed_at ? (
{monitor.lastChangedAt ? (
<>
<p className="font-semibold text-foreground">
{new Date(monitor.last_changed_at).toLocaleDateString()}
{new Date(monitor.lastChangedAt).toLocaleDateString()}
</p>
<p className="text-muted-foreground">Last Change</p>
</>
@@ -737,9 +866,9 @@ export default function MonitorsPage() {
</div>
{/* Last Checked */}
{monitor.last_checked_at ? (
{monitor.lastCheckedAt ? (
<p className="mb-4 text-xs text-muted-foreground">
Last checked: {new Date(monitor.last_checked_at).toLocaleString()}
Last checked: {new Date(monitor.lastCheckedAt).toLocaleString()}
</p>
) : (
<p className="mb-4 text-xs text-muted-foreground">
@@ -747,9 +876,26 @@ export default function MonitorsPage() {
</p>
)}
{/* SEO Rankings */}
{monitor.latestRankings && monitor.latestRankings.length > 0 && (
<div className="mb-4 space-y-1">
<p className="text-[10px] font-medium text-purple-600 uppercase tracking-wider">Top Rankings</p>
<div className="grid grid-cols-1 gap-1">
{monitor.latestRankings.slice(0, 3).map((r: any, idx: number) => (
<div key={idx} className="flex items-center justify-between text-[11px] bg-purple-50/50 rounded px-2 py-1 border border-purple-100/50">
<span className="truncate max-w-[140px] text-purple-900 font-medium">{r.keyword}</span>
<Badge variant="outline" className="bg-white border-purple-200 text-purple-700 h-4 px-1 text-[9px] leading-none min-w-[30px] justify-center">
#{r.rank || '100+'}
</Badge>
</div>
))}
</div>
</div>
)}
{/* Change Summary */}
{monitor.recentSnapshots && monitor.recentSnapshots[0]?.summary && (
<p className="mb-4 text-xs text-muted-foreground italic border-l-2 border-blue-400 pl-2">
<p className="mb-4 text-xs text-muted-foreground italic border-l-2 border-primary/40 pl-2 line-clamp-2">
"{monitor.recentSnapshots[0].summary}"
</p>
)}
@@ -784,12 +930,24 @@ export default function MonitorsPage() {
variant="outline"
size="sm"
className="flex-1"
onClick={() => handleCheckNow(monitor.id)}
onClick={() => handleCheckNow(monitor.id, 'content')}
loading={checkingId === monitor.id}
disabled={checkingId !== null}
>
{checkingId === monitor.id ? 'Checking...' : 'Check Now'}
</Button>
{monitor.seoKeywords && monitor.seoKeywords.length > 0 && (
<Button
variant="outline"
size="sm"
className="flex-1 border-purple-200 text-purple-700 hover:bg-purple-50"
onClick={() => handleCheckNow(monitor.id, 'seo')}
loading={checkingSeoId === monitor.id}
disabled={checkingSeoId !== null}
>
{checkingSeoId === monitor.id ? 'SEO Checking...' : 'Check SEO'}
</Button>
)}
<Button
variant="ghost"
size="sm"
@@ -887,7 +1045,7 @@ export default function MonitorsPage() {
)}
<div className="text-center">
<p className="font-medium text-foreground">
{monitor.last_changed_at ? new Date(monitor.last_changed_at).toLocaleDateString() : '-'}
{monitor.lastChangedAt ? new Date(monitor.lastChangedAt).toLocaleDateString() : '-'}
</p>
<p className="text-xs">Last Change</p>
</div>
@@ -898,12 +1056,25 @@ export default function MonitorsPage() {
<Button
variant="outline"
size="sm"
onClick={() => handleCheckNow(monitor.id)}
onClick={() => handleCheckNow(monitor.id, 'content')}
loading={checkingId === monitor.id}
disabled={checkingId !== null}
>
{checkingId === monitor.id ? 'Checking...' : 'Check Now'}
</Button>
{monitor.seoKeywords && monitor.seoKeywords.length > 0 && (
<Button
variant="outline"
size="sm"
className="border-purple-200 text-purple-700 hover:bg-purple-50"
onClick={() => handleCheckNow(monitor.id, 'seo')}
loading={checkingSeoId === monitor.id}
disabled={checkingSeoId !== null}
title="Check SEO Rankings"
>
{checkingSeoId === monitor.id ? 'Checking SEO...' : 'SEO'}
</Button>
)}
<Button variant="ghost" size="sm" onClick={() => handleEdit(monitor)}>
Edit
</Button>