initial commit

This commit is contained in:
michael 2025-12-12 09:03:17 -06:00
commit e97d0e935c
117 changed files with 21379 additions and 0 deletions

33
.gitignore vendored Normal file
View file

@ -0,0 +1,33 @@
# Environment files with secrets
.env
.env.local
.env.*.local
# Python
__pycache__/
*.py[cod]
*$py.class
*.so
.Python
venv/
ENV/
env/
.venv/
# Node
node_modules/
dist/
build/
*.log
npm-debug.log*
# IDE
.idea/
.vscode/
*.swp
*.swo
.DS_Store
# Build outputs
*.egg-info/
.eggs/

63
CLAUDE.md Normal file
View file

@ -0,0 +1,63 @@
# CLAUDE.md
This file provides guidance to Claude Code (claude.ai/code) when working with code in this repository.
## Project Overview
Mod Comms is an AI-powered proof review tool for Barclays marketing materials. It uses multiple AI agents to analyze uploaded proofs (marketing assets) for legal compliance, brand adherence, tone of voice, and channel suitability.
## Development Commands
```bash
cd frontend
npm install # Install dependencies
npm run dev # Start dev server on port 3000
npm run build # Production build
npm run preview # Preview production build
```
## Environment Setup
Create `frontend/.env.local` with:
```
GEMINI_API_KEY=your_api_key_here
```
## Architecture
### Frontend Structure (`frontend/`)
- **App.tsx** - Main application component, handles routing between views and manages global state (campaigns, proofs, flagged/resolved/error items)
- **components/** - React components for each view and UI element
- **services/geminiService.ts** - Google Gemini AI integration for proof analysis
### Multi-Agent System
Four specialist AI agents analyze each proof:
- **Legal Agent** - Checks advertising standards, disclaimers, financial promotion detection
- **Brand Agent** - Verifies brand guidelines (logos, colors, branding)
- **Tone Agent** - Analyzes copy clarity, brand personality, grammar
- **Channel Agent** - Assesses technical suitability for digital channels
A **Lead Agent** mediates specialist feedback and determines overall RAG status:
- Green: All pass, max 1 amber issue per agent (except Legal amber triggers overall Amber)
- Amber: >1 actionable issue per agent, or any Legal amber
- Red: Failure from any agent
### Key Data Types (`types.ts`)
- `AgentReview` - Complete analysis result with all agent reviews
- `RagStatus` - 'Red' | 'Amber' | 'Green' | 'Error'
- `OverallStatus` - 'Passed' | 'Failed' | 'Analysis Error' | 'Requires Manual Legal Review'
### State Persistence
Application state persists to localStorage with `barclays_modcomms_*_v3` keys. Large preview URLs are stripped before saving to avoid quota errors.
### User Roles
- **Admin** - Full access to all campaigns, Settings, Analytics, Auditing
- **Basic User** - Limited to own agency's campaigns
## Reference Documentation
Brand and channel guidelines are in `reference_docs/`:
- `brand/` - Barclays/Barclaycard brand guidelines, tone of voice, design principles
- `channel/` - Digital and social media guidelines
Product requirements specification: `specs/Barclays_ModComms_Req_v5.md`

15
backend/.env.example Normal file
View file

@ -0,0 +1,15 @@
# Gemini API Configuration
# Get your API key from: https://aistudio.google.com/app/apikey
GEMINI_API_KEY=your_gemini_api_key_here
# Reference Documents Path (optional)
# Defaults to ../reference_docs relative to backend/
# REFERENCE_DOCS_PATH=/path/to/reference_docs
# CORS Configuration
# Comma-separated list of allowed origins
CORS_ORIGINS=http://localhost:3000
# Server Configuration
HOST=0.0.0.0
PORT=8000

1
backend/app/__init__.py Normal file
View file

@ -0,0 +1 @@
# ModComms Backend Application

View file

@ -0,0 +1,6 @@
from .base_agent import BaseAgent
from .brand_agent import BrandAgent
from .channel_agent import ChannelAgent
from .legal_agent import LegalAgent
from .tone_agent import ToneAgent
from .lead_agent import LeadAgent

View file

@ -0,0 +1,22 @@
from abc import ABC, abstractmethod
from app.models.schemas import SubReview
class BaseAgent(ABC):
"""Abstract base class for all review agents."""
name: str = "Base Agent"
@abstractmethod
async def analyze(self, file_data: bytes, file_type: str) -> SubReview:
"""
Analyze the proof and return a SubReview.
Args:
file_data: Raw bytes of the file to analyze
file_type: MIME type of the file
Returns:
SubReview containing ragStatus, feedback, and issues
"""
pass

View file

@ -0,0 +1,66 @@
from app.agents.base_agent import BaseAgent
from app.models.schemas import SubReview
from app.services.gemini_service import GeminiService
from app.services.reference_docs import ReferenceDocsService
class BrandAgent(BaseAgent):
"""Brand Agent - analyzes proofs against Barclays brand guidelines using Gemini."""
name = "Brand Agent"
def __init__(self, gemini_service: GeminiService, reference_docs: ReferenceDocsService):
"""
Initialize the Brand Agent.
Args:
gemini_service: Service for making Gemini API calls
reference_docs: Service for loading reference documents
"""
self.gemini = gemini_service
self.brand_context = reference_docs.get_brand_context()
async def analyze(self, file_data: bytes, file_type: str) -> SubReview:
"""
Analyze the proof for brand guideline adherence.
Args:
file_data: Raw bytes of the file to analyze
file_type: MIME type of the file
Returns:
SubReview with brand compliance assessment
"""
prompt = f"""You are a brand expert for Barclays Bank. Your role is to analyze marketing proofs for adherence to Barclays UK brand guidelines.
Here are the brand guidelines to use for your analysis:
{self.brand_context}
---
Analyze the uploaded proof against these Barclays brand guidelines, checking for:
1. **Logo Usage**: Is the Barclays Eagle/logo used correctly? Check sizing, placement (should be top-right or as specified), clear space requirements, and that it hasn't been altered.
2. **Card Portal**: If the Card Portal asset is present, verify it follows the sizing rules (1/12 of shortest side for print, responsive for digital), proper cyan color, and corner visibility.
3. **Color Palette**: Are only approved brand colors used? Check for proper color pairings and that cyan is used appropriately as a sacred asset.
4. **Typography**: Is Barclays Effra (or approved fallback) used correctly? Check font weights, sizes, and hierarchy.
5. **Design Principles**: Does the overall design reflect the brand principles of Bold, Purposeful, and Expressive?
6. **Sacred Assets**: Verify that sacred assets (Cyan, Logo/Eagle, Portal) are not altered or misused.
Provide your analysis as a JSON object. Be specific about any issues found and reference the relevant guideline sections.
RAG Status Guidelines:
- **Green**: Fully compliant with brand guidelines, no issues
- **Amber**: Minor deviations that should be addressed but don't severely impact brand integrity
- **Red**: Significant brand guideline violations that must be fixed before use
If the proof is nonsensical, not a marketing material, or cannot be analyzed, set analysisStatus to 'low_confidence'.
"""
return await self.gemini.analyze_with_image(prompt, file_data, file_type)

View file

@ -0,0 +1,84 @@
from app.agents.base_agent import BaseAgent
from app.models.schemas import SubReview
from app.services.gemini_service import GeminiService
from app.services.reference_docs import ReferenceDocsService
class ChannelAgent(BaseAgent):
"""Channel Agent - analyzes proofs for digital channel suitability using Gemini."""
name = "Channel Agent"
def __init__(self, gemini_service: GeminiService, reference_docs: ReferenceDocsService):
"""
Initialize the Channel Agent.
Args:
gemini_service: Service for making Gemini API calls
reference_docs: Service for loading reference documents
"""
self.gemini = gemini_service
self.channel_context = reference_docs.get_channel_context()
async def analyze(self, file_data: bytes, file_type: str) -> SubReview:
"""
Analyze the proof for channel suitability.
Args:
file_data: Raw bytes of the file to analyze
file_type: MIME type of the file
Returns:
SubReview with channel suitability assessment
"""
prompt = f"""You are a digital channel specialist for Barclays Bank. Your role is to analyze marketing proofs for technical suitability across digital and social media channels.
Here are the channel guidelines to use for your analysis:
{self.channel_context}
---
Analyze the uploaded proof for technical suitability for its intended digital channel, checking:
1. **Social Media Compliance** (if applicable):
- Logo placement (should be top-right corner, 40px from edges on social)
- Portal sizing for different platforms (8px thin, 16px thin, or standard as appropriate)
- Format specifications (proper dimensions for the target platform)
2. **Digital Grid System**:
- Desktop: 12-column grid
- Tablet: 12-column grid
- Mobile: 6-column grid
- 8px baseline grid adherence
3. **Typography Scale**:
- Check if text sizing follows the 8-level scale (Supersize 80px down to X Small 12px)
- Responsive type considerations for different breakpoints (640px mobile threshold)
4. **Accessibility**:
- Color contrast meets accessibility requirements
- Only documented color pairings are used
- Text is readable at the intended display size
5. **Platform-Specific Requirements**:
- Hashtag usage guidelines (if social media)
- Emoji usage (appropriate vs. inappropriate as per guidelines)
- Character limits (Headlines: 65 chars, Body: 300 chars, Quotations: 250 chars)
6. **Motion/Video** (if applicable):
- Start and end frame compliance
- Subtitle formatting
- Frame rate and format requirements
Provide your analysis as a JSON object. Be specific about any technical issues and reference the relevant platform or guideline.
RAG Status Guidelines:
- **Green**: Fully suitable for the intended channel, all specs met
- **Amber**: Minor technical adjustments needed for optimal display
- **Red**: Significant technical issues that will impact display or accessibility
If the proof is nonsensical, not a marketing material, or cannot be analyzed, set analysisStatus to 'low_confidence'.
"""
return await self.gemini.analyze_with_image(prompt, file_data, file_type)

View file

@ -0,0 +1,124 @@
from app.models.schemas import SubReview, RagStatus, OverallStatus
from app.services.gemini_service import GeminiService
class LeadAgent:
"""
Lead Agent - synthesizes specialist agent reviews into final verdict.
Applies the decision logic:
- Financial promotion detected Requires Manual Legal Review
- Any Error status Analysis Error
- Any Red status Failed
- Otherwise Passed
"""
name = "Lead Agent"
def __init__(self, gemini_service: GeminiService):
"""
Initialize the Lead Agent.
Args:
gemini_service: Service for making Gemini API calls (for summary generation)
"""
self.gemini = gemini_service
async def synthesize(
self,
reviews: dict[str, SubReview],
) -> tuple[OverallStatus, str, str | None]:
"""
Synthesize specialist reviews into final verdict and summary.
Args:
reviews: Dictionary mapping agent names to their SubReview results
Returns:
Tuple of (overall_status, summary, financial_promotion_reason)
"""
legal_review = reviews.get("Legal Agent")
# Check for financial promotion (from Legal Agent)
is_financial_promotion = (
legal_review is not None
and legal_review.isFinancialPromotion is True
)
financial_promotion_reason = (
legal_review.financialPromotionReason
if legal_review and legal_review.financialPromotionReason
else None
)
# Build the prompt for Gemini to generate summary
prompt = f"""
You are a Lead Agent responsible for auditing a marketing proof. You have received feedback from specialist AI agents.
Your task is to provide a final verdict and write a concise, professional summary to the user.
Here is the logic you must follow:
1. The Legal Agent has determined if this is a financial promotion: {is_financial_promotion}.
2. If it IS a financial promotion, the final verdict MUST be 'Requires Manual Legal Review'. Your summary should state this clearly, explain that a separate manual legal review is required, and then summarize any other issues found by the other agents.
3. If it is NOT a financial promotion, follow the standard logic:
a. If ANY specialist agent reports a 'ragStatus' of 'Error', the final verdict MUST be 'Analysis Error'.
b. If ANY specialist agent reports a 'Red' status (and there are no 'Error' statuses), the final verdict MUST be 'Failed'.
c. If there are NO 'Red' or 'Error' statuses, the final verdict is 'Passed'.
Your summary should:
- For a 'Requires Manual Legal Review' verdict, start by stating this. Then, consolidate feedback from all agents, highlighting critical issues ('Red' items) or suggestions ('Amber' items).
- For an 'Analysis Error' verdict, explain that the proof could not be reliably processed and has been logged for human review. Advise the user to try again with a revised proof.
- For a 'Failed' status, highlight the critical 'Red' issues that must be addressed.
- For a 'Passed' status, mention any 'Amber' areas for consideration, if they exist, while maintaining an encouraging tone.
- Be professional, clear, and constructive.
Here are the specialist reviews:
{self._format_reviews(reviews)}
Now, provide your final verdict and summary as a JSON object.
"""
result = await self.gemini.generate_summary(prompt)
overall_status = OverallStatus(result.get("overallStatus", "Analysis Error"))
summary = result.get("summary", "Unable to generate summary.")
# Override with financial promotion logic if applicable
if is_financial_promotion:
overall_status = OverallStatus.REQUIRES_MANUAL_LEGAL_REVIEW
return overall_status, summary, financial_promotion_reason
def _format_reviews(self, reviews: dict[str, SubReview]) -> str:
"""Format reviews as a readable string for the prompt."""
formatted = []
for agent_name, review in reviews.items():
formatted.append(f"""
{agent_name}:
RAG Status: {review.ragStatus}
Feedback: {review.feedback}
Issues: {review.issues if review.issues else 'None'}
""")
return "\n".join(formatted)
def determine_status_locally(self, reviews: dict[str, SubReview]) -> OverallStatus:
"""
Determine overall status using local logic (without Gemini).
This can be used as a fallback or for faster processing.
"""
legal_review = reviews.get("Legal Agent")
# Check for financial promotion
if legal_review and legal_review.isFinancialPromotion:
return OverallStatus.REQUIRES_MANUAL_LEGAL_REVIEW
# Check for Error status
for review in reviews.values():
if review.ragStatus == RagStatus.ERROR:
return OverallStatus.ANALYSIS_ERROR
# Check for Red status
for review in reviews.values():
if review.ragStatus == RagStatus.RED:
return OverallStatus.FAILED
return OverallStatus.PASSED

View file

@ -0,0 +1,35 @@
import asyncio
from app.agents.base_agent import BaseAgent
from app.models.schemas import SubReview, RagStatus
class LegalAgent(BaseAgent):
"""
Legal Agent - STUB implementation.
Returns mock Green status. Full legal review requires manual verification.
"""
name = "Legal Agent"
async def analyze(self, file_data: bytes, file_type: str) -> SubReview:
"""
Stub implementation that returns mock Green status.
Args:
file_data: Raw bytes of the file (not used in stub)
file_type: MIME type of the file (not used in stub)
Returns:
SubReview with Green status and stub notice
"""
# Simulate some processing time for realistic UX
await asyncio.sleep(0.5)
return SubReview(
ragStatus=RagStatus.GREEN,
feedback="[STUB] Legal compliance check passed. This is a placeholder response - the Legal Agent has not been implemented for this POC. Full legal review, including financial promotion detection and regulatory compliance, requires the complete implementation.",
issues=[],
isFinancialPromotion=False,
financialPromotionReason=""
)

View file

@ -0,0 +1,33 @@
import asyncio
from app.agents.base_agent import BaseAgent
from app.models.schemas import SubReview, RagStatus
class ToneAgent(BaseAgent):
"""
Tone Agent - STUB implementation.
Returns mock Green status. Full tone analysis requires implementation.
"""
name = "Tone Agent"
async def analyze(self, file_data: bytes, file_type: str) -> SubReview:
"""
Stub implementation that returns mock Green status.
Args:
file_data: Raw bytes of the file (not used in stub)
file_type: MIME type of the file (not used in stub)
Returns:
SubReview with Green status and stub notice
"""
# Simulate some processing time for realistic UX
await asyncio.sleep(0.5)
return SubReview(
ragStatus=RagStatus.GREEN,
feedback="[STUB] Tone of voice analysis passed. This is a placeholder response - the Tone Agent has not been implemented for this POC. The copy appears to demonstrate appropriate brand personality traits: Pioneering, Connected, Optimistic, and Professional. Full analysis of clarity, grammar, and brand voice alignment requires the complete implementation.",
issues=[]
)

27
backend/app/config.py Normal file
View file

@ -0,0 +1,27 @@
import os
from pathlib import Path
from dotenv import load_dotenv
# Load environment variables from .env file
load_dotenv()
class Settings:
"""Application settings loaded from environment variables."""
GEMINI_API_KEY: str = os.getenv("GEMINI_API_KEY", "")
CORS_ORIGINS: str = os.getenv("CORS_ORIGINS", "http://localhost:3000")
HOST: str = os.getenv("HOST", "0.0.0.0")
PORT: int = int(os.getenv("PORT", "8000"))
# Reference docs path - defaults to ../reference_docs relative to backend/
_default_ref_docs = Path(__file__).parent.parent.parent / "reference_docs"
REFERENCE_DOCS_PATH: str = os.getenv("REFERENCE_DOCS_PATH", str(_default_ref_docs))
def validate(self) -> None:
"""Validate required settings are present."""
if not self.GEMINI_API_KEY:
raise ValueError("GEMINI_API_KEY environment variable is required")
settings = Settings()

157
backend/app/main.py Normal file
View file

@ -0,0 +1,157 @@
import logging
import uuid
from contextlib import asynccontextmanager
from fastapi import FastAPI, WebSocket, WebSocketDisconnect
from fastapi.middleware.cors import CORSMiddleware
from app.config import settings
# Configure logging
logging.basicConfig(
level=logging.INFO,
format="%(asctime)s - %(name)s - %(levelname)s - %(message)s",
datefmt="%Y-%m-%d %H:%M:%S"
)
logger = logging.getLogger(__name__)
from app.websocket.manager import ConnectionManager
from app.websocket.handlers import handle_analyze_message
from app.services.gemini_service import GeminiService
from app.services.reference_docs import ReferenceDocsService
from app.services.analysis_service import AnalysisService
# Global services - initialized at startup
manager = ConnectionManager()
analysis_service: AnalysisService | None = None
@asynccontextmanager
async def lifespan(app: FastAPI):
"""
Initialize services on startup and cleanup on shutdown.
Loads reference documents and initializes the analysis service.
"""
global analysis_service
# Validate settings
settings.validate()
# Initialize services
print("Loading reference documents...")
reference_docs = ReferenceDocsService(settings.REFERENCE_DOCS_PATH)
# Log document info
doc_summary = reference_docs.get_context_summary()
print(f" Brand documents: {len(doc_summary['brand_files'])} files ({doc_summary['brand_context_length']} chars)")
print(f" Channel documents: {len(doc_summary['channel_files'])} files ({doc_summary['channel_context_length']} chars)")
print("Initializing Gemini service...")
gemini_service = GeminiService(settings.GEMINI_API_KEY)
print("Initializing analysis service...")
analysis_service = AnalysisService(gemini_service, reference_docs)
print("Backend ready!")
yield
# Cleanup on shutdown (if needed)
print("Shutting down...")
# Create FastAPI app
app = FastAPI(
title="ModComms Proof Review API",
description="AI-powered proof review backend for Barclays marketing materials",
version="1.0.0",
lifespan=lifespan,
)
# CORS middleware - allow frontend to connect
app.add_middleware(
CORSMiddleware,
allow_origins=settings.CORS_ORIGINS.split(","),
allow_credentials=True,
allow_methods=["*"],
allow_headers=["*"],
)
@app.get("/health")
async def health_check():
"""Health check endpoint."""
return {"status": "healthy", "service": "modcomms-backend"}
@app.get("/info")
async def info():
"""Get backend information."""
if analysis_service:
ref_docs = analysis_service.reference_docs
doc_summary = ref_docs.get_context_summary()
return {
"status": "ready",
"agents": ["Legal Agent", "Brand Agent", "Tone Agent", "Channel Agent"],
"reference_docs": doc_summary,
}
return {"status": "initializing"}
@app.websocket("/ws/analyze")
async def websocket_analyze(websocket: WebSocket):
"""
WebSocket endpoint for proof analysis with real-time updates.
Protocol:
- Client sends: {"type": "analyze", "file_data": "<base64>", "file_type": "image/png", "is_wip": false}
- Server sends: {"type": "agent_started", "agent_name": "..."}
- Server sends: {"type": "agent_completed", "agent_name": "...", "review": {...}}
- Server sends: {"type": "complete", "result": {...}}
- On error: {"type": "error", "message": "..."}
"""
client_id = str(uuid.uuid4())
logger.info(f"[MAIN] WebSocket connection established - client_id: {client_id}")
await manager.connect(websocket, client_id)
try:
while True:
# Wait for a message from the client
data = await websocket.receive_json()
logger.info(f"[MAIN] Received message from client {client_id} - type: {data.get('type')}")
if data.get("type") == "analyze":
if analysis_service is None:
logger.error("[MAIN] Analysis service not ready")
await manager.send_message(client_id, {
"type": "error",
"message": "Backend not ready. Please wait for initialization."
})
continue
# Handle the analysis request
await handle_analyze_message(
websocket=websocket,
client_id=client_id,
data=data,
manager=manager,
analysis_service=analysis_service,
)
else:
logger.warning(f"[MAIN] Unknown message type: {data.get('type')}")
await manager.send_message(client_id, {
"type": "error",
"message": f"Unknown message type: {data.get('type')}"
})
except WebSocketDisconnect:
logger.info(f"[MAIN] Client {client_id} disconnected")
manager.disconnect(client_id)
except Exception as e:
logger.error(f"[MAIN] Error for client {client_id}: {str(e)}")
await manager.send_message(client_id, {
"type": "error",
"message": str(e)
})
manager.disconnect(client_id)

View file

@ -0,0 +1 @@
from .schemas import RagStatus, OverallStatus, SubReview, AgentReview

View file

@ -0,0 +1,96 @@
from enum import Enum
from typing import Optional
from pydantic import BaseModel
class RagStatus(str, Enum):
"""RAG status for agent reviews."""
RED = "Red"
AMBER = "Amber"
GREEN = "Green"
ERROR = "Error"
class OverallStatus(str, Enum):
"""Overall status for the proof review."""
PASSED = "Passed"
FAILED = "Failed"
ANALYSIS_ERROR = "Analysis Error"
REQUIRES_MANUAL_LEGAL_REVIEW = "Requires Manual Legal Review"
class SubReview(BaseModel):
"""Individual agent review result."""
ragStatus: RagStatus
feedback: str
issues: list[str]
isFinancialPromotion: Optional[bool] = None
financialPromotionReason: Optional[str] = None
class Config:
use_enum_values = True
class AgentReview(BaseModel):
"""Complete review from all agents."""
legalAgentReview: SubReview
brandAgentReview: SubReview
toneAgentReview: SubReview
channelAgentReview: SubReview
leadAgentSummary: str
overallStatus: OverallStatus
financialPromotionReason: Optional[str] = None
class Config:
use_enum_values = True
# WebSocket message types
class AnalyzeRequest(BaseModel):
"""Request to analyze a proof via WebSocket."""
type: str # Should be "analyze"
file_data: str # Base64 encoded file
file_type: str # MIME type
is_wip: bool = False
class AgentStartedMessage(BaseModel):
"""Message sent when an agent starts processing."""
type: str = "agent_started"
agent_name: str
class AgentCompletedMessage(BaseModel):
"""Message sent when an agent completes."""
type: str = "agent_completed"
agent_name: str
review: SubReview
class Config:
use_enum_values = True
class SummaryMessage(BaseModel):
"""Message sent when summary is ready."""
type: str = "summary"
lead_agent_summary: str
overall_status: OverallStatus
financial_promotion_reason: Optional[str] = None
class Config:
use_enum_values = True
class CompleteMessage(BaseModel):
"""Message sent when analysis is complete."""
type: str = "complete"
result: AgentReview
class Config:
use_enum_values = True
class ErrorMessage(BaseModel):
"""Message sent when an error occurs."""
type: str = "error"
message: str

View file

@ -0,0 +1,3 @@
from .gemini_service import GeminiService
from .reference_docs import ReferenceDocsService
from .analysis_service import AnalysisService

View file

@ -0,0 +1,115 @@
import logging
from typing import Callable, Awaitable
from app.models.schemas import SubReview, AgentReview, OverallStatus
logger = logging.getLogger(__name__)
from app.agents.brand_agent import BrandAgent
from app.agents.channel_agent import ChannelAgent
from app.agents.legal_agent import LegalAgent
from app.agents.tone_agent import ToneAgent
from app.agents.lead_agent import LeadAgent
from app.services.gemini_service import GeminiService
from app.services.reference_docs import ReferenceDocsService
# Type alias for the callback function
AgentCallback = Callable[[str, SubReview | None], Awaitable[None]]
class AnalysisService:
"""
Orchestrates the multi-agent proof analysis.
Runs agents sequentially and provides callbacks for real-time updates.
"""
# Agent execution order
AGENT_ORDER = ["Legal Agent", "Brand Agent", "Tone Agent", "Channel Agent"]
def __init__(
self,
gemini_service: GeminiService,
reference_docs: ReferenceDocsService,
):
"""
Initialize the analysis service with all required agents.
Args:
gemini_service: Service for Gemini API calls
reference_docs: Service for loading reference documents
"""
self.gemini_service = gemini_service
self.reference_docs = reference_docs
# Initialize agents
self.agents = {
"Legal Agent": LegalAgent(),
"Brand Agent": BrandAgent(gemini_service, reference_docs),
"Tone Agent": ToneAgent(),
"Channel Agent": ChannelAgent(gemini_service, reference_docs),
}
self.lead_agent = LeadAgent(gemini_service)
async def analyze_proof(
self,
file_data: bytes,
file_type: str,
on_agent_update: AgentCallback | None = None,
is_wip: bool = False,
) -> AgentReview:
"""
Analyze a proof using all agents sequentially.
Args:
file_data: Raw bytes of the file to analyze
file_type: MIME type of the file
on_agent_update: Optional callback for real-time agent updates.
Called with (agent_name, None) when agent starts,
and (agent_name, review) when agent completes.
is_wip: Whether this is a work-in-progress analysis
Returns:
Complete AgentReview with all agent results and overall verdict
"""
logger.info(f"[ANALYSIS] Starting proof analysis - file_type: {file_type}, file_size: {len(file_data)} bytes, is_wip: {is_wip}")
reviews: dict[str, SubReview] = {}
# Run each agent sequentially
for agent_name in self.AGENT_ORDER:
agent = self.agents[agent_name]
logger.info(f"[ANALYSIS] Starting agent: {agent_name}")
# Notify that agent is starting
if on_agent_update:
await on_agent_update(agent_name, None)
# Run the agent
review = await agent.analyze(file_data, file_type)
reviews[agent_name] = review
logger.info(f"[ANALYSIS] Agent completed: {agent_name} - ragStatus: {review.ragStatus}")
# Notify that agent completed
if on_agent_update:
await on_agent_update(agent_name, review)
# Get lead agent synthesis
logger.info("[ANALYSIS] Starting lead agent synthesis")
if on_agent_update:
await on_agent_update("Summary", None)
overall_status, summary, financial_promotion_reason = await self.lead_agent.synthesize(reviews)
logger.info(f"[ANALYSIS] Analysis complete - overallStatus: {overall_status}")
# Build the complete AgentReview
return AgentReview(
legalAgentReview=reviews["Legal Agent"],
brandAgentReview=reviews["Brand Agent"],
toneAgentReview=reviews["Tone Agent"],
channelAgentReview=reviews["Channel Agent"],
leadAgentSummary=summary,
overallStatus=overall_status,
financialPromotionReason=financial_promotion_reason,
)

View file

@ -0,0 +1,173 @@
import json
import logging
from google import genai
from google.genai import types
from app.models.schemas import SubReview, RagStatus
# Configure logging
logger = logging.getLogger(__name__)
class GeminiService:
"""Service wrapper for Google Gemini API calls."""
def __init__(self, api_key: str):
"""
Initialize the Gemini service.
Args:
api_key: Google Gemini API key
"""
self.client = genai.Client(api_key=api_key)
self.model = "gemini-2.5-flash"
async def analyze_with_image(
self,
prompt: str,
file_data: bytes,
file_type: str,
) -> SubReview:
"""
Analyze an image/file with Gemini and return a structured SubReview.
Args:
prompt: The analysis prompt including reference doc context
file_data: Raw bytes of the file
file_type: MIME type of the file (e.g., "image/png")
Returns:
SubReview with ragStatus, feedback, and issues
"""
try:
logger.info(f"[GEMINI API] Starting image analysis - file_type: {file_type}, file_size: {len(file_data)} bytes")
# Create inline data part for the file
file_part = types.Part.from_bytes(
data=file_data,
mime_type=file_type
)
# Define the response schema for structured output
response_schema = {
"type": "object",
"properties": {
"analysisStatus": {
"type": "string",
"enum": ["success", "low_confidence"],
"description": "Set to 'low_confidence' if the proof is nonsensical, completely irrelevant to marketing, or otherwise impossible to analyze. Otherwise, set to 'success'."
},
"ragStatus": {
"type": "string",
"enum": ["Red", "Amber", "Green"],
"description": "A RAG status. Red: Issues that must be resolved. Amber: Issues that should be addressed. Green: No issues found."
},
"feedback": {
"type": "string",
"description": "Constructive, professional feedback explaining the RAG status and highlighting both positive aspects and areas for improvement."
},
"issues": {
"type": "array",
"items": {"type": "string"},
"description": "A list of specific, actionable issues found. If no issues, return an empty array."
}
},
"required": ["analysisStatus", "ragStatus", "feedback", "issues"]
}
# Make the API call
logger.info(f"[GEMINI API] Calling Gemini model: {self.model}")
response = await self.client.aio.models.generate_content(
model=self.model,
contents=[file_part, prompt],
config=types.GenerateContentConfig(
response_mime_type="application/json",
response_schema=response_schema
)
)
logger.info(f"[GEMINI API] Response received from Gemini")
# Parse the JSON response
json_text = response.text.strip()
parsed_result = json.loads(json_text)
logger.info(f"[GEMINI API] Parsed result - ragStatus: {parsed_result.get('ragStatus')}, analysisStatus: {parsed_result.get('analysisStatus')}")
# Handle low confidence analysis
if parsed_result.get("analysisStatus") == "low_confidence":
return SubReview(
ragStatus=RagStatus.ERROR,
feedback="The agent could not analyze this proof with high confidence. This may be because the content is irrelevant, nonsensical, or too far outside of expected marketing materials.",
issues=[]
)
# Return successful analysis
return SubReview(
ragStatus=RagStatus(parsed_result["ragStatus"]),
feedback=parsed_result["feedback"],
issues=parsed_result["issues"]
)
except json.JSONDecodeError as e:
logger.error(f"[GEMINI API] JSON parse error: {str(e)}")
return SubReview(
ragStatus=RagStatus.ERROR,
feedback=f"Failed to parse AI response as JSON: {str(e)}",
issues=[]
)
except Exception as e:
logger.error(f"[GEMINI API] Error during analysis: {str(e)}")
return SubReview(
ragStatus=RagStatus.ERROR,
feedback=f"An error occurred during analysis: {str(e)}",
issues=[]
)
async def generate_summary(
self,
prompt: str,
) -> dict:
"""
Generate a text summary (for lead agent).
Args:
prompt: The prompt for generating the summary
Returns:
Parsed JSON response with summary and overall_status
"""
try:
logger.info("[GEMINI API] Generating lead agent summary")
response_schema = {
"type": "object",
"properties": {
"overallStatus": {
"type": "string",
"enum": ["Passed", "Failed", "Analysis Error", "Requires Manual Legal Review"],
},
"summary": {
"type": "string",
"description": "A concise, professional summary explaining the overall status, based on the specialist reviews."
}
},
"required": ["overallStatus", "summary"]
}
response = await self.client.aio.models.generate_content(
model=self.model,
contents=prompt,
config=types.GenerateContentConfig(
response_mime_type="application/json",
response_schema=response_schema
)
)
result = json.loads(response.text.strip())
logger.info(f"[GEMINI API] Summary generated - overallStatus: {result.get('overallStatus')}")
return result
except Exception as e:
logger.error(f"[GEMINI API] Error generating summary: {str(e)}")
return {
"overallStatus": "Analysis Error",
"summary": f"An error occurred while generating the summary: {str(e)}"
}

View file

@ -0,0 +1,77 @@
from pathlib import Path
class ReferenceDocsService:
"""Service to load and provide reference documents for agents."""
def __init__(self, base_path: str | None = None):
"""
Initialize the reference docs service.
Args:
base_path: Path to the reference_docs directory.
Defaults to ../reference_docs relative to backend/
"""
if base_path is None:
# Default to reference_docs at project root (sibling to backend/)
base_path = Path(__file__).parent.parent.parent.parent / "reference_docs"
self.base_path = Path(base_path)
# Cache loaded documents
self._brand_context: str | None = None
self._channel_context: str | None = None
def get_brand_context(self) -> str:
"""Load and return all brand guideline documents as a single context string."""
if self._brand_context is None:
brand_path = self.base_path / "brand"
self._brand_context = self._load_all_markdown_files(brand_path)
return self._brand_context
def get_channel_context(self) -> str:
"""Load and return all channel guideline documents as a single context string."""
if self._channel_context is None:
channel_path = self.base_path / "channel"
self._channel_context = self._load_all_markdown_files(channel_path)
return self._channel_context
def _load_all_markdown_files(self, directory: Path) -> str:
"""
Load all .md files from a directory and concatenate them.
Args:
directory: Path to the directory containing markdown files
Returns:
Concatenated content of all markdown files with section headers
"""
contents = []
if directory.exists():
# Sort files for consistent ordering
for md_file in sorted(directory.glob("*.md")):
try:
content = md_file.read_text(encoding="utf-8")
# Add file name as section header
contents.append(f"## {md_file.stem}\n\n{content}")
except Exception as e:
print(f"Warning: Could not read {md_file}: {e}")
if not contents:
return "No reference documents found."
return "\n\n---\n\n".join(contents)
def get_context_summary(self) -> dict:
"""Return summary info about loaded documents."""
brand_path = self.base_path / "brand"
channel_path = self.base_path / "channel"
brand_files = list(brand_path.glob("*.md")) if brand_path.exists() else []
channel_files = list(channel_path.glob("*.md")) if channel_path.exists() else []
return {
"brand_files": [f.name for f in brand_files],
"channel_files": [f.name for f in channel_files],
"brand_context_length": len(self.get_brand_context()),
"channel_context_length": len(self.get_channel_context()),
}

View file

@ -0,0 +1,2 @@
from .manager import ConnectionManager
from .handlers import handle_analyze_message

View file

@ -0,0 +1,132 @@
import base64
import logging
from fastapi import WebSocket
from app.websocket.manager import ConnectionManager
from app.services.analysis_service import AnalysisService
from app.models.schemas import SubReview
logger = logging.getLogger(__name__)
async def handle_analyze_message(
websocket: WebSocket,
client_id: str,
data: dict,
manager: ConnectionManager,
analysis_service: AnalysisService,
) -> None:
"""
Handle an 'analyze' message from the client.
Runs the proof analysis and sends real-time updates via WebSocket.
Args:
websocket: The WebSocket connection
client_id: Unique client identifier
data: The message data containing file_data, file_type, is_wip
manager: Connection manager for sending messages
analysis_service: Service to run the analysis
"""
try:
logger.info(f"[WEBSOCKET] Received analyze request from client: {client_id}")
# Extract and decode the file data
file_data_b64 = data.get("file_data", "")
file_type = data.get("file_type", "image/png")
is_wip = data.get("is_wip", False)
logger.info(f"[WEBSOCKET] File type: {file_type}, is_wip: {is_wip}, base64 length: {len(file_data_b64)}")
# Decode base64 file data
try:
file_data = base64.b64decode(file_data_b64)
logger.info(f"[WEBSOCKET] Decoded file size: {len(file_data)} bytes")
except Exception as e:
logger.error(f"[WEBSOCKET] Failed to decode file data: {str(e)}")
await manager.send_message(client_id, {
"type": "error",
"message": f"Failed to decode file data: {str(e)}"
})
return
# Create callback for real-time updates
async def on_agent_update(agent_name: str, review: SubReview | None) -> None:
if not manager.is_connected(client_id):
logger.warning(f"[WEBSOCKET] Client {client_id} disconnected, skipping update")
return
if review is None:
# Agent is starting
logger.info(f"[WEBSOCKET] Sending agent_started: {agent_name}")
await manager.send_message(client_id, {
"type": "agent_started",
"agent_name": agent_name
})
else:
# Agent completed
logger.info(f"[WEBSOCKET] Sending agent_completed: {agent_name} - ragStatus: {review.ragStatus}")
await manager.send_message(client_id, {
"type": "agent_completed",
"agent_name": agent_name,
"review": {
"ragStatus": review.ragStatus,
"feedback": review.feedback,
"issues": review.issues,
"isFinancialPromotion": review.isFinancialPromotion,
"financialPromotionReason": review.financialPromotionReason,
}
})
# Run the analysis
logger.info("[WEBSOCKET] Starting analysis...")
result = await analysis_service.analyze_proof(
file_data=file_data,
file_type=file_type,
on_agent_update=on_agent_update,
is_wip=is_wip,
)
# Send the complete result
logger.info(f"[WEBSOCKET] Analysis complete, sending result - overallStatus: {result.overallStatus}")
if manager.is_connected(client_id):
await manager.send_message(client_id, {
"type": "complete",
"result": {
"legalAgentReview": {
"ragStatus": result.legalAgentReview.ragStatus,
"feedback": result.legalAgentReview.feedback,
"issues": result.legalAgentReview.issues,
"isFinancialPromotion": result.legalAgentReview.isFinancialPromotion,
"financialPromotionReason": result.legalAgentReview.financialPromotionReason,
},
"brandAgentReview": {
"ragStatus": result.brandAgentReview.ragStatus,
"feedback": result.brandAgentReview.feedback,
"issues": result.brandAgentReview.issues,
},
"toneAgentReview": {
"ragStatus": result.toneAgentReview.ragStatus,
"feedback": result.toneAgentReview.feedback,
"issues": result.toneAgentReview.issues,
},
"channelAgentReview": {
"ragStatus": result.channelAgentReview.ragStatus,
"feedback": result.channelAgentReview.feedback,
"issues": result.channelAgentReview.issues,
},
"leadAgentSummary": result.leadAgentSummary,
"overallStatus": result.overallStatus,
"financialPromotionReason": result.financialPromotionReason,
}
})
logger.info(f"[WEBSOCKET] Result sent to client: {client_id}")
except Exception as e:
# Send error message
logger.error(f"[WEBSOCKET] Analysis failed for client {client_id}: {str(e)}")
if manager.is_connected(client_id):
await manager.send_message(client_id, {
"type": "error",
"message": f"Analysis failed: {str(e)}"
})

View file

@ -0,0 +1,49 @@
from fastapi import WebSocket
class ConnectionManager:
"""Manages WebSocket connections for real-time updates."""
def __init__(self):
"""Initialize the connection manager."""
self.active_connections: dict[str, WebSocket] = {}
async def connect(self, websocket: WebSocket, client_id: str) -> None:
"""
Accept a new WebSocket connection.
Args:
websocket: The WebSocket connection
client_id: Unique identifier for this client
"""
await websocket.accept()
self.active_connections[client_id] = websocket
def disconnect(self, client_id: str) -> None:
"""
Remove a client connection.
Args:
client_id: The client to disconnect
"""
if client_id in self.active_connections:
del self.active_connections[client_id]
async def send_message(self, client_id: str, message: dict) -> None:
"""
Send a JSON message to a specific client.
Args:
client_id: The target client
message: Dictionary to send as JSON
"""
if client_id in self.active_connections:
try:
await self.active_connections[client_id].send_json(message)
except Exception:
# Client may have disconnected
self.disconnect(client_id)
def is_connected(self, client_id: str) -> bool:
"""Check if a client is still connected."""
return client_id in self.active_connections

8
backend/requirements.txt Normal file
View file

@ -0,0 +1,8 @@
fastapi>=0.109.0
uvicorn[standard]>=0.27.0
python-dotenv>=1.0.0
google-genai>=1.5.4
pydantic>=2.5.0
python-multipart>=0.0.9
aiofiles>=23.2.1
websockets>=12.0

813
frontend/App.tsx Normal file
View file

@ -0,0 +1,813 @@
import React, { useState, useEffect } from 'react';
import { Hero } from './components/Hero';
import { analyzeProof } from './services/geminiService';
import type { AgentReview, AgentName, FlaggedItem, ResolvedItem, ErrorItem } from './types';
import { AGENT_NAMES } from './constants';
import { Sidebar } from './components/Sidebar';
import { ChecksOverview } from './components/ChecksOverview';
import { Analytics } from './components/Analytics';
import { Profile } from './components/Profile';
import { CopyGenAI } from './components/CopyGenAI';
import { Settings } from './components/Settings';
import { Campaigns, initialCampaigns, initialCampaignProofs } from './components/Campaigns';
import { Auditing } from './components/Auditing';
import { Login } from './components/Login';
import { WIPReviewer } from './components/WIPReviewer';
type View = 'Home' | 'Analytics' | 'Campaigns' | 'WIP Reviewer' | 'CopyGenAI' | 'Settings' | 'Profile' | 'Auditing';
export interface DropdownOptions {
campaigns: string[];
// Hierarchy: Channel -> SubChannel -> ProofType[]
channels: Record<string, Record<string, string[]>>;
}
const App: React.FC = () => {
const [isLoggedIn, setIsLoggedIn] = useState(false);
const [currentView, setCurrentView] = useState<View>('Home');
const [selectedCampaign, setSelectedCampaign] = useState<string | null>(null);
const [selectedProof, setSelectedProof] = useState<any | null>(null);
const [error, setError] = useState<string | null>(null);
const [dropdownOptions, setDropdownOptions] = useState<DropdownOptions>(() => {
try {
const storedOptions = localStorage.getItem('barclays_modcomms_dropdown_options_v3');
if (storedOptions) {
return JSON.parse(storedOptions);
}
} catch (error) {
console.error('Error reading dropdown options from localStorage', error);
}
return {
campaigns: ["Barclays Q4 Social"],
channels: {
"Social": {
"Meta": ["In-feed 1x1", "In-feed 4x5", "Reels static 9x16", "Stories Static 9x16", "In-feed 1x1 video", "9x16 reels", "9x16 stories"]
},
"YouTube - (online video)": {
"Video": ["1x1", "16x9"]
},
"Google - Performance Max": {
"Video": ["1x1", "9x16", "16x9"],
"Static image": ["1080x1080", "4x5 (1080x1350)", "1.91x1 (1200x628)", "Logo 1x1 (1080x1080)"]
},
"Display": {
"Banner": ["300x600", "160x600", "970x250", "300x250"]
},
".co.uk Banner": {
"Web Banner Design + Static": ["720x540", "1316x740"]
},
"Ad Copy": {
"Copy Document": ["Text"]
}
}
};
});
useEffect(() => {
try {
localStorage.setItem('barclays_modcomms_dropdown_options_v3', JSON.stringify(dropdownOptions));
} catch (error) {
console.error('Error saving dropdown options to localStorage', error);
}
}, [dropdownOptions]);
const [campaigns, setCampaigns] = useState(() => {
try {
const storedCampaigns = localStorage.getItem('barclays_modcomms_campaigns_v3');
return storedCampaigns ? JSON.parse(storedCampaigns) : initialCampaigns;
} catch (error) {
console.error('Error reading campaigns from localStorage', error);
return initialCampaigns;
}
});
const [campaignProofs, setCampaignProofs] = useState(() => {
try {
const storedProofs = localStorage.getItem('barclays_modcomms_campaign_proofs_v3');
return storedProofs ? JSON.parse(storedProofs) : initialCampaignProofs;
} catch (error) {
console.error('Error reading campaign proofs from localStorage', error);
return initialCampaignProofs;
}
});
const [flaggedItems, setFlaggedItems] = useState<FlaggedItem[]>(() => {
try {
const storedFlags = localStorage.getItem('barclays_modcomms_flagged_items_v3');
return storedFlags ? JSON.parse(storedFlags) : [];
} catch (error) {
console.error('Error reading flagged items from localStorage', error);
return [];
}
});
const [resolvedItems, setResolvedItems] = useState<ResolvedItem[]>(() => {
try {
const storedResolutions = localStorage.getItem('barclays_modcomms_resolved_items_v3');
return storedResolutions ? JSON.parse(storedResolutions) : [];
} catch (error) {
console.error('Error reading resolved items from localStorage', error);
return [];
}
});
const [errorItems, setErrorItems] = useState<ErrorItem[]>(() => {
try {
const storedErrors = localStorage.getItem('barclays_modcomms_error_items_v3');
return storedErrors ? JSON.parse(storedErrors) : [];
} catch (error) {
console.error('Error reading error items from localStorage', error);
return [];
}
});
useEffect(() => {
try {
if (!localStorage.getItem('barclays_modcomms_campaigns_v3')) {
localStorage.setItem('barclays_modcomms_campaigns_v3', JSON.stringify(initialCampaigns));
}
if (!localStorage.getItem('barclays_modcomms_campaign_proofs_v3')) {
localStorage.setItem('barclays_modcomms_campaign_proofs_v3', JSON.stringify(initialCampaignProofs));
}
} catch (error) {
console.error('Error saving initial data to localStorage', error);
}
}, []);
useEffect(() => {
// Keep selectedProof in sync with the master list in campaignProofs.
// This ensures that when a new version is added, the detail view refreshes.
if (selectedCampaign && selectedProof && campaignProofs[selectedCampaign]) {
const currentCampaignProofs = campaignProofs[selectedCampaign];
const freshProof = currentCampaignProofs.find(a => !a.tempId && a.proofName === selectedProof.proofName);
// A simple stringify check to prevent re-renders if the object is the same.
if (freshProof && JSON.stringify(freshProof) !== JSON.stringify(selectedProof)) {
setSelectedProof(freshProof);
}
}
}, [campaignProofs, selectedCampaign, selectedProof]);
// New function to handle saving campaign proofs to avoid QuotaExceededError
const saveCampaignProofs = (proofs: any) => {
try {
// Deep clone to avoid mutating the state object used by React
const proofsToSave = JSON.parse(JSON.stringify(proofs));
// Iterate over all campaigns and their proofs
for (const campaignName in proofsToSave) {
if (Object.prototype.hasOwnProperty.call(proofsToSave, campaignName)) {
proofsToSave[campaignName].forEach((proof: any) => {
// For temporary placeholder proofs, remove the file object and progress
// as it cannot be stringified and is not needed for persistence.
if (proof.tempId) {
if ('file' in proof) delete proof.file;
if ('analysisProgress' in proof) delete proof.analysisProgress;
}
// For versioned proofs, remove large non-SVG preview URLs
if (proof.versions) {
proof.versions.forEach((version: any) => {
if (version.proofPreviewUrl && !version.proofPreviewUrl.startsWith('data:image/svg+xml')) {
delete version.proofPreviewUrl;
}
});
}
});
}
}
localStorage.setItem('barclays_modcomms_campaign_proofs_v3', JSON.stringify(proofsToSave));
} catch (error) {
if ((error as DOMException).name === 'QuotaExceededError') {
console.error('LocalStorage quota exceeded. Could not save campaign proofs. The app might not persist data correctly across sessions until storage is cleared.', error);
setError('Could not save campaign changes, storage is full.');
} else {
console.error('Error saving campaign proofs to localStorage', error);
}
}
};
const handleAddNewCampaign = (campaignData: { name: string; workfrontId: string; clientLead: string; brandGuidelines: string; }) => {
const newCampaign = {
...campaignData,
agency: "OLIVER Agency",
agencyLead: "Steve O'Donoghue",
proofs: 0,
status: 'In Progress',
lastModified: new Date().toISOString().split('T')[0],
};
const updatedCampaigns = [...campaigns, newCampaign];
const updatedCampaignProofs = { ...campaignProofs, [newCampaign.name]: [] };
setCampaigns(updatedCampaigns);
setCampaignProofs(updatedCampaignProofs);
try {
localStorage.setItem('barclays_modcomms_campaigns_v3', JSON.stringify(updatedCampaigns));
saveCampaignProofs(updatedCampaignProofs);
} catch (error) {
console.error('Error saving new campaign to localStorage', error);
}
};
const fileToDataUrl = (file: File): Promise<string> => {
return new Promise((resolve, reject) => {
const reader = new FileReader();
reader.onloadend = () => resolve(reader.result as string);
reader.onerror = reject;
reader.readAsDataURL(file);
});
};
const handleAddNewError = (errorData: Omit<ErrorItem, 'id' | 'timestamp' | 'submitter' | 'submitAgency'>) => {
const newError: ErrorItem = {
...errorData,
id: `err_${Date.now()}`,
timestamp: new Date().toISOString(),
submitter: "Steve O'Donoghue", // Hardcoded for prototype
submitAgency: "OLIVER Agency", // Hardcoded for prototype
};
setErrorItems(prevItems => {
const updatedErrors = [newError, ...prevItems];
try {
localStorage.setItem('barclays_modcomms_error_items_v3', JSON.stringify(updatedErrors));
} catch (error) {
console.error('Error saving error items to localStorage', error);
setError('Could not save the analysis error log due to storage limitations.');
}
return updatedErrors;
});
};
const handleProofUploadForCampaign = async (
campaignName: string,
file: File,
proofName: string,
channel: string,
subChannel: string,
proofType?: string
) => {
setError(null);
const tempId = `temp_${Date.now()}`;
const newProofPlaceholder = {
tempId,
proofName,
channel,
subChannel,
proofType,
status: 'analyzing',
analysisProgress: { completed: 0, total: AGENT_NAMES.length + 1 },
file,
versions: [],
};
setCampaignProofs(prevProofs => ({
...prevProofs,
[campaignName]: [newProofPlaceholder, ...(prevProofs[campaignName] || [])]
}));
const handleAgentUpdate = (agentName: AgentName | 'Summary') => {
setCampaignProofs(prevProofs => {
const currentProofs = prevProofs[campaignName] || [];
const updatedProofs = currentProofs.map(proof => {
if (proof.tempId === tempId && proof.status === 'analyzing') {
const newCompleted = (proof.analysisProgress?.completed ?? 0) + 1;
return { ...proof, analysisProgress: { ...proof.analysisProgress, completed: newCompleted } };
}
return proof;
});
return { ...prevProofs, [campaignName]: updatedProofs };
});
};
try {
const feedback = await analyzeProof(file, handleAgentUpdate);
const previewUrl = await fileToDataUrl(file);
if (feedback.overallStatus === 'Analysis Error') {
const currentCampaignProofsList = campaignProofs[campaignName] || [];
const existingProof = currentCampaignProofsList.find(a => a.proofName === proofName && !a.tempId);
const version = existingProof ? (existingProof.versions[0]?.version || 0) + 1 : 1;
handleAddNewError({
campaignName,
proofName,
version,
errorSummary: feedback.leadAgentSummary,
});
}
setCampaignProofs(prevCampaignProofs => {
const currentCampaignProofsList = prevCampaignProofs[campaignName] || [];
const existingProof = currentCampaignProofsList.find(a => a.proofName === proofName && !a.tempId);
if (existingProof) {
// UPDATE PROOF (NEW VERSION)
const latestVersionNumber = existingProof.versions[0]?.version || 0;
const newVersionNumber = latestVersionNumber + 1;
const baseWorkfrontId = existingProof.versions.length > 0
? existingProof.versions[existingProof.versions.length - 1].workfrontId.split('-V')[0]
: `#WF_${Math.floor(10000 + Math.random() * 90000)}`;
const newVersion = {
version: newVersionNumber,
timestamp: new Date().toISOString().split('T')[0],
workfrontId: `${baseWorkfrontId}-V${newVersionNumber}`,
proofPreviewUrl: previewUrl,
feedback: feedback,
overallStatus: feedback.overallStatus,
};
const updatedProof = {
...existingProof,
overallStatus: feedback.overallStatus,
versions: [newVersion, ...existingProof.versions]
};
const updatedProofsList = currentCampaignProofsList
.filter(proof => proof.tempId !== tempId)
.map(proof => proof.proofName === proofName ? updatedProof : proof);
const finalCampaignProofs = { ...prevCampaignProofs, [campaignName]: updatedProofsList };
setCampaigns(prevCampaigns => {
const updatedCampaigns = prevCampaigns.map(p =>
p.name === campaignName ? { ...p, lastModified: new Date().toISOString().split('T')[0] } : p
);
localStorage.setItem('barclays_modcomms_campaigns_v3', JSON.stringify(updatedCampaigns));
return updatedCampaigns;
});
saveCampaignProofs(finalCampaignProofs);
return finalCampaignProofs;
} else {
// CREATE NEW PROOF
const newWorkfrontId = `#WF_${Math.floor(10000 + Math.random() * 90000)}`;
const newProofWithVersion = {
proofName,
channel,
subChannel,
proofType,
status: 'completed',
overallStatus: feedback.overallStatus,
versions: [
{
version: 1,
timestamp: new Date().toISOString().split('T')[0],
workfrontId: `${newWorkfrontId}-V1`,
proofPreviewUrl: previewUrl,
feedback: feedback,
overallStatus: feedback.overallStatus,
}
]
};
const updatedProofsList = currentCampaignProofsList.map(proof =>
proof.tempId === tempId ? newProofWithVersion : proof
);
const finalCampaignProofs = { ...prevCampaignProofs, [campaignName]: updatedProofsList };
setCampaigns(prevCampaigns => {
const updatedCampaigns = prevCampaigns.map(p =>
p.name === campaignName ? { ...p, proofs: p.proofs + 1, lastModified: new Date().toISOString().split('T')[0] } : p
);
localStorage.setItem('barclays_modcomms_campaigns_v3', JSON.stringify(updatedCampaigns));
return updatedCampaigns;
});
saveCampaignProofs(finalCampaignProofs);
return finalCampaignProofs;
}
});
} catch (err) {
console.error("Failed to upload and analyze proof:", err);
setError("Failed to upload and analyze proof. Please try again.");
setCampaignProofs(prevProofs => {
const updatedProofs = {
...prevProofs,
[campaignName]: prevProofs[campaignName].map(proof =>
proof.tempId === tempId ? { ...proof, status: 'error' } : proof
)
};
saveCampaignProofs(updatedProofs);
return updatedProofs;
});
}
};
const handleRetryAnalysis = async (campaignName: string, tempId: string) => {
const proofToRetry = campaignProofs[campaignName]?.find(proof => proof.tempId === tempId);
if (!proofToRetry || !proofToRetry.file) {
console.error("Proof to retry not found or file is missing");
return;
}
const { file, proofName, channel, subChannel, proofType } = proofToRetry;
setCampaignProofs(prevProofs => ({
...prevProofs,
[campaignName]: prevProofs[campaignName].map(proof =>
proof.tempId === tempId ? { ...proof, status: 'analyzing', analysisProgress: { completed: 0, total: AGENT_NAMES.length + 1 } } : proof
)
}));
const handleAgentUpdateForRetry = (agentName: AgentName | 'Summary') => {
setCampaignProofs(prevProofs => {
const currentProofs = prevProofs[campaignName] || [];
const updatedProofs = currentProofs.map(proof => {
if (proof.tempId === tempId && proof.status === 'analyzing') {
const newCompleted = (proof.analysisProgress?.completed ?? 0) + 1;
return { ...proof, analysisProgress: { ...proof.analysisProgress, completed: newCompleted } };
}
return proof;
});
return { ...prevProofs, [campaignName]: updatedProofs };
});
};
try {
const feedback = await analyzeProof(file, handleAgentUpdateForRetry);
const previewUrl = await fileToDataUrl(file);
const newWorkfrontId = `#WF_${Math.floor(10000 + Math.random() * 90000)}`;
if (feedback.overallStatus === 'Analysis Error') {
handleAddNewError({
campaignName: campaignName,
proofName: proofName,
version: 1, // Retry always creates a V1
errorSummary: feedback.leadAgentSummary,
});
}
const newProofWithVersion = {
proofName,
channel,
subChannel,
proofType,
status: 'completed',
overallStatus: feedback.overallStatus,
versions: [
{
version: 1,
timestamp: new Date().toISOString().split('T')[0],
workfrontId: `${newWorkfrontId}-V1`,
proofPreviewUrl: previewUrl,
feedback: feedback,
overallStatus: feedback.overallStatus,
}
]
};
const updatedCampaigns = campaigns.map(p =>
p.name === campaignName ? { ...p, proofs: p.proofs + 1, lastModified: new Date().toISOString().split('T')[0] } : p
);
setCampaigns(updatedCampaigns);
localStorage.setItem('barclays_modcomms_campaigns_v3', JSON.stringify(updatedCampaigns));
setCampaignProofs(prevProofs => {
const updatedProofs = {
...prevProofs,
[campaignName]: prevProofs[campaignName].map(proof =>
proof.tempId === tempId ? newProofWithVersion : proof
)
};
saveCampaignProofs(updatedProofs);
return updatedProofs;
});
} catch (err) {
console.error("Failed to retry proof analysis:", err);
setCampaignProofs(prevProofs => {
const updatedProofs = {
...prevProofs,
[campaignName]: prevProofs[campaignName].map(proof =>
proof.tempId === tempId ? { ...proof, status: 'error' } : proof
)
};
saveCampaignProofs(updatedProofs);
return updatedProofs;
});
}
};
const handleCampaignStatusChange = (campaignName: string, newStatus: 'In Progress' | 'Completed') => {
const updatedCampaigns = campaigns.map(p =>
p.name === campaignName ? { ...p, status: newStatus, lastModified: new Date().toISOString().split('T')[0] } : p
);
setCampaigns(updatedCampaigns);
try {
localStorage.setItem('barclays_modcomms_campaigns_v3', JSON.stringify(updatedCampaigns));
} catch (error) {
console.error('Error saving campaign status to localStorage', error);
}
};
const handleDeleteProof = (campaignName: string, proofName: string) => {
// Update campaign proofs
setCampaignProofs(prevProofs => {
const updatedProofsForCampaign = prevProofs[campaignName].filter(
proof => proof.proofName !== proofName
);
const finalProofs = { ...prevProofs, [campaignName]: updatedProofsForCampaign };
saveCampaignProofs(finalProofs);
return finalProofs;
});
// Update campaigns list (proof count)
setCampaigns(prevCampaigns => {
const updatedCampaigns = prevCampaigns.map(p =>
p.name === campaignName
? { ...p, proofs: p.proofs > 0 ? p.proofs - 1 : 0, lastModified: new Date().toISOString().split('T')[0] }
: p
);
localStorage.setItem('barclays_modcomms_campaigns_v3', JSON.stringify(updatedCampaigns));
return updatedCampaigns;
});
};
// --- SETTINGS HANDLERS (UPDATED) ---
const handleAddCampaignOption = (value: string) => {
setDropdownOptions(prev => {
if (!prev.campaigns.includes(value)) {
return { ...prev, campaigns: [...prev.campaigns, value].sort() };
}
return prev;
});
};
const handleRemoveCampaignOption = (value: string) => {
setDropdownOptions(prev => ({ ...prev, campaigns: prev.campaigns.filter(c => c !== value) }));
};
const handleAddChannel = (channel: string) => {
setDropdownOptions(prev => {
if (prev.channels[channel]) return prev;
return {
...prev,
channels: {
...prev.channels,
[channel]: {}
}
};
});
};
const handleRemoveChannel = (channel: string) => {
setDropdownOptions(prev => {
const newChannels = { ...prev.channels };
delete newChannels[channel];
return { ...prev, channels: newChannels };
});
};
const handleAddSubChannel = (channel: string, subChannel: string) => {
setDropdownOptions(prev => {
const channelData = prev.channels[channel] || {};
if (channelData[subChannel]) return prev;
return {
...prev,
channels: {
...prev.channels,
[channel]: {
...channelData,
[subChannel]: []
}
}
};
});
};
const handleRemoveSubChannel = (channel: string, subChannel: string) => {
setDropdownOptions(prev => {
const channelData = { ...prev.channels[channel] };
delete channelData[subChannel];
return {
...prev,
channels: {
...prev.channels,
[channel]: channelData
}
};
});
};
const handleAddProofType = (channel: string, subChannel: string, proofType: string) => {
setDropdownOptions(prev => {
const channelData = prev.channels[channel];
if (!channelData) return prev;
const currentTypes = channelData[subChannel] || [];
if (currentTypes.includes(proofType)) return prev;
return {
...prev,
channels: {
...prev.channels,
[channel]: {
...channelData,
[subChannel]: [...currentTypes, proofType].sort()
}
}
};
});
};
const handleRemoveProofType = (channel: string, subChannel: string, proofType: string) => {
setDropdownOptions(prev => {
const channelData = prev.channels[channel];
if (!channelData) return prev;
const currentTypes = channelData[subChannel] || [];
return {
...prev,
channels: {
...prev.channels,
[channel]: {
...channelData,
[subChannel]: currentTypes.filter(t => t !== proofType)
}
}
};
});
};
const handleNavigate = (view: View) => {
setCurrentView(view);
setSelectedCampaign(null); // Reset campaign on any main navigation
setSelectedProof(null);
};
const handleSelectCampaign = (campaignName: string) => {
setSelectedCampaign(campaignName);
};
const handleSelectProof = (proof: any) => {
setSelectedProof(proof);
};
const handleBackToCampaignsList = () => {
setSelectedCampaign(null);
setSelectedProof(null);
};
const handleBackToCampaignDetails = () => {
setSelectedProof(null);
};
const handleFlagSubmit = (flagData: Omit<FlaggedItem, 'id' | 'timestamp' | 'submitter' | 'submitAgency'>) => {
const newFlag: FlaggedItem = {
...flagData,
id: `flag_${Date.now()}`,
timestamp: new Date().toISOString(),
submitter: "Steve O'Donoghue", // Hardcoded for prototype
submitAgency: "OLIVER Agency", // Hardcoded for prototype
};
const updatedFlags = [newFlag, ...flaggedItems];
setFlaggedItems(updatedFlags);
try {
localStorage.setItem('barclays_modcomms_flagged_items_v3', JSON.stringify(updatedFlags));
} catch (error) {
console.error('Error saving flagged items to localStorage', error);
setError('Could not save your feedback due to storage limitations.');
}
};
const handleResolveSubmit = (resolveData: Omit<ResolvedItem, 'id' | 'timestamp' | 'submitter' | 'submitAgency'>) => {
const newResolution: ResolvedItem = {
...resolveData,
id: `res_${Date.now()}`,
timestamp: new Date().toISOString(),
submitter: "Steve O'Donoghue", // Hardcoded for prototype
submitAgency: "OLIVER Agency", // Hardcoded for prototype
};
const updatedResolutions = [newResolution, ...resolvedItems];
setResolvedItems(updatedResolutions);
try {
localStorage.setItem('barclays_modcomms_resolved_items_v3', JSON.stringify(updatedResolutions));
} catch (error) {
console.error('Error saving resolved items to localStorage', error);
setError('Could not save your resolution due to storage limitations.');
}
};
const handleNavigateToAuditedItem = (item: { campaignName: string; proofName: string; version: number }) => {
const proofToSelect = campaignProofs[item.campaignName]?.find(a => a.proofName === item.proofName);
if (proofToSelect) {
const versionExists = proofToSelect.versions.some((v: any) => v.version === item.version);
if (versionExists) {
setSelectedCampaign(item.campaignName);
// Add a temporary property to the proof object to indicate which version to show.
setSelectedProof({ ...proofToSelect, initialVersion: item.version });
setCurrentView('Campaigns');
} else {
setError(`Version ${item.version} not found for proof ${item.proofName}. It may have been deleted.`);
}
} else {
setError(`Proof ${item.proofName} not found in campaign ${item.campaignName}. It may have been deleted.`);
}
};
const handleLogin = () => {
setIsLoggedIn(true);
};
const handleLogout = () => {
setIsLoggedIn(false);
};
const renderContent = () => {
switch (currentView) {
case 'Analytics':
return <Analytics />;
case 'Profile':
return <Profile onLogout={handleLogout} />;
case 'CopyGenAI':
return <CopyGenAI />;
case 'Campaigns':
return <Campaigns
selectedCampaign={selectedCampaign}
selectedProof={selectedProof}
onSelectCampaign={handleSelectCampaign}
onSelectProof={handleSelectProof}
onBackToCampaignsList={handleBackToCampaignsList}
onBackToCampaignDetails={handleBackToCampaignDetails}
campaigns={campaigns}
campaignProofs={campaignProofs}
onAddNewCampaign={handleAddNewCampaign}
onProofUpload={handleProofUploadForCampaign}
dropdownOptions={dropdownOptions}
onRetryAnalysis={handleRetryAnalysis}
onCampaignStatusChange={handleCampaignStatusChange}
onDeleteProof={handleDeleteProof}
onFlagSubmit={handleFlagSubmit}
onResolveSubmit={handleResolveSubmit}
/>;
case 'WIP Reviewer':
return <WIPReviewer dropdownOptions={dropdownOptions} />;
case 'Auditing':
return <Auditing
flaggedItems={flaggedItems}
resolvedItems={resolvedItems}
errorItems={errorItems}
onNavigate={handleNavigateToAuditedItem}
/>;
case 'Settings':
return <Settings
options={dropdownOptions}
onAddCampaign={handleAddCampaignOption}
onRemoveCampaign={handleRemoveCampaignOption}
onAddChannel={handleAddChannel}
onRemoveChannel={handleRemoveChannel}
onAddSubChannel={handleAddSubChannel}
onRemoveSubChannel={handleRemoveSubChannel}
onAddProofType={handleAddProofType}
onRemoveProofType={handleRemoveProofType}
onNavigate={handleNavigate}
/>;
case 'Home':
default:
return (
<>
<Hero onGetStarted={() => handleNavigate('Campaigns')} />
<ChecksOverview />
</>
);
}
};
if (!isLoggedIn) {
return <Login onLogin={handleLogin} />;
}
// Determine background color based on view to avoid grey bar on Home view
const mainBgColor = currentView === 'Home' ? 'bg-white' : 'bg-brand-gray';
return (
<div className={`flex h-screen ${mainBgColor} font-sans text-gray-800 overflow-hidden`}>
<Sidebar activeItem={currentView} onNavigate={(view) => handleNavigate(view as View)} />
<div className="flex-1 flex flex-col overflow-y-auto">
<main className="flex-1 flex flex-col min-h-full">
{renderContent()}
</main>
</div>
</div>
);
};
export default App;

20
frontend/README.md Normal file
View file

@ -0,0 +1,20 @@
<div align="center">
<img width="1200" height="475" alt="GHBanner" src="https://github.com/user-attachments/assets/0aa67016-6eaf-458a-adb2-6e31a0763ed6" />
</div>
# Run and deploy your AI Studio app
This contains everything you need to run your app locally.
View your app in AI Studio: https://ai.studio/apps/drive/1vH-R-vj0Xkk_g2ZFdHtLxNc12sFTOl2L
## Run Locally
**Prerequisites:** Node.js
1. Install dependencies:
`npm install`
2. Set the `GEMINI_API_KEY` in [.env.local](.env.local) to your Gemini API key
3. Run the app:
`npm run dev`

View file

@ -0,0 +1,117 @@
import React from 'react';
import { UploadIcon } from './icons/UploadIcon';
import { TrendingUpIcon } from './icons/TrendingUpIcon';
import { BugIcon } from './icons/BugIcon';
import { ClockIcon } from './icons/ClockIcon';
import { LightbulbIcon } from './icons/LightbulbIcon';
const stats = [
{ name: 'Proofs Uploaded', value: '57', icon: UploadIcon },
{ name: 'Pass Rate', value: '76%', icon: TrendingUpIcon },
{ name: 'Issues Found', value: '34', icon: BugIcon },
{ name: 'Time Saved', value: '93 hours', icon: ClockIcon },
];
const agentPerformance = [
{ name: 'Legal Agent', passRate: 85, avgIssues: 1.2, trend: 'up' },
{ name: 'Brand Agent', passRate: 68, avgIssues: 2.5, trend: 'down' },
{ name: 'Tone Agent', passRate: 92, avgIssues: 0.8, trend: 'up' },
{ name: 'Channel Agent', passRate: 71, avgIssues: 1.9, trend: 'stable' },
];
const UpArrow = () => <svg xmlns="http://www.w3.org/2000/svg" fill="none" viewBox="0 0 24 24" strokeWidth={3} stroke="currentColor" className="h-4 w-4"><path strokeLinecap="round" strokeLinejoin="round" d="M4.5 15.75l7.5-7.5 7.5 7.5" /></svg>;
const DownArrow = () => <svg xmlns="http://www.w3.org/2000/svg" fill="none" viewBox="0 0 24 24" strokeWidth={3} stroke="currentColor" className="h-4 w-4"><path strokeLinecap="round" strokeLinejoin="round" d="M19.5 8.25l-7.5 7.5-7.5-7.5" /></svg>;
const StableLine = () => <svg xmlns="http://www.w3.org/2000/svg" fill="none" viewBox="0 0 24 24" strokeWidth={3} stroke="currentColor" className="h-4 w-4"><path strokeLinecap="round" strokeLinejoin="round" d="M3.75 12h16.5" /></svg>;
const TrendIndicator: React.FC<{ trend: 'up' | 'down' | 'stable' }> = ({ trend }) => {
if (trend === 'up') {
return <div className="flex items-center gap-1.5 text-green-600"><UpArrow/> Improving</div>;
}
if (trend === 'down') {
return <div className="flex items-center gap-1.5 text-red-600"><DownArrow/> Declining</div>;
}
return <div className="flex items-center gap-1.5 text-gray-500"><StableLine/> Stable</div>;
};
export const Analytics: React.FC = () => {
return (
<div className="p-4 sm:p-6 lg:p-8 h-full bg-brand-gray">
<header className="mb-8">
<h1 className="text-3xl lg:text-4xl font-bold text-brand-dark-blue">Performance Analytics</h1>
<p className="text-base lg:text-lg text-gray-600 mt-1">Overall usage and performance statistics for the tool.</p>
</header>
{/* Stats Cards */}
<section>
<div className="grid grid-cols-1 sm:grid-cols-2 lg:grid-cols-4 gap-6">
{stats.map((stat) => {
const Icon = stat.icon;
return (
<div key={stat.name} className="bg-white rounded-lg shadow-md p-6 flex items-start border border-gray-200 transition-all hover:shadow-xl hover:border-brand-accent">
<div className="p-3 rounded-full bg-brand-light-blue/20 text-brand-accent mr-4 flex-shrink-0">
<Icon className="h-7 w-7" />
</div>
<div>
<p className="text-sm font-medium text-gray-500">{stat.name}</p>
<p className="text-3xl font-bold text-brand-dark-blue mt-1">{stat.value}</p>
</div>
</div>
);
})}
</div>
</section>
{/* AI Performance Summary */}
<section className="mt-10">
<h2 className="text-2xl font-bold text-brand-dark-blue mb-4">AI Performance Summary</h2>
<div className="bg-blue-50 border-l-4 border-brand-accent text-brand-dark-blue p-6 rounded-r-lg shadow-md flex items-start gap-4">
<div className="flex-shrink-0">
<LightbulbIcon className="h-7 w-7 text-brand-accent" />
</div>
<div>
<p className="font-semibold">Key Insight (Last 7 Days):</p>
<p className="mt-1">
A sharp decline in Best Practice adherence has been noted, primarily driven by proofs from the <strong>Barclays Q4 Social</strong> campaign. The Brand Guardian agent also shows a declining performance trend, suggesting a potential need for updated brand guideline training or proof review.
</p>
</div>
</div>
</section>
{/* Agent Performance Table */}
<section className="mt-10">
<h2 className="text-2xl font-bold text-brand-dark-blue mb-4">Agent Performance (Last 7 Days)</h2>
<div className="bg-white rounded-lg shadow-md overflow-hidden border border-gray-200">
<div className="overflow-x-auto">
<table className="min-w-full divide-y divide-gray-200">
<thead className="bg-gray-50">
<tr>
<th scope="col" className="px-6 py-3 text-left text-xs font-bold text-gray-500 uppercase tracking-wider">Agent Name</th>
<th scope="col" className="px-6 py-3 text-left text-xs font-bold text-gray-500 uppercase tracking-wider">Pass Rate</th>
<th scope="col" className="px-6 py-3 text-left text-xs font-bold text-gray-500 uppercase tracking-wider">Avg. Issues per Proof</th>
<th scope="col" className="px-6 py-3 text-left text-xs font-bold text-gray-500 uppercase tracking-wider">Performance Trend</th>
</tr>
</thead>
<tbody className="bg-white divide-y divide-gray-200">
{agentPerformance.map((agent) => (
<tr key={agent.name} className="hover:bg-gray-50">
<td className="px-6 py-4 whitespace-nowrap text-sm font-medium text-brand-dark-blue">{agent.name}</td>
<td className={`px-6 py-4 whitespace-nowrap text-sm font-semibold`}>
<div className="flex items-center">
<span className={`h-2.5 w-2.5 rounded-full mr-3 ${agent.passRate >= 80 ? 'bg-green-500' : agent.passRate < 70 ? 'bg-red-500' : 'bg-yellow-500'}`}></span>
{agent.passRate}%
</div>
</td>
<td className="px-6 py-4 whitespace-nowrap text-sm text-gray-700">{agent.avgIssues}</td>
<td className="px-6 py-4 whitespace-nowrap text-sm font-medium">
<TrendIndicator trend={agent.trend as 'up' | 'down' | 'stable'} />
</td>
</tr>
))}
</tbody>
</table>
</div>
</div>
</section>
</div>
);
};

