Release Candidate: Production Release with Multi-Tenant & Onboarding Enhancements
This commit is contained in:
@@ -1,5 +1,5 @@
|
||||
import React, { useState, useEffect, useRef } from 'react';
|
||||
import { Badge, IconButton, Menu, MenuItem, Typography, Box, Divider, Chip, Tooltip, List, ListItem, ListItemText, ListItemIcon, Button } from '@mui/material';
|
||||
import React, { useState, useEffect, useRef, useCallback } from 'react';
|
||||
import { Badge, IconButton, Menu, Typography, Box, Divider, Chip, Tooltip, List, ListItem, ListItemText, ListItemIcon, Button } from '@mui/material';
|
||||
import { Notifications as NotificationsIcon, NotificationsActive as NotificationsActiveIcon } from '@mui/icons-material';
|
||||
import { Warning as WarningIcon, Error as ErrorIcon, Info as InfoIcon, CheckCircle as CheckCircleIcon } from '@mui/icons-material';
|
||||
import { billingService } from '../../services/billingService';
|
||||
@@ -54,7 +54,7 @@ const AlertsBadge: React.FC<AlertsBadgeProps> = ({ colorMode = 'light' }) => {
|
||||
|
||||
const getSchedulerStorageKey = (uid: string) => `scheduler_alerts_dismissed_${uid}`;
|
||||
|
||||
const loadSchedulerDismissed = (uid: string) => {
|
||||
const loadSchedulerDismissed = useCallback((uid: string) => {
|
||||
if (!uid) return new Set<string>();
|
||||
try {
|
||||
const stored = localStorage.getItem(getSchedulerStorageKey(uid));
|
||||
@@ -67,7 +67,7 @@ const AlertsBadge: React.FC<AlertsBadgeProps> = ({ colorMode = 'light' }) => {
|
||||
} catch {
|
||||
return new Set<string>();
|
||||
}
|
||||
};
|
||||
}, []);
|
||||
|
||||
const persistSchedulerDismissed = (uid: string, dismissed: Set<string>) => {
|
||||
if (!uid) return;
|
||||
@@ -89,7 +89,7 @@ const AlertsBadge: React.FC<AlertsBadgeProps> = ({ colorMode = 'light' }) => {
|
||||
useEffect(() => {
|
||||
if (!userId) return;
|
||||
schedulerDismissedRef.current = loadSchedulerDismissed(userId);
|
||||
}, [userId]);
|
||||
}, [userId, loadSchedulerDismissed]);
|
||||
|
||||
// Fetch all alerts
|
||||
const rebuildGroups = (alertList: Alert[]) => {
|
||||
@@ -208,13 +208,18 @@ const AlertsBadge: React.FC<AlertsBadgeProps> = ({ colorMode = 'light' }) => {
|
||||
useEffect(() => {
|
||||
if (!userId) return;
|
||||
|
||||
fetchAlerts();
|
||||
// Delay initial fetch slightly to ensure auth token getter is installed
|
||||
const timeoutId = setTimeout(() => {
|
||||
fetchAlerts();
|
||||
}, 1000);
|
||||
|
||||
// Poll every 60 seconds
|
||||
intervalRef.current = setInterval(() => {
|
||||
fetchAlerts();
|
||||
}, 60000);
|
||||
|
||||
return () => {
|
||||
clearTimeout(timeoutId);
|
||||
if (intervalRef.current) {
|
||||
clearInterval(intervalRef.current);
|
||||
}
|
||||
|
||||
@@ -29,6 +29,7 @@ import {
|
||||
} from '@mui/icons-material';
|
||||
import { useContentAssets, ContentAsset } from '../../hooks/useContentAssets';
|
||||
import { fetchMediaBlobUrl } from '../../utils/fetchMediaBlobUrl';
|
||||
import { getLatestBrandAvatar, AssetResponse as BrandAvatarResponse } from '../../api/brandAssets';
|
||||
|
||||
export interface AssetLibraryImageModalProps {
|
||||
open: boolean;
|
||||
@@ -37,6 +38,7 @@ export interface AssetLibraryImageModalProps {
|
||||
title?: string;
|
||||
sourceModule?: string | string[]; // Optional filter by source module(s) (e.g., 'youtube_creator', 'podcast_maker', or ['youtube_creator', 'podcast_maker'])
|
||||
allowFavoritesOnly?: boolean; // Optional favorites-only filter toggle
|
||||
showBrandAvatarShortcut?: boolean; // Optional shortcut to show latest onboarding brand avatar
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -50,6 +52,7 @@ export const AssetLibraryImageModal: React.FC<AssetLibraryImageModalProps> = ({
|
||||
title = 'Select Image from Asset Library',
|
||||
sourceModule,
|
||||
allowFavoritesOnly = false,
|
||||
showBrandAvatarShortcut = false,
|
||||
}) => {
|
||||
const [searchQuery, setSearchQuery] = useState('');
|
||||
const [selectedAsset, setSelectedAsset] = useState<ContentAsset | null>(null);
|
||||
@@ -57,6 +60,9 @@ export const AssetLibraryImageModal: React.FC<AssetLibraryImageModalProps> = ({
|
||||
const [favoritesOnly, setFavoritesOnly] = useState(false);
|
||||
const [imageBlobUrls, setImageBlobUrls] = useState<Map<number, string>>(new Map());
|
||||
const [loadingImages, setLoadingImages] = useState<Set<number>>(new Set());
|
||||
const [brandAvatar, setBrandAvatar] = useState<BrandAvatarResponse | null>(null);
|
||||
const [brandAvatarLoading, setBrandAvatarLoading] = useState(false);
|
||||
const [brandAvatarError, setBrandAvatarError] = useState<string | null>(null);
|
||||
const pageSize = 24;
|
||||
|
||||
// Filter for images only
|
||||
@@ -71,6 +77,48 @@ export const AssetLibraryImageModal: React.FC<AssetLibraryImageModalProps> = ({
|
||||
|
||||
const { assets, loading, error, total, toggleFavorite, refetch } = useContentAssets(filters);
|
||||
|
||||
// Load latest brand avatar generated in onboarding (Step 4)
|
||||
useEffect(() => {
|
||||
if (!open || !showBrandAvatarShortcut) {
|
||||
setBrandAvatar(null);
|
||||
setBrandAvatarError(null);
|
||||
setBrandAvatarLoading(false);
|
||||
return;
|
||||
}
|
||||
|
||||
let cancelled = false;
|
||||
|
||||
const loadBrandAvatar = async () => {
|
||||
try {
|
||||
setBrandAvatarLoading(true);
|
||||
setBrandAvatarError(null);
|
||||
const response = await getLatestBrandAvatar();
|
||||
if (cancelled) return;
|
||||
|
||||
if (response.success && response.image_url) {
|
||||
setBrandAvatar(response);
|
||||
} else {
|
||||
setBrandAvatar(null);
|
||||
}
|
||||
} catch (err: any) {
|
||||
if (cancelled) return;
|
||||
console.error('[AssetLibraryImageModal] Failed to load brand avatar:', err);
|
||||
setBrandAvatar(null);
|
||||
setBrandAvatarError('Failed to load brand avatar');
|
||||
} finally {
|
||||
if (!cancelled) {
|
||||
setBrandAvatarLoading(false);
|
||||
}
|
||||
}
|
||||
};
|
||||
|
||||
loadBrandAvatar();
|
||||
|
||||
return () => {
|
||||
cancelled = true;
|
||||
};
|
||||
}, [open, showBrandAvatarShortcut]);
|
||||
|
||||
// Check if a URL requires authentication (internal API endpoints)
|
||||
const isAuthenticatedUrl = useCallback((url: string): boolean => {
|
||||
if (!url) return false;
|
||||
@@ -172,6 +220,43 @@ export const AssetLibraryImageModal: React.FC<AssetLibraryImageModalProps> = ({
|
||||
setSelectedAsset(asset);
|
||||
}, []);
|
||||
|
||||
const handleBrandAvatarSelect = useCallback(() => {
|
||||
if (!brandAvatar || !brandAvatar.image_url) return;
|
||||
|
||||
const now = new Date().toISOString();
|
||||
|
||||
const asset: ContentAsset = {
|
||||
id: brandAvatar.asset_id ?? -1,
|
||||
user_id: 'current_user',
|
||||
asset_type: 'image',
|
||||
source_module: 'brand_avatar_generator',
|
||||
filename: brandAvatar.image_url,
|
||||
file_url: brandAvatar.image_url,
|
||||
file_path: undefined,
|
||||
file_size: undefined,
|
||||
mime_type: 'image/png',
|
||||
title: 'Brand Avatar',
|
||||
description: brandAvatar.prompt || 'Brand avatar from onboarding',
|
||||
prompt: brandAvatar.prompt,
|
||||
tags: ['brand_avatar', 'onboarding'],
|
||||
asset_metadata: {
|
||||
source: 'onboarding_step4',
|
||||
},
|
||||
provider: undefined,
|
||||
model: undefined,
|
||||
cost: 0,
|
||||
generation_time: undefined,
|
||||
is_favorite: false,
|
||||
download_count: 0,
|
||||
share_count: 0,
|
||||
created_at: now,
|
||||
updated_at: now,
|
||||
};
|
||||
|
||||
onSelect(asset);
|
||||
handleClose();
|
||||
}, [brandAvatar, onSelect, handleClose]);
|
||||
|
||||
const handleFavoriteToggle = useCallback(
|
||||
async (assetId: number, e: React.MouseEvent) => {
|
||||
e.stopPropagation();
|
||||
@@ -214,6 +299,80 @@ export const AssetLibraryImageModal: React.FC<AssetLibraryImageModalProps> = ({
|
||||
</DialogTitle>
|
||||
|
||||
<DialogContent dividers sx={{ backgroundColor: '#f9fafb' }}>
|
||||
{/* Brand Avatar Shortcut (from Onboarding Step 4) */}
|
||||
{showBrandAvatarShortcut && (
|
||||
<Box sx={{ mb: 3 }}>
|
||||
<Stack direction="row" alignItems="center" justifyContent="space-between" sx={{ mb: 1 }}>
|
||||
<Typography variant="subtitle1" sx={{ fontWeight: 600, color: '#111827' }}>
|
||||
Brand Avatar from Onboarding
|
||||
</Typography>
|
||||
{brandAvatarLoading && <CircularProgress size={18} />}
|
||||
</Stack>
|
||||
{brandAvatarError && (
|
||||
<Alert severity="warning" sx={{ mb: 1 }}>
|
||||
{brandAvatarError}
|
||||
</Alert>
|
||||
)}
|
||||
{!brandAvatarLoading && !brandAvatar && !brandAvatarError && (
|
||||
<Typography variant="body2" sx={{ color: '#6b7280' }}>
|
||||
No brand avatar found. Create one in Step 4 of onboarding to see it here.
|
||||
</Typography>
|
||||
)}
|
||||
{!brandAvatarLoading && brandAvatar && brandAvatar.image_url && (
|
||||
<Card
|
||||
sx={{
|
||||
display: 'flex',
|
||||
alignItems: 'center',
|
||||
p: 1.5,
|
||||
borderRadius: 2,
|
||||
border: '1px solid #e5e7eb',
|
||||
cursor: 'pointer',
|
||||
mb: 1.5,
|
||||
'&:hover': {
|
||||
boxShadow: 3,
|
||||
borderColor: '#9ca3af',
|
||||
},
|
||||
}}
|
||||
onClick={handleBrandAvatarSelect}
|
||||
>
|
||||
<Box
|
||||
sx={{
|
||||
width: 64,
|
||||
height: 64,
|
||||
borderRadius: 2,
|
||||
overflow: 'hidden',
|
||||
mr: 2,
|
||||
bgcolor: '#f3f4f6',
|
||||
}}
|
||||
>
|
||||
<CardMedia
|
||||
component="img"
|
||||
image={brandAvatar.image_url}
|
||||
alt="Brand Avatar"
|
||||
sx={{
|
||||
width: '100%',
|
||||
height: '100%',
|
||||
objectFit: 'cover',
|
||||
}}
|
||||
onError={(e) => {
|
||||
const target = e.target as HTMLImageElement;
|
||||
target.style.display = 'none';
|
||||
}}
|
||||
/>
|
||||
</Box>
|
||||
<Box sx={{ flex: 1 }}>
|
||||
<Typography variant="subtitle2" sx={{ fontWeight: 600, color: '#111827' }}>
|
||||
Use Brand Avatar
|
||||
</Typography>
|
||||
<Typography variant="body2" sx={{ color: '#6b7280' }}>
|
||||
Quickly apply the avatar you created during onboarding.
|
||||
</Typography>
|
||||
</Box>
|
||||
</Card>
|
||||
)}
|
||||
</Box>
|
||||
)}
|
||||
|
||||
{/* Search and Filters */}
|
||||
<Box sx={{ mb: 3 }}>
|
||||
<Stack direction={{ xs: 'column', sm: 'row' }} spacing={2} alignItems="center">
|
||||
|
||||
28
frontend/src/components/shared/ChipLegend.tsx
Normal file
28
frontend/src/components/shared/ChipLegend.tsx
Normal file
@@ -0,0 +1,28 @@
|
||||
import React from 'react';
|
||||
import { Box, Chip, Tooltip } from '@mui/material';
|
||||
|
||||
export interface LegendItem {
|
||||
label: string;
|
||||
icon?: React.ReactNode;
|
||||
tooltip: string;
|
||||
sx?: any;
|
||||
}
|
||||
|
||||
interface ChipLegendProps {
|
||||
items: LegendItem[];
|
||||
sx?: any;
|
||||
}
|
||||
|
||||
const ChipLegend: React.FC<ChipLegendProps> = ({ items, sx }) => {
|
||||
return (
|
||||
<Box sx={{ display: 'flex', alignItems: 'center', gap: 1, mb: 1, ...sx }}>
|
||||
{items.map((item, idx) => (
|
||||
<Tooltip key={`${item.label}-${idx}`} title={item.tooltip}>
|
||||
<Chip icon={item.icon as any} label={item.label} size="small" sx={item.sx} />
|
||||
</Tooltip>
|
||||
))}
|
||||
</Box>
|
||||
);
|
||||
};
|
||||
|
||||
export default ChipLegend;
|
||||
99
frontend/src/components/shared/GscSuggestionsPanel.tsx
Normal file
99
frontend/src/components/shared/GscSuggestionsPanel.tsx
Normal file
@@ -0,0 +1,99 @@
|
||||
import React from 'react';
|
||||
import { Card, CardContent, Box, Typography, Tooltip, Chip, Button, List, ListItem, ListItemText, Paper } from '@mui/material';
|
||||
import { Info, Visibility, TrendingDown, BarChart } from '@mui/icons-material';
|
||||
import ChipLegend from './ChipLegend';
|
||||
|
||||
interface Suggestion {
|
||||
query: string;
|
||||
impressions: number;
|
||||
ctr: number;
|
||||
position: number;
|
||||
}
|
||||
|
||||
interface GscSuggestionsPanelProps {
|
||||
suggestions: Suggestion[];
|
||||
rangeDays: number;
|
||||
onUseInWriter?: (s: Suggestion) => void;
|
||||
onProposeMeta?: (s: Suggestion) => void;
|
||||
formatNumber: (n: number) => string;
|
||||
}
|
||||
|
||||
const GscSuggestionsPanel: React.FC<GscSuggestionsPanelProps> = ({ suggestions, rangeDays, onUseInWriter, onProposeMeta, formatNumber }) => {
|
||||
const impTh = rangeDays <= 7 ? 100 : rangeDays <= 30 ? 500 : 1500;
|
||||
const ctrTh = 2.5;
|
||||
|
||||
return (
|
||||
<Card sx={{ mt: 2, bgcolor: '#ffffff !important', color: '#1f2937 !important', border: '1px solid #e5e7eb !important', boxShadow: '0 1px 3px 0 rgba(0,0,0,0.1) !important' }}>
|
||||
<CardContent>
|
||||
<Box sx={{ display: 'flex', alignItems: 'center', justifyContent: 'space-between', mb: 1 }}>
|
||||
<Box sx={{ display: 'flex', alignItems: 'center', gap: 1 }}>
|
||||
<Typography variant="subtitle1">GSC Suggestions (High Impressions • Low CTR)</Typography>
|
||||
<Tooltip title="Opportunities where many people saw your result but few clicked. Focus on improving titles and descriptions for these queries. CTR means click‑through rate.">
|
||||
<Info fontSize="small" color="action" />
|
||||
</Tooltip>
|
||||
</Box>
|
||||
<Typography variant="caption" color="text.secondary">Top {suggestions.length} opportunities</Typography>
|
||||
</Box>
|
||||
<ChipLegend
|
||||
items={[
|
||||
{ label: 'Impressions', icon: <Visibility fontSize="small" />, tooltip: `Impressions ≥ ${impTh} are considered high visibility for this window.`, sx: { backgroundImage: 'linear-gradient(135deg, #e2e8f0 0%, #f8fafc 100%)', color: '#0f172a', border: '1px solid #cbd5e1', boxShadow: '0 1px 2px rgba(0,0,0,0.05)', fontWeight: 700 } },
|
||||
{ label: 'Low CTR', icon: <TrendingDown fontSize="small" />, tooltip: `CTR ≤ ${ctrTh}% indicates low click‑through. Improve titles/meta.`, sx: { backgroundImage: 'linear-gradient(135deg, #fee2e2 0%, #fff1f2 100%)', color: '#991b1b', border: '1px solid #fecaca', boxShadow: '0 1px 2px rgba(0,0,0,0.05)', fontWeight: 700 } },
|
||||
{ label: 'Avg Pos', icon: <BarChart fontSize="small" />, tooltip: 'Average position gives ranking context.', sx: { backgroundImage: 'linear-gradient(135deg, #ede9fe 0%, #eff6ff 100%)', color: '#4c1d95', border: '1px solid #ddd6fe', boxShadow: '0 1px 2px rgba(0,0,0,0.05)', fontWeight: 700 } },
|
||||
]}
|
||||
/>
|
||||
{suggestions.length === 0 ? (
|
||||
<Typography variant="body2" color="text.secondary">No low‑CTR queries found for this window.</Typography>
|
||||
) : (
|
||||
<List dense>
|
||||
{suggestions.map((s, idx) => {
|
||||
const ctrColor = s.ctr >= 3 ? '#065f46' : s.ctr >= 1 ? '#92400e' : '#7f1d1d';
|
||||
const ctrBg = s.ctr >= 3 ? 'linear-gradient(135deg, #d1fae5 0%, #ecfdf5 100%)' : s.ctr >= 1 ? 'linear-gradient(135deg, #fef3c7 0%, #fffbeb 100%)' : 'linear-gradient(135deg, #fee2e2 0%, #fff1f2 100%)';
|
||||
return (
|
||||
<ListItem key={`${s.query}-${idx}`} sx={{ px: 0, py: 0.5 }}>
|
||||
<Paper elevation={0} sx={{ px: 1.25, py: 1, width: '100%', borderRadius: 2, border: '1px solid #e5e7eb', background: 'linear-gradient(180deg, #ffffff 0%, #f8fafc 100%)' }}>
|
||||
<Box sx={{ display: 'flex', alignItems: 'center', gap: 1.2, width: '100%', justifyContent: 'space-between' }}>
|
||||
<Box sx={{ minWidth: 0, flex: 1 }}>
|
||||
<Tooltip title={`Impressions ≥ ${impTh}, CTR ≤ ${ctrTh}% • This: ${formatNumber(s.impressions)} impressions, ${s.ctr.toFixed(1)}% CTR, position ${s.position.toFixed(1)}`}>
|
||||
<ListItemText
|
||||
primary={s.query}
|
||||
primaryTypographyProps={{
|
||||
variant: 'body2',
|
||||
sx: {
|
||||
overflow: 'hidden',
|
||||
textOverflow: 'ellipsis',
|
||||
whiteSpace: 'nowrap',
|
||||
color: '#111827',
|
||||
fontWeight: 500,
|
||||
},
|
||||
}}
|
||||
/>
|
||||
</Tooltip>
|
||||
</Box>
|
||||
<Box sx={{ display: 'flex', alignItems: 'center', gap: 1.2, flexShrink: 0 }}>
|
||||
<Tooltip title="Impressions">
|
||||
<Chip label={`${formatNumber(s.impressions)} imp`} size="small" sx={{ backgroundImage: 'linear-gradient(135deg, #e2e8f0 0%, #f8fafc 100%)', color: '#0f172a', border: '1px solid #cbd5e1', boxShadow: '0 1px 2px rgba(0,0,0,0.05)', fontWeight: 700 }} />
|
||||
</Tooltip>
|
||||
<Tooltip title="CTR">
|
||||
<Chip label={`${s.ctr.toFixed(1)}% CTR`} size="small" sx={{ backgroundImage: ctrBg, color: ctrColor, border: '1px solid rgba(0,0,0,0.06)', boxShadow: '0 1px 2px rgba(0,0,0,0.04)', fontWeight: 700 }} />
|
||||
</Tooltip>
|
||||
<Tooltip title="Average position">
|
||||
<Chip label={`pos ${s.position.toFixed(1)}`} size="small" sx={{ backgroundImage: 'linear-gradient(135deg, #ede9fe 0%, #eff6ff 100%)', color: '#4c1d95', border: '1px solid #ddd6fe', boxShadow: '0 1px 2px rgba(0,0,0,0.05)', fontWeight: 700 }} />
|
||||
</Tooltip>
|
||||
</Box>
|
||||
<Box sx={{ display: 'flex', alignItems: 'center', gap: 1 }}>
|
||||
<Button size="small" variant="outlined" sx={{ textTransform: 'none' }} onClick={() => onUseInWriter && onUseInWriter(s)}>Use in Writer</Button>
|
||||
<Button size="small" variant="contained" sx={{ textTransform: 'none' }} onClick={() => onProposeMeta && onProposeMeta(s)}>Propose Title/Meta</Button>
|
||||
</Box>
|
||||
</Box>
|
||||
</Paper>
|
||||
</ListItem>
|
||||
);
|
||||
})}
|
||||
</List>
|
||||
)}
|
||||
</CardContent>
|
||||
</Card>
|
||||
);
|
||||
};
|
||||
|
||||
export default GscSuggestionsPanel;
|
||||
File diff suppressed because it is too large
Load Diff
128
frontend/src/components/shared/RefreshQueuePanel.tsx
Normal file
128
frontend/src/components/shared/RefreshQueuePanel.tsx
Normal file
@@ -0,0 +1,128 @@
|
||||
import React from 'react';
|
||||
import { Card, CardContent, Box, Typography, Tooltip, Chip, Button, Grid, List, ListItem, Paper } from '@mui/material';
|
||||
import { Info, MouseOutlined, Visibility } from '@mui/icons-material';
|
||||
import ChipLegend from './ChipLegend';
|
||||
|
||||
interface DeltaQuery {
|
||||
query: string;
|
||||
deltaClicks: number;
|
||||
deltaImpressions: number;
|
||||
}
|
||||
|
||||
interface RefreshQueuePanelProps {
|
||||
risingQueries: DeltaQuery[];
|
||||
decliningQueries: DeltaQuery[];
|
||||
loading: boolean;
|
||||
onRecompute: () => void;
|
||||
formatNumber: (n: number) => string;
|
||||
}
|
||||
|
||||
const RefreshQueuePanel: React.FC<RefreshQueuePanelProps> = ({ risingQueries, decliningQueries, loading, onRecompute, formatNumber }) => {
|
||||
const hasNoData = risingQueries.length === 0 && decliningQueries.length === 0;
|
||||
|
||||
return (
|
||||
<Card sx={{ mt: 2, bgcolor: '#ffffff !important', color: '#1f2937 !important', border: '1px solid #e5e7eb !important', boxShadow: '0 1px 3px 0 rgba(0,0,0,0.1) !important' }}>
|
||||
<CardContent>
|
||||
<Box sx={{ display: 'flex', alignItems: 'center', justifyContent: 'space-between', mb: 1 }}>
|
||||
<Box sx={{ display: 'flex', alignItems: 'center', gap: 1 }}>
|
||||
<Typography variant="subtitle1">Refresh Queue (Current vs Previous)</Typography>
|
||||
<Tooltip title="Highlights topics gaining or losing traction compared to the previous window. Rising = more clicks or impressions; declining = fewer. Use this to refresh content.">
|
||||
<Info fontSize="small" color="action" />
|
||||
</Tooltip>
|
||||
</Box>
|
||||
<Button onClick={onRecompute} disabled={loading} variant="outlined" size="small" sx={{ textTransform: 'none' }}>
|
||||
{loading ? 'Computing…' : 'Recompute'}
|
||||
</Button>
|
||||
</Box>
|
||||
|
||||
<ChipLegend
|
||||
items={[
|
||||
{ label: 'Δ Clicks', icon: <MouseOutlined fontSize="small" />, tooltip: 'Change in clicks vs previous period', sx: { backgroundImage: 'linear-gradient(135deg, #dbeafe 0%, #eff6ff 100%)', color: '#1e40af', border: '1px solid #bfdbfe', boxShadow: '0 1px 2px rgba(0,0,0,0.05)', fontWeight: 700 } },
|
||||
{ label: 'Δ Impr', icon: <Visibility fontSize="small" />, tooltip: 'Change in impressions vs previous period', sx: { backgroundImage: 'linear-gradient(135deg, #ecfdf5 0%, #ffffff 100%)', color: '#065f46', border: '1px solid #a7f3d0', boxShadow: '0 1px 2px rgba(0,0,0,0.05)', fontWeight: 700 } },
|
||||
]}
|
||||
/>
|
||||
|
||||
{hasNoData ? (
|
||||
<Typography variant="body2" color="text.secondary">No rising or declining queries detected.</Typography>
|
||||
) : (
|
||||
<Grid container spacing={2}>
|
||||
<Grid item xs={12} md={6}>
|
||||
<Typography variant="subtitle2" gutterBottom>Rising Queries</Typography>
|
||||
<List dense>
|
||||
{risingQueries.map((q, i) => (
|
||||
<ListItem key={`rise-${i}`} sx={{ px: 0, py: 0.5 }}>
|
||||
<Paper elevation={0} sx={{ px: 1, py: 0.75, width: '100%', borderRadius: 2, border: '1px solid #e5e7eb', background: 'linear-gradient(180deg, #ffffff 0%, #f8fafc 100%)' }}>
|
||||
<Box sx={{ display: 'flex', alignItems: 'center', gap: 1.2, width: '100%', justifyContent: 'space-between' }}>
|
||||
<Box sx={{ minWidth: 0, flex: 1 }}>
|
||||
<Typography
|
||||
variant="body2"
|
||||
sx={{
|
||||
overflow: 'hidden',
|
||||
textOverflow: 'ellipsis',
|
||||
whiteSpace: 'nowrap',
|
||||
color: '#111827',
|
||||
fontWeight: 500,
|
||||
}}
|
||||
>
|
||||
{q.query}
|
||||
</Typography>
|
||||
</Box>
|
||||
<Box sx={{ display: 'flex', alignItems: 'center', gap: 1.2, flexShrink: 0 }}>
|
||||
<Tooltip title="Additional clicks compared to previous window">
|
||||
<Chip icon={<MouseOutlined fontSize="small" />} label={`+${formatNumber(Math.max(0, q.deltaClicks))}`} size="small" sx={{ backgroundImage: 'linear-gradient(135deg, #ecfdf5 0%, #ffffff 100%)', color: '#065f46', border: '1px solid #a7f3d0', fontWeight: 700 }} />
|
||||
</Tooltip>
|
||||
<Tooltip title="Additional impressions compared to previous window">
|
||||
<Chip icon={<Visibility fontSize="small" />} label={`+${formatNumber(Math.max(0, q.deltaImpressions))}`} size="small" sx={{ backgroundImage: 'linear-gradient(135deg, #dbeafe 0%, #eff6ff 100%)', color: '#1e40af', border: '1px solid #bfdbfe', fontWeight: 700 }} />
|
||||
</Tooltip>
|
||||
</Box>
|
||||
<Button size="small" variant="outlined" sx={{ textTransform: 'none' }}>Open in Writer</Button>
|
||||
</Box>
|
||||
</Paper>
|
||||
</ListItem>
|
||||
))}
|
||||
</List>
|
||||
</Grid>
|
||||
<Grid item xs={12} md={6}>
|
||||
<Typography variant="subtitle2" gutterBottom>Declining Queries</Typography>
|
||||
<List dense>
|
||||
{decliningQueries.map((q, i) => (
|
||||
<ListItem key={`decl-${i}`} sx={{ px: 0, py: 0.5 }}>
|
||||
<Paper elevation={0} sx={{ px: 1, py: 0.75, width: '100%', borderRadius: 2, border: '1px solid #e5e7eb', background: 'linear-gradient(180deg, #ffffff 0%, #fff7ed 100%)' }}>
|
||||
<Box sx={{ display: 'flex', alignItems: 'center', gap: 1.2, width: '100%', justifyContent: 'space-between' }}>
|
||||
<Box sx={{ minWidth: 0, flex: 1 }}>
|
||||
<Typography
|
||||
variant="body2"
|
||||
sx={{
|
||||
overflow: 'hidden',
|
||||
textOverflow: 'ellipsis',
|
||||
whiteSpace: 'nowrap',
|
||||
color: '#111827',
|
||||
fontWeight: 500,
|
||||
}}
|
||||
>
|
||||
{q.query}
|
||||
</Typography>
|
||||
</Box>
|
||||
<Box sx={{ display: 'flex', alignItems: 'center', gap: 1.2, flexShrink: 0 }}>
|
||||
<Tooltip title="Lost clicks compared to previous window">
|
||||
<Chip icon={<MouseOutlined fontSize="small" />} label={`${formatNumber(q.deltaClicks)}`} size="small" sx={{ backgroundImage: 'linear-gradient(135deg, #fee2e2 0%, #fff1f2 100%)', color: '#991b1b', border: '1px solid #fecaca', fontWeight: 700 }} />
|
||||
</Tooltip>
|
||||
<Tooltip title="Lost impressions compared to previous window">
|
||||
<Chip icon={<Visibility fontSize="small" />} label={`${formatNumber(q.deltaImpressions)}`} size="small" sx={{ backgroundImage: 'linear-gradient(135deg, #ffedd5 0%, #fff7ed 100%)', color: '#9a3412', border: '1px solid #fed7aa', fontWeight: 700 }} />
|
||||
</Tooltip>
|
||||
</Box>
|
||||
<Button size="small" variant="outlined" color="warning" sx={{ textTransform: 'none' }}>Create Brief</Button>
|
||||
</Box>
|
||||
</Paper>
|
||||
</ListItem>
|
||||
))}
|
||||
</List>
|
||||
</Grid>
|
||||
</Grid>
|
||||
)}
|
||||
</CardContent>
|
||||
</Card>
|
||||
);
|
||||
};
|
||||
|
||||
export default RefreshQueuePanel;
|
||||
107
frontend/src/components/shared/TopPagesInsightsPanel.tsx
Normal file
107
frontend/src/components/shared/TopPagesInsightsPanel.tsx
Normal file
@@ -0,0 +1,107 @@
|
||||
import React from 'react';
|
||||
import { Card, CardContent, Box, Typography, Tooltip, Chip, Button, List, ListItem, ListItemText, Paper, IconButton } from '@mui/material';
|
||||
import { Info, MouseOutlined, Visibility, TrendingUp, OpenInNew } from '@mui/icons-material';
|
||||
import ChipLegend from './ChipLegend';
|
||||
|
||||
type Query = { query: string; clicks: number; impressions: number; ctr: number };
|
||||
|
||||
interface TopPagesInsightsPanelProps {
|
||||
pages: Array<{ page: string; clicks: number; impressions: number; ctr: number; queries?: Query[] }>;
|
||||
risingQueries: Array<{ query: string }>;
|
||||
onOpenPage: (url: string) => void;
|
||||
onCreateBrief: (page: string, queries: Query[]) => void;
|
||||
formatNumber: (n: number) => string;
|
||||
}
|
||||
|
||||
const TopPagesInsightsPanel: React.FC<TopPagesInsightsPanelProps> = ({ pages, risingQueries, onOpenPage, onCreateBrief, formatNumber }) => {
|
||||
const risingSet = React.useMemo(() => new Set(risingQueries.map(r => String(r.query || '').toLowerCase())), [risingQueries]);
|
||||
|
||||
return (
|
||||
<Card sx={{ mt: 2, bgcolor: '#ffffff !important', color: '#1f2937 !important', border: '1px solid #e5e7eb !important', boxShadow: '0 1px 3px 0 rgba(0,0,0,0.1) !important' }}>
|
||||
<CardContent>
|
||||
<Box sx={{ display: 'flex', alignItems: 'center', justifyContent: 'space-between', mb: 1 }}>
|
||||
<Box sx={{ display: 'flex', alignItems: 'center', gap: 1 }}>
|
||||
<Typography variant="subtitle1">Top Pages Insights</Typography>
|
||||
<Tooltip title="Your pages with the most traffic from Google in this window. Improve winners and link to related pages to spread authority.">
|
||||
<Info fontSize="small" color="action" />
|
||||
</Tooltip>
|
||||
</Box>
|
||||
<Typography variant="caption" color="text.secondary">Sorted by clicks</Typography>
|
||||
</Box>
|
||||
<ChipLegend
|
||||
items={[
|
||||
{ label: 'Clicks', icon: <MouseOutlined fontSize="small" />, tooltip: 'Total clicks in the selected date range', sx: { backgroundImage: 'linear-gradient(135deg, #dbeafe 0%, #eef2ff 100%)', color: '#1e3a8a', border: '1px solid #c7d2fe', boxShadow: '0 1px 2px rgba(0,0,0,0.05)', fontWeight: 700 } },
|
||||
{ label: 'Impressions', icon: <Visibility fontSize="small" />, tooltip: 'Total impressions in the selected date range', sx: { backgroundImage: 'linear-gradient(135deg, #e2e8f0 0%, #f8fafc 100%)', color: '#0f172a', border: '1px solid #cbd5e1', boxShadow: '0 1px 2px rgba(0,0,0,0.05)', fontWeight: 700 } },
|
||||
{ label: 'CTR', tooltip: 'Click-through rate', sx: { backgroundImage: 'linear-gradient(135deg, #d1fae5 0%, #ecfdf5 100%)', color: '#065f46', border: '1px solid #86efac', boxShadow: '0 1px 2px rgba(0,0,0,0.05)', fontWeight: 700 } },
|
||||
{ label: 'Trending', icon: <TrendingUp fontSize="small" />, tooltip: 'Appears in Rising Queries', sx: { backgroundImage: 'linear-gradient(135deg, #ecfdf5 0%, #ffffff 100%)', color: '#065f46', border: '1px solid #a7f3d0', boxShadow: '0 1px 2px rgba(0,0,0,0.05)', fontWeight: 700 } },
|
||||
]}
|
||||
/>
|
||||
{(!pages || pages.length === 0) ? (
|
||||
<Typography variant="body2" color="text.secondary">No top pages for this window.</Typography>
|
||||
) : (
|
||||
<List dense>
|
||||
{pages.slice(0, 10).map((p, idx) => {
|
||||
const clicks = Number(p.clicks || 0);
|
||||
const impressions = Number(p.impressions || 0);
|
||||
const ctr = Number(p.ctr || 0);
|
||||
const ctrColor = ctr >= 3 ? '#065f46' : ctr >= 1 ? '#92400e' : '#7f1d1d';
|
||||
const ctrBg = ctr >= 3 ? 'linear-gradient(135deg, #d1fae5 0%, #ecfdf5 100%)' : ctr >= 1 ? 'linear-gradient(135deg, #fef3c7 0%, #fffbeb 100%)' : 'linear-gradient(135deg, #fee2e2 0%, #fff1f2 100%)';
|
||||
const hasTrending = Array.isArray(p.queries) && p.queries!.some(q => risingSet.has(String(q.query || '').toLowerCase()));
|
||||
return (
|
||||
<ListItem key={`${p.page || 'page'}-${idx}`} sx={{ px: 0, py: 0.75 }}>
|
||||
<Paper elevation={0} sx={{ px: 1.25, py: 1, width: '100%', borderRadius: 2, border: '1px solid #e5e7eb', background: 'linear-gradient(180deg, #ffffff 0%, #f8fafc 100%)', transition: 'all .2s', '&:hover': { boxShadow: '0 6px 12px rgba(17,24,39,0.06)', transform: 'translateY(-1px)' } }}>
|
||||
<Box sx={{ display: 'flex', alignItems: 'center', gap: 2, width: '100%', justifyContent: 'space-between' }}>
|
||||
<Box sx={{ minWidth: 0, flex: 1 }}>
|
||||
<Tooltip title={`Total clicks ${clicks}, total impressions ${impressions}, CTR ${ctr.toFixed(1)}% for the selected range`}>
|
||||
<ListItemText
|
||||
primary={p.page || '(unknown page)'}
|
||||
primaryTypographyProps={{
|
||||
variant: 'body2',
|
||||
sx: {
|
||||
overflow: 'hidden',
|
||||
textOverflow: 'ellipsis',
|
||||
whiteSpace: 'nowrap',
|
||||
color: '#111827',
|
||||
fontWeight: 500,
|
||||
},
|
||||
}}
|
||||
/>
|
||||
</Tooltip>
|
||||
{hasTrending && <Chip icon={<TrendingUp fontSize="small" />} label="Trending" size="small" sx={{ mt: 0.5, backgroundImage: 'linear-gradient(135deg, #ecfdf5 0%, #ffffff 100%)', color: '#065f46', border: '1px solid #a7f3d0', boxShadow: '0 1px 2px rgba(0,0,0,0.04)', fontWeight: 700 }} />}
|
||||
</Box>
|
||||
<Box sx={{ display: 'flex', alignItems: 'center', gap: 1.2, mr: 1, flexShrink: 0 }}>
|
||||
<Tooltip title="Total clicks across the selected date range. Higher is better.">
|
||||
<Chip icon={<MouseOutlined fontSize="small" />} label={`${formatNumber(clicks)} clicks`} size="small" sx={{ backgroundImage: 'linear-gradient(135deg, #dbeafe 0%, #eef2ff 100%)', color: '#1e3a8a', border: '1px solid #c7d2fe', boxShadow: '0 1px 2px rgba(0,0,0,0.05)', fontWeight: 700 }} />
|
||||
</Tooltip>
|
||||
<Tooltip title="Total impressions across the selected date range. Indicates visibility in search results.">
|
||||
<Chip icon={<Visibility fontSize="small" />} label={`${formatNumber(impressions)} imp`} size="small" sx={{ backgroundImage: 'linear-gradient(135deg, #e2e8f0 0%, #f8fafc 100%)', color: '#0f172a', border: '1px solid #cbd5e1', boxShadow: '0 1px 2px rgba(0,0,0,0.05)', fontWeight: 700 }} />
|
||||
</Tooltip>
|
||||
<Tooltip title="Click-through rate. Higher indicates titles/meta attract clicks for given impressions.">
|
||||
<Chip label={`${ctr.toFixed(1)}% CTR`} size="small" sx={{ backgroundImage: ctrBg, color: ctrColor, border: '1px solid rgba(0,0,0,0.06)', boxShadow: '0 1px 2px rgba(0,0,0,0.04)', fontWeight: 700 }} />
|
||||
</Tooltip>
|
||||
</Box>
|
||||
<Box sx={{ display: 'flex', alignItems: 'center', gap: 1, flexShrink: 0 }}>
|
||||
<Tooltip title="Open page in new tab">
|
||||
<IconButton
|
||||
size="small"
|
||||
onClick={() => onOpenPage(p.page)}
|
||||
sx={{ color: '#4f46e5' }}
|
||||
>
|
||||
<OpenInNew fontSize="small" />
|
||||
</IconButton>
|
||||
</Tooltip>
|
||||
<Button size="small" variant="contained" sx={{ textTransform: 'none' }} onClick={() => onCreateBrief(p.page, p.queries || [])}>Create Brief</Button>
|
||||
</Box>
|
||||
</Box>
|
||||
</Paper>
|
||||
</ListItem>
|
||||
);
|
||||
})}
|
||||
</List>
|
||||
)}
|
||||
</CardContent>
|
||||
</Card>
|
||||
);
|
||||
};
|
||||
|
||||
export default TopPagesInsightsPanel;
|
||||
Reference in New Issue
Block a user