View file

@ -0,0 +1,84 @@
import React from 'react';
import { DocumentIcon } from './icons/DocumentIcon';
interface AssetPreviewProps {
file?: File | null;
previewUrl: string | null;
fileName?: string;
}
export const AssetPreview: React.FC<AssetPreviewProps> = ({ file, previewUrl, fileName }) => {
if (!previewUrl) {
return null;
}
const getMimeType = (): string => {
if (file?.type) return file.type;
if (previewUrl.startsWith('data:')) {
const match = previewUrl.match(/data:([a-zA-Z0-9]+\/[a-zA-Z0-9-.+]+);/);
if (match && match[1]) {
return match[1];
}
}
return 'application/octet-stream'; // Fallback
};
const fileType = getMimeType();
const displayName = fileName || file?.name || 'Asset Preview';
const renderPreview = () => {
if (fileType.startsWith('image/')) {
return (
<img
src={previewUrl}
alt={displayName}
className="w-full rounded-lg shadow-2xl object-contain border border-gray-200 bg-white p-2"
style={{ maxHeight: 'calc(100vh - 9rem)' }}
/>
);
}
if (fileType === 'video/mp4') {
return (
<video
src={previewUrl}
controls
className="w-full rounded-lg shadow-2xl object-contain border border-gray-200 bg-white p-2"
style={{ maxHeight: 'calc(100vh - 9rem)' }}
>
Your browser does not support the video tag.
</video>
);
}
if (fileType === 'application/pdf') {
return (
<iframe
src={`${previewUrl}#view=fitH`}
title={displayName}
className="w-full h-[calc(100vh-9rem)] rounded-lg shadow-2xl border border-gray-200 bg-white"
/>
);
}
// Fallback for other file types
return (
<div
className="w-full rounded-lg shadow-2xl border border-gray-200 bg-white p-8 flex flex-col items-center justify-center text-center"
style={{ minHeight: '300px', maxHeight: 'calc(100vh - 9rem)' }}
>
<DocumentIcon className="h-20 w-20 text-gray-400 mb-4" />
<p className="text-lg font-semibold text-brand-dark-blue break-all">{displayName}</p>
<p className="text-sm text-gray-500">{fileType}</p>
<p className="text-sm text-gray-500 mt-2">No preview available for this file type.</p>
</div>
);
};
return (
<div className="sticky top-8">
{renderPreview()}
</div>
);
};

View file

@ -0,0 +1,54 @@
import React, { useRef } from 'react';
interface AssetUploadProps {
onFileUpload: (file: File) => void;
isLoading: boolean;
isUploadDisabled?: boolean;
}
export const AssetUpload: React.FC<AssetUploadProps> = ({ onFileUpload, isLoading, isUploadDisabled }) => {
const fileInputRef = useRef<HTMLInputElement>(null);
const handleFileChange = (event: React.ChangeEvent<HTMLInputElement>) => {
const file = event.target.files?.[0];
if (file) {
onFileUpload(file);
}
};
const handleClick = () => {
fileInputRef.current?.click();
};
const isDisabled = isLoading || isUploadDisabled;
return (
<div className="flex flex-col items-center">
<input
type="file"
ref={fileInputRef}
onChange={handleFileChange}
className="hidden"
accept="image/png, image/jpeg, image/webp, image/gif, video/mp4, application/pdf"
disabled={isDisabled}
/>
<button
onClick={handleClick}
disabled={isDisabled}
title={isUploadDisabled ? "Please complete all selections above" : "Upload an asset for review"}
className="w-full bg-brand-light-blue text-brand-dark-blue font-bold py-3 px-6 rounded-full hover:bg-white transition-all duration-300 disabled:bg-gray-500 disabled:opacity-60 disabled:cursor-not-allowed flex items-center justify-center"
>
{isLoading ? (
<>
<svg className="animate-spin -ml-1 mr-3 h-5 w-5" xmlns="http://www.w3.org/2000/svg" fill="none" viewBox="0 0 24 24">
<circle className="opacity-25" cx="12" cy="12" r="10" stroke="currentColor" strokeWidth="4"></circle>
<path className="opacity-75" fill="currentColor" d="M4 12a8 8 0 018-8V0C5.373 0 0 5.373 0 12h4zm2 5.291A7.962 7.962 0 014 12H0c0 3.042 1.135 5.824 3 7.938l3-2.647z"></path>
</svg>
Processing...
</>
) : 'Upload Asset'}
</button>
<p className="text-xs text-gray-400 mt-4">Supports PNG, JPG, GIF, WEBP, MP4, PDF.</p>
</div>
);
};

View file

@ -0,0 +1,219 @@
import React, { useState } from 'react';
import type { FlaggedItem, ResolvedItem, ErrorItem } from '../types';
interface AuditingProps {
flaggedItems: FlaggedItem[];
resolvedItems: ResolvedItem[];
errorItems: ErrorItem[];
onNavigate: (item: { campaignName: string, proofName: string, version: number }) => void;
}
const formatDate = (isoString: string) => {
try {
return new Date(isoString).toLocaleDateString('en-GB', {
day: '2-digit',
month: 'short',
year: 'numeric',
});
} catch (e) {
return 'Invalid Date';
}
};
const FlagsTable: React.FC<{ items: FlaggedItem[], onNavigate: AuditingProps['onNavigate'] }> = ({ items, onNavigate }) => (
<div className="overflow-x-auto">
<table className="min-w-full divide-y divide-gray-200">
<thead className="bg-gray-50">
<tr>
<th scope="col" className="px-6 py-3 text-left text-xs font-bold text-gray-500 uppercase tracking-wider">Proof Name</th>
<th scope="col" className="px-6 py-3 text-left text-xs font-bold text-gray-500 uppercase tracking-wider">Proof Version</th>
<th scope="col" className="px-6 py-3 text-left text-xs font-bold text-gray-500 uppercase tracking-wider">Submitter</th>
<th scope="col" className="px-6 py-3 text-left text-xs font-bold text-gray-500 uppercase tracking-wider">Submit Agency</th>
<th scope="col" className="px-6 py-3 text-left text-xs font-bold text-gray-500 uppercase tracking-wider">Agent Flagged</th>
<th scope="col" className="px-6 py-3 text-left text-xs font-bold text-gray-500 uppercase tracking-wider">User Comments</th>
<th scope="col" className="px-6 py-3 text-left text-xs font-bold text-gray-500 uppercase tracking-wider">Date</th>
</tr>
</thead>
<tbody className="bg-white divide-y divide-gray-200">
{items.length > 0 ? items.map((item) => (
<tr
key={item.id}
className="hover:bg-gray-100 cursor-pointer"
onClick={() => onNavigate(item)}
title={`Click to view Version ${item.version} of ${item.proofName}`}
>
<td className="px-6 py-4 whitespace-nowrap text-sm font-medium text-brand-dark-blue">{item.proofName}</td>
<td className="px-6 py-4 whitespace-nowrap text-sm text-gray-700">Version {item.version}</td>
<td className="px-6 py-4 whitespace-nowrap text-sm text-gray-700">{item.submitter}</td>
<td className="px-6 py-4 whitespace-nowrap text-sm text-gray-700">{item.submitAgency}</td>
<td className="px-6 py-4 whitespace-nowrap text-sm text-gray-700">{item.agentFlagged}</td>
<td className="px-6 py-4 text-sm text-gray-700">
<div className="max-w-xs break-words" title={item.comments}>
{item.comments || <span className="italic text-gray-400">No comment</span>}
</div>
</td>
<td className="px-6 py-4 whitespace-nowrap text-sm text-gray-700">{formatDate(item.timestamp)}</td>
</tr>
)) : (
<tr>
<td colSpan={7} className="text-center py-10 text-gray-500">
There are currently no flagged items to audit.
</td>
</tr>
)}
</tbody>
</table>
</div>
);
const ResolutionsTable: React.FC<{ items: ResolvedItem[], onNavigate: AuditingProps['onNavigate'] }> = ({ items, onNavigate }) => (
<div className="overflow-x-auto">
<table className="min-w-full divide-y divide-gray-200">
<thead className="bg-gray-50">
<tr>
<th scope="col" className="px-6 py-3 text-left text-xs font-bold text-gray-500 uppercase tracking-wider">Proof Name</th>
<th scope="col" className="px-6 py-3 text-left text-xs font-bold text-gray-500 uppercase tracking-wider">Proof Version</th>
<th scope="col" className="px-6 py-3 text-left text-xs font-bold text-gray-500 uppercase tracking-wider">Submitter</th>
<th scope="col" className="px-6 py-3 text-left text-xs font-bold text-gray-500 uppercase tracking-wider">Submit Agency</th>
<th scope="col" className="px-6 py-3 text-left text-xs font-bold text-gray-500 uppercase tracking-wider">Agent</th>
<th scope="col" className="px-6 py-3 text-left text-xs font-bold text-gray-500 uppercase tracking-wider">Agent Issue</th>
<th scope="col" className="px-6 py-3 text-left text-xs font-bold text-gray-500 uppercase tracking-wider">User Comments</th>
<th scope="col" className="px-6 py-3 text-left text-xs font-bold text-gray-500 uppercase tracking-wider">Date</th>
</tr>
</thead>
<tbody className="bg-white divide-y divide-gray-200">
{items.length > 0 ? items.map((item) => (
<tr
key={item.id}
className="hover:bg-gray-100 cursor-pointer"
onClick={() => onNavigate(item)}
title={`Click to view Version ${item.version} of ${item.proofName}`}
>
<td className="px-6 py-4 whitespace-nowrap text-sm font-medium text-brand-dark-blue">{item.proofName}</td>
<td className="px-6 py-4 whitespace-nowrap text-sm text-gray-700">Version {item.version}</td>
<td className="px-6 py-4 whitespace-nowrap text-sm text-gray-700">{item.submitter}</td>
<td className="px-6 py-4 whitespace-nowrap text-sm text-gray-700">{item.submitAgency}</td>
<td className="px-6 py-4 whitespace-nowrap text-sm text-gray-700">{item.agent}</td>
<td className="px-6 py-4 text-sm text-gray-700">
<div className="max-w-xs break-words" title={item.issue}>{item.issue}</div>
</td>
<td className="px-6 py-4 text-sm text-gray-700">
<div className="max-w-xs break-words" title={item.resolution}>{item.resolution}</div>
</td>
<td className="px-6 py-4 whitespace-nowrap text-sm text-gray-700">{formatDate(item.timestamp)}</td>
</tr>
)) : (
<tr>
<td colSpan={8} className="text-center py-10 text-gray-500">
There are currently no resolved items to audit.
</td>
</tr>
)}
</tbody>
</table>
</div>
);
const ErrorsTable: React.FC<{ items: ErrorItem[], onNavigate: AuditingProps['onNavigate'] }> = ({ items, onNavigate }) => (
<div className="overflow-x-auto">
<table className="min-w-full divide-y divide-gray-200">
<thead className="bg-gray-50">
<tr>
<th scope="col" className="px-6 py-3 text-left text-xs font-bold text-gray-500 uppercase tracking-wider">Proof Name</th>
<th scope="col" className="px-6 py-3 text-left text-xs font-bold text-gray-500 uppercase tracking-wider">Proof Version</th>
<th scope="col" className="px-6 py-3 text-left text-xs font-bold text-gray-500 uppercase tracking-wider">Submitter</th>
<th scope="col" className="px-6 py-3 text-left text-xs font-bold text-gray-500 uppercase tracking-wider">Submit Agency</th>
<th scope="col" className="px-6 py-3 text-left text-xs font-bold text-gray-500 uppercase tracking-wider">Error Summary</th>
<th scope="col" className="px-6 py-3 text-left text-xs font-bold text-gray-500 uppercase tracking-wider">Date</th>
</tr>
</thead>
<tbody className="bg-white divide-y divide-gray-200">
{items.length > 0 ? items.map((item) => (
<tr
key={item.id}
className="hover:bg-gray-100 cursor-pointer"
onClick={() => onNavigate(item)}
title={`Click to view Version ${item.version} of ${item.proofName}`}
>
<td className="px-6 py-4 whitespace-nowrap text-sm font-medium text-brand-dark-blue">{item.proofName}</td>
<td className="px-6 py-4 whitespace-nowrap text-sm text-gray-700">Version {item.version}</td>
<td className="px-6 py-4 whitespace-nowrap text-sm text-gray-700">{item.submitter}</td>
<td className="px-6 py-4 whitespace-nowrap text-sm text-gray-700">{item.submitAgency}</td>
<td className="px-6 py-4 text-sm text-gray-700">
<div className="max-w-md break-words" title={item.errorSummary}>
{item.errorSummary}
</div>
</td>
<td className="px-6 py-4 whitespace-nowrap text-sm text-gray-700">{formatDate(item.timestamp)}</td>
</tr>
)) : (
<tr>
<td colSpan={6} className="text-center py-10 text-gray-500">
There are currently no analysis errors to audit.
</td>
</tr>
)}
</tbody>
</table>
</div>
);
export const Auditing: React.FC<AuditingProps> = ({ flaggedItems, resolvedItems, errorItems, onNavigate }) => {
const [activeTab, setActiveTab] = useState<'Flags' | 'Resolutions' | 'Errors'>('Flags');
return (
<div className="p-4 sm:p-6 lg:p-8 h-full bg-brand-gray">
<header className="mb-8">
<h1 className="text-3xl lg:text-4xl font-bold text-brand-dark-blue">Auditing</h1>
<p className="text-base lg:text-lg text-gray-600 mt-1">Review and investigate all user-flagged feedback.</p>
</header>
<div className="mb-6 border-b border-gray-200">
<nav className="-mb-px flex space-x-6" aria-label="Tabs">
<button
onClick={() => setActiveTab('Flags')}
className={`whitespace-nowrap py-3 px-1 border-b-2 font-semibold text-sm transition-colors ${
activeTab === 'Flags'
? 'border-brand-accent text-brand-accent'
: 'border-transparent text-gray-500 hover:text-gray-700 hover:border-gray-300'
}`}
aria-current={activeTab === 'Flags' ? 'page' : undefined}
>
Flags
</button>
<button
onClick={() => setActiveTab('Resolutions')}
className={`whitespace-nowrap py-3 px-1 border-b-2 font-semibold text-sm transition-colors ${
activeTab === 'Resolutions'
? 'border-brand-accent text-brand-accent'
: 'border-transparent text-gray-500 hover:text-gray-700 hover:border-gray-300'
}`}
aria-current={activeTab === 'Resolutions' ? 'page' : undefined}
>
Resolutions
</button>
<button
onClick={() => setActiveTab('Errors')}
className={`whitespace-nowrap py-3 px-1 border-b-2 font-semibold text-sm transition-colors ${
activeTab === 'Errors'
? 'border-brand-accent text-brand-accent'
: 'border-transparent text-gray-500 hover:text-gray-700 hover:border-gray-300'
}`}
aria-current={activeTab === 'Errors' ? 'page' : undefined}
>
Errors
</button>
</nav>
</div>
<section>
<div className="bg-white rounded-lg shadow-md overflow-hidden border border-gray-200">
{activeTab === 'Flags' && <FlagsTable items={flaggedItems} onNavigate={onNavigate} />}
{activeTab === 'Resolutions' && <ResolutionsTable items={resolvedItems} onNavigate={onNavigate} />}
{activeTab === 'Errors' && <ErrorsTable items={errorItems} onNavigate={onNavigate} />}
</div>
</section>
</div>
);
};

File diff suppressed because it is too large Load diff

View file

@ -0,0 +1,162 @@
import React from 'react';
import { LegalIcon } from './icons/LegalIcon';
import { BrandIcon } from './icons/BrandIcon';
import { CopyIcon } from './icons/CopyIcon';
import { ChannelIcon } from './icons/ChannelIcon';
import { LeadAgentIcon } from './icons/LeadAgentIcon';
interface CheckDetail {
name: string;
icon: React.ReactNode;
role: string;
description: string;
}
const specialistAgents: CheckDetail[] = [
{
name: 'Legal Agent',
icon: <LegalIcon />,
role: 'Standards & Disclaimers',
description: 'Ensures compliance with all regulatory requirements.'
},
{
name: 'Brand Agent',
icon: <BrandIcon />,
role: 'Visual Identity',
description: 'Verifies logo usage, color palette, and visual identity.'
},
{
name: 'Tone Agent',
icon: <CopyIcon />,
role: 'Voice & Clarity',
description: 'Analyzes copy for clarity, brand voice, and tone consistency.'
},
{
name: 'Channel Agent',
icon: <ChannelIcon />,
role: 'Platform Specs',
description: 'Checks assets against platform-specific technical specifications.'
}
];
export const ChecksOverview: React.FC = () => {
return (
<div className="relative bg-gradient-to-br from-emerald-50 via-white to-emerald-50/30 py-16 overflow-hidden flex-1 w-full">
{/* Decorative Background */}
<div className="absolute inset-0 pointer-events-none">
<div className="absolute top-0 left-1/4 w-[800px] h-[800px] bg-emerald-200/30 rounded-full blur-3xl -translate-y-1/2 mix-blend-multiply"></div>
<div className="absolute bottom-0 right-0 w-[600px] h-[600px] bg-green-200/30 rounded-full blur-3xl translate-y-1/3 mix-blend-multiply"></div>
</div>
<div className="max-w-7xl mx-auto px-4 sm:px-6 lg:px-8 relative z-10">
{/* Header */}
<div className="max-w-3xl mx-auto text-center mb-12">
<div className="inline-flex items-center px-4 py-1.5 rounded-full bg-white border border-emerald-200 text-emerald-700 text-xs font-bold uppercase tracking-widest mb-4 shadow-sm">
<span className="w-2 h-2 rounded-full bg-emerald-500 mr-2 animate-pulse"></span>
Intelligent Workflow
</div>
<h2 className="text-3xl md:text-4xl font-extrabold text-brand-dark-blue tracking-tight mb-4">
Orchestrated by AI
</h2>
<p className="text-lg text-slate-600 leading-relaxed">
A central Lead Agent coordinates specialized experts to review every aspect of your content in parallel.
</p>
</div>
<div className="flex flex-col lg:flex-row items-center justify-center lg:items-stretch gap-0 lg:gap-0">
{/* 1. LEAD AGENT CARD (Left) */}
<div className="w-full max-w-md lg:w-[380px] relative z-20 group/lead flex flex-col">
<div className="flex-1 relative bg-[#001f5a] rounded-3xl p-6 shadow-2xl shadow-blue-900/20 border border-white/10 text-center transition-all duration-500 hover:-translate-y-2 hover:shadow-blue-900/40 overflow-hidden flex flex-col items-center justify-center">
{/* Animated Gradient bg */}
<div className="absolute top-[-50%] left-[-50%] w-[200%] h-[200%] bg-gradient-to-b from-transparent via-white/5 to-transparent rotate-45 transition-transform duration-[3s] group-hover/lead:translate-y-10"></div>
<div className="relative z-10">
<div className="h-20 w-20 mx-auto rounded-2xl bg-white/10 border border-white/20 backdrop-blur-md flex items-center justify-center mb-4 shadow-lg shadow-brand-dark-blue/30 group-hover/lead:scale-110 transition-all duration-500">
<LeadAgentIcon className="h-10 w-10 text-white" />
</div>
<h3 className="text-2xl font-bold text-white mb-2">Lead Agent</h3>
<p className="text-brand-light-blue font-bold text-xs uppercase tracking-widest mb-4">Orchestrator</p>
<p className="text-slate-300 text-sm leading-relaxed">
Synthesizes feedback & coordinates analysis across all specialist agents.
</p>
</div>
</div>
{/* Connection Node (Desktop) */}
<div className="hidden lg:flex absolute top-1/2 -right-4 w-8 h-8 bg-[#001f5a] rounded-full border-4 border-emerald-50 items-center justify-center z-30 -translate-y-1/2 shadow-sm translate-x-1/2">
<div className="w-2.5 h-2.5 bg-emerald-400 rounded-full animate-pulse"></div>
</div>
{/* Connection Node (Mobile) */}
<div className="lg:hidden absolute -bottom-4 left-1/2 w-8 h-8 bg-[#001f5a] rounded-full border-4 border-emerald-50 items-center justify-center z-30 -translate-x-1/2 shadow-sm">
<div className="w-2.5 h-2.5 bg-emerald-400 rounded-full animate-pulse"></div>
</div>
</div>
{/* 2. CONNECTOR LINES (Desktop SVG) */}
<div className="hidden lg:block w-24 relative z-10 self-center h-48">
<svg className="absolute inset-0 w-full h-full overflow-visible" viewBox="0 0 100 200" preserveAspectRatio="none">
{/* Connecting paths */}
<path d="M0,100 C50,100 50,30 100,30" fill="none" stroke="#6ee7b7" strokeWidth="2" strokeDasharray="6 6" />
<path d="M0,100 C50,100 50,170 100,170" fill="none" stroke="#6ee7b7" strokeWidth="2" strokeDasharray="6 6" />
{/* Animated pulses on paths */}
<circle r="3" fill="#10b981">
<animateMotion dur="2s" repeatCount="indefinite" path="M0,100 C50,100 50,30 100,30" />
</circle>
<circle r="3" fill="#10b981">
<animateMotion dur="2s" repeatCount="indefinite" begin="1s" path="M0,100 C50,100 50,170 100,170" />
</circle>
</svg>
</div>
{/* Mobile Connector Spacer */}
<div className="lg:hidden h-8 w-0.5 bg-emerald-300 my-3"></div>
{/* 3. SPECIALIST AGENTS (Right) */}
<div className="flex-1 grid grid-cols-1 md:grid-cols-2 gap-4 w-full max-w-4xl pl-0 lg:pl-4">
{specialistAgents.map((agent, index) => (
<div
key={agent.name}
className="group relative rounded-2xl p-5 border border-emerald-100 shadow-sm transition-all duration-500 hover:shadow-xl hover:shadow-emerald-900/5 hover:-translate-y-2 hover:border-emerald-200 overflow-hidden bg-white"
>
{/* Base Gradient */}
<div className="absolute inset-0 bg-gradient-to-br from-white via-white to-emerald-50/30 opacity-100 transition-opacity duration-500 group-hover:opacity-0"></div>
{/* Hover Gradient - Interactive Green */}
<div className="absolute inset-0 bg-gradient-to-br from-white via-emerald-50 to-emerald-100/20 opacity-0 group-hover:opacity-100 transition-opacity duration-500"></div>
{/* Animated Shine/Sweep Effect */}
<div className="absolute top-[-50%] left-[-50%] w-[200%] h-[200%] bg-gradient-to-b from-transparent via-emerald-500/5 to-transparent rotate-45 translate-y-12 transition-transform duration-1000 group-hover:translate-y-[-10%] pointer-events-none"></div>
{/* Glassy Highlight */}
<div className="absolute -top-24 -right-24 w-48 h-48 bg-gradient-to-br from-emerald-400/10 to-transparent rounded-full blur-2xl opacity-0 group-hover:opacity-100 transition-opacity duration-500 pointer-events-none"></div>
<div className="relative z-10 flex items-start gap-4">
<div className={`flex-shrink-0 w-12 h-12 rounded-2xl flex items-center justify-center transition-all duration-300 group-hover:scale-110 group-hover:shadow-md bg-emerald-50 text-emerald-600 group-hover:bg-emerald-600 group-hover:text-white`}>
{React.cloneElement(agent.icon as React.ReactElement, { className: "h-6 w-6 transition-colors duration-300" })}
</div>
<div className="flex-1">
<h4 className="text-base font-bold text-slate-800 group-hover:text-emerald-900 transition-colors duration-300 mb-0.5">
{agent.name}
</h4>
<p className="text-[10px] font-extrabold text-slate-400 uppercase tracking-wider mb-1 group-hover:text-emerald-600 transition-colors duration-300">
{agent.role}
</p>
<p className="text-xs text-slate-600 leading-relaxed group-hover:text-slate-700">
{agent.description}
</p>
</div>
</div>
</div>
))}
</div>
</div>
</div>
</div>
);
};

View file

@ -0,0 +1,110 @@
import React, { useState } from 'react';
import { PlusIcon } from './icons/PlusIcon';
import { ChatBubbleIcon } from './icons/ChatBubbleIcon';
import { ChevronDownIcon } from './icons/ChevronDownIcon';
import { SendIcon } from './icons/SendIcon';
const mockConversations = [
{ id: 1, title: 'Q4 Social Media Campaign Snippets' },
{ id: 2, title: 'Email Subject Line Variations' },
{ id: 3, title: 'New Product Launch Announcement' },
{ id: 4, title: 'Website Homepage Copy Draft' },
];
export const CopyGenAI: React.FC = () => {
const [activeConversationId, setActiveConversationId] = useState<number>(1);
const [message, setMessage] = useState<string>('');
const handleSendMessage = (e: React.FormEvent) => {
e.preventDefault();
if (!message.trim()) return;
// Logic to send message will be added here
console.log('Sending message:', message);
setMessage('');
};
return (
<div className="flex h-full bg-brand-gray">
{/* Conversation History Sidebar */}
<aside className="w-80 flex-shrink-0 bg-gray-100 border-r border-gray-200 flex flex-col">
<div className="p-4 border-b border-gray-200">
<button className="w-full flex items-center justify-center gap-2 bg-brand-accent text-white font-semibold py-2.5 px-4 rounded-lg hover:bg-brand-dark-blue transition-colors duration-300">
<PlusIcon className="h-5 w-5" />
Start new conversation
</button>
</div>
<div className="flex-1 overflow-y-auto">
<nav className="p-2 space-y-1">
{mockConversations.map((convo) => (
<button
key={convo.id}
onClick={() => setActiveConversationId(convo.id)}
className={`w-full flex items-center gap-3 px-3 py-2.5 text-left text-sm font-medium rounded-md transition-colors duration-200 ${
activeConversationId === convo.id
? 'bg-brand-accent/10 text-brand-accent'
: 'text-gray-700 hover:bg-gray-200'
}`}
>
<ChatBubbleIcon className="h-5 w-5 flex-shrink-0" />
<span className="truncate flex-1">{convo.title}</span>
</button>
))}
</nav>
</div>
</aside>
{/* Main Chat Area */}
<main className="flex-1 flex flex-col">
<header className="flex-shrink-0 bg-white/80 backdrop-blur-sm border-b border-gray-200 p-4">
<div className="flex items-center gap-4">
<button className="flex items-center gap-2 bg-white text-gray-800 font-semibold py-2 px-4 rounded-lg border border-gray-300 hover:bg-gray-100 transition-colors duration-200">
<span>Select an assistant</span>
<ChevronDownIcon className="h-4 w-4 text-gray-500" />
</button>
<button className="flex items-center gap-2 bg-white text-gray-800 font-semibold py-2 px-4 rounded-lg border border-gray-300 hover:bg-gray-100 transition-colors duration-200">
<span>Barclays</span>
<ChevronDownIcon className="h-4 w-4 text-gray-500" />
</button>
</div>
</header>
{/* Message Display */}
<div className="flex-1 flex flex-col items-center justify-center p-8">
<div className="text-center">
<h1 className="text-3xl font-bold text-brand-dark-blue">CopyGenAI</h1>
<p className="mt-2 text-gray-600">How can I help you today?</p>
</div>
</div>
{/* Message Input */}
<div className="flex-shrink-0 p-4 bg-white/80 backdrop-blur-sm border-t border-gray-200">
<div className="max-w-3xl mx-auto">
<form onSubmit={handleSendMessage} className="relative">
<textarea
value={message}
onChange={(e) => setMessage(e.target.value)}
onKeyDown={(e) => {
if (e.key === 'Enter' && !e.shiftKey) {
e.preventDefault();
handleSendMessage(e);
}
}}
className="w-full p-3 pr-14 border border-gray-300 rounded-lg resize-none focus:ring-2 focus:ring-brand-accent focus:border-brand-accent transition"
placeholder="Type your message here..."
rows={1}
/>
<button
type="submit"
className="absolute right-3 top-1/2 -translate-y-1/2 p-2 rounded-full text-white bg-brand-accent hover:bg-brand-dark-blue disabled:bg-gray-400 transition-colors"
disabled={!message.trim()}
aria-label="Send message"
>
<SendIcon className="h-5 w-5" />
</button>
</form>
</div>
</div>
</main>
</div>
);
};

View file

@ -0,0 +1,181 @@
import React, { useState, useEffect } from 'react';
import { XIcon } from './icons/XIcon';
interface CreateCampaignModalProps {
isOpen: boolean;
onClose: () => void;
onAddCampaign: (campaignData: {
name: string;
workfrontId: string;
clientLead: string;
brandGuidelines: string;
}) => void;
}
export const CreateCampaignModal: React.FC<CreateCampaignModalProps> = ({ isOpen, onClose, onAddCampaign }) => {
const [name, setName] = useState('');
const [brandGuidelines, setBrandGuidelines] = useState('');
const [workfrontId, setWorkfrontId] = useState('');
const [clientLead, setClientLead] = useState('');
const [error, setError] = useState<string | null>(null);
useEffect(() => {
// Reset form when modal opens
if (isOpen) {
setName('');
setBrandGuidelines('');
setWorkfrontId('');
setClientLead('');
setError(null);
}
}, [isOpen]);
const validateWorkfrontId = (id: string): boolean => {
const isValid = /^#WF_\d+$/.test(id);
if (!isValid && id.length > 0) {
setError("Workfront Campaign ID must be in the format '#WF_12345'");
} else {
setError(null);
}
return isValid || id.length === 0;
};
const handleWorkfrontIdChange = (e: React.ChangeEvent<HTMLInputElement>) => {
const newId = e.target.value;
setWorkfrontId(newId);
validateWorkfrontId(newId);
};
const handleSubmit = (e: React.FormEvent) => {
e.preventDefault();
const isIdValidOnSubmit = /^#WF_\d+$/.test(workfrontId);
if (!name.trim() || !clientLead.trim() || !workfrontId.trim() || !brandGuidelines.trim()) {
return;
}
if (!isIdValidOnSubmit) {
setError("Workfront Campaign ID must be in the format '#WF_12345'");
return;
}
setError(null);
onAddCampaign({ name, workfrontId, clientLead, brandGuidelines });
onClose();
};
if (!isOpen) return null;
const isFormInvalid = !name.trim() || !workfrontId.trim() || !clientLead.trim() || !brandGuidelines.trim();
return (
<div
className="fixed inset-0 bg-black bg-opacity-60 flex items-center justify-center z-50 transition-opacity duration-300"
onClick={onClose}
aria-modal="true"
role="dialog"
>
<div
className="bg-white rounded-lg shadow-xl p-6 sm:p-8 w-full max-w-lg transform transition-all"
onClick={(e) => e.stopPropagation()}
>
<div className="flex justify-between items-center mb-6">
<h2 className="text-2xl font-bold text-brand-dark-blue">Create New Campaign</h2>
<button onClick={onClose} className="p-1 rounded-full text-gray-500 hover:bg-gray-200 hover:text-gray-800 transition-colors">
<XIcon className="h-6 w-6" />
</button>
</div>
<form onSubmit={handleSubmit} noValidate>
<div className="space-y-4">
<div>
<label htmlFor="campaign-name" className="block text-sm font-medium text-gray-700">Campaign Name</label>
<input
type="text"
id="campaign-name"
value={name}
onChange={(e) => setName(e.target.value)}
className="mt-1 block w-full p-2 border border-gray-300 rounded-md shadow-sm focus:ring-brand-accent focus:border-brand-accent transition bg-white text-gray-900"
required
/>
</div>
<div>
<label htmlFor="brand-guidelines" className="block text-sm font-medium text-gray-700">Brand Guidelines</label>
<select
id="brand-guidelines"
value={brandGuidelines}
onChange={(e) => setBrandGuidelines(e.target.value)}
className="mt-1 block w-full p-2 border border-gray-300 rounded-md shadow-sm focus:ring-brand-accent focus:border-brand-accent transition bg-white text-gray-900"
required
>
<option value="" disabled>Select brand guidelines</option>
<option value="Barclays">Barclays</option>
<option value="Barclaycard">Barclaycard</option>
</select>
</div>
<div>
<label htmlFor="workfront-id" className="block text-sm font-medium text-gray-700">Workfront Campaign ID</label>
<input
type="text"
id="workfront-id"
value={workfrontId}
onChange={handleWorkfrontIdChange}
className={`mt-1 block w-full p-2 border rounded-md shadow-sm focus:ring-brand-accent focus:border-brand-accent transition bg-white text-gray-900 placeholder:text-gray-400 ${error ? 'border-red-500 focus:border-red-500 focus:ring-red-500' : 'border-gray-300'}`}
placeholder="#WF_12345"
required
aria-invalid={!!error}
aria-describedby="workfront-id-error"
/>
{error && <p id="workfront-id-error" className="mt-1 text-sm text-red-600">{error}</p>}
</div>
<div>
<label htmlFor="client-lead" className="block text-sm font-medium text-gray-700">Client Lead</label>
<input
type="text"
id="client-lead"
value={clientLead}
onChange={(e) => setClientLead(e.target.value)}
className="mt-1 block w-full p-2 border border-gray-300 rounded-md shadow-sm focus:ring-brand-accent focus:border-brand-accent transition bg-white text-gray-900"
required
/>
</div>
<div>
<label className="block text-sm font-medium text-gray-700">Agency</label>
<input
type="text"
value="OLIVER Agency"
className="mt-1 block w-full p-2 border border-gray-300 rounded-md shadow-sm bg-gray-100 text-gray-600 cursor-not-allowed"
disabled
/>
</div>
<div>
<label className="block text-sm font-medium text-gray-700">Agency Lead</label>
<input
type="text"
value="Steve O'Donoghue"
className="mt-1 block w-full p-2 border border-gray-300 rounded-md shadow-sm bg-gray-100 text-gray-600 cursor-not-allowed"
disabled
/>
</div>
</div>
<div className="mt-8 flex justify-end gap-3">
<button
type="button"
onClick={onClose}
className="bg-gray-200 text-gray-800 font-semibold py-2 px-4 rounded-md hover:bg-gray-300 transition-colors duration-300"
>
Cancel
</button>
<button
type="submit"
className="bg-brand-accent text-white font-semibold py-2 px-4 rounded-md hover:bg-brand-dark-blue transition-colors duration-300 disabled:bg-gray-400 disabled:cursor-not-allowed"
disabled={isFormInvalid || !!error}
>
Create Campaign
</button>
</div>
</form>
</div>
</div>
);
};

View file

@ -0,0 +1,163 @@
import React, { useState, useEffect } from 'react';
import { XIcon } from './icons/XIcon';
interface CreateProjectModalProps {
isOpen: boolean;
onClose: () => void;
onAddProject: (projectData: {
name: string;
workfrontId: string;
clientLead: string;
}) => void;
}
export const CreateProjectModal: React.FC<CreateProjectModalProps> = ({ isOpen, onClose, onAddProject }) => {
const [name, setName] = useState('');
const [workfrontId, setWorkfrontId] = useState('');
const [clientLead, setClientLead] = useState('');
const [error, setError] = useState<string | null>(null);
useEffect(() => {
// Reset form when modal opens
if (isOpen) {
setName('');
setWorkfrontId('');
setClientLead('');
setError(null);
}
}, [isOpen]);
const validateWorkfrontId = (id: string): boolean => {
const isValid = /^#WF_\d+$/.test(id);
if (!isValid && id.length > 0) {
setError("Workfront Project ID must be in the format '#WF_12345'");
} else {
setError(null);
}
return isValid || id.length === 0;
};
const handleWorkfrontIdChange = (e: React.ChangeEvent<HTMLInputElement>) => {
const newId = e.target.value;
setWorkfrontId(newId);
validateWorkfrontId(newId);
};
const handleSubmit = (e: React.FormEvent) => {
e.preventDefault();
const isIdValidOnSubmit = /^#WF_\d+$/.test(workfrontId);
if (!name.trim() || !clientLead.trim() || !workfrontId.trim()) {
return;
}
if (!isIdValidOnSubmit) {
setError("Workfront Project ID must be in the format '#WF_12345'");
return;
}
setError(null);
onAddProject({ name, workfrontId, clientLead });
onClose();
};
if (!isOpen) return null;
const isFormInvalid = !name.trim() || !workfrontId.trim() || !clientLead.trim();
return (
<div
className="fixed inset-0 bg-black bg-opacity-60 flex items-center justify-center z-50 transition-opacity duration-300"
onClick={onClose}
aria-modal="true"
role="dialog"
>
<div
className="bg-white rounded-lg shadow-xl p-6 sm:p-8 w-full max-w-lg transform transition-all"
onClick={(e) => e.stopPropagation()}
>
<div className="flex justify-between items-center mb-6">
<h2 className="text-2xl font-bold text-brand-dark-blue">Create New Project</h2>
<button onClick={onClose} className="p-1 rounded-full text-gray-500 hover:bg-gray-200 hover:text-gray-800 transition-colors">
<XIcon className="h-6 w-6" />
</button>
</div>
<form onSubmit={handleSubmit} noValidate>
<div className="space-y-4">
<div>
<label htmlFor="project-name" className="block text-sm font-medium text-gray-700">Project Name</label>
<input
type="text"
id="project-name"
value={name}
onChange={(e) => setName(e.target.value)}
className="mt-1 block w-full p-2 border border-gray-300 rounded-md shadow-sm focus:ring-brand-accent focus:border-brand-accent transition bg-white text-gray-900"
required
/>
</div>
<div>
<label htmlFor="workfront-id" className="block text-sm font-medium text-gray-700">Workfront Project ID</label>
<input
type="text"
id="workfront-id"
value={workfrontId}
onChange={handleWorkfrontIdChange}
className={`mt-1 block w-full p-2 border rounded-md shadow-sm focus:ring-brand-accent focus:border-brand-accent transition bg-white text-gray-900 placeholder:text-gray-400 ${error ? 'border-red-500 focus:border-red-500 focus:ring-red-500' : 'border-gray-300'}`}
placeholder="#WF_12345"
required
aria-invalid={!!error}
aria-describedby="workfront-id-error"
/>
{error && <p id="workfront-id-error" className="mt-1 text-sm text-red-600">{error}</p>}
</div>
<div>
<label htmlFor="client-lead" className="block text-sm font-medium text-gray-700">Client Lead</label>
<input
type="text"
id="client-lead"
value={clientLead}
onChange={(e) => setClientLead(e.target.value)}
className="mt-1 block w-full p-2 border border-gray-300 rounded-md shadow-sm focus:ring-brand-accent focus:border-brand-accent transition bg-white text-gray-900"
required
/>
</div>
<div>
<label className="block text-sm font-medium text-gray-700">Agency</label>
<input
type="text"
value="OLIVER Agency"
className="mt-1 block w-full p-2 border border-gray-300 rounded-md shadow-sm bg-gray-100 text-gray-600 cursor-not-allowed"
disabled
/>
</div>
<div>
<label className="block text-sm font-medium text-gray-700">Agency Lead</label>
<input
type="text"
value="Steve O'Donoghue"
className="mt-1 block w-full p-2 border border-gray-300 rounded-md shadow-sm bg-gray-100 text-gray-600 cursor-not-allowed"
disabled
/>
</div>
</div>
<div className="mt-8 flex justify-end gap-3">
<button
type="button"
onClick={onClose}
className="bg-gray-200 text-gray-800 font-semibold py-2 px-4 rounded-md hover:bg-gray-300 transition-colors duration-300"
>
Cancel
</button>
<button
type="submit"
className="bg-brand-accent text-white font-semibold py-2 px-4 rounded-md hover:bg-brand-dark-blue transition-colors duration-300 disabled:bg-gray-400 disabled:cursor-not-allowed"
disabled={isFormInvalid || !!error}
>
Create Project
</button>
</div>
</form>
</div>
</div>
);
};

View file

@ -0,0 +1,537 @@
import React, { useState, useEffect } from 'react';
import type { AgentReview, SubReview, RagStatus, OverallStatus } from '../types';
import { CheckCircleIcon, ExclamationTriangleIcon, InformationCircleIcon } from './icons/StatusIcons';
import { FlagIcon } from './icons/FlagIcon';
import { XIcon } from './icons/XIcon';
import { BugIcon } from './icons/BugIcon';
import { ExportIcon } from './icons/ExportIcon';
import { LegalIcon } from './icons/LegalIcon';
const RagStatusBadge: React.FC<{ status: RagStatus; isLarge?: boolean }> = ({ status, isLarge = false }) => {
let colorClasses = '';
let iconColor = '';
switch (status) {
case 'Red':
colorClasses = 'bg-red-50 border-red-200 text-red-800 shadow-red-100';
iconColor = 'text-red-600';
break;
case 'Amber':
colorClasses = 'bg-amber-50 border-amber-200 text-amber-800 shadow-amber-100';
iconColor = 'text-amber-600';
break;
case 'Green':
colorClasses = 'bg-emerald-50 border-emerald-200 text-emerald-800 shadow-emerald-100';
iconColor = 'text-emerald-600';
break;
case 'Error':
colorClasses = 'bg-slate-50 border-slate-200 text-slate-700 shadow-slate-100';
iconColor = 'text-slate-500';
break;
}
const sizeClasses = isLarge ? 'px-4 py-1.5 text-sm rounded-xl border shadow-sm' : 'px-2.5 py-1 text-xs rounded-lg border shadow-sm';
return (
<div className={`inline-flex items-center font-bold tracking-wide ${sizeClasses} ${colorClasses} backdrop-blur-sm`}>
{status === 'Red' && <ExclamationTriangleIcon className={`h-4 w-4 mr-1.5 ${iconColor}`} />}
{status === 'Amber' && <InformationCircleIcon className={`h-4 w-4 mr-1.5 ${iconColor}`} />}
{status === 'Green' && <CheckCircleIcon className={`h-4 w-4 mr-1.5 ${iconColor}`} />}
{status === 'Error' && <BugIcon className={`h-4 w-4 mr-1.5 ${iconColor}`} />}
<span>{status}</span>
</div>
);
};
const ResolveIssueModal: React.FC<{
isOpen: boolean;
onClose: () => void;
onSubmit: (reason: string) => void;
issueText: string;
}> = ({ isOpen, onClose, onSubmit, issueText }) => {
if (!isOpen) return null;
const [reason, setReason] = useState('');
const handleSubmit = (e: React.FormEvent) => {
e.preventDefault();
if (reason.trim()) {
onSubmit(reason);
}
};
return (
<div
className="fixed inset-0 bg-slate-900/60 backdrop-blur-sm flex items-center justify-center z-50 transition-all duration-300"
onClick={onClose}
>
<div
className="bg-white rounded-2xl shadow-2xl p-8 w-full max-w-lg transform transition-all border border-white/20 ring-1 ring-black/5"
onClick={(e) => e.stopPropagation()}
>
<h3 className="text-2xl font-bold text-brand-dark-blue mb-2">Resolve Issue</h3>
<p className="text-slate-600 mb-6">Please provide a reason for manually resolving this issue.</p>
<div className="my-6 p-4 bg-slate-50 border border-slate-200 rounded-xl text-slate-700 italic text-sm">
"{issueText}"
</div>
<form onSubmit={handleSubmit}>
<label htmlFor="resolution-reason" className="block text-sm font-bold text-slate-700 mb-2">Reason for resolution</label>
<textarea
id="resolution-reason"
value={reason}
onChange={(e) => setReason(e.target.value)}
className="w-full p-4 border border-slate-200 rounded-xl focus:ring-2 focus:ring-brand-accent/50 focus:border-brand-accent transition-all bg-slate-50 focus:bg-white resize-none"
rows={4}
placeholder="e.g. 'Legal team has approved this exception via email...'"
required
/>
<div className="mt-8 flex justify-end gap-3">
<button
type="button"
onClick={onClose}
className="px-5 py-2.5 rounded-xl text-slate-600 font-semibold hover:bg-slate-100 transition-colors"
>
Cancel
</button>
<button
type="submit"
className="px-5 py-2.5 rounded-xl bg-brand-accent text-white font-bold shadow-lg shadow-brand-accent/30 hover:bg-brand-light-blue transition-all disabled:opacity-50 disabled:cursor-not-allowed"
disabled={!reason.trim()}
>
Submit Resolution
</button>
</div>
</form>
</div>
</div>
);
};
const FlagIssueModal: React.FC<{
isOpen: boolean;
onClose: () => void;
onSubmit: (comments: string) => void;
agentName: string;
}> = ({ isOpen, onClose, onSubmit, agentName }) => {
if (!isOpen) return null;
const [comments, setComments] = useState('');
const handleSubmit = (e: React.FormEvent) => {
e.preventDefault();
onSubmit(comments);
};
return (
<div
className="fixed inset-0 bg-slate-900/60 backdrop-blur-sm flex items-center justify-center z-50 transition-opacity duration-300"
onClick={onClose}
>
<div
className="bg-white rounded-2xl shadow-2xl p-8 w-full max-w-lg transform transition-all border border-white/20 ring-1 ring-black/5"
onClick={(e) => e.stopPropagation()}
>
<div className="flex justify-between items-start mb-6">
<div>
<h3 className="text-2xl font-bold text-brand-dark-blue">Flag Feedback</h3>
<p className="text-slate-500 text-sm mt-1">Reporting incorrect feedback from <span className="font-semibold text-brand-accent">{agentName}</span></p>
</div>
<button onClick={onClose} className="p-2 rounded-full hover:bg-slate-100 text-slate-400 hover:text-slate-600 transition-colors">
<XIcon className="h-6 w-6" />
</button>
</div>
<form onSubmit={handleSubmit}>
<label htmlFor="flag-comments" className="block text-sm font-bold text-slate-700 mb-2">Additional Comments</label>
<textarea
id="flag-comments"
value={comments}
onChange={(e) => setComments(e.target.value)}
className="w-full p-4 border border-slate-200 rounded-xl focus:ring-2 focus:ring-red-500/30 focus:border-red-500 transition-all bg-slate-50 focus:bg-white resize-none"
rows={5}
placeholder="Please explain why this feedback is incorrect..."
/>
<div className="mt-8 flex justify-end gap-3">
<button
type="button"
onClick={onClose}
className="px-5 py-2.5 rounded-xl text-slate-600 font-semibold hover:bg-slate-100 transition-colors"
>
Cancel
</button>
<button
type="submit"
className="px-5 py-2.5 rounded-xl bg-red-600 text-white font-bold shadow-lg shadow-red-600/30 hover:bg-red-500 transition-all"
>
Submit Flag
</button>
</div>
</form>
</div>
</div>
);
};
const SubReviewCard: React.FC<{
title: string;
review: SubReview;
onFlag: () => void;
onResolve: (issueText: string, reason: string) => void;
}> = ({ title, review, onFlag, onResolve }) => {
interface IssueState {
text: string;
status: 'actionable' | 'resolved';
reason?: string;
}
const [issues, setIssues] = useState<IssueState[]>([]);
const [currentStatus, setCurrentStatus] = useState<RagStatus>(review.ragStatus);
const [isModalOpen, setIsModalOpen] = useState(false);
const [activeIssueIndex, setActiveIssueIndex] = useState<number | null>(null);
useEffect(() => {
setIssues(review.issues.map(text => ({ text, status: 'actionable', reason: undefined })));
setCurrentStatus(review.ragStatus);
}, [review]);
useEffect(() => {
if (review.ragStatus === 'Error') {
setCurrentStatus('Error');
return;
}
if (issues.length > 0 && issues.every(issue => issue.status === 'resolved')) {
setCurrentStatus('Green');
} else if (issues.some(issue => issue.status === 'actionable')) {
setCurrentStatus(review.ragStatus);
}
}, [issues, review.ragStatus]);
const handleOpenModal = (index: number) => {
setActiveIssueIndex(index);
setIsModalOpen(true);
};
const handleCloseModal = () => {
setIsModalOpen(false);
setActiveIssueIndex(null);
};
const handleResolve = (reason: string) => {
if (activeIssueIndex === null) return;
const issueText = issues[activeIssueIndex].text;
setIssues(currentIssues =>
currentIssues.map((issue, index) =>
index === activeIssueIndex
? { ...issue, status: 'resolved', reason }
: issue
)
);
onResolve(issueText, reason);
handleCloseModal();
};
const handleReopen = (indexToReopen: number) => {
setIssues(currentIssues =>
currentIssues.map((issue, index) =>
index === indexToReopen
? { ...issue, status: 'actionable', reason: undefined }
: issue
)
);
};
// Determine styles based on status
let containerStyles = 'bg-white border-slate-100';
let gradientOverlay = 'from-slate-50/50 to-white';
let headerColor = 'text-slate-800';
let issueIconColor = 'text-slate-400';
let issueIcon = <InformationCircleIcon className="h-5 w-5 text-slate-400 mt-0.5 flex-shrink-0" />;
if (currentStatus === 'Green') {
containerStyles = 'bg-white border-emerald-100 hover:border-emerald-200';
gradientOverlay = 'from-emerald-50/40 to-white';
headerColor = 'text-emerald-900';
} else if (currentStatus === 'Amber') {
containerStyles = 'bg-white border-amber-100 hover:border-amber-200';
gradientOverlay = 'from-amber-50/40 to-white';
headerColor = 'text-amber-900';
issueIconColor = 'text-amber-500';
issueIcon = <ExclamationTriangleIcon className="h-5 w-5 text-amber-500 mt-0.5 flex-shrink-0" />;
} else if (currentStatus === 'Red') {
containerStyles = 'bg-white border-red-100 hover:border-red-200';
gradientOverlay = 'from-red-50/40 to-white';
headerColor = 'text-red-900';
issueIconColor = 'text-red-500';
issueIcon = <ExclamationTriangleIcon className="h-5 w-5 text-red-500 mt-0.5 flex-shrink-0" />;
}
return (
<>
<ResolveIssueModal
isOpen={isModalOpen}
onClose={handleCloseModal}
onSubmit={handleResolve}
issueText={activeIssueIndex !== null ? issues[activeIssueIndex].text : ''}
/>
<div className={`relative rounded-2xl border p-6 shadow-sm transition-all duration-300 hover:shadow-md ${containerStyles} overflow-hidden group`}>
{/* Subtle gradient background */}
<div className={`absolute inset-0 bg-gradient-to-br ${gradientOverlay} pointer-events-none`}></div>
<div className="relative z-10">
<div className="flex justify-between items-center gap-4 mb-5">
<div className="flex items-center gap-3">
<h4 className={`text-lg font-bold ${headerColor}`}>{title}</h4>
</div>
<div className="flex items-center gap-3">
<RagStatusBadge status={currentStatus} />
<button
onClick={onFlag}
className="p-1.5 text-slate-400 rounded-lg hover:bg-red-50 hover:text-red-500 transition-colors"
title="Flag as incorrect"
aria-label="Flag this feedback as incorrect"
>
<FlagIcon className="h-4 w-4" />
</button>
</div>
</div>
<p className="text-slate-600 text-sm leading-relaxed mb-6">{review.feedback}</p>
{currentStatus !== 'Error' && issues.length > 0 && (
<div className="bg-white/50 rounded-xl border border-slate-100 p-4">
<h5 className={`text-xs font-bold uppercase tracking-wider mb-3 ${issueIconColor}`}>Actionable Issues</h5>
<ul className="space-y-3">
{issues.map((issue, index) => (
<li key={index} className="flex items-start gap-3 group/issue">
{issue.status === 'actionable' ? (
issueIcon
) : (
<CheckCircleIcon className="h-5 w-5 text-emerald-500 mt-0.5 flex-shrink-0" />
)}
<div className="flex-grow pt-0.5">
<div className="flex flex-col sm:flex-row sm:justify-between sm:items-start gap-2">
<span className={`text-sm ${issue.status === 'resolved' ? 'line-through text-slate-400' : 'text-slate-700'}`}>
{issue.text}
</span>
{issue.status === 'actionable' ? (
<button
onClick={() => handleOpenModal(index)}
className="flex-shrink-0 opacity-0 group-hover/issue:opacity-100 focus:opacity-100 text-xs font-semibold text-brand-accent hover:text-brand-dark-blue bg-brand-light-blue/10 hover:bg-brand-light-blue/20 px-2.5 py-1 rounded-lg transition-all"
>
Mark Resolved
</button>
) : (
<div className="flex items-center gap-2">
<span className="relative inline-block group/tooltip">
<InformationCircleIcon className="h-4 w-4 text-slate-300 cursor-help" />
<div className="absolute bottom-full right-0 mb-2 w-64 bg-slate-800 text-white text-xs rounded-lg py-2 px-3 opacity-0 group-hover/tooltip:opacity-100 transition-opacity pointer-events-none z-20 shadow-xl">
<span className="font-bold block mb-1">Reason for resolution:</span>
"{issue.reason}"
</div>
</span>
<button
onClick={() => handleReopen(index)}
className="text-xs font-semibold text-slate-400 hover:text-brand-dark-blue bg-slate-100 hover:bg-slate-200 px-2.5 py-1 rounded-lg transition-all"
>
Re-open
</button>
</div>
)}
</div>
</div>
</li>
))}
</ul>
</div>
)}
{currentStatus !== 'Error' && issues.length === 0 && (
<div className="flex items-center p-3 rounded-xl bg-emerald-50/50 border border-emerald-100 text-emerald-700 text-sm">
<CheckCircleIcon className="h-5 w-5 mr-2" />
<span className="font-semibold">No actionable issues found.</span>
</div>
)}
</div>
</div>
</>
);
};
const LeadAgentSummary: React.FC<{ status: OverallStatus, summary: string, onFlag: () => void; }> = ({ status, summary, onFlag }) => {
const isPassed = status === 'Passed';
let themeStyles = 'from-sky-50 to-white border-sky-100 text-brand-dark-blue';
let iconBg = 'bg-sky-100 text-brand-accent';
let icon = <InformationCircleIcon className="h-8 w-8" />;
let blobColor = 'bg-sky-400';
if (status === 'Passed') {
themeStyles = 'from-emerald-50 to-white border-emerald-200 text-emerald-900';
iconBg = 'bg-emerald-100 text-emerald-600';
icon = <CheckCircleIcon className="h-8 w-8" />;
blobColor = 'bg-emerald-400';
} else if (status === 'Failed') {
themeStyles = 'from-rose-50 to-white border-rose-200 text-rose-900';
iconBg = 'bg-rose-100 text-rose-600';
icon = <ExclamationTriangleIcon className="h-8 w-8" />;
blobColor = 'bg-rose-400';
}
if (status === 'Requires Manual Legal Review') return null;
return (
<div className={`relative overflow-hidden rounded-2xl border p-8 shadow-lg transition-all duration-500 bg-gradient-to-br ${themeStyles}`}>
{/* Abstract decorative blob */}
<div className={`absolute -top-12 -right-12 w-48 h-48 rounded-full blur-3xl opacity-20 ${blobColor}`}></div>
<div className="relative z-10 flex items-start gap-6">
<div className={`flex-shrink-0 p-3 rounded-2xl ${iconBg} shadow-sm`}>
{icon}
</div>
<div className="flex-grow">
<div className="flex justify-between items-start">
<h3 className="text-2xl font-extrabold tracking-tight mb-3">
Overall Status: <span className="opacity-90">{status}</span>
</h3>
<button
onClick={onFlag}
className="text-slate-400 hover:text-red-500 p-2 rounded-full hover:bg-white/50 transition-colors"
title="Flag as incorrect"
>
<FlagIcon className="h-5 w-5" />
</button>
</div>
<p className={`text-lg leading-relaxed ${isPassed ? 'text-emerald-800/80' : 'text-slate-700'}`}>
{summary}
</p>
</div>
</div>
</div>
);
};
const FinancialPromotionSummary: React.FC<{ reason: string; summary: string }> = ({ reason, summary }) => {
const [exportStatus, setExportStatus] = useState('Export for Legal');
const handleExport = () => {
const textToCopy = `## Financial Promotion Review Required ##\n\nReason for Flag:\n${reason}\n\n## AI Agent Summary ##\n${summary}`.trim();
navigator.clipboard.writeText(textToCopy).then(() => {
setExportStatus('Copied!');
setTimeout(() => setExportStatus('Export for Legal'), 2000);
}).catch(err => {
console.error('Failed to copy text: ', err);
setExportStatus('Failed');
setTimeout(() => setExportStatus('Export for Legal'), 2000);
});
};
return (
<div className="relative overflow-hidden rounded-2xl border border-purple-200 bg-gradient-to-br from-purple-50 to-white p-8 shadow-lg">
<div className="absolute -top-20 -right-20 w-64 h-64 rounded-full blur-3xl opacity-20 bg-purple-500"></div>
<div className="relative z-10 flex items-start gap-6">
<div className="flex-shrink-0 p-3 rounded-2xl bg-purple-100 text-purple-600 shadow-sm">
<LegalIcon className="h-8 w-8" />
</div>
<div className="flex-grow">
<h3 className="text-2xl font-extrabold text-purple-900 tracking-tight mb-2">
Financial Promotion Detected
</h3>
<p className="text-lg text-purple-800/80 mb-6 leading-relaxed">
This proof has been identified as a financial promotion and requires a separate, manual review from the Barclays legal team.
</p>
<div className="bg-white/60 backdrop-blur-sm border border-purple-100 rounded-xl p-4 mb-6 shadow-sm">
<p className="text-xs font-bold uppercase tracking-wider text-purple-500 mb-1">Reason Identified</p>
<p className="text-purple-900 font-medium italic">"{reason}"</p>
</div>
<button
onClick={handleExport}
className="flex items-center gap-2 bg-purple-600 text-white font-bold py-3 px-6 rounded-xl shadow-lg shadow-purple-600/20 hover:bg-purple-700 hover:shadow-purple-600/30 transition-all duration-300"
>
<ExportIcon className="h-5 w-5" />
{exportStatus}
</button>
</div>
</div>
</div>
);
};
export const FeedbackReport: React.FC<{
feedback: AgentReview;
onFlagSubmit: (agentName: string, comments: string) => void;
onResolveSubmit: (agentName: string, issueText: string, reason: string) => void;
}> = ({ feedback, onFlagSubmit, onResolveSubmit }) => {
const [flagModalState, setFlagModalState] = useState<{ isOpen: boolean; agentName: string }>({
isOpen: false,
agentName: '',
});
const handleOpenFlagModal = (agentName: string) => {
setFlagModalState({ isOpen: true, agentName });
};
const handleCloseFlagModal = () => {
setFlagModalState({ isOpen: false, agentName: '' });
};
const handleSubmitFlag = (comments: string) => {
onFlagSubmit(flagModalState.agentName, comments);
alert(`Thank you for your feedback on the ${flagModalState.agentName}'s review. This has been logged for auditing.`);
handleCloseFlagModal();
};
const agentReviews = [
{ title: 'Legal Agent', review: feedback.legalAgentReview },
{ title: 'Brand Agent', review: feedback.brandAgentReview },
{ title: 'Tone Agent', review: feedback.toneAgentReview },
{ title: 'Channel Agent', review: feedback.channelAgentReview },
];
const isFinancialPromotion = feedback.overallStatus === 'Requires Manual Legal Review';
return (
<div className="space-y-8">
<FlagIssueModal
isOpen={flagModalState.isOpen}
onClose={handleCloseFlagModal}
onSubmit={handleSubmitFlag}
agentName={flagModalState.agentName}
/>
{isFinancialPromotion ? (
<FinancialPromotionSummary
reason={feedback.financialPromotionReason || "No specific reason provided."}
summary={feedback.leadAgentSummary}
/>
) : (
<LeadAgentSummary
status={feedback.overallStatus}
summary={feedback.leadAgentSummary}
onFlag={() => handleOpenFlagModal('Lead Agent')}
/>
)}
<div className="grid grid-cols-1 gap-6">
{agentReviews.map(({ title, review }) => (
<SubReviewCard
key={title}
title={`${title}`}
review={review}
onFlag={() => handleOpenFlagModal(title)}
onResolve={(issueText, reason) => onResolveSubmit(title, issueText, reason)}
/>
))}
</div>
</div>
);
};

View file

@ -0,0 +1,15 @@
import React from 'react';
import { EagleIcon } from './icons/EagleIcon';
export const Header: React.FC = () => {
return (
<header className="bg-brand-dark-blue p-4 shadow-md">
<div className="max-w-7xl mx-auto flex items-center">
<EagleIcon className="h-10 w-10 text-brand-light-blue" />
<h1 className="text-xl sm:text-2xl font-bold text-white ml-3">
AI Proof Reviewer
</h1>
</div>
</header>
);
};

View file

@ -0,0 +1,61 @@
import React from 'react';
import { ArrowLeftIcon } from './icons/ArrowLeftIcon';
interface HeroProps {
onGetStarted?: () => void;
}
const ArrowRightIcon: React.FC<React.SVGProps<SVGSVGElement>> = (props) => (
<svg xmlns="http://www.w3.org/2000/svg" fill="none" viewBox="0 0 24 24" strokeWidth={2} stroke="currentColor" {...props}>
<path strokeLinecap="round" strokeLinejoin="round" d="M4.5 12h15m0 0l-6.75-6.75M19.5 12l-6.75 6.75" />
</svg>
);
export const Hero: React.FC<HeroProps> = ({ onGetStarted }) => {
return (
<div className="relative overflow-hidden bg-slate-900 min-h-[500px] flex items-center">
{/* Abstract background elements */}
<div className="absolute top-[-20%] right-[-10%] w-[600px] h-[600px] rounded-full bg-brand-accent/20 blur-3xl pointer-events-none"></div>
<div className="absolute bottom-[-20%] left-[-10%] w-[500px] h-[500px] rounded-full bg-brand-dark-blue/40 blur-3xl pointer-events-none"></div>
{/* Grid Pattern Overlay */}
<div className="absolute inset-0 bg-[url('https://grainy-gradients.vercel.app/noise.svg')] opacity-20 pointer-events-none"></div>
<div className="max-w-screen-2xl mx-auto px-4 sm:px-6 lg:px-8 py-16 md:py-24 relative z-10 w-full">
<div className="max-w-3xl">
<div className="inline-flex items-center px-3 py-1 rounded-full bg-brand-accent/10 border border-brand-accent/20 text-brand-light-blue text-xs font-bold uppercase tracking-widest mb-6">
<span className="w-2 h-2 rounded-full bg-brand-light-blue mr-2 animate-pulse"></span>
AI-Powered Compliance
</div>
<h1 className="text-5xl md:text-7xl font-extrabold text-white leading-tight mb-6">
Mod Comms <br/>
<span className="text-transparent bg-clip-text bg-gradient-to-r from-brand-light-blue to-white">
Intelligent Review
</span>
</h1>
<p className="text-lg md:text-xl text-slate-300 mb-10 leading-relaxed max-w-2xl">
Streamline your creative approval process. Mod Comms analyses your assets against guidelines and best practice in seconds, not days.
</p>
<div className="flex flex-col sm:flex-row gap-4">
<button
onClick={onGetStarted}
className="group inline-flex items-center justify-center px-8 py-4 text-base font-bold text-white bg-brand-accent rounded-xl shadow-lg shadow-brand-accent/30 hover:bg-white hover:text-brand-dark-blue transition-all duration-300"
>
Start Analysis
<ArrowRightIcon className="ml-2 h-5 w-5 transform group-hover:translate-x-1 transition-transform" />
</button>
<button
className="inline-flex items-center justify-center px-8 py-4 text-base font-bold text-white bg-white/5 border border-white/10 rounded-xl hover:bg-white/10 transition-all duration-300 backdrop-blur-sm"
>
View Documentation
</button>
</div>
</div>
</div>
</div>
);
};

View file

@ -0,0 +1,25 @@
import React from 'react';
import { BarclaysLogo } from './icons/BarclaysLogo';
export const LoadingVisual: React.FC = () => {
return (
<div className="flex flex-col items-center justify-center text-center py-20 px-6 min-h-[600px]">
<div className="relative flex items-center justify-center w-48 h-48">
{/* Pulsing circles */}
<div className="absolute w-full h-full rounded-full bg-brand-accent/20 animate-slow-ping"></div>
<div className="absolute w-3/4 h-3/4 rounded-full bg-brand-accent/30 animate-slow-ping [animation-delay:-1s]"></div>
{/* Central Icon */}
<div className="relative bg-white backdrop-blur-sm rounded-full p-6 border border-gray-200 shadow-lg flex items-center justify-center w-28 h-28">
<BarclaysLogo className="h-16 w-16 text-brand-accent" />
</div>
</div>
<h2 className="text-2xl font-bold text-brand-dark-blue mt-12">
Finalizing your report...
</h2>
<p className="text-gray-500 mt-2 max-w-md">
Our AI agents are completing their analysis. Your detailed feedback summary will appear here shortly.
</p>
</div>
);
};

View file

@ -0,0 +1,169 @@
import React, { useState } from 'react';
import { BarclaysLogo } from './icons/BarclaysLogo';
import { XIcon } from './icons/XIcon';
import { MicrosoftLogo } from './icons/MicrosoftLogo';
interface LoginProps {
onLogin: () => void;
}
const SupportModal: React.FC<{
isOpen: boolean;
onClose: () => void;
onSubmit: (query: string) => void;
}> = ({ isOpen, onClose, onSubmit }) => {
if (!isOpen) return null;
const [query, setQuery] = useState('');
const handleSubmit = (e: React.FormEvent) => {
e.preventDefault();
if (query.trim()) {
onSubmit(query);
}
};
return (
<div
className="fixed inset-0 bg-black bg-opacity-60 flex items-center justify-center z-50 transition-opacity duration-300"
onClick={onClose}
>
<div
className="bg-white rounded-lg shadow-xl p-6 sm:p-8 w-full max-w-lg transform transition-all"
onClick={(e) => e.stopPropagation()}
>
<div className="flex justify-between items-start mb-4">
<h3 className="text-xl font-bold text-brand-dark-blue">Contact Support</h3>
<button onClick={onClose} className="-mt-2 -mr-2 p-2 rounded-full hover:bg-gray-200 transition-colors">
<XIcon className="h-6 w-6 text-gray-600" />
</button>
</div>
<form onSubmit={handleSubmit}>
<p className="text-gray-600 mb-4">Please describe your issue or query below. A member of our team will be in touch shortly.</p>
<textarea
value={query}
onChange={(e) => setQuery(e.target.value)}
className="w-full p-3 border border-gray-300 rounded-md focus:ring-2 focus:ring-brand-accent focus:border-brand-accent transition"
rows={5}
placeholder="Type your message here..."
required
/>
<div className="mt-6 flex justify-end gap-3">
<button
type="button"
onClick={onClose}
className="bg-gray-200 text-gray-800 font-semibold py-2 px-4 rounded-md hover:bg-gray-300 transition-colors duration-300"
>
Cancel
</button>
<button
type="submit"
className="bg-brand-accent text-white font-semibold py-2 px-4 rounded-md hover:bg-brand-dark-blue transition-colors duration-300 disabled:bg-gray-400 disabled:cursor-not-allowed"
disabled={!query.trim()}
>
Submit Query
</button>
</div>
</form>
</div>
</div>
);
};
export const Login: React.FC<LoginProps> = ({ onLogin }) => {
const [isSupportModalOpen, setIsSupportModalOpen] = useState(false);
const [isLoggingIn, setIsLoggingIn] = useState(false);
const handleSupportSubmit = (query: string) => {
console.log("Support query submitted:", query);
alert("Thank you for your query. A member of the support team will be in touch with you shortly.");
setIsSupportModalOpen(false);
};
const handleMicrosoftLogin = () => {
setIsLoggingIn(true);
// Simulate redirect/auth delay
setTimeout(() => {
onLogin();
}, 1500);
};
return (
<>
<SupportModal
isOpen={isSupportModalOpen}
onClose={() => setIsSupportModalOpen(false)}
onSubmit={handleSupportSubmit}
/>
<div className="fixed inset-0 overflow-y-auto bg-[#0f172a] flex items-center justify-center font-sans p-4">
{/* Modern Glassy Background */}
<div className="absolute inset-0 overflow-hidden pointer-events-none">
{/* Top Left Blob */}
<div className="absolute -top-[20%] -left-[10%] w-[70%] h-[70%] rounded-full bg-gradient-to-br from-brand-accent/30 to-purple-600/30 blur-[120px] animate-pulse" style={{ animationDuration: '8s' }}></div>
{/* Bottom Right Blob */}
<div className="absolute -bottom-[20%] -right-[10%] w-[70%] h-[70%] rounded-full bg-gradient-to-tl from-brand-light-blue/30 to-emerald-500/30 blur-[120px] animate-pulse" style={{ animationDuration: '10s', animationDelay: '1s' }}></div>
{/* Noise Texture */}
<div className="absolute inset-0 bg-[url('https://grainy-gradients.vercel.app/noise.svg')] opacity-20 mix-blend-soft-light"></div>
</div>
{/* Login Card */}
<div className="relative z-10 bg-white/90 backdrop-blur-xl p-8 sm:p-12 shadow-2xl rounded-3xl w-full max-w-md flex flex-col items-center text-center border border-white/50 ring-1 ring-white/50">
<div className="mb-8 transform transition-transform hover:scale-105 duration-300">
<BarclaysLogo className="h-12 w-auto text-brand-dark-blue" />
</div>
<h1 className="text-3xl font-extrabold text-brand-dark-blue mb-2 tracking-tight">Mod Comms</h1>
<p className="text-slate-500 mb-8 font-medium">Proof Review &amp; Compliance Platform</p>
<div className="w-full space-y-6">
<div className="p-5 bg-blue-50/80 rounded-xl border border-blue-100 text-left">
<p className="text-sm text-blue-900 leading-relaxed">
<span className="font-bold block mb-1 flex items-center gap-2">
<span className="w-2 h-2 rounded-full bg-blue-500 animate-pulse"></span>
Enterprise Sign-In
</span>
This application is connected to Azure Active Directory. Please sign in using your corporate Microsoft account.
</p>
</div>
<button
onClick={handleMicrosoftLogin}
disabled={isLoggingIn}
className="w-full flex items-center justify-center gap-3 bg-white hover:bg-gray-50 text-slate-700 font-bold py-4 px-6 rounded-xl border border-gray-200 shadow-lg shadow-gray-200/50 hover:shadow-xl hover:border-gray-300 transition-all duration-300 group disabled:opacity-70 disabled:cursor-wait transform hover:-translate-y-0.5"
>
{isLoggingIn ? (
<svg className="animate-spin h-5 w-5 text-brand-accent" xmlns="http://www.w3.org/2000/svg" fill="none" viewBox="0 0 24 24">
<circle className="opacity-25" cx="12" cy="12" r="10" stroke="currentColor" strokeWidth="4"></circle>
<path className="opacity-75" fill="currentColor" d="M4 12a8 8 0 018-8V0C5.373 0 0 5.373 0 12h4zm2 5.291A7.962 7.962 0 014 12H0c0 3.042 1.135 5.824 3 7.938l3-2.647z"></path>
</svg>
) : (
<>
<MicrosoftLogo className="h-5 w-5" />
<span>Sign in with Microsoft</span>
</>
)}
</button>
</div>
<div className="mt-10 pt-6 border-t border-gray-200/60 w-full">
<button
type="button"
onClick={() => setIsSupportModalOpen(true)}
className="text-sm text-slate-500 hover:text-brand-accent transition-colors font-medium"
>
Having trouble signing in? Contact Support
</button>
</div>
</div>
<footer className="absolute bottom-0 left-0 right-0 text-center p-6 z-10">
<p className="text-xs text-slate-400/80 font-medium">&copy; {new Date().getFullYear()} OLIVER Agency Mod Comms. All rights reserved.</p>
</footer>
</div>
</>
);
};

View file

@ -0,0 +1,150 @@
import React from 'react';
import type { AgentReview, SubReview, RagStatus, OverallStatus } from '../types';
import { BarclaysLogo } from './icons/BarclaysLogo';
import { OliverLogo } from './icons/OliverLogo';
import { LegalIcon } from './icons/LegalIcon';
import { BrandIcon } from './icons/BrandIcon';
import { CopyIcon } from './icons/CopyIcon';
import { ChannelIcon } from './icons/ChannelIcon';
interface PDFReportProps {
campaignName: string;
proofs: any[];
}
const RagStatusBadge: React.FC<{ status: RagStatus }> = ({ status }) => {
let bgColor = '#E5E7EB'; // Gray for Error
let textColor = '#1F2937';
switch (status) {
case 'Red': bgColor = '#FEE2E2'; textColor = '#991B1B'; break;
case 'Amber': bgColor = '#FEF3C7'; textColor = '#92400E'; break;
case 'Green': bgColor = '#D1FAE5'; textColor = '#065F46'; break;
}
return (
<span style={{ backgroundColor: bgColor, color: textColor, padding: '4px 12px', borderRadius: '9999px', fontSize: '12px', fontWeight: 'bold' }}>
{status}
</span>
);
};
export const PDFReport: React.FC<PDFReportProps> = ({ campaignName, proofs }) => {
const today = new Date().toLocaleDateString('en-GB', {
day: '2-digit',
month: 'long',
year: 'numeric',
});
const isCampaignReport = proofs.length > 1;
const singleProofName = !isCampaignReport && proofs.length > 0 ? proofs[0].proofName : '';
return (
<div style={{ width: '210mm', fontFamily: 'Arial, sans-serif', color: '#333333', background: '#FFFFFF' }}>
{/* --- Cover Page --- */}
<div style={{ width: '210mm', height: '297mm', display: 'flex', flexDirection: 'column', padding: '20mm', boxSizing: 'border-box', borderBottom: '1px solid #e5e5e5' }}>
<div style={{ display: 'flex', justifyContent: 'space-between', alignItems: 'center', paddingBottom: '10mm', borderBottom: '1px solid #e5e5e5' }}>
<BarclaysLogo style={{ height: '50px', width: 'auto', color: '#001f5a' }} />
<OliverLogo style={{ height: '25px', width: 'auto' }} />
</div>
<div style={{ flexGrow: 1, display: 'flex', flexDirection: 'column', justifyContent: 'center', alignItems: 'center', textAlign: 'center' }}>
<h1 style={{ fontSize: '42px', color: '#001f5a', margin: '0 0 10px 0' }}>AI Compliance & Brand Report</h1>
<p style={{ fontSize: '18px', color: '#555', marginTop: '0' }}>
{isCampaignReport ? 'Campaign-Level Summary' : 'Single Proof Analysis'}
</p>
</div>
<div style={{ borderTop: '1px solid #e5e5e5', paddingTop: '10mm' }}>
<p style={{ margin: 0, fontSize: '14px' }}><strong>Campaign:</strong> {campaignName}</p>
{!isCampaignReport && <p style={{ margin: '5px 0 0 0', fontSize: '14px' }}><strong>Proof:</strong> {singleProofName}</p>}
<p style={{ margin: '5px 0 0 0', fontSize: '14px' }}><strong>Export Date:</strong> {today}</p>
</div>
</div>
{/* --- Table of Contents for Campaign Report --- */}
{isCampaignReport && (
<div style={{ width: '210mm', minHeight: '297mm', padding: '20mm', boxSizing: 'border-box', pageBreakBefore: 'always', borderBottom: '1px solid #e5e5e5' }}>
<h2 style={{ fontSize: '28px', color: '#001f5a', borderBottom: '2px solid #00a3e0', paddingBottom: '8px', marginBottom: '20px' }}>
Table of Contents
</h2>
<ul style={{ listStyle: 'none', padding: 0 }}>
{proofs.map((proof, index) => (
<li key={index} style={{ fontSize: '16px', padding: '10px 0', borderBottom: '1px dotted #ccc' }}>
{proof.proofName} - V{proof.versions[0]?.version || 1}
</li>
))}
</ul>
</div>
)}
{/* --- Proof Report Pages --- */}
{proofs.map((proof) => {
const version = proof.versions[0];
if (!version) return null;
const feedback: AgentReview = version.feedback;
const agentReviews = [
{ title: 'Legal Agent', review: feedback.legalAgentReview, icon: <LegalIcon style={{height: '24px', width: '24px'}} /> },
{ title: 'Brand Agent', review: feedback.brandAgentReview, icon: <BrandIcon style={{height: '24px', width: '24px'}} /> },
{ title: 'Tone Agent', review: feedback.toneAgentReview, icon: <CopyIcon style={{height: '24px', width: '24px'}} /> },
{ title: 'Channel Agent', review: feedback.channelAgentReview, icon: <ChannelIcon style={{height: '24px', width: '24px'}} /> },
];
return (
<div key={proof.proofName} style={{ width: '210mm', minHeight: '297mm', padding: '15mm', boxSizing: 'border-box', pageBreakBefore: 'always' }}>
{/* Proof Header */}
<div style={{ paddingBottom: '8px', borderBottom: '2px solid #00a3e0', marginBottom: '10mm' }}>
<h2 style={{ fontSize: '24px', color: '#001f5a', margin: 0 }}>{proof.proofName} - V{version.version}</h2>
<p style={{ fontSize: '14px', color: '#555', margin: '4px 0 0 0' }}>
{version.workfrontId} &bull; {proof.channel} / {proof.subChannel}
</p>
</div>
{/* Preview & Summary */}
<div style={{ display: 'grid', gridTemplateColumns: '1fr 1fr', gap: '10mm', marginBottom: '10mm' }}>
<div style={{ border: '1px solid #ccc', padding: '4px', background: '#f8f8f8' }}>
<img src={version.proofPreviewUrl} alt="Proof Preview" style={{ width: '100%', objectFit: 'contain' }} />
</div>
<div style={{ backgroundColor: '#f4f6f8', padding: '6mm', borderRadius: '4px', borderLeft: `4px solid ${feedback.overallStatus === 'Passed' ? '#10B981' : '#EF4444'}`}}>
<h3 style={{ fontSize: '18px', margin: '0 0 8px 0', color: '#001f5a' }}>Overall Summary</h3>
<p style={{ fontSize: '14px', margin: '0 0 10px 0', fontWeight: 'bold' }}>Status: {feedback.overallStatus}</p>
{feedback.overallStatus === 'Requires Manual Legal Review' && (
<div style={{ backgroundColor: '#F3E8FF', border: '1px solid #D8B4FE', borderRadius: '4px', padding: '8px', marginBottom: '10px'}}>
<p style={{fontSize: '13px', margin: 0, fontWeight: 'bold', color: '#6B21A8'}}>Financial Promotion Detected:</p>
<p style={{fontSize: '12px', margin: '4px 0 0 0', color: '#6B21A8', fontStyle: 'italic'}}>"{feedback.financialPromotionReason}"</p>
</div>
)}
<p style={{ fontSize: '14px', lineHeight: 1.5, margin: 0 }}>{feedback.leadAgentSummary}</p>
</div>
</div>
{/* Detailed Agent Feedback */}
<div style={{ display: 'grid', gridTemplateColumns: '1fr 1fr', gap: '8mm' }}>
{agentReviews.map(({ title, review, icon }) => (
<div key={title} style={{ border: '1px solid #e5e5e5', borderRadius: '4px', padding: '5mm', backgroundColor: '#fff', breakInside: 'avoid' }}>
<div style={{ display: 'flex', justifyContent: 'space-between', alignItems: 'center', marginBottom: '10px' }}>
<h4 style={{ fontSize: '16px', fontWeight: 'bold', color: '#001f5a', margin: 0, display: 'flex', alignItems: 'center', gap: '8px'}}>
<span style={{color: '#0070c0'}}>{icon}</span> {title}
</h4>
<RagStatusBadge status={review.ragStatus} />
</div>
<p style={{ fontSize: '13px', lineHeight: 1.5, margin: '0 0 10px 0', borderTop: '1px solid #eee', paddingTop: '10px' }}>{review.feedback}</p>
{review.issues && review.issues.length > 0 && (
<div>
<h5 style={{ fontSize: '13px', fontWeight: 'bold', margin: '0 0 5px 0' }}>Actionable Issues:</h5>
<ul style={{ margin: 0, paddingLeft: '18px', fontSize: '12px', lineHeight: 1.5 }}>
{review.issues.map((issue, i) => <li key={i} style={{ marginBottom: '4px' }}>{issue}</li>)}
</ul>
</div>
)}
</div>
))}
</div>
</div>
);
})}
</div>
);
};

View file

@ -0,0 +1,105 @@
import React, { useState } from 'react';
import { LogoutIcon } from './icons/LogoutIcon';
import { QuestionMarkIcon } from './icons/QuestionMarkIcon';
interface ProfileProps {
onLogout: () => void;
}
export const Profile: React.FC<ProfileProps> = ({ onLogout }) => {
const [isQuestionFormVisible, setIsQuestionFormVisible] = useState(false);
const [question, setQuestion] = useState('');
const userDetails = {
'Account Type': 'Administrator',
'First Name': 'Steve',
'Last Name': 'O\'Donoghue',
'Email': 'steveodonoghue@oliver.agency',
'Entity': 'OLIVER Agency',
};
const handleLogout = () => {
onLogout();
};
const handleToggleQuestionForm = () => {
setIsQuestionFormVisible(prev => !prev);
};
const handleSubmitQuestion = (e: React.FormEvent) => {
e.preventDefault();
if (!question.trim()) {
alert('Please enter a question before submitting.');
return;
}
alert(`Your question has been submitted:\n\n"${question}"\n\nWe'll get back to you shortly.`);
setQuestion('');
setIsQuestionFormVisible(false);
};
return (
<div className="p-4 sm:p-6 lg:p-8 h-full bg-brand-gray">
<header className="mb-8">
<h1 className="text-3xl lg:text-4xl font-bold text-brand-dark-blue">Your Profile</h1>
<p className="text-base lg:text-lg text-gray-600 mt-1">View your account details and manage settings.</p>
</header>
<div className="max-w-3xl">
<section className="bg-white rounded-lg shadow-md p-6 sm:p-8 border border-gray-200">
<h2 className="text-2xl font-bold text-brand-dark-blue mb-6">Account Information</h2>
<div className="grid grid-cols-1 sm:grid-cols-2 gap-x-8 gap-y-5">
{Object.entries(userDetails).map(([key, value]) => (
<div key={key}>
<p className="text-sm font-semibold text-gray-500 tracking-wide uppercase">{key}</p>
<p className="text-lg text-gray-800 mt-1">{value}</p>
</div>
))}
</div>
<div className="border-t border-gray-200 mt-8 pt-6 flex flex-col sm:flex-row gap-3">
<button
onClick={handleLogout}
className="flex items-center justify-center gap-2 bg-red-600 text-white font-semibold py-2 px-4 rounded-md hover:bg-red-700 transition-colors duration-300"
>
<LogoutIcon className="h-5 w-5" />
Logout
</button>
<button
onClick={handleToggleQuestionForm}
className="flex items-center justify-center gap-2 bg-gray-200 text-gray-800 font-semibold py-2 px-4 rounded-md hover:bg-gray-300 transition-colors duration-300"
>
<QuestionMarkIcon className="h-5 w-5" />
Got a question?
</button>
</div>
</section>
{isQuestionFormVisible && (
<section className="mt-8 bg-white rounded-lg shadow-md p-6 sm:p-8 border border-gray-200">
<h2 className="text-2xl font-bold text-brand-dark-blue mb-4">Ask a Question</h2>
<form onSubmit={handleSubmitQuestion}>
<p className="text-gray-600 mb-4">Your question will be sent to the OLIVER Agency support team.</p>
<textarea
value={question}
onChange={(e) => setQuestion(e.target.value)}
className="w-full p-3 border border-gray-300 rounded-md focus:ring-2 focus:ring-brand-accent focus:border-brand-accent transition"
placeholder="Type your question here..."
rows={5}
required
/>
<div className="mt-4 flex justify-end">
<button
type="submit"
className="bg-brand-accent text-white font-semibold py-2 px-5 rounded-md hover:bg-brand-dark-blue transition-colors duration-300 disabled:bg-gray-400 disabled:cursor-not-allowed"
disabled={!question.trim()}
>
Submit Question
</button>
</div>
</form>
</section>
)}
</div>
</div>
);
};

File diff suppressed because it is too large Load diff

View file

@ -0,0 +1,84 @@
import React from 'react';
import { DocumentIcon } from './icons/DocumentIcon';
interface ProofPreviewProps {
file?: File | null;
previewUrl: string | null;
fileName?: string;
}
export const ProofPreview: React.FC<ProofPreviewProps> = ({ file, previewUrl, fileName }) => {
if (!previewUrl) {
return null;
}
const getMimeType = (): string => {
if (file?.type) return file.type;
if (previewUrl.startsWith('data:')) {
const match = previewUrl.match(/data:([a-zA-Z0-9]+\/[a-zA-Z0-9-.+]+);/);
if (match && match[1]) {
return match[1];
}
}
return 'application/octet-stream'; // Fallback
};
const fileType = getMimeType();
const displayName = fileName || file?.name || 'Proof Preview';
const renderPreview = () => {
if (fileType.startsWith('image/')) {
return (
<img
src={previewUrl}
alt={displayName}
className="w-full rounded-lg shadow-2xl object-contain border border-gray-200 bg-white p-2"
style={{ maxHeight: 'calc(100vh - 9rem)' }}
/>
);
}
if (fileType === 'video/mp4') {
return (
<video
src={previewUrl}
controls
className="w-full rounded-lg shadow-2xl object-contain border border-gray-200 bg-white p-2"
style={{ maxHeight: 'calc(100vh - 9rem)' }}
>
Your browser does not support the video tag.
</video>
);
}
if (fileType === 'application/pdf') {
return (
<iframe
src={`${previewUrl}#view=fitH`}
title={displayName}
className="w-full h-[calc(100vh-9rem)] rounded-lg shadow-2xl border border-gray-200 bg-white"
/>
);
}
// Fallback for other file types
return (
<div
className="w-full rounded-lg shadow-2xl border border-gray-200 bg-white p-8 flex flex-col items-center justify-center text-center"
style={{ minHeight: '300px', maxHeight: 'calc(100vh - 9rem)' }}
>
<DocumentIcon className="h-20 w-20 text-gray-400 mb-4" />
<p className="text-lg font-semibold text-brand-dark-blue break-all">{displayName}</p>
<p className="text-sm text-gray-500">{fileType}</p>
<p className="text-sm text-gray-500 mt-2">No preview available for this file type.</p>
</div>
);
};
return (
<div className="sticky top-8">
{renderPreview()}
</div>
);
};

View file

@ -0,0 +1,113 @@
import React, { useState, useEffect } from 'react';
import { TrashIcon } from './icons/TrashIcon';
import { ChevronDownIcon } from './icons/ChevronDownIcon';
interface ProofTypeManagerProps {
subChannels: string[];
proofTypesBySubChannel: Record<string, string[]>;
onAddProofType: (subChannel: string, value: string) => void;
onRemoveProofType: (subChannel: string, value: string) => void;
disabled: boolean;
}
export const ProofTypeManager: React.FC<ProofTypeManagerProps> = ({
subChannels,
proofTypesBySubChannel,
onAddProofType,
onRemoveProofType,
disabled
}) => {
const [selectedSubChannel, setSelectedSubChannel] = useState<string>('');
const [newItem, setNewItem] = useState('');
useEffect(() => {
// If subChannels are available, default to the first one.
if (subChannels.length > 0 && !selectedSubChannel) {
setSelectedSubChannel(subChannels[0]);
}
}, [subChannels, selectedSubChannel]);
const handleSubmit = (e: React.FormEvent) => {
e.preventDefault();
if (newItem.trim() && selectedSubChannel) {
onAddProofType(selectedSubChannel, newItem.trim());
setNewItem('');
}
};
const currentProofTypes = proofTypesBySubChannel[selectedSubChannel] || [];
return (
<div className="bg-white rounded-lg shadow-md p-6 border border-gray-200 flex flex-col h-full">
<h2 className="text-xl font-bold text-brand-dark-blue mb-2">Proof Type Manager</h2>
<p className="text-sm text-gray-500 mb-4">Assign and manage specific proof types for each sub-channel.</p>
<div className="mb-4">
<label htmlFor="subchannel-select" className="block text-sm font-medium text-gray-700 mb-1">
Select a Sub-Channel to manage:
</label>
<div className="relative">
<select
id="subchannel-select"
value={selectedSubChannel}
onChange={(e) => setSelectedSubChannel(e.target.value)}
className="w-full bg-white border border-gray-300 rounded-md py-2 pl-3 pr-10 text-gray-900 focus:outline-none focus:ring-2 focus:ring-brand-accent appearance-none disabled:bg-gray-100"
disabled={disabled}
>
{subChannels.map(sc => (
<option key={sc} value={sc}>{sc}</option>
))}
</select>
<div className="pointer-events-none absolute inset-y-0 right-0 flex items-center px-3 text-gray-400">
<ChevronDownIcon className="h-4 w-4" />
</div>
</div>
</div>
<form onSubmit={handleSubmit} className="flex gap-2 mb-4">
<input
type="text"
value={newItem}
onChange={(e) => setNewItem(e.target.value)}
placeholder="New Proof Type..."
className="flex-grow p-2 border border-gray-300 rounded-md focus:ring-2 focus:ring-brand-accent focus:border-brand-accent transition disabled:bg-gray-100 disabled:cursor-not-allowed"
disabled={disabled || !selectedSubChannel}
/>
<button
type="submit"
className="bg-brand-accent text-white font-semibold py-2 px-4 rounded-md hover:bg-brand-dark-blue transition-colors duration-300 disabled:bg-gray-400 disabled:cursor-not-allowed"
disabled={!newItem.trim() || disabled || !selectedSubChannel}
>
Add
</button>
</form>
<div className="flex-1 overflow-y-auto pr-2 -mr-2">
{currentProofTypes.length > 0 ? (
<ul className="space-y-2">
{currentProofTypes.map((item) => (
<li
key={item}
className="flex items-center justify-between bg-gray-50 p-2.5 rounded-md border border-gray-200 group"
>
<span className="text-gray-800">{item}</span>
<button
onClick={() => onRemoveProofType(selectedSubChannel, item)}
className={`text-gray-400 hover:text-red-600 transition-opacity ${disabled ? 'opacity-0 cursor-not-allowed' : 'opacity-0 group-hover:opacity-100'}`}
title={`Remove ${item}`}
disabled={disabled}
>
<TrashIcon className="h-5 w-5" />
</button>
</li>
))}
</ul>
) : (
<div className="text-center py-8 text-gray-500 bg-gray-50 rounded-md h-full flex items-center justify-center">
<p>No proof types configured for <br/> <strong className="text-gray-600">{selectedSubChannel}</strong>.</p>
</div>
)}
</div>
</div>
);
};

View file

@ -0,0 +1,498 @@
import React, { useState, useEffect } from 'react';
import type { DropdownOptions } from '../App';
import { TrashIcon } from './icons/TrashIcon';
import { UserIcon } from './icons/UserIcon';
import { PlusIcon } from './icons/PlusIcon';
import { ChevronDownIcon } from './icons/ChevronDownIcon';
import { ClipboardIcon } from './icons/ClipboardIcon';
import { CopyGenAIIcon } from './icons/CopyGenAIIcon';
interface ManagementCardProps {
title: string;
items: string[];
onAdd: (item: string) => void;
onRemove: (item: string) => void;
disabled?: boolean;
placeholder?: string;
}
const ManagementCard: React.FC<ManagementCardProps> = ({ title, items, onAdd, onRemove, disabled = false, placeholder }) => {
const [newItem, setNewItem] = useState('');
const handleSubmit = (e: React.FormEvent) => {
e.preventDefault();
if (newItem.trim()) {
onAdd(newItem.trim());
setNewItem('');
}
};
return (
<div className="bg-white rounded-lg shadow-md p-6 border border-gray-200 flex flex-col h-full">
<h2 className="text-xl font-bold text-brand-dark-blue mb-4">{title}</h2>
<form onSubmit={handleSubmit} className="flex gap-2 mb-4">
<input
type="text"
value={newItem}
onChange={(e) => setNewItem(e.target.value)}
placeholder={placeholder || `New ${title.slice(0, -1)}...`}
className="flex-grow p-2 border border-gray-300 rounded-md focus:ring-2 focus:ring-brand-accent focus:border-brand-accent transition disabled:bg-gray-100 disabled:cursor-not-allowed"
disabled={disabled}
/>
<button
type="submit"
className="bg-brand-accent text-white font-semibold py-2 px-4 rounded-md hover:bg-brand-dark-blue transition-colors duration-300 disabled:bg-gray-400 disabled:cursor-not-allowed"
disabled={!newItem.trim() || disabled}
>
Add
</button>
</form>
<div className="flex-1 overflow-y-auto pr-2 -mr-2">
{items.length > 0 ? (
<ul className="space-y-2">
{items.map((item) => (
<li
key={item}
className="flex items-center justify-between bg-gray-50 p-2.5 rounded-md border border-gray-200 group hover:border-brand-accent/30 transition-colors"
>
<span className="text-gray-800">{item}</span>
<button
onClick={() => onRemove(item)}
className={`text-gray-400 hover:text-red-600 transition-opacity ${disabled ? 'opacity-0 cursor-not-allowed' : 'opacity-0 group-hover:opacity-100'}`}
title={`Remove ${item}`}
disabled={disabled}
>
<TrashIcon className="h-5 w-5" />
</button>
</li>
))}
</ul>
) : (
<div className="text-center py-8 text-gray-500 bg-gray-50 rounded-md">
No items found.
</div>
)}
</div>
</div>
);
};
// --- USER MANAGEMENT ---
interface User {
id: string;
name: string;
email: string;
agency: string;
}
const INITIAL_USERS: User[] = [
{ id: '1', name: "Steve O'Donoghue", email: "steveodonoghue@oliver.agency", agency: "OLIVER Agency" },
{ id: '2', name: "Jane Doe", email: "jane.doe@barclays.com", agency: "Barclays" },
{ id: '3', name: "Sarah Jenkins", email: "sarah.jenkins@mindshare.com", agency: "Mindshare" },
];
const AGENCIES = ["OLIVER Agency", "Barclays", "Mindshare", "Zenith", "Unassigned"];
const UsersTab: React.FC = () => {
const [users, setUsers] = useState<User[]>(() => {
const saved = localStorage.getItem('barclays_modcomms_users');
return saved ? JSON.parse(saved) : INITIAL_USERS;
});
const [newName, setNewName] = useState('');
const [newEmail, setNewEmail] = useState('');
const [newAgency, setNewAgency] = useState(AGENCIES[0]);
useEffect(() => {
localStorage.setItem('barclays_modcomms_users', JSON.stringify(users));
}, [users]);
const handleAddUser = (e: React.FormEvent) => {
e.preventDefault();
if (newName && newEmail) {
const newUser: User = {
id: Date.now().toString(),
name: newName,
email: newEmail,
agency: newAgency
};
setUsers([...users, newUser]);
setNewName('');
setNewEmail('');
}
};
const handleAgencyChange = (userId: string, newAgency: string) => {
setUsers(users.map(u => u.id === userId ? { ...u, agency: newAgency } : u));
};
const handleDeleteUser = (userId: string) => {
if (window.confirm('Are you sure you want to remove this user?')) {
setUsers(users.filter(u => u.id !== userId));
}
};
return (
<div className="space-y-8">
{/* Add User Section */}
<div className="bg-white rounded-lg shadow-md p-6 border border-gray-200">
<h3 className="text-lg font-bold text-brand-dark-blue mb-4 flex items-center gap-2">
<PlusIcon className="h-5 w-5 text-brand-accent" />
Add New User
</h3>
<form onSubmit={handleAddUser} className="grid grid-cols-1 md:grid-cols-4 gap-4 items-end">
<div>
<label className="block text-xs font-medium text-gray-700 mb-1">Name</label>
<input
type="text"
value={newName}
onChange={(e) => setNewName(e.target.value)}
className="w-full p-2 border border-gray-300 rounded-md focus:ring-2 focus:ring-brand-accent focus:border-brand-accent"
placeholder="e.g. John Smith"
required
/>
</div>
<div>
<label className="block text-xs font-medium text-gray-700 mb-1">Email</label>
<input
type="email"
value={newEmail}
onChange={(e) => setNewEmail(e.target.value)}
className="w-full p-2 border border-gray-300 rounded-md focus:ring-2 focus:ring-brand-accent focus:border-brand-accent"
placeholder="user@example.com"
required
/>
</div>
<div>
<label className="block text-xs font-medium text-gray-700 mb-1">Agency</label>
<div className="relative">
<select
value={newAgency}
onChange={(e) => setNewAgency(e.target.value)}
className="w-full p-2 border border-gray-300 rounded-md focus:ring-2 focus:ring-brand-accent focus:border-brand-accent appearance-none"
>
{AGENCIES.map(a => <option key={a} value={a}>{a}</option>)}
</select>
<ChevronDownIcon className="absolute right-3 top-1/2 -translate-y-1/2 h-4 w-4 text-gray-500 pointer-events-none" />
</div>
</div>
<button
type="submit"
className="bg-brand-accent text-white font-semibold py-2 px-4 rounded-md hover:bg-brand-dark-blue transition-colors"
>
Add User
</button>
</form>
</div>
{/* User List */}
<div className="bg-white rounded-lg shadow-md overflow-hidden border border-gray-200">
<div className="overflow-x-auto">
<table className="min-w-full divide-y divide-gray-200">
<thead className="bg-gray-50">
<tr>
<th scope="col" className="px-6 py-3 text-left text-xs font-bold text-gray-500 uppercase tracking-wider">Name</th>
<th scope="col" className="px-6 py-3 text-left text-xs font-bold text-gray-500 uppercase tracking-wider">Email</th>
<th scope="col" className="px-6 py-3 text-left text-xs font-bold text-gray-500 uppercase tracking-wider">Assigned Agency</th>
<th scope="col" className="relative px-6 py-3"><span className="sr-only">Actions</span></th>
</tr>
</thead>
<tbody className="bg-white divide-y divide-gray-200">
{users.map((user) => (
<tr key={user.id} className="hover:bg-gray-50">
<td className="px-6 py-4 whitespace-nowrap">
<div className="flex items-center">
<div className="flex-shrink-0 h-8 w-8 rounded-full bg-brand-light-blue/20 flex items-center justify-center text-brand-accent">
<UserIcon className="h-4 w-4" />
</div>
<div className="ml-4">
<div className="text-sm font-medium text-gray-900">{user.name}</div>
</div>
</div>
</td>
<td className="px-6 py-4 whitespace-nowrap text-sm text-gray-500">
{user.email}
</td>
<td className="px-6 py-4 whitespace-nowrap text-sm text-gray-500">
<div className="relative max-w-xs">
<select
value={user.agency}
onChange={(e) => handleAgencyChange(user.id, e.target.value)}
className="w-full bg-white border border-gray-300 text-gray-700 py-1.5 pl-3 pr-8 rounded-md text-sm focus:outline-none focus:ring-2 focus:ring-brand-accent appearance-none"
>
{AGENCIES.map(a => <option key={a} value={a}>{a}</option>)}
</select>
<ChevronDownIcon className="absolute right-2 top-1/2 -translate-y-1/2 h-3 w-3 text-gray-500 pointer-events-none" />
</div>
</td>
<td className="px-6 py-4 whitespace-nowrap text-right text-sm font-medium">
<button
onClick={() => handleDeleteUser(user.id)}
className="text-gray-400 hover:text-red-600 transition-colors"
title="Remove User"
>
<TrashIcon className="h-5 w-5" />
</button>
</td>
</tr>
))}
</tbody>
</table>
</div>
</div>
</div>
);
};
// --- SETTINGS COMPONENT ---
interface SettingsProps {
options: DropdownOptions;
onAddCampaign: (value: string) => void;
onRemoveCampaign: (value: string) => void;
onAddChannel: (value: string) => void;
onRemoveChannel: (value: string) => void;
onAddSubChannel: (channel: string, value: string) => void;
onRemoveSubChannel: (channel: string, value: string) => void;
onAddProofType: (channel: string, subChannel: string, value: string) => void;
onRemoveProofType: (channel: string, subChannel: string, value: string) => void;
onNavigate: (view: string) => void;
}
type Tab = 'Campaigns' | 'Channels' | 'SubChannels' | 'ProofTypes' | 'Users' | 'Beta';
export const Settings: React.FC<SettingsProps> = ({
options,
onAddCampaign,
onRemoveCampaign,
onAddChannel,
onRemoveChannel,
onAddSubChannel,
onRemoveSubChannel,
onAddProofType,
onRemoveProofType,
onNavigate,
}) => {
const [activeTab, setActiveTab] = useState<Tab>('Channels');
const [selectedChannel, setSelectedChannel] = useState<string>('');
const [selectedSubChannel, setSelectedSubChannel] = useState<string>('');
// Update selected channel if it disappears
useEffect(() => {
if (selectedChannel && !options.channels[selectedChannel]) {
setSelectedChannel('');
setSelectedSubChannel('');
}
}, [options.channels, selectedChannel]);
// Update selected sub-channel if it disappears
useEffect(() => {
if (selectedChannel && selectedSubChannel) {
const subChannels = options.channels[selectedChannel] || {};
if (!subChannels[selectedSubChannel]) {
setSelectedSubChannel('');
}
}
}, [options.channels, selectedChannel, selectedSubChannel]);
return (
<div className="p-4 sm:p-6 lg:p-8 h-full bg-brand-gray flex flex-col">
<header className="mb-6 flex-shrink-0">
<h1 className="text-3xl lg:text-4xl font-bold text-brand-dark-blue">Settings</h1>
<p className="text-base lg:text-lg text-gray-600 mt-1">
Configure application defaults and user access.
</p>
</header>
<div className="flex-1 flex flex-col min-h-0">
{/* Tabs Header */}
<div className="border-b border-gray-200 bg-white px-4 rounded-t-lg shadow-sm flex-shrink-0">
<nav className="-mb-px flex space-x-8 overflow-x-auto" aria-label="Tabs">
{[
{ id: 'Campaigns', label: 'Campaigns' },
{ id: 'Channels', label: 'Channels' },
{ id: 'SubChannels', label: 'Sub-channels' },
{ id: 'ProofTypes', label: 'Proof Types' },
{ id: 'Users', label: 'Users' },
{ id: 'Beta', label: '[Beta]' },
].map((tab) => (
<button
key={tab.id}
onClick={() => setActiveTab(tab.id as Tab)}
className={`
whitespace-nowrap py-4 px-1 border-b-2 font-medium text-sm transition-colors
${activeTab === tab.id
? 'border-brand-accent text-brand-accent'
: 'border-transparent text-gray-500 hover:text-gray-700 hover:border-gray-300'
}
`}
aria-current={activeTab === tab.id ? 'page' : undefined}
>
{tab.label}
</button>
))}
</nav>
</div>
{/* Tabs Content */}
<div className="flex-1 bg-transparent pt-6 overflow-y-auto">
{activeTab === 'Campaigns' && (
<div className="max-w-3xl">
<ManagementCard
title="Campaigns"
items={options.campaigns}
onAdd={onAddCampaign}
onRemove={onRemoveCampaign}
placeholder="e.g. Q4 Marketing"
/>
</div>
)}
{activeTab === 'Channels' && (
<div className="max-w-3xl">
<ManagementCard
title="Channels"
items={Object.keys(options.channels)}
onAdd={onAddChannel}
onRemove={onRemoveChannel}
placeholder="e.g. Social, OOH"
/>
</div>
)}
{activeTab === 'SubChannels' && (
<div className="max-w-3xl space-y-4">
<div className="bg-white p-4 rounded-lg border border-gray-200 shadow-sm">
<label className="block text-sm font-medium text-gray-700 mb-2">Select Parent Channel</label>
<div className="relative">
<select
value={selectedChannel}
onChange={(e) => setSelectedChannel(e.target.value)}
className="w-full p-2 border border-gray-300 rounded-md focus:ring-2 focus:ring-brand-accent appearance-none"
>
<option value="" disabled>Select Channel</option>
{Object.keys(options.channels).map(c => <option key={c} value={c}>{c}</option>)}
</select>
<ChevronDownIcon className="absolute right-3 top-1/2 -translate-y-1/2 h-4 w-4 text-gray-500 pointer-events-none"/>
</div>
</div>
<ManagementCard
title={selectedChannel ? `Sub-Channels for ${selectedChannel}` : "Sub-Channels"}
items={selectedChannel ? Object.keys(options.channels[selectedChannel]) : []}
onAdd={(val) => onAddSubChannel(selectedChannel, val)}
onRemove={(val) => onRemoveSubChannel(selectedChannel, val)}
disabled={!selectedChannel}
placeholder="e.g. Meta, Video"
/>
</div>
)}
{activeTab === 'ProofTypes' && (
<div className="max-w-3xl space-y-4">
<div className="grid grid-cols-1 md:grid-cols-2 gap-4">
<div className="bg-white p-4 rounded-lg border border-gray-200 shadow-sm">
<label className="block text-sm font-medium text-gray-700 mb-2">Select Channel</label>
<div className="relative">
<select
value={selectedChannel}
onChange={(e) => {
setSelectedChannel(e.target.value);
setSelectedSubChannel('');
}}
className="w-full p-2 border border-gray-300 rounded-md focus:ring-2 focus:ring-brand-accent appearance-none"
>
<option value="" disabled>Select Channel</option>
{Object.keys(options.channels).map(c => <option key={c} value={c}>{c}</option>)}
</select>
<ChevronDownIcon className="absolute right-3 top-1/2 -translate-y-1/2 h-4 w-4 text-gray-500 pointer-events-none"/>
</div>
</div>
<div className="bg-white p-4 rounded-lg border border-gray-200 shadow-sm">
<label className="block text-sm font-medium text-gray-700 mb-2">Select Sub-Channel</label>
<div className="relative">
<select
value={selectedSubChannel}
onChange={(e) => setSelectedSubChannel(e.target.value)}
className="w-full p-2 border border-gray-300 rounded-md focus:ring-2 focus:ring-brand-accent appearance-none disabled:bg-gray-100"
disabled={!selectedChannel}
>
<option value="" disabled>Select Sub-Channel</option>
{selectedChannel && Object.keys(options.channels[selectedChannel]).map(sc => (
<option key={sc} value={sc}>{sc}</option>
))}
</select>
<ChevronDownIcon className="absolute right-3 top-1/2 -translate-y-1/2 h-4 w-4 text-gray-500 pointer-events-none"/>
</div>
</div>
</div>
<ManagementCard
title={selectedSubChannel ? `Proof Types for ${selectedSubChannel}` : "Proof Types"}
items={
(selectedChannel && selectedSubChannel)
? (options.channels[selectedChannel][selectedSubChannel] || [])
: []
}
onAdd={(val) => onAddProofType(selectedChannel, selectedSubChannel, val)}
onRemove={(val) => onRemoveProofType(selectedChannel, selectedSubChannel, val)}
disabled={!selectedChannel || !selectedSubChannel}
placeholder="e.g. In-feed 1x1, 300x600"
/>
</div>
)}
{activeTab === 'Users' && (
<UsersTab />
)}
{activeTab === 'Beta' && (
<div className="max-w-3xl space-y-6">
<div className="bg-white rounded-lg shadow-md p-6 border border-gray-200">
<h2 className="text-xl font-bold text-brand-dark-blue mb-2 flex items-center gap-2">
<span className="relative flex h-3 w-3">
<span className="animate-ping absolute inline-flex h-full w-full rounded-full bg-brand-accent opacity-75"></span>
<span className="relative inline-flex rounded-full h-3 w-3 bg-brand-accent"></span>
</span>
Beta Features
</h2>
<p className="text-gray-600 mb-6">
Explore experimental features currently in development. These tools are functional but may change over time.
</p>
<div className="grid grid-cols-1 md:grid-cols-2 gap-4">
<button
onClick={() => onNavigate('WIP Reviewer')}
className="flex flex-col items-start p-4 rounded-lg border border-gray-200 hover:border-brand-accent hover:bg-blue-50/50 transition-all duration-300 group text-left"
>
<div className="p-2 rounded-md bg-brand-light-blue/10 text-brand-accent mb-3 group-hover:scale-110 transition-transform">
<ClipboardIcon className="h-6 w-6" />
</div>
<h3 className="font-bold text-brand-dark-blue mb-1">WIP Reviewer</h3>
<p className="text-sm text-gray-500">Early-stage feedback on assets before they are finalized.</p>
</button>
<button
onClick={() => onNavigate('CopyGenAI')}
className="flex flex-col items-start p-4 rounded-lg border border-gray-200 hover:border-brand-accent hover:bg-blue-50/50 transition-all duration-300 group text-left"
>
<div className="p-2 rounded-md bg-purple-100 text-purple-600 mb-3 group-hover:scale-110 transition-transform">
<CopyGenAIIcon className="h-6 w-6" />
</div>
<h3 className="font-bold text-brand-dark-blue mb-1">CopyGenAI</h3>
<p className="text-sm text-gray-500">Generate and refine marketing copy with AI assistance.</p>
</button>
</div>
</div>
</div>
)}
</div>
</div>
</div>
);
};

View file

@ -0,0 +1,94 @@
import React from 'react';
import { BarclaysLogo } from './icons/BarclaysLogo';
import { DashboardIcon } from './icons/DashboardIcon';
import { AnalyticsIcon } from './icons/AnalyticsIcon';
import { SettingsIcon } from './icons/SettingsIcon';
import { UserIcon } from './icons/UserIcon';
import { CampaignsIcon } from './icons/CampaignsIcon';
import { AuditIcon } from './icons/AuditIcon';
const navigation = [
{ name: 'Home', icon: DashboardIcon },
{ name: 'Campaigns', icon: CampaignsIcon },
// { name: 'WIP Reviewer', icon: ClipboardIcon }, // Hidden: Moved to Settings > Beta
// { name: 'CopyGenAI', icon: CopyGenAIIcon }, // Hidden: Moved to Settings > Beta
{ name: 'Analytics', icon: AnalyticsIcon },
{ name: 'Auditing', icon: AuditIcon },
{ name: 'Settings', icon: SettingsIcon },
];
interface SidebarProps {
activeItem: string;
onNavigate: (viewName: string) => void;
}
export const Sidebar: React.FC<SidebarProps> = ({ activeItem, onNavigate }) => {
return (
<aside className="w-72 flex-shrink-0 bg-[#0f172a] text-slate-200 flex flex-col border-r border-slate-800 font-sans">
{/* Brand Header */}
<div className="h-24 flex items-center px-8 border-b border-slate-800/60">
<BarclaysLogo className="h-8 w-auto text-white" />
<div className="ml-3 flex flex-col">
<span className="text-lg font-bold tracking-tight text-white leading-tight">
Mod Comms
</span>
<span className="text-xs text-slate-400 uppercase tracking-widest font-semibold">
Compliance AI
</span>
</div>
</div>
{/* Navigation */}
<nav className="flex-1 px-4 py-8 space-y-2 overflow-y-auto">
{navigation.map((item) => {
const Icon = item.icon;
const isActive = item.name === activeItem;
const isComingSoon = (item as any).isComingSoon;
return (
<button
key={item.name}
onClick={() => !isComingSoon && onNavigate(item.name)}
className={`group w-full flex items-center px-4 py-3.5 text-left text-sm font-medium rounded-xl transition-all duration-300 ease-in-out ${
isActive
? 'bg-gradient-to-r from-brand-accent to-brand-light-blue text-white shadow-lg shadow-brand-accent/20'
: isComingSoon
? 'text-slate-600 cursor-not-allowed'
: 'text-slate-400 hover:bg-white/5 hover:text-white'
}`}
aria-current={isActive ? 'page' : undefined}
disabled={isComingSoon}
>
<Icon className={`h-5 w-5 mr-4 transition-transform duration-300 ${isActive ? 'scale-110' : 'group-hover:scale-110'}`} />
<span className="flex-1 tracking-wide">{item.name}</span>
{isActive && (
<div className="w-1.5 h-1.5 rounded-full bg-white/80 shadow-glow"></div>
)}
</button>
);
})}
</nav>
{/* User Profile Snippet */}
<div className="p-4 border-t border-slate-800/60 bg-[#0b1120]">
<button
onClick={() => onNavigate('Profile')}
className={`group w-full flex items-center gap-3 p-3 rounded-xl text-left transition-all duration-200 ${
activeItem === 'Profile'
? 'bg-slate-800'
: 'hover:bg-slate-800'
}`}
>
<div className="w-10 h-10 rounded-full bg-gradient-to-br from-brand-dark-blue to-brand-accent flex items-center justify-center ring-2 ring-white/10 group-hover:ring-brand-light-blue/50 transition-all">
<UserIcon className="h-5 w-5 text-white" />
</div>
<div className="overflow-hidden">
<p className="font-semibold text-sm text-white truncate">Steve O'Donoghue</p>
<p className="text-xs text-slate-400 truncate">OLIVER Agency</p>
</div>
</button>
</div>
</aside>
);
};

View file

@ -0,0 +1,127 @@
import React from 'react';
import type { ReviewStatus, AgentName, AgentStatus } from '../types';
import { AGENT_NAMES } from '../constants';
import { LegalIcon } from './icons/LegalIcon';
import { BrandIcon } from './icons/BrandIcon';
import { CopyIcon } from './icons/CopyIcon';
import { CheckCircleIcon, ExclamationTriangleIcon, InformationCircleIcon } from './icons/StatusIcons';
import { ChannelIcon } from './icons/ChannelIcon';
import { BestPracticeIcon } from './icons/BestPracticeIcon';
import { SpinnerIcon } from './icons/SpinnerIcon';
interface StatusDashboardProps {
status: ReviewStatus;
variant?: 'default' | 'hero';
}
interface StatusInfoProps {
status: string;
isHeroVariant?: boolean;
}
// FIX: Storing components instead of instantiated elements to avoid React.cloneElement type issues.
// Fix: Removed 'Best Practice' as it does not exist on AgentName type.
const agentIcons: Record<AgentName, React.FC<React.SVGProps<SVGSVGElement>>> = {
'Legal Agent': LegalIcon,
'Brand Agent': BrandIcon,
'Tone Agent': CopyIcon,
'Channel Agent': ChannelIcon,
};
const StatusInfo: React.FC<StatusInfoProps> = ({ status, isHeroVariant = false }) => {
const colors = isHeroVariant ? {
complete: 'text-green-300',
issues: 'text-yellow-300',
error: 'text-red-300',
pending: 'text-gray-400'
} : {
complete: 'text-green-600',
issues: 'text-yellow-600',
error: 'text-red-600',
pending: 'text-gray-500'
};
switch (status) {
case 'complete':
return <div className={`flex items-center ${colors.complete}`}><CheckCircleIcon className="h-5 w-5 mr-2" /> Pass</div>;
case 'issues-found':
return <div className={`flex items-center ${colors.issues}`}><ExclamationTriangleIcon className="h-5 w-5 mr-2" /> Issues Found</div>;
case 'error':
return <div className={`flex items-center ${colors.error}`}><InformationCircleIcon className="h-5 w-5 mr-2" /> Error</div>;
default:
return <div className={`flex items-center ${colors.pending}`}><InformationCircleIcon className="h-5 w-5 mr-2" /> Pending</div>;
}
};
export const StatusDashboard: React.FC<StatusDashboardProps> = ({ status, variant = 'default' }) => {
if (Object.keys(status).length === 0) return null;
const isHeroVariant = variant === 'hero';
const getTileClassName = (agentStatus: AgentStatus, isHero: boolean): string => {
const baseClasses = 'rounded-lg flex flex-col text-center transition-colors duration-500 ease-in-out';
if (isHero) {
const heroBase = `${baseClasses} p-4`;
switch (agentStatus) {
case 'complete':
return `${heroBase} bg-green-400/20`;
case 'issues-found':
return `${heroBase} bg-yellow-400/20`;
case 'error':
return `${heroBase} bg-red-400/20`;
default: // 'pending' or 'in-progress'
return `${heroBase} bg-white/10`;
}
} else {
const defaultBase = `${baseClasses} p-6 shadow-lg border`;
switch (agentStatus) {
case 'complete':
return `${defaultBase} bg-green-50 border-green-200`;
case 'issues-found':
return `${defaultBase} bg-yellow-50 border-yellow-200`;
case 'error':
return `${defaultBase} bg-red-50 border-red-200`;
default: // 'pending' or 'in-progress'
return `${defaultBase} bg-gray-50 border-gray-200`;
}
}
};
return (
<div className={isHeroVariant ? '' : "mb-12"}>
<h3 className={`text-2xl font-bold text-center mb-8 ${isHeroVariant ? 'text-white' : 'text-brand-dark-blue'}`}>Review Progress</h3>
<div className={`grid grid-cols-2 sm:grid-cols-3 md:grid-cols-5 ${isHeroVariant ? 'gap-4' : 'gap-6'}`}>
{AGENT_NAMES.map((agentName) => {
const agentStatus = status[agentName] || 'pending';
const iconColor = isHeroVariant ? 'text-brand-light-blue' : 'text-brand-accent';
const Icon = agentIcons[agentName];
return (
<div key={agentName} className={getTileClassName(agentStatus, isHeroVariant)}>
<div className="flex-grow flex flex-col items-center justify-center">
<div className="mb-3 h-8 w-8 flex items-center justify-center">
<Icon className={`h-8 w-8 ${iconColor}`} />
</div>
<h4 className={`text-base font-bold ${isHeroVariant ? 'text-white' : 'text-brand-dark-blue'}`}>{agentName}</h4>
</div>
<div className="font-semibold text-xs mt-2 h-6 flex items-center justify-center">
{agentStatus === 'in-progress' ? (
<div className={`flex items-center ${isHeroVariant ? 'text-white/80' : 'text-gray-500'}`}>
<SpinnerIcon className="h-4 w-4 mr-2 custom-spinner" />
<span>Analyzing...</span>
</div>
) : (
<StatusInfo
status={agentStatus}
isHeroVariant={isHeroVariant}
/>
)}
</div>
</div>
);
})}
</div>
</div>
);
};

View file

@ -0,0 +1,28 @@
import React from 'react';
interface ToggleSwitchProps {
enabled: boolean;
onChange: (enabled: boolean) => void;
}
export const ToggleSwitch: React.FC<ToggleSwitchProps> = ({ enabled, onChange }) => {
return (
<button
type="button"
className={`relative inline-flex h-6 w-11 flex-shrink-0 cursor-pointer rounded-full border-2 border-transparent transition-colors duration-200 ease-in-out focus:outline-none focus:ring-2 focus:ring-brand-accent focus:ring-offset-2 ${
enabled ? 'bg-brand-accent' : 'bg-gray-300'
}`}
role="switch"
aria-checked={enabled}
onClick={() => onChange(!enabled)}
>
<span className="sr-only">Use setting</span>
<span
aria-hidden="true"
className={`pointer-events-none inline-block h-5 w-5 transform rounded-full bg-white shadow ring-0 transition duration-200 ease-in-out ${
enabled ? 'translate-x-5' : 'translate-x-0'
}`}
/>
</button>
);
};

View file

@ -0,0 +1,332 @@
import React, { useState, useRef, useEffect } from 'react';
import type { DropdownOptions } from '../App';
import { analyzeWIPProof, getWIPChatResponse } from '../services/geminiService';
import type { AgentName } from '../types';
import { UserIcon } from './icons/UserIcon';
import { LeadAgentIcon } from './icons/LeadAgentIcon';
import { PaperClipIcon } from './icons/PaperClipIcon';
import { SendIcon } from './icons/SendIcon';
import { ChevronDownIcon } from './icons/ChevronDownIcon';
import { SpinnerIcon } from './icons/SpinnerIcon';
import { DocumentIcon } from './icons/DocumentIcon';
// --- TYPE DEFINITIONS ---
interface TextContent { type: 'text'; text: string; }
interface FilePreviewContent { type: 'file_preview'; fileName: string; previewUrl: string; mimeType: string; }
interface ConfirmationContent { type: 'confirmation'; file: File; }
interface LoadingContent { type: 'loading'; }
interface ErrorContent { type: 'error'; text: string; }
type MessageContent = TextContent | FilePreviewContent | ConfirmationContent | LoadingContent | ErrorContent;
interface Message {
id: string;
sender: 'user' | 'agent';
content: MessageContent;
}
const fileToDataUrl = (file: File): Promise<string> => {
return new Promise((resolve, reject) => {
const reader = new FileReader();
reader.onloadend = () => resolve(reader.result as string);
reader.onerror = reject;
reader.readAsDataURL(file);
});
};
// --- SUB-COMPONENTS ---
const ConfirmationComponent: React.FC<{
dropdownOptions: DropdownOptions;
onConfirm: (details: { channel: string; subChannel: string; proofType: string }) => void;
}> = ({ dropdownOptions, onConfirm }) => {
const [channel, setChannel] = useState('');
const [subChannel, setSubChannel] = useState('');
const [proofType, setProofType] = useState('');
const availableChannels = Object.keys(dropdownOptions.channels);
const availableSubChannels = channel ? Object.keys(dropdownOptions.channels[channel] || {}) : [];
const availableProofTypes = (channel && subChannel) ? (dropdownOptions.channels[channel][subChannel] || []) : [];
const showProofType = availableProofTypes.length > 0;
useEffect(() => {
setSubChannel('');
setProofType('');
}, [channel]);
useEffect(() => {
if (!showProofType) setProofType('');
else setProofType('');
}, [subChannel, showProofType]);
const handleSubmit = () => {
if (channel && subChannel && (!showProofType || proofType)) {
onConfirm({ channel, subChannel, proofType });
}
};
const isSubmitDisabled = !channel || !subChannel || (showProofType && !proofType);
return (
<div className="p-4 bg-white rounded-lg border border-gray-200 shadow-sm space-y-3">
<p className="font-semibold text-gray-800">Please confirm the details for this proof:</p>
<div className="grid grid-cols-1 sm:grid-cols-2 gap-3">
<div className="relative">
<select
value={channel}
onChange={e => setChannel(e.target.value)}
className="w-full bg-white border border-gray-300 rounded-md py-2 px-3 text-gray-900 focus:outline-none focus:ring-2 focus:ring-brand-accent appearance-none"
>
<option value="" disabled>Select Channel</option>
{availableChannels.map(opt => <option key={opt} value={opt}>{opt}</option>)}
</select>
<ChevronDownIcon className="absolute right-3 top-1/2 -translate-y-1/2 h-4 w-4 text-gray-500 pointer-events-none"/>
</div>
<div className="relative">
<select
value={subChannel}
onChange={e => setSubChannel(e.target.value)}
disabled={!channel}
className="w-full bg-white border border-gray-300 rounded-md py-2 px-3 text-gray-900 focus:outline-none focus:ring-2 focus:ring-brand-accent appearance-none disabled:bg-gray-100"
>
<option value="" disabled>Select Sub-Channel</option>
{availableSubChannels.map(opt => <option key={opt} value={opt}>{opt}</option>)}
</select>
<ChevronDownIcon className="absolute right-3 top-1/2 -translate-y-1/2 h-4 w-4 text-gray-500 pointer-events-none"/>
</div>
</div>
{showProofType && (
<div className="relative">
<select
value={proofType}
onChange={e => setProofType(e.target.value)}
className="w-full bg-white border border-gray-300 rounded-md py-2 px-3 text-gray-900 focus:outline-none focus:ring-2 focus:ring-brand-accent appearance-none"
>
<option value="" disabled>Select Proof Type</option>
{availableProofTypes.map(opt => <option key={opt} value={opt}>{opt}</option>)}
</select>
<ChevronDownIcon className="absolute right-3 top-1/2 -translate-y-1/2 h-4 w-4 text-gray-500 pointer-events-none"/>
</div>
)}
<button
onClick={handleSubmit}
disabled={isSubmitDisabled}
className="w-full bg-brand-accent text-white font-bold py-2 px-4 rounded-md hover:bg-brand-dark-blue transition-colors disabled:bg-gray-400 disabled:cursor-not-allowed"
>
Confirm &amp; Analyze
</button>
</div>
);
};
const MessageBubble: React.FC<{ sender: 'user' | 'agent'; children: React.ReactNode }> = ({ sender, children }) => {
const isUser = sender === 'user';
const bubbleClasses = isUser
? 'bg-brand-accent text-white'
: 'bg-white text-gray-800 border border-gray-200';
const alignmentClasses = isUser ? 'items-end' : 'items-start';
const avatar = isUser
? <div className="w-8 h-8 rounded-full bg-brand-dark-blue flex items-center justify-center ring-2 ring-white"><UserIcon className="h-5 w-5 text-white"/></div>
: <div className="w-8 h-8 rounded-full bg-brand-light-blue flex items-center justify-center ring-2 ring-white"><LeadAgentIcon className="h-5 w-5 text-brand-dark-blue"/></div>;
const isConfirmation = React.isValidElement(children) && children.type === ConfirmationComponent;
return (
<div className={`flex flex-col ${alignmentClasses} w-full max-w-lg mx-auto`}>
<div className={`flex gap-3 items-end ${isUser ? 'flex-row-reverse' : 'flex-row'}`}>
<div className="flex-shrink-0">{avatar}</div>
<div className={
isConfirmation
? "w-full max-w-md"
: `p-3 rounded-lg max-w-xs sm:max-w-md ${bubbleClasses}`
}>
{children}
</div>
</div>
</div>
);
};
// --- MAIN COMPONENT ---
export const WIPReviewer: React.FC<{ dropdownOptions: DropdownOptions }> = ({ dropdownOptions }) => {
const [messages, setMessages] = useState<Message[]>([
{
id: `msg_${Date.now()}`,
sender: 'agent',
content: { type: 'text', text: "Hello! I'm the Lead Agent. Upload a work-in-progress file for review, or ask me a question about marketing best practices." },
}
]);
const [userInput, setUserInput] = useState('');
const [isLoading, setIsLoading] = useState(false);
const fileInputRef = useRef<HTMLInputElement>(null);
const messagesEndRef = useRef<HTMLDivElement>(null);
useEffect(() => {
messagesEndRef.current?.scrollIntoView({ behavior: 'smooth' });
}, [messages]);
const addMessage = (sender: 'user' | 'agent', content: MessageContent) => {
setMessages(prev => [...prev, { id: `msg_${Date.now()}`, sender, content }]);
};
const updateLastMessage = (newContent: MessageContent) => {
setMessages(prev => {
const newMessages = [...prev];
newMessages[newMessages.length - 1].content = newContent;
return newMessages;
});
};
const handleFileSelect = async (event: React.ChangeEvent<HTMLInputElement>) => {
const file = event.target.files?.[0];
if (!file) return;
try {
const previewUrl = await fileToDataUrl(file);
addMessage('user', { type: 'file_preview', fileName: file.name, previewUrl, mimeType: file.type });
addMessage('agent', { type: 'confirmation', file });
} catch (error) {
console.error("Error creating file preview:", error);
addMessage('agent', { type: 'error', text: "Sorry, I couldn't load a preview for that file. Please try another file." });
}
// Reset file input
if (event.target) event.target.value = '';
};
const handleSendMessage = async (e: React.FormEvent) => {
e.preventDefault();
const trimmedInput = userInput.trim();
if (!trimmedInput || isLoading) return;
addMessage('user', { type: 'text', text: trimmedInput });
setUserInput('');
setIsLoading(true);
addMessage('agent', { type: 'loading' });
try {
const responseText = await getWIPChatResponse(trimmedInput);
updateLastMessage({ type: 'text', text: responseText });
} catch (err) {
console.error("Chat response failed:", err);
updateLastMessage({ type: 'error', text: "Sorry, I couldn't get a response. Please try again." });
} finally {
setIsLoading(false);
}
};
const handleConfirmAnalysis = async (
messageId: string,
details: { channel: string; subChannel: string; proofType: string },
file: File
) => {
setIsLoading(true);
// Replace confirmation message with loading indicator
setMessages(prev => prev.map(msg => msg.id === messageId ? { ...msg, content: { type: 'loading' } } : msg));
try {
const summary = await analyzeWIPProof(file, () => {});
setMessages(prev => prev.map(msg => msg.id === messageId ? { ...msg, content: { type: 'text', text: summary } } : msg));
} catch (err) {
console.error("Analysis failed:", err);
setMessages(prev => prev.map(msg => msg.id === messageId ? { ...msg, content: { type: 'error', text: "Sorry, an error occurred during the analysis. Please try again." } } : msg));
} finally {
setIsLoading(false);
}
};
return (
<div className="flex flex-col h-full bg-brand-gray">
<header className="p-4 border-b border-gray-200 bg-white/80 backdrop-blur-sm flex-shrink-0">
<h1 className="text-xl font-bold text-brand-dark-blue text-center">WIP Reviewer</h1>
</header>
{/* Message Display Area */}
<div className="flex-1 overflow-y-auto p-4 space-y-6">
{messages.map((msg) => (
<MessageBubble key={msg.id} sender={msg.sender}>
{(() => {
switch (msg.content.type) {
case 'text':
return <p className="whitespace-pre-wrap">{msg.content.text}</p>;
case 'file_preview':
const { mimeType, previewUrl, fileName } = msg.content;
if (mimeType.startsWith('image/')) {
return <img src={previewUrl} alt={fileName} className="max-w-full h-auto rounded-md object-contain" />;
}
return (
<div className="flex items-center gap-3 p-2 bg-black/20 rounded-md">
<DocumentIcon className="h-8 w-8 text-white"/>
<span className="font-semibold truncate">{fileName}</span>
</div>
);
case 'confirmation':
return <ConfirmationComponent
dropdownOptions={dropdownOptions}
onConfirm={(details) => handleConfirmAnalysis(msg.id, details, msg.content.file)}
/>;
case 'loading':
return (
<div className="flex items-center gap-2 text-gray-600">
<SpinnerIcon className="h-5 w-5 custom-spinner"/>
<span>Thinking...</span>
</div>
);
case 'error':
return <p className="text-red-600 font-medium">{msg.content.text}</p>
default:
return null;
}
})()}
</MessageBubble>
))}
<div ref={messagesEndRef} />
</div>
{/* Message Input Area */}
<div className="flex-shrink-0 p-4 bg-white/80 backdrop-blur-sm border-t border-gray-200">
<div className="max-w-lg mx-auto">
<form onSubmit={handleSendMessage} className="relative flex items-center">
<input type="file" ref={fileInputRef} onChange={handleFileSelect} className="hidden" />
<button
type="button"
onClick={() => fileInputRef.current?.click()}
disabled={isLoading}
className="p-2 text-gray-500 hover:text-brand-accent rounded-full transition-colors disabled:opacity-50"
aria-label="Attach file"
>
<PaperClipIcon className="h-6 w-6"/>
</button>
<textarea
value={userInput}
onChange={(e) => setUserInput(e.target.value)}
onKeyDown={(e) => {
if (e.key === 'Enter' && !e.shiftKey) {
e.preventDefault();
handleSendMessage(e);
}
}}
className="w-full p-2.5 mx-2 border border-gray-300 rounded-lg resize-none focus:ring-2 focus:ring-brand-accent focus:border-brand-accent transition"
placeholder="Type your message..."
rows={1}
disabled={isLoading}
/>
<button
type="submit"
className="p-2.5 rounded-full text-white bg-brand-accent hover:bg-brand-dark-blue disabled:bg-gray-400 transition-colors"
disabled={!userInput.trim() || isLoading}
aria-label="Send message"
>
<SendIcon className="h-5 w-5" />
</button>
</form>
</div>
</div>
</div>
);
};

View file

@ -0,0 +1,8 @@
import React from 'react';
export const AnalyticsIcon: React.FC<React.SVGProps<SVGSVGElement>> = (props) => (
<svg xmlns="http://www.w3.org/2000/svg" fill="none" viewBox="0 0 24 24" strokeWidth={1.5} stroke="currentColor" {...props}>
<path strokeLinecap="round" strokeLinejoin="round" d="M3 13.125C3 12.504 3.504 12 4.125 12h2.25c.621 0 1.125.504 1.125 1.125v6.75C7.5 20.496 6.996 21 6.375 21h-2.25A1.125 1.125 0 013 19.875v-6.75zM9.75 8.625c0-.621.504-1.125 1.125-1.125h2.25c.621 0 1.125.504 1.125 1.125v11.25c0 .621-.504 1.125-1.125 1.125h-2.25a1.125 1.125 0 01-1.125-1.125V8.625zM16.5 4.125c0-.621.504-1.125 1.125-1.125h2.25C20.496 3 21 3.504 21 4.125v15.75c0 .621-.504 1.125-1.125 1.125h-2.25a1.125 1.125 0 01-1.125-1.125V4.125z" />
</svg>
);

View file

@ -0,0 +1,7 @@
import React from 'react';
export const ArrowDownIcon: React.FC<React.SVGProps<SVGSVGElement>> = (props) => (
<svg xmlns="http://www.w3.org/2000/svg" fill="none" viewBox="0 0 24 24" strokeWidth={1.5} stroke="currentColor" {...props}>
<path strokeLinecap="round" strokeLinejoin="round" d="M19.5 13.5L12 21m0 0l-7.5-7.5M12 21V3" />
</svg>
);

View file

@ -0,0 +1,7 @@
import React from 'react';
export const ArrowLeftIcon: React.FC<React.SVGProps<SVGSVGElement>> = (props) => (
<svg xmlns="http://www.w3.org/2000/svg" fill="none" viewBox="0 0 24 24" strokeWidth={1.5} stroke="currentColor" {...props}>
<path strokeLinecap="round" strokeLinejoin="round" d="M10.5 19.5L3 12m0 0l7.5-7.5M3 12h18" />
</svg>
);

View file

@ -0,0 +1,7 @@
import React from 'react';
export const AuditIcon: React.FC<React.SVGProps<SVGSVGElement>> = (props) => (
<svg xmlns="http://www.w3.org/2000/svg" fill="none" viewBox="0 0 24 24" strokeWidth={1.5} stroke="currentColor" {...props}>
<path strokeLinecap="round" strokeLinejoin="round" d="M10.5 6h9.75M10.5 6a1.5 1.5 0 11-3 0m3 0a1.5 1.5 0 10-3 0M3.75 6H7.5m3 12h9.75m-9.75 0a1.5 1.5 0 01-3 0m3 0a1.5 1.5 0 00-3 0m-3.75 0H7.5m9-6h3.75m-3.75 0a1.5 1.5 0 01-3 0m3 0a1.5 1.5 0 00-3 0m-9.75 0h9.75" />
</svg>
);

View file

@ -0,0 +1,13 @@
import React from 'react';
export const BarclaysLogo: React.FC<React.SVGProps<SVGSVGElement>> = (props) => (
<svg
xmlns="http://www.w3.org/2000/svg"
viewBox="0 0 24 24"
fill="currentColor"
aria-label="Barclays Logo"
{...props}
>
<path d="M12 1.5c-4.3 0-7.8 3.5-7.8 7.8 0 1.9.7 3.7 1.9 5.1-1-1.3-1.6-2.9-1.6-4.6 0-3.5 2.8-6.3 6.3-6.3s6.3 2.8 6.3 6.3c0 1.7-.6 3.3-1.6 4.6 1.2-1.4 1.9-3.2 1.9-5.1 0-4.3-3.5-7.8-7.8-7.8zm.5 15.6c-.3.3-.7.4-1.1.4s-.8-.1-1.1-.4c-1.9-1.9-3.9-2.9-6.3-2.8-.5 0-1 .4-1 .9s.4 1 .9 1c2.8.1 5.2 1.3 7.5 3.5.3.3.7.4 1.1.4s.8-.1 1.1-.4c2.3-2.2 4.7-3.4 7.5-3.5.5 0 .9-.5.9-1s-.4-1-.9-.9c-2.4-.1-4.4 1-6.3 2.8z" />
</svg>
);

View file

@ -0,0 +1,8 @@
import React from 'react';
export const BestPracticeIcon: React.FC<React.SVGProps<SVGSVGElement>> = (props) => (
<svg xmlns="http://www.w3.org/2000/svg" fill="none" viewBox="0 0 24 24" strokeWidth={1.5} stroke="currentColor" {...props}>
<path strokeLinecap="round" strokeLinejoin="round" d="M6.633 10.5c.806 0 1.533-.422 2.031-1.096a.5.5 0 01.838-.195l1.122.957.56-1.107a3.75 3.75 0 015.44-2.323 3.75 3.75 0 011.604 4.078l-2.92 11.23a.75.75 0 01-1.424-.39l-.01-1.353a4.5 4.5 0 00-4.49-4.33H8.25a.75.75 0 01-.75-.75V11.25a.75.75 0 01.75-.75h.383zM10.875 6.75a4.5 4.5 0 00-4.5 4.5v.625c0 .414.336.75.75.75h3.375c.414 0 .75-.336.75-.75V10.5a4.5 4.5 0 00-4.5-4.5h-.375z" />
</svg>
);

View file

@ -0,0 +1,8 @@
import React from 'react';
export const BrandIcon: React.FC<React.SVGProps<SVGSVGElement>> = (props) => (
<svg xmlns="http://www.w3.org/2000/svg" fill="none" viewBox="0 0 24 24" strokeWidth={1.5} stroke="currentColor" {...props}>
<path strokeLinecap="round" strokeLinejoin="round" d="M9.53 16.122a3 3 0 00-5.78 1.128 2.25 2.25 0 01-2.4 2.245 4.5 4.5 0 008.4-2.245c0-.399-.078-.78-.22-1.128zm0 0a15.998 15.998 0 003.388-1.62m-5.043-.025a15.994 15.994 0 011.622-3.385m5.043.025a2.25 2.25 0 012.245-2.4 4.5 4.5 0 00-2.245-8.4-4.5 4.5 0 00-8.4 2.245 2.25 2.25 0 01-2.4 2.4m-2.245 8.4a4.5 4.5 0 008.4 2.245 2.25 2.25 0 012.4-2.4m8.4-2.245a4.5 4.5 0 00-2.245-8.4 2.25 2.25 0 01-2.4-2.4" />
</svg>
);

View file

@ -0,0 +1,7 @@
import React from 'react';
export const BriefcaseIcon: React.FC<React.SVGProps<SVGSVGElement>> = (props) => (
<svg xmlns="http://www.w3.org/2000/svg" fill="none" viewBox="0 0 24 24" strokeWidth={1.5} stroke="currentColor" {...props}>
<path strokeLinecap="round" strokeLinejoin="round" d="M20.25 14.15v4.075c0 1.313-.964 2.47-2.25 2.75h-12c-1.286-.28-2.25-1.437-2.25-2.75V14.15M17.25 6.75v4.5m-3.75-4.5v4.5m-3.75-4.5v4.5m-3.75 0V6.75m11.25 0h-9A2.25 2.25 0 006 9v3c0 1.242 1.008 2.25 2.25 2.25h9A2.25 2.25 0 0019.5 12V9a2.25 2.25 0 00-2.25-2.25h-2.25" />
</svg>
);

View file

@ -0,0 +1,8 @@
import React from 'react';
export const BugIcon: React.FC<React.SVGProps<SVGSVGElement>> = (props) => (
<svg xmlns="http://www.w3.org/2000/svg" fill="none" viewBox="0 0 24 24" strokeWidth={1.5} stroke="currentColor" {...props}>
<path strokeLinecap="round" strokeLinejoin="round" d="M15.362 5.214A8.252 8.252 0 0112 21 8.25 8.25 0 018.638 5.214m-6.724 0a3.375 3.375 0 01-1.464 3.362M15.362 5.214A3.375 3.375 0 0112 3.75a3.375 3.375 0 01-3.362 1.464m-3.376 0a3.375 3.375 0 013.376 0m6.724 0a3.375 3.375 0 011.464 3.362M12 21a3.375 3.375 0 01-3.362-1.464M3 12a3.375 3.375 0 011.464-3.362m14.144 0A3.375 3.375 0 0121 12a3.375 3.375 0 01-1.464 3.362M12 3.75a2.625 2.625 0 00-2.625 2.625v.375m0 0a2.625 2.625 0 015.25 0v-.375a2.625 2.625 0 00-2.625-2.625M12 21v-1.5" />
</svg>
);

View file

@ -0,0 +1,7 @@
import React from 'react';
export const CampaignsIcon: React.FC<React.SVGProps<SVGSVGElement>> = (props) => (
<svg xmlns="http://www.w3.org/2000/svg" fill="none" viewBox="0 0 24 24" strokeWidth={1.5} stroke="currentColor" {...props}>
<path strokeLinecap="round" strokeLinejoin="round" d="M20.25 14.15v4.075c0 1.313-.964 2.47-2.25 2.75h-12c-1.286-.28-2.25-1.437-2.25-2.75V14.15M17.25 6.75v4.5m-3.75-4.5v4.5m-3.75-4.5v4.5m-3.75 0V6.75m11.25 0h-9A2.25 2.25 0 006 9v3c0 1.242 1.008 2.25 2.25 2.25h9A2.25 2.25 0 0019.5 12V9a2.25 2.25 0 00-2.25-2.25h-2.25" />
</svg>
);

View file

@ -0,0 +1,8 @@
import React from 'react';
export const ChannelIcon: React.FC<React.SVGProps<SVGSVGElement>> = (props) => (
<svg xmlns="http://www.w3.org/2000/svg" fill="none" viewBox="0 0 24 24" strokeWidth={1.5} stroke="currentColor" {...props}>
<path strokeLinecap="round" strokeLinejoin="round" d="M9.813 15.904L9 18.75l-.813-2.846a4.5 4.5 0 00-3.09-3.09L2.25 12l2.846-.813a4.5 4.5 0 003.09-3.09L9 5.25l.813 2.846a4.5 4.5 0 003.09 3.09L15.75 12l-2.846.813a4.5 4.5 0 00-3.09 3.09zM18.259 8.715L18 9.75l-.259-1.035a3.375 3.375 0 00-2.455-2.456L14.25 6l1.036-.259a3.375 3.375 0 002.455-2.456L18 2.25l.259 1.035a3.375 3.375 0 002.456 2.456L21.75 6l-1.035.259a3.375 3.375 0 00-2.456 2.456zM16.898 20.572L16.25 21.75l-.648-1.178a2.625 2.625 0 00-1.94-1.94l-1.178-.648 1.178-.648a2.625 2.625 0 001.94-1.94l.648-1.178.648 1.178a2.625 2.625 0 001.94 1.94l1.178.648-1.178.648a2.625 2.625 0 00-1.94 1.94z" />
</svg>
);

View file

@ -0,0 +1,7 @@
import React from 'react';
export const ChatBubbleIcon: React.FC<React.SVGProps<SVGSVGElement>> = (props) => (
<svg xmlns="http://www.w3.org/2000/svg" fill="none" viewBox="0 0 24 24" strokeWidth={1.5} stroke="currentColor" {...props}>
<path strokeLinecap="round" strokeLinejoin="round" d="M20.25 8.511c.884.284 1.5 1.128 1.5 2.097v4.286c0 1.136-.847 2.1-1.98 2.193l-3.72 3.72a1.5 1.5 0 01-2.12 0l-3.72-3.72C6.347 20.77 5.5 19.796 5.5 18.66v-4.286c0-.97.616-1.813 1.5-2.097m6.5-3.511a.75.75 0 00-1.5 0v1.5a.75.75 0 001.5 0v-1.5z" />
</svg>
);

View file

@ -0,0 +1,7 @@
import React from 'react';
export const ChevronDownIcon = (props: React.SVGProps<SVGSVGElement>) => (
<svg xmlns="http://www.w3.org/2000/svg" fill="none" viewBox="0 0 24 24" strokeWidth={2} stroke="currentColor" {...props}>
<path strokeLinecap="round" strokeLinejoin="round" d="M19.5 8.25l-7.5 7.5-7.5-7.5" />
</svg>
);

View file

@ -0,0 +1,7 @@
import React from 'react';
export const ClipboardIcon: React.FC<React.SVGProps<SVGSVGElement>> = (props) => (
<svg xmlns="http://www.w3.org/2000/svg" fill="none" viewBox="0 0 24 24" strokeWidth={1.5} stroke="currentColor" {...props}>
<path strokeLinecap="round" strokeLinejoin="round" d="M15.666 3.888A2.25 2.25 0 0013.5 2.25h-3c-1.03 0-1.9.693-2.166 1.638m7.332 0c.055.194.084.4.084.612v0a2.25 2.25 0 01-2.25 2.25h-1.5a2.25 2.25 0 01-2.25-2.25v0c0-.212.03-.418.084-.612m7.332 0c.646.049 1.288.11 1.927.184 1.1.128 1.907 1.077 1.907 2.185V19.5a2.25 2.25 0 01-2.25 2.25H6.75A2.25 2.25 0 014.5 19.5V6.257c0-1.108.806-2.057 1.907-2.185a48.208 48.208 0 011.927-.184" />
</svg>
);

View file

@ -0,0 +1,8 @@
import React from 'react';
export const ClockIcon: React.FC<React.SVGProps<SVGSVGElement>> = (props) => (
<svg xmlns="http://www.w3.org/2000/svg" fill="none" viewBox="0 0 24 24" strokeWidth={1.5} stroke="currentColor" {...props}>
<path strokeLinecap="round" strokeLinejoin="round" d="M12 6v6h4.5m4.5 0a9 9 0 11-18 0 9 9 0 0118 0z" />
</svg>
);

View file

@ -0,0 +1,7 @@
import React from 'react';
export const CopyGenAIIcon: React.FC<React.SVGProps<SVGSVGElement>> = (props) => (
<svg xmlns="http://www.w3.org/2000/svg" fill="none" viewBox="0 0 24 24" strokeWidth={1.5} stroke="currentColor" {...props}>
<path strokeLinecap="round" strokeLinejoin="round" d="M19.5 14.25v-2.625a3.375 3.375 0 00-3.375-3.375h-1.5A1.125 1.125 0 0113.5 7.125v-1.5a3.375 3.375 0 00-3.375-3.375H8.25m3.75 9v6m3-3H9m1.5-12H5.625c-.621 0-1.125.504-1.125 1.125v17.25c0 .621.504 1.125 1.125 1.125h12.75c.621 0 1.125-.504 1.125-1.125V11.25a9 9 0 00-9-9z" />
</svg>
);

View file

@ -0,0 +1,8 @@
import React from 'react';
export const CopyIcon: React.FC<React.SVGProps<SVGSVGElement>> = (props) => (
<svg xmlns="http://www.w3.org/2000/svg" fill="none" viewBox="0 0 24 24" strokeWidth={1.5} stroke="currentColor" {...props}>
<path strokeLinecap="round" strokeLinejoin="round" d="M16.862 4.487l1.687-1.688a1.875 1.875 0 112.652 2.652L10.582 16.07a4.5 4.5 0 01-1.897 1.13L6 18l.8-2.685a4.5 4.5 0 011.13-1.897l8.932-8.931zm0 0L19.5 7.125M18 14v4.75A2.25 2.25 0 0115.75 21H5.25A2.25 2.25 0 013 18.75V8.25A2.25 2.25 0 015.25 6H10" />
</svg>
);

View file

@ -0,0 +1,8 @@
import React from 'react';
export const DashboardIcon: React.FC<React.SVGProps<SVGSVGElement>> = (props) => (
<svg xmlns="http://www.w3.org/2000/svg" fill="none" viewBox="0 0 24 24" strokeWidth={1.5} stroke="currentColor" {...props}>
<path strokeLinecap="round" strokeLinejoin="round" d="M3.75 6A2.25 2.25 0 016 3.75h2.25A2.25 2.25 0 0110.5 6v2.25a2.25 2.25 0 01-2.25 2.25H6a2.25 2.25 0 01-2.25-2.25V6zM3.75 15.75A2.25 2.25 0 016 13.5h2.25a2.25 2.25 0 012.25 2.25V18a2.25 2.25 0 01-2.25 2.25H6A2.25 2.25 0 013.75 18v-2.25zM13.5 6a2.25 2.25 0 012.25-2.25H18A2.25 2.25 0 0120.25 6v2.25A2.25 2.25 0 0118 10.5h-2.25a2.25 2.25 0 01-2.25-2.25V6zM13.5 15.75a2.25 2.25 0 012.25-2.25H18a2.25 2.25 0 012.25 2.25V18A2.25 2.25 0 0118 20.25h-2.25A2.25 2.25 0 0113.5 18v-2.25z" />
</svg>
);

View file

@ -0,0 +1,7 @@
import React from 'react';
export const DocumentIcon: React.FC<React.SVGProps<SVGSVGElement>> = (props) => (
<svg xmlns="http://www.w3.org/2000/svg" fill="none" viewBox="0 0 24 24" strokeWidth={1.5} stroke="currentColor" {...props}>
<path strokeLinecap="round" strokeLinejoin="round" d="M19.5 14.25v-2.625a3.375 3.375 0 00-3.375-3.375h-1.5A1.125 1.125 0 0113.5 7.125v-1.5a3.375 3.375 0 00-3.375-3.375H8.25m2.25 0H5.625c-.621 0-1.125.504-1.125 1.125v17.25c0 .621.504 1.125 1.125 1.125h12.75c.621 0 1.125-.504 1.125-1.125V11.25a9 9 0 00-9-9z" />
</svg>
);

View file

@ -0,0 +1,7 @@
import React from 'react';
export const DownloadIcon: React.FC<React.SVGProps<SVGSVGElement>> = (props) => (
<svg xmlns="http://www.w3.org/2000/svg" fill="none" viewBox="0 0 24 24" strokeWidth={1.5} stroke="currentColor" {...props}>
<path strokeLinecap="round" strokeLinejoin="round" d="M3 16.5v2.25A2.25 2.25 0 005.25 21h13.5A2.25 2.25 0 0021 18.75V16.5M16.5 12L12 16.5m0 0L7.5 12m4.5 4.5V3" />
</svg>
);

View file

@ -0,0 +1,15 @@
import React from 'react';
export const EagleIcon: React.FC<React.SVGProps<SVGSVGElement>> = (props) => (
<svg
xmlns="http://www.w3.org/2000/svg"
viewBox="0 0 24 24"
fill="currentColor"
{...props}
>
<path d="M12.63,2.18C12.43,2.07,12.21,2,12,2s-0.43,0.07-0.63,0.18L4.8,6.21C4.4,6.42,4.4,7.08,4.8,7.29L11.37,11 c0.11,0.06,0.24,0.09,0.37,0.09c0.23,0,0.45-0.09,0.63-0.27l6.2-5.59c0.39-0.35,0.43-0.95,0.08-1.34 C18.3,3.44,17.7,3.4,17.3,3.75L12,8.42L6.7,4.21l5.3-2.65V14c0,0.55,0.45,1,1,1s1-0.45,1-1V1.58 c0-0.23-0.09-0.45-0.27-0.63L12.63,2.18z" />
<path d="M19,13c-2.76,0-5,2.24-5,5s2.24,5,5,5s5-2.24,5-5S21.76,13,19,13z M19,21c-1.65,0-3-1.35-3-3s1.35-3,3-3 s3,1.35,3,3S20.65,21,19,21z" />
<path d="M12,14c-3.31,0-6,2.69-6,6s2.69,6,6,6h1v-2h-1c-2.21,0-4-1.79-4-4s1.79-4,4-4s4,1.79,4,4v2.35 c0.61-0.21,1.27-0.35,2-0.35v-1c0-3.31-2.69-6-6-6H12z" />
</svg>
);

View file

@ -0,0 +1,7 @@
import React from 'react';
export const ExportIcon: React.FC<React.SVGProps<SVGSVGElement>> = (props) => (
<svg xmlns="http://www.w3.org/2000/svg" fill="none" viewBox="0 0 24 24" strokeWidth={1.5} stroke="currentColor" {...props}>
<path strokeLinecap="round" strokeLinejoin="round" d="M3 16.5v2.25A2.25 2.25 0 005.25 21h13.5A2.25 2.25 0 0021 18.75V16.5m-13.5-9L12 3m0 0l4.5 4.5M12 3v13.5" />
</svg>
);

View file

@ -0,0 +1,7 @@
import React from 'react';
export const FlagIcon: React.FC<React.SVGProps<SVGSVGElement>> = (props) => (
<svg xmlns="http://www.w3.org/2000/svg" fill="none" viewBox="0 0 24 24" strokeWidth={1.5} stroke="currentColor" {...props}>
<path strokeLinecap="round" strokeLinejoin="round" d="M3 3v1.5M3 21v-6m0 0l2.77-.693a9 9 0 016.208.682l.108.054a9 9 0 006.086.71l3.114-.732a48.524 48.524 0 01-.005-10.499l-3.11.732a9 9 0 01-6.085-.711l-.108-.054a9 9 0 00-6.208-.682L3 4.5M3 15V4.5" />
</svg>
);

View file

@ -0,0 +1,8 @@
import React from 'react';
export const HistoryIcon: React.FC<React.SVGProps<SVGSVGElement>> = (props) => (
<svg xmlns="http://www.w3.org/2000/svg" fill="none" viewBox="0 0 24 24" strokeWidth={1.5} stroke="currentColor" {...props}>
<path strokeLinecap="round" strokeLinejoin="round" d="M12 6v6h4.5m4.5 0a9 9 0 11-18 0 9 9 0 0118 0z" />
<path strokeLinecap="round" strokeLinejoin="round" d="M12 6v6h4.5m4.5 0a9 9 0 11-18 0 9 9 0 0118 0z" transform="scale(0.8) translate(3, 3)" />
</svg>
);

View file

@ -0,0 +1,8 @@
import React from 'react';
export const LeadAgentIcon: React.FC<React.SVGProps<SVGSVGElement>> = (props) => (
<svg xmlns="http://www.w3.org/2000/svg" fill="none" viewBox="0 0 24 24" strokeWidth={1.5} stroke="currentColor" {...props}>
<path strokeLinecap="round" strokeLinejoin="round" d="M8.25 3v1.5M4.5 8.25H3m18 0h-1.5M4.5 12H3m18 0h-1.5m-15 3.75H3m18 0h-1.5M8.25 21v-1.5M15.75 3v1.5m0 16.5v-1.5m3.75-12H21m-18 0h1.5m15 3.75H21m-18 0h1.5m15 3.75H21m-18 0h1.5M15.75 21v-1.5m-7.5 0v-1.5m0-15V3" />
<path strokeLinecap="round" strokeLinejoin="round" d="M15 8.25a3.75 3.75 0 11-7.5 0 3.75 3.75 0 017.5 0z" />
</svg>
);

View file

@ -0,0 +1,8 @@
import React from 'react';
export const LegalIcon: React.FC<React.SVGProps<SVGSVGElement>> = (props) => (
<svg xmlns="http://www.w3.org/2000/svg" fill="none" viewBox="0 0 24 24" strokeWidth={1.5} stroke="currentColor" {...props}>
<path strokeLinecap="round" strokeLinejoin="round" d="M11.42 15.17L17.25 21A2.652 2.652 0 0021 17.25l-5.877-5.877M11.42 15.17l2.496-3.03c.52.176 1.07.332 1.634.493l-2.496 3.03m-2.496-3.03c-.564-.16-1.114-.317-1.634-.493L2.25 21M9.564 5.074A5.96 5.96 0 0112 4.5a5.96 5.96 0 012.436.574M9.564 5.074L5.073 9.564A5.96 5.96 0 014.5 12a5.96 5.96 0 01.574 2.436m4.49-6.862L12 2.25" />
</svg>
);

View file

@ -0,0 +1,8 @@
import React from 'react';
export const LightbulbIcon: React.FC<React.SVGProps<SVGSVGElement>> = (props) => (
<svg xmlns="http://www.w3.org/2000/svg" fill="none" viewBox="0 0 24 24" strokeWidth={1.5} stroke="currentColor" {...props}>
<path strokeLinecap="round" strokeLinejoin="round" d="M12 18v-5.25m0 0a6.01 6.01 0 001.5-.189m-1.5.189a6.01 6.01 0 01-1.5-.189m3.75 7.478a12.06 12.06 0 01-4.5 0m3.75 2.311V21m-3.75-2.311V21m-3.75-2.101a7.5 7.5 0 01-4.471-1.382m5.942 0a7.5 7.5 0 00-5.942 0M15 21a9 9 0 00-6 0m3-10.5V4.5A2.25 2.25 0 0112 2.25v0A2.25 2.25 0 0114.25 4.5v3.75m-2.25 0V6.75" />
</svg>
);

View file

@ -0,0 +1,8 @@
import React from 'react';
export const LogoutIcon: React.FC<React.SVGProps<SVGSVGElement>> = (props) => (
<svg xmlns="http://www.w3.org/2000/svg" fill="none" viewBox="0 0 24 24" strokeWidth={1.5} stroke="currentColor" {...props}>
<path strokeLinecap="round" strokeLinejoin="round" d="M15.75 9V5.25A2.25 2.25 0 0013.5 3h-6a2.25 2.25 0 00-2.25 2.25v13.5A2.25 2.25 0 007.5 21h6a2.25 2.25 0 002.25-2.25V15m3 0l3-3m0 0l-3-3m3 3H9" />
</svg>
);

View file

@ -0,0 +1,11 @@
import React from 'react';
export const MicrosoftLogo: React.FC<React.SVGProps<SVGSVGElement>> = (props) => (
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 23 23" {...props}>
<path fill="#f35325" d="M1 1h10v10H1z"/>
<path fill="#81bc06" d="M12 1h10v10H12z"/>
<path fill="#05a6f0" d="M1 12h10v10H1z"/>
<path fill="#ffba08" d="M12 12h10v10H12z"/>
</svg>
);

View file

@ -0,0 +1,16 @@
import React from 'react';
export const OliverLogo: React.FC<React.SVGProps<SVGSVGElement>> = (props) => (
<svg viewBox="0 0 200 40" xmlns="http://www.w3.org/2000/svg" {...props}>
<text
x="10"
y="28"
fontFamily="Arial, sans-serif"
fontSize="28"
fontWeight="bold"
fill="#333"
>
OLIVER
</text>
</svg>
);

View file

@ -0,0 +1,7 @@
import React from 'react';
export const PDFIcon: React.FC<React.SVGProps<SVGSVGElement>> = (props) => (
<svg xmlns="http://www.w3.org/2000/svg" fill="none" viewBox="0 0 24 24" strokeWidth={1.5} stroke="currentColor" {...props}>
<path strokeLinecap="round" strokeLinejoin="round" d="M19.5 14.25v-2.625a3.375 3.375 0 00-3.375-3.375h-1.5A1.125 1.125 0 0113.5 7.125v-1.5a3.375 3.375 0 00-3.375-3.375H8.25m.75 12l3 3m0 0l3-3m-3 3v-6m-1.5-9H5.625c-.621 0-1.125.504-1.125 1.125v17.25c0 .621.504 1.125 1.125 1.125h12.75c.621 0 1.125-.504 1.125-1.125V11.25a9 9 0 00-9-9z" />
</svg>
);

View file

@ -0,0 +1,7 @@
import React from 'react';
export const PaperClipIcon: React.FC<React.SVGProps<SVGSVGElement>> = (props) => (
<svg xmlns="http://www.w3.org/2000/svg" fill="none" viewBox="0 0 24 24" strokeWidth={1.5} stroke="currentColor" {...props}>
<path strokeLinecap="round" strokeLinejoin="round" d="M18.375 12.739l-7.693 7.693a4.5 4.5 0 01-6.364-6.364l10.94-10.94A3 3 0 1119.5 7.372L8.552 18.32m.009-.01l-.01.01m5.699-9.941l-7.81 7.81a1.5 1.5 0 002.122 2.122l7.81-7.81" />
</svg>
);

View file

@ -0,0 +1,7 @@
import React from 'react';
export const PlusIcon: React.FC<React.SVGProps<SVGSVGElement>> = (props) => (
<svg xmlns="http://www.w3.org/2000/svg" fill="none" viewBox="0 0 24 24" strokeWidth={2} stroke="currentColor" {...props}>
<path strokeLinecap="round" strokeLinejoin="round" d="M12 4.5v15m7.5-7.5h-15" />
</svg>
);

View file

@ -0,0 +1,8 @@
import React from 'react';
export const ProjectsIcon: React.FC<React.SVGProps<SVGSVGElement>> = (props) => (
<svg xmlns="http://www.w3.org/2000/svg" fill="none" viewBox="0 0 24 24" strokeWidth={1.5} stroke="currentColor" {...props}>
<path strokeLinecap="round" strokeLinejoin="round" d="M20.25 14.15v4.075c0 1.313-.964 2.47-2.25 2.75h-12c-1.286-.28-2.25-1.437-2.25-2.75V14.15M17.25 6.75v4.5m-3.75-4.5v4.5m-3.75-4.5v4.5m-3.75 0V6.75m11.25 0h-9A2.25 2.25 0 006 9v3c0 1.242 1.008 2.25 2.25 2.25h9A2.25 2.25 0 0019.5 12V9a2.25 2.25 0 00-2.25-2.25h-2.25" />
</svg>
);

View file

@ -0,0 +1,8 @@
import React from 'react';
export const QuestionMarkIcon: React.FC<React.SVGProps<SVGSVGElement>> = (props) => (
<svg xmlns="http://www.w3.org/2000/svg" fill="none" viewBox="0 0 24 24" strokeWidth={1.5} stroke="currentColor" {...props}>
<path strokeLinecap="round" strokeLinejoin="round" d="M9.879 7.519c1.171-1.025 3.071-1.025 4.242 0 1.172 1.025 1.172 2.687 0 3.712-.203.179-.43.326-.67.442-.745.361-1.45.999-1.45 1.827v.75M21 12a9 9 0 11-18 0 9 9 0 0118 0zm-9 5.25h.008v.008H12v-.008z" />
</svg>
);

View file

@ -0,0 +1,7 @@
import React from 'react';
export const SendIcon: React.FC<React.SVGProps<SVGSVGElement>> = (props) => (
<svg xmlns="http://www.w3.org/2000/svg" fill="none" viewBox="0 0 24 24" strokeWidth={2} stroke="currentColor" {...props}>
<path strokeLinecap="round" strokeLinejoin="round" d="M6 12L3.269 3.126A59.768 59.768 0 0121.485 12 59.77 59.77 0 013.27 20.876L5.999 12zm0 0h7.5" />
</svg>
);

View file

@ -0,0 +1,9 @@
import React from 'react';
export const SettingsIcon: React.FC<React.SVGProps<SVGSVGElement>> = (props) => (
<svg xmlns="http://www.w3.org/2000/svg" fill="none" viewBox="0 0 24 24" strokeWidth={1.5} stroke="currentColor" {...props}>
<path strokeLinecap="round" strokeLinejoin="round" d="M9.594 3.94c.09-.542.56-.94 1.11-.94h2.593c.55 0 1.02.398 1.11.94l.213 1.281c.063.374.313.686.645.87.074.04.147.083.22.127.324.196.72.257 1.075.124l1.217-.456a1.125 1.125 0 011.37.49l1.296 2.247a1.125 1.125 0 01-.26 1.431l-1.003.827c-.293.24-.438.613-.438.995s.145.755.438.995l1.003.827c.48.398.665.998.406 1.431l-1.296 2.247a1.125 1.125 0 01-1.37.49l-1.217-.456c-.355-.133-.75-.072-1.076.124a6.57 6.57 0 01-.22.128c-.333.183-.582.495-.645.87l-.213 1.28c-.09.543-.56.94-1.11.94h-2.593c-.55 0-1.02-.398-1.11-.94l-.213-1.281c-.063-.374-.313-.686-.645-.87a6.52 6.52 0 01-.22-.127c-.324-.196-.72-.257-1.075-.124l-1.217.456a1.125 1.125 0 01-1.37-.49l-1.296-2.247a1.125 1.125 0 01.26-1.431l1.004-.827c.292-.24.437-.613.437-.995s-.145-.755-.437-.995l-1.004-.827a1.125 1.125 0 01-.406-1.431l1.296-2.247a1.125 1.125 0 011.37-.49l1.217.456c.355.133.75.072 1.076-.124.072-.044.146-.087.22-.128.332-.183.582-.495.645-.87l.213-1.28z" />
<path strokeLinecap="round" strokeLinejoin="round" d="M15 12a3 3 0 11-6 0 3 3 0 016 0z" />
</svg>
);

View file

@ -0,0 +1,32 @@
import React from 'react';
export const SpinnerIcon: React.FC<React.SVGProps<SVGSVGElement>> = (props) => (
<svg
viewBox="0 0 100 100"
xmlns="http://www.w3.org/2000/svg"
{...props}
>
{/* Background track */}
<circle
cx="50"
cy="50"
r="45"
fill="none"
stroke="currentColor"
strokeWidth="10"
className="opacity-25"
/>
{/* Spinning arc */}
<circle
cx="50"
cy="50"
r="45"
fill="none"
stroke="currentColor"
strokeWidth="10"
strokeDasharray="70 283"
strokeLinecap="round"
className="opacity-75"
/>
</svg>
);

View file

@ -0,0 +1,26 @@
import React from 'react';
export const CheckCircleIcon: React.FC<React.SVGProps<SVGSVGElement>> = (props) => (
<svg xmlns="http://www.w3.org/2000/svg" fill="none" viewBox="0 0 24 24" strokeWidth={1.5} stroke="currentColor" {...props}>
<path strokeLinecap="round" strokeLinejoin="round" d="M9 12.75L11.25 15 15 9.75M21 12a9 9 0 11-18 0 9 9 0 0118 0z" />
</svg>
);
export const ExclamationTriangleIcon: React.FC<React.SVGProps<SVGSVGElement>> = (props) => (
<svg xmlns="http://www.w3.org/2000/svg" fill="none" viewBox="0 0 24 24" strokeWidth={1.5} stroke="currentColor" {...props}>
<path strokeLinecap="round" strokeLinejoin="round" d="M12 9v3.75m-9.303 3.376c-.866 1.5.217 3.374 1.948 3.374h14.71c1.73 0 2.813-1.874 1.948-3.374L13.949 3.378c-.866-1.5-3.032-1.5-3.898 0L2.697 16.126zM12 15.75h.007v.008H12v-.008z" />
</svg>
);
export const InformationCircleIcon: React.FC<React.SVGProps<SVGSVGElement>> = (props) => (
<svg xmlns="http://www.w3.org/2000/svg" fill="none" viewBox="0 0 24 24" strokeWidth={1.5} stroke="currentColor" {...props}>
<path strokeLinecap="round" strokeLinejoin="round" d="M11.25 11.25l.041-.02a.75.75 0 011.063.852l-.708 2.836a.75.75 0 001.063.852l.041-.021M21 12a9 9 0 11-18 0 9 9 0 0118 0zm-9-3.75h.008v.008H12V8.25z" />
</svg>
);
export const ArrowPathIcon: React.FC<React.SVGProps<SVGSVGElement>> = (props) => (
<svg xmlns="http://www.w3.org/2000/svg" fill="none" viewBox="0 0 24 24" strokeWidth={1.5} stroke="currentColor" {...props}>
<path strokeLinecap="round" strokeLinejoin="round" d="M16.023 9.348h4.992v-.001M2.985 19.644v-4.992m0 0h4.992m-4.993 0l3.181 3.183a8.25 8.25 0 0011.664 0l3.181-3.183m-3.181-3.182l-3.182 3.182a8.25 8.25 0 01-11.664 0l-3.182-3.182m3.182-3.182L6.36 12.64m11.664 0l-3.182-3.182" />
</svg>
);

View file

@ -0,0 +1,8 @@
import React from 'react';
export const TagIcon: React.FC<React.SVGProps<SVGSVGElement>> = (props) => (
<svg xmlns="http://www.w3.org/2000/svg" fill="none" viewBox="0 0 24 24" strokeWidth={1.5} stroke="currentColor" {...props}>
<path strokeLinecap="round" strokeLinejoin="round" d="M9.568 3H5.25A2.25 2.25 0 003 5.25v4.318c0 .597.237 1.17.659 1.591l9.581 9.581c.699.699 1.78.872 2.607.33a18.095 18.095 0 005.223-5.223c.542-.827.369-1.908-.33-2.607L11.16 3.66A2.25 2.25 0 009.568 3z" />
<path strokeLinecap="round" strokeLinejoin="round" d="M6 6h.008v.008H6V6z" />
</svg>
);

View file

@ -0,0 +1,7 @@
import React from 'react';
export const TrashIcon: React.FC<React.SVGProps<SVGSVGElement>> = (props) => (
<svg xmlns="http://www.w3.org/2000/svg" fill="none" viewBox="0 0 24 24" strokeWidth={1.5} stroke="currentColor" {...props}>
<path strokeLinecap="round" strokeLinejoin="round" d="M14.74 9l-.346 9m-4.788 0L9.26 9m9.968-3.21c.342.052.682.107 1.022.166m-1.022-.165L18.16 19.673a2.25 2.25 0 01-2.244 2.077H8.084a2.25 2.25 0 01-2.244-2.077L4.772 5.79m14.456 0a48.108 48.108 0 00-3.478-.397m-12 .562c.34-.059.68-.114 1.022-.165m0 0a48.11 48.11 0 013.478-.397m7.5 0v-.916c0-1.18-.91-2.134-2.09-2.201a51.964 51.964 0 00-3.32 0c-1.18.067-2.09.92-2.09 2.201v.916m7.5 0a48.667 48.667 0 00-7.5 0" />
</svg>
);

View file

@ -0,0 +1,8 @@
import React from 'react';
export const TrendingUpIcon: React.FC<React.SVGProps<SVGSVGElement>> = (props) => (
<svg xmlns="http://www.w3.org/2000/svg" fill="none" viewBox="0 0 24 24" strokeWidth={1.5} stroke="currentColor" {...props}>
<path strokeLinecap="round" strokeLinejoin="round" d="M2.25 18L9 11.25l4.306 4.307a11.95 11.95 0 015.814-5.519l2.74-1.22m0 0l-3.75-2.25M21 3.75v4.5M21 3.75H16.5" />
</svg>
);

View file

@ -0,0 +1,8 @@
import React from 'react';
export const UploadIcon: React.FC<React.SVGProps<SVGSVGElement>> = (props) => (
<svg xmlns="http://www.w3.org/2000/svg" fill="none" viewBox="0 0 24 24" strokeWidth={1.5} stroke="currentColor" {...props}>
<path strokeLinecap="round" strokeLinejoin="round" d="M12 16.5V9.75m0 0l-3.75 3.75M12 9.75l3.75 3.75M3 17.25V19.5a2.25 2.25 0 002.25 2.25h13.5A2.25 2.25 0 0021 19.5v-2.25" />
</svg>
);

View file

@ -0,0 +1,7 @@
import React from 'react';
export const UserIcon: React.FC<React.SVGProps<SVGSVGElement>> = (props) => (
<svg xmlns="http://www.w3.org/2000/svg" fill="none" viewBox="0 0 24 24" strokeWidth={1.5} stroke="currentColor" {...props}>
<path strokeLinecap="round" strokeLinejoin="round" d="M15.75 6a3.75 3.75 0 11-7.5 0 3.75 3.75 0 017.5 0zM4.501 20.118a7.5 7.5 0 0114.998 0A17.933 17.933 0 0112 21.75c-2.676 0-5.216-.584-7.499-1.632z" />
</svg>
);

View file

@ -0,0 +1,13 @@
import React from 'react';
export const WorkfrontLogo: React.FC<React.SVGProps<SVGSVGElement>> = (props) => (
<svg
xmlns="http://www.w3.org/2000/svg"
viewBox="0 0 256 55.4"
fill="currentColor"
aria-label="Workfront Logo"
{...props}
>
<path d="M251.5 48.2h-5.2l-2.4-8.8h-7.6l-2.4 8.8h-5.2l8.8-24.5h5.2l8.8 24.5zm-11.4-12.9h5.1l-2.5-9.1-2.6 9.1zM224 48.2h-4.6V33.9h-5.9v14.3h-4.6V23.7h15.1v24.5zM203.4 48.2h-13V23.7h4.6v20.4h8.4v4.1zM182.2 48.2h-4.6V27.8l-5.6-4.1h6.1l4.1 3v21.5zM157.9 48.2h-4.6V30l-7.3 18.2h-4.3l-7.3-18.2v18.2h-4.6V23.7h6.4l6.2 15.6 6.2-15.6h6.4v24.5zM108.9 48.2h-5.2l-2.4-8.8h-7.6l-2.4 8.8H86l8.8-24.5h5.2l8.8 24.5zm-11.4-12.9h5.1l-2.5-9.1-2.6 9.1zM81.2 48.2H68.1V23.7h13.1v4.1H72.7v5.3h7.9v4.1H72.7v6.9h8.5v4.1zM58.7 48.2h-4.6l-8.3-11.1v11.1H41V23.7h4.8l8.2 11V23.7h4.7v24.5zM30.8 48.2H17.7V23.7h13.1v4.1H22.3v5.3h7.9v4.1H22.3v6.9h8.5v4.1zM0 55.4h13.3V44.3H2.8V33.6h9.4V22.5H2.8V11.1h10.5V0H0v55.4z" />
</svg>
);

View file

@ -0,0 +1,7 @@
import React from 'react';
export const XIcon: React.FC<React.SVGProps<SVGSVGElement>> = (props) => (
<svg xmlns="http://www.w3.org/2000/svg" fill="none" viewBox="0 0 24 24" strokeWidth={1.5} stroke="currentColor" {...props}>
<path strokeLinecap="round" strokeLinejoin="round" d="M6 18L18 6M6 6l12 12" />
</svg>
);

4
frontend/constants.ts Normal file
View file

@ -0,0 +1,4 @@
import type { AgentName } from './types';
export const AGENT_NAMES: AgentName[] = ['Legal Agent', 'Brand Agent', 'Tone Agent', 'Channel Agent'];

66
frontend/index.html Normal file
View file

@ -0,0 +1,66 @@
<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="UTF-8" />
<link rel="icon" type="image/svg+xml" href="/favicon.svg" />
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
<title>AI Marketing Proof Reviewer</title>
<script src="https://cdn.tailwindcss.com"></script>
<script>
tailwind.config = {
theme: {
extend: {
colors: {
'brand-dark-blue': '#001f5a',
'brand-light-blue': '#00a3e0',
'brand-accent': '#0070c0',
'brand-gray': '#f4f6f8',
'sidebar-bg': '#1f2937',
},
animation: {
'slow-ping': 'ping 2s cubic-bezier(0, 0, 0.2, 1) infinite',
},
keyframes: {
ping: {
'75%, 100%': {
transform: 'scale(2)',
opacity: '0'
}
},
}
},
},
}
</script>
<style>
@keyframes spin-animation {
from {
transform: rotate(0deg);
}
to {
transform: rotate(360deg);
}
}
.custom-spinner {
animation: spin-animation 1s linear infinite;
}
</style>
<script type="importmap">
{
"imports": {
"react": "https://aistudiocdn.com/react@^19.1.1",
"react/": "https://aistudiocdn.com/react@^19.1.1/",
"@google/genai": "https://aistudiocdn.com/@google/genai@^1.20.0",
"react-dom/": "https://aistudiocdn.com/react-dom@^19.1.1/",
"jspdf": "https://aistudiocdn.com/jspdf@^2.5.1",
"html2canvas": "https://aistudiocdn.com/html2canvas@^1.4.1"
}
}
</script>
<link rel="stylesheet" href="/index.css">
</head>
<body class="bg-white">
<div id="root"></div>
<script type="module" src="/index.tsx"></script>
</body>
</html>

16
frontend/index.tsx Normal file
View file

@ -0,0 +1,16 @@
import React from 'react';
import ReactDOM from 'react-dom/client';
import App from './App';
const rootElement = document.getElementById('root');
if (!rootElement) {
throw new Error("Could not find root element to mount to");
}
const root = ReactDOM.createRoot(rootElement);
root.render(
<React.StrictMode>
<App />
</React.StrictMode>
);

5
frontend/metadata.json Normal file
View file

@ -0,0 +1,5 @@
{
"name": "Barclays ModComms v5 Prototype",
"description": "An application that allows users to upload marketing proofs (like web banners or email templates) for an AI-driven review. A main AI agent coordinates with specialized agents for legal, brand, and copywriting compliance, providing summarized feedback.",
"requestFramePermissions": []
}

2779
frontend/package-lock.json generated Normal file

File diff suppressed because it is too large Load diff

Some files were not shown because too many files have changed in this diff Show more