First build
Some checks failed
Client Admin / build-deploy (push) Failing after 8s
Client Client / build-deploy (push) Failing after 3s
Client Registration / build-deploy (push) Failing after 20s
Client Tech / build-deploy (push) Failing after 1s
Client Home / build-deploy (push) Successful in 14s
Some checks failed
Client Admin / build-deploy (push) Failing after 8s
Client Client / build-deploy (push) Failing after 3s
Client Registration / build-deploy (push) Failing after 20s
Client Tech / build-deploy (push) Failing after 1s
Client Home / build-deploy (push) Successful in 14s
This commit is contained in:
@@ -3,4 +3,4 @@
|
||||
"@babel/preset-env",
|
||||
["@babel/preset-react", { "runtime": "automatic" }]
|
||||
]
|
||||
}
|
||||
}
|
||||
72
Client-Client/README.md
Normal file
72
Client-Client/README.md
Normal file
@@ -0,0 +1,72 @@
|
||||
# AdPlatform Management Console
|
||||
|
||||
React-based admin interface for AdPlatform management.
|
||||
|
||||
## Project Structure
|
||||
|
||||
```
|
||||
management-client/
|
||||
├── src/
|
||||
│ ├── app/
|
||||
│ │ └── App.js # Main app routing
|
||||
│ ├── auth/
|
||||
│ │ ├── authConfig.js # MSAL + API config
|
||||
│ │ └── AuthProvider.jsx # Auth context
|
||||
│ ├── components/
|
||||
│ │ ├── admin/
|
||||
│ │ │ ├── ClientsPanel.jsx # Client management
|
||||
│ │ │ ├── UsersPanel.jsx # User management
|
||||
│ │ │ └── SessionsPanel.jsx # Session management
|
||||
│ │ ├── Shell.jsx # Layout wrapper
|
||||
│ │ ├── SignInOverlay.jsx # Sign in screen
|
||||
│ │ ├── RegistrationForm.jsx # New org setup
|
||||
│ │ └── Dashboard.jsx # Main dashboard
|
||||
│ ├── styles/
|
||||
│ │ └── app.css
|
||||
│ └── index.js
|
||||
├── public/
|
||||
│ └── index.html
|
||||
├── package.json
|
||||
└── webpack.config.js
|
||||
```
|
||||
|
||||
## Features
|
||||
|
||||
- **Authentication**: Microsoft Entra External ID (MSAL.js)
|
||||
- **Onboarding**: New user registration with organization setup
|
||||
- **Client Management**: Create, list, deactivate clients
|
||||
- **User Management**: Create, list, link to clients, deactivate users
|
||||
- **Session Management**: View active sessions, revoke, cleanup
|
||||
|
||||
## Setup
|
||||
|
||||
```bash
|
||||
npm install
|
||||
npm start
|
||||
```
|
||||
|
||||
Opens at http://localhost:3000
|
||||
|
||||
## Configuration
|
||||
|
||||
Edit `src/auth/authConfig.js`:
|
||||
|
||||
```javascript
|
||||
export const msalConfig = {
|
||||
auth: {
|
||||
clientId: 'YOUR_CLIENT_ID',
|
||||
authority: 'https://login.microsoftonline.com/YOUR_TENANT_ID',
|
||||
}
|
||||
};
|
||||
|
||||
export const API_BASE_URL = 'https://your-management-api.com';
|
||||
export const GATEWAY_API_URL = 'https://your-gateway-api.com';
|
||||
```
|
||||
|
||||
## User Flow
|
||||
|
||||
1. User signs in with Microsoft → JWT obtained
|
||||
2. App checks `/api/onboarding/status`
|
||||
3. If not registered → Registration form shown
|
||||
4. After registration → Session created via Gateway
|
||||
5. Dashboard displayed with admin tabs
|
||||
2
Client-Client/dist/bundle.js
vendored
2
Client-Client/dist/bundle.js
vendored
File diff suppressed because one or more lines are too long
2
Client-Client/dist/index.html
vendored
2
Client-Client/dist/index.html
vendored
@@ -1 +1 @@
|
||||
<!doctype html><html lang="en"><head><meta charset="UTF-8"/><meta name="viewport" content="width=device-width,initial-scale=1"/><title>AdPlatform</title><link rel="preconnect" href="https://fonts.googleapis.com"/><link href="https://fonts.googleapis.com/css2?family=DM+Sans:wght@400;500;600;700&display=swap" rel="stylesheet"/><script defer="defer" src="/bundle.js"></script></head><body><div id="root"></div></body></html>
|
||||
<!doctype html><html lang="en"><head><meta charset="UTF-8"><meta name="viewport" content="width=device-width,initial-scale=1"><title>AdPlatform Management</title><link rel="preconnect" href="https://fonts.googleapis.com"><link rel="preconnect" href="https://fonts.gstatic.com" crossorigin><link href="https://fonts.googleapis.com/css2?family=DM+Sans:wght@400;500;600;700&display=swap" rel="stylesheet"></head><body><div id="root"></div><script defer="defer" src="bundle.js"></script></body></html>
|
||||
@@ -1,27 +1,29 @@
|
||||
{
|
||||
"name": "adplatform-client",
|
||||
"name": "adplatform-management",
|
||||
"version": "1.0.0",
|
||||
"private": true,
|
||||
"description": "AdPlatform Management Console",
|
||||
"scripts": {
|
||||
"start": "webpack serve --mode development",
|
||||
"start": "webpack serve --mode development --open",
|
||||
"build": "webpack --mode production"
|
||||
},
|
||||
"dependencies": {
|
||||
"@azure/msal-browser": "^3.6.0",
|
||||
"@azure/msal-react": "^2.0.0",
|
||||
"@azure/msal-react": "^2.0.12",
|
||||
"@emoji-mart/data": "^1.2.1",
|
||||
"@emoji-mart/react": "^1.1.1",
|
||||
"react": "^18.2.0",
|
||||
"react-dom": "^18.2.0"
|
||||
},
|
||||
"devDependencies": {
|
||||
"@babel/core": "^7.24.0",
|
||||
"@babel/preset-env": "^7.24.0",
|
||||
"@babel/core": "^7.23.0",
|
||||
"@babel/preset-env": "^7.23.0",
|
||||
"@babel/preset-react": "^7.23.0",
|
||||
"babel-loader": "^9.1.3",
|
||||
"css-loader": "^6.10.0",
|
||||
"html-webpack-plugin": "^5.6.0",
|
||||
"style-loader": "^3.3.4",
|
||||
"webpack": "^5.90.0",
|
||||
"css-loader": "^6.8.1",
|
||||
"html-webpack-plugin": "^5.5.3",
|
||||
"style-loader": "^3.3.3",
|
||||
"webpack": "^5.89.0",
|
||||
"webpack-cli": "^5.1.4",
|
||||
"webpack-dev-server": "^5.0.0"
|
||||
"webpack-dev-server": "^5.2.3"
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,13 +1,14 @@
|
||||
<!DOCTYPE html>
|
||||
<html lang="en">
|
||||
<head>
|
||||
<meta charset="UTF-8" />
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
|
||||
<title>AdPlatform</title>
|
||||
<link rel="preconnect" href="https://fonts.googleapis.com" />
|
||||
<link href="https://fonts.googleapis.com/css2?family=DM+Sans:wght@400;500;600;700&display=swap" rel="stylesheet" />
|
||||
<meta charset="UTF-8">
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1.0">
|
||||
<title>AdPlatform Management</title>
|
||||
<link rel="preconnect" href="https://fonts.googleapis.com">
|
||||
<link rel="preconnect" href="https://fonts.gstatic.com" crossorigin>
|
||||
<link href="https://fonts.googleapis.com/css2?family=DM+Sans:wght@400;500;600;700&display=swap" rel="stylesheet">
|
||||
</head>
|
||||
<body>
|
||||
<div id="root"></div>
|
||||
<div id="root"></div>
|
||||
</body>
|
||||
</html>
|
||||
|
||||
@@ -1,40 +1,37 @@
|
||||
import AuthProvider, { useAuth } from '../auth/AuthProvider';
|
||||
import LandingPage from '../components/LandingPage';
|
||||
import RegistrationPage from '../components/RegistrationPage';
|
||||
import AuthErrorPage from '../components/AuthErrorPage';
|
||||
import Platform from '../components/Platform';
|
||||
|
||||
function AppRouter() {
|
||||
const { authState, error } = useAuth();
|
||||
|
||||
switch (authState) {
|
||||
case 'active':
|
||||
return <Platform />;
|
||||
|
||||
case 'needsRegistration':
|
||||
return <RegistrationPage />;
|
||||
|
||||
case 'error':
|
||||
return <AuthErrorPage message={error} />;
|
||||
|
||||
case 'authenticating':
|
||||
return (
|
||||
<div className="loading-screen">
|
||||
<div className="loading-spinner" />
|
||||
<p>Signing in…</p>
|
||||
</div>
|
||||
);
|
||||
|
||||
case 'unauthenticated':
|
||||
default:
|
||||
return <LandingPage />;
|
||||
}
|
||||
}
|
||||
import React from 'react';
|
||||
import { useAuth } from '../auth/AuthProvider';
|
||||
import { AdminProvider } from '../context/AdminContext';
|
||||
import Shell from '../components/Shell';
|
||||
import SignInOverlay from '../components/SignInOverlay';
|
||||
import Dashboard from '../components/Dashboard';
|
||||
|
||||
export default function App() {
|
||||
return (
|
||||
<AuthProvider>
|
||||
<AppRouter />
|
||||
</AuthProvider>
|
||||
);
|
||||
const { authState } = useAuth();
|
||||
|
||||
switch (authState) {
|
||||
case 'active':
|
||||
return (
|
||||
<AdminProvider>
|
||||
<Dashboard />
|
||||
</AdminProvider>
|
||||
);
|
||||
case 'authenticating':
|
||||
return (
|
||||
<Shell>
|
||||
<div className="loading-container">
|
||||
<div className="spinner"></div>
|
||||
<p>Signing in…</p>
|
||||
</div>
|
||||
</Shell>
|
||||
);
|
||||
case 'needsRegistration':
|
||||
case 'error':
|
||||
case 'unauthenticated':
|
||||
default:
|
||||
return (
|
||||
<Shell>
|
||||
<SignInOverlay />
|
||||
</Shell>
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -260,3 +260,7 @@ export default function AuthProvider({ children }) {
|
||||
</MsalProvider>
|
||||
);
|
||||
}
|
||||
|
||||
|
||||
// ─── Named export alias (index.js uses { AuthProvider }) ───
|
||||
export { AuthProvider };
|
||||
|
||||
@@ -4,7 +4,10 @@ const CLIENT_ID = '154c9111-14a0-4c0f-8132-7bc68254a74e';
|
||||
const AUTHORITY = `https://USIMClients.ciamlogin.com/${TENANT_ID}`;
|
||||
|
||||
// ─── Gateway API ───
|
||||
export const GATEWAY_URL = 'https://adsapi.usimdev.com';
|
||||
export const GATEWAY_URL = 'https://adpapi.usimdev.com';
|
||||
|
||||
// ─── Management API (used by HelpIcon for contextual help content) ───
|
||||
export const MANAGEMENT_URL = 'https://adpmgmt.usimdev.com';
|
||||
|
||||
// ─── Session endpoint ───
|
||||
export const SESSION_ENDPOINT = `${GATEWAY_URL}/api/auth/session`;
|
||||
@@ -23,7 +26,7 @@ export const msalConfig = {
|
||||
navigateToLoginRequestUrl: true
|
||||
},
|
||||
cache: {
|
||||
cacheLocation: 'sessionStorage',
|
||||
cacheLocation: 'localStorage',
|
||||
storeAuthStateInCookie: false
|
||||
},
|
||||
system: {
|
||||
@@ -45,3 +48,10 @@ export const msalConfig = {
|
||||
export const loginRequest = {
|
||||
scopes: ['openid', 'profile', 'email']
|
||||
};
|
||||
|
||||
|
||||
// ─── Aliases for current Client components ───
|
||||
export const API_BASE_URL = GATEWAY_URL;
|
||||
export const GATEWAY_API_URL = GATEWAY_URL;
|
||||
export const API_BASE = GATEWAY_URL;
|
||||
export const MGMT_BASE = MANAGEMENT_URL;
|
||||
|
||||
@@ -1,28 +0,0 @@
|
||||
import { useAuth } from '../auth/AuthProvider';
|
||||
import { REGISTRATION_URL } from '../auth/authConfig';
|
||||
|
||||
export default function AuthErrorPage({ message }) {
|
||||
const { clearError, retrySignIn } = useAuth();
|
||||
|
||||
return (
|
||||
<div className="status-page">
|
||||
<div className="status-card">
|
||||
<div className="status-icon error">✕</div>
|
||||
<h2>Sign-in Unsuccessful</h2>
|
||||
<p className="error-text">{message || 'An unexpected error occurred during sign-in.'}</p>
|
||||
|
||||
<div className="status-actions">
|
||||
<button className="btn btn-primary btn-lg" onClick={clearError}>
|
||||
Try Again
|
||||
</button>
|
||||
<button className="btn btn-outline" onClick={retrySignIn}>
|
||||
Sign in with Different Account
|
||||
</button>
|
||||
<a href={REGISTRATION_URL} className="btn btn-outline">
|
||||
Register for Access
|
||||
</a>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
68
Client-Client/src/components/Dashboard.jsx
Normal file
68
Client-Client/src/components/Dashboard.jsx
Normal file
@@ -0,0 +1,68 @@
|
||||
import React from 'react';
|
||||
import { useAdmin, CATEGORY_LABELS } from '../context/AdminContext';
|
||||
import Sidebar from './Sidebar';
|
||||
import OverviewPanel from './client/OverviewPanel';
|
||||
import CampaignsPanel from './client/CampaignsPanel';
|
||||
import NewCampaignPanel from './client/NewCampaignPanel';
|
||||
import PerformancePanel from './client/PerformancePanel';
|
||||
import RecommendationsPanel from './client/RecommendationsPanel';
|
||||
import DocumentsPanel from './client/DocumentsPanel';
|
||||
import HelpPanel from './client/HelpPanel';
|
||||
|
||||
export default function Dashboard() {
|
||||
const {
|
||||
activeCategory, activeTab, tabs, collapsed,
|
||||
setActiveCategory, setActiveTab, setCollapsed,
|
||||
} = useAdmin();
|
||||
|
||||
return (
|
||||
<div className="dashboard-layout">
|
||||
<Sidebar
|
||||
activeCategory={activeCategory}
|
||||
onSelectCategory={setActiveCategory}
|
||||
collapsed={collapsed}
|
||||
onToggleCollapse={() => setCollapsed(c => !c)}
|
||||
/>
|
||||
|
||||
<div className="dashboard-main">
|
||||
<header className="dashboard-header">
|
||||
<div className="dashboard-header-top">
|
||||
<h1 className="dashboard-title">
|
||||
{CATEGORY_LABELS[activeCategory] || activeCategory}
|
||||
</h1>
|
||||
</div>
|
||||
{tabs.length > 1 && (
|
||||
<div className="dashboard-tabs">
|
||||
{tabs.map(tab => (
|
||||
<button
|
||||
key={tab.id}
|
||||
className={`tab-btn ${activeTab === tab.id ? 'active' : ''}`}
|
||||
onClick={() => setActiveTab(tab.id)}
|
||||
>
|
||||
{tab.label}
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
</header>
|
||||
|
||||
<div className="dashboard-content">
|
||||
{activeTab === 'overview' && <OverviewPanel />}
|
||||
{activeTab === 'myCampaigns' && <CampaignsPanel />}
|
||||
{activeTab === 'newCampaign' && <NewCampaignPanel />}
|
||||
{activeTab === 'metrics' && <PerformancePanel />}
|
||||
{activeTab === 'recommendations' && <RecommendationsPanel />}
|
||||
{activeTab === 'myDocuments' && <DocumentsPanel />}
|
||||
{activeTab === 'help' && <HelpPanel />}
|
||||
{activeTab === 'invoices' && (
|
||||
<div className="placeholder-panel">
|
||||
<div className="placeholder-icon">💳</div>
|
||||
<h3>Billing</h3>
|
||||
<p>Invoice history and payment management coming soon.</p>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
143
Client-Client/src/components/HelpIcon.jsx
Normal file
143
Client-Client/src/components/HelpIcon.jsx
Normal file
@@ -0,0 +1,143 @@
|
||||
import React, { useState, useEffect, useRef, useCallback } from 'react';
|
||||
import { MANAGEMENT_URL } from '../auth/authConfig';
|
||||
|
||||
// ─── Session-level cache — avoids repeat API calls ────────────
|
||||
const _cache = new Map();
|
||||
|
||||
// ─── HelpIcon ─────────────────────────────────────────────────
|
||||
// Usage: <HelpIcon helpKey="client.wizard.budget" label="Budget" />
|
||||
//
|
||||
// helpKey format: {app}.{section}.{element}
|
||||
// client.wizard.objective client.wizard.budget
|
||||
// client.wizard.audience client.wizard.channels
|
||||
// ─────────────────────────────────────────────────────────────
|
||||
export default function HelpIcon({ helpKey, label }) {
|
||||
const [open, setOpen] = useState(false);
|
||||
const [content, setContent] = useState(null); // { title, body }
|
||||
const [loading, setLoading] = useState(false);
|
||||
const [error, setError] = useState(null);
|
||||
const modalRef = useRef(null);
|
||||
|
||||
// ── Fetch on open ──
|
||||
const fetchContent = useCallback(async () => {
|
||||
if (_cache.has(helpKey)) {
|
||||
setContent(_cache.get(helpKey));
|
||||
return;
|
||||
}
|
||||
|
||||
setLoading(true);
|
||||
setError(null);
|
||||
|
||||
try {
|
||||
// /api/help is anonymous — no session token required
|
||||
const res = await fetch(`${MANAGEMENT_URL}/api/help/${encodeURIComponent(helpKey)}`);
|
||||
const data = await res.json();
|
||||
|
||||
if (data.ok) {
|
||||
const entry = { title: data.title, body: data.body };
|
||||
_cache.set(helpKey, entry);
|
||||
setContent(entry);
|
||||
} else {
|
||||
setError(data.error || 'No help content available yet.');
|
||||
}
|
||||
} catch (err) {
|
||||
setError('Could not load help content.');
|
||||
} finally {
|
||||
setLoading(false);
|
||||
}
|
||||
}, [helpKey]);
|
||||
|
||||
const handleOpen = () => {
|
||||
setOpen(true);
|
||||
fetchContent();
|
||||
};
|
||||
|
||||
const handleClose = () => setOpen(false);
|
||||
|
||||
// ── Close on Escape or outside click ──
|
||||
useEffect(() => {
|
||||
if (!open) return;
|
||||
|
||||
const onKey = (e) => { if (e.key === 'Escape') handleClose(); };
|
||||
const onClick = (e) => {
|
||||
if (modalRef.current && !modalRef.current.contains(e.target)) handleClose();
|
||||
};
|
||||
|
||||
document.addEventListener('keydown', onKey);
|
||||
document.addEventListener('mousedown', onClick);
|
||||
return () => {
|
||||
document.removeEventListener('keydown', onKey);
|
||||
document.removeEventListener('mousedown', onClick);
|
||||
};
|
||||
}, [open]);
|
||||
|
||||
return (
|
||||
<>
|
||||
{/* ── Trigger: floating icon + label ── */}
|
||||
<button className="help-trigger" onClick={handleOpen} title={`Help: ${label || helpKey}`}>
|
||||
<span className="help-trigger-icon">
|
||||
<svg width="14" height="14" viewBox="0 0 16 16" fill="none"
|
||||
stroke="currentColor" strokeWidth="1.75" strokeLinecap="round" strokeLinejoin="round">
|
||||
<circle cx="8" cy="8" r="7" />
|
||||
<path d="M6 6c0-1.1.9-2 2-2s2 .9 2 2c0 1-.6 1.5-1.3 2S8 9.5 8 10" />
|
||||
<circle cx="8" cy="12.5" r=".75" fill="currentColor" stroke="none" />
|
||||
</svg>
|
||||
</span>
|
||||
{label && <span className="help-trigger-label">{label}</span>}
|
||||
</button>
|
||||
|
||||
{/* ── Modal ── */}
|
||||
{open && (
|
||||
<div className="help-overlay">
|
||||
<div className="help-modal" ref={modalRef} role="dialog" aria-modal="true"
|
||||
aria-label={content?.title || 'Help'}>
|
||||
|
||||
<div className="help-modal-header">
|
||||
<div className="help-modal-title-row">
|
||||
<span className="help-modal-icon">
|
||||
<svg width="16" height="16" viewBox="0 0 16 16" fill="none"
|
||||
stroke="currentColor" strokeWidth="1.75" strokeLinecap="round" strokeLinejoin="round">
|
||||
<circle cx="8" cy="8" r="7" />
|
||||
<path d="M6 6c0-1.1.9-2 2-2s2 .9 2 2c0 1-.6 1.5-1.3 2S8 9.5 8 10" />
|
||||
<circle cx="8" cy="12.5" r=".75" fill="currentColor" stroke="none" />
|
||||
</svg>
|
||||
</span>
|
||||
<h3 className="help-modal-title">
|
||||
{loading ? 'Loading…' : (content?.title || 'Help')}
|
||||
</h3>
|
||||
</div>
|
||||
<button className="help-modal-close" onClick={handleClose} aria-label="Close">
|
||||
<svg width="16" height="16" viewBox="0 0 16 16" fill="none"
|
||||
stroke="currentColor" strokeWidth="2" strokeLinecap="round">
|
||||
<path d="M3 3l10 10M13 3L3 13" />
|
||||
</svg>
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<div className="help-modal-body">
|
||||
{loading && (
|
||||
<div className="help-loading">
|
||||
<div className="help-spinner" />
|
||||
<span>Loading help…</span>
|
||||
</div>
|
||||
)}
|
||||
{!loading && error && (
|
||||
<p className="help-error">{error}</p>
|
||||
)}
|
||||
{!loading && content && (
|
||||
<div
|
||||
className="help-content"
|
||||
dangerouslySetInnerHTML={{ __html: content.body }}
|
||||
/>
|
||||
)}
|
||||
</div>
|
||||
|
||||
<div className="help-modal-footer">
|
||||
<span className="help-key-badge">{helpKey}</span>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</>
|
||||
);
|
||||
}
|
||||
@@ -1,50 +0,0 @@
|
||||
import React from 'react';
|
||||
import { useAuth } from '../auth/AuthProvider';
|
||||
|
||||
export default function LandingPage() {
|
||||
const { signIn } = useAuth();
|
||||
|
||||
return (
|
||||
<div className="landing-page">
|
||||
<div className="landing-header">
|
||||
<div className="landing-logo">
|
||||
<div className="landing-logo-icon">
|
||||
<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2" strokeLinecap="round" strokeLinejoin="round">
|
||||
<polygon points="13 2 3 14 12 14 11 22 21 10 12 10 13 2" />
|
||||
</svg>
|
||||
</div>
|
||||
<span className="landing-logo-text">AdPlatform</span>
|
||||
</div>
|
||||
<button className="btn btn-primary" onClick={signIn}>Sign In</button>
|
||||
</div>
|
||||
<div className="landing-hero">
|
||||
<h1>Manage Your Google Ads</h1>
|
||||
<p>A simple, self-service platform for managing your advertising campaigns under expert guidance.</p>
|
||||
<button className="btn btn-primary btn-lg" onClick={signIn}>Get Started</button>
|
||||
<div className="landing-features">
|
||||
<div className="feature-card">
|
||||
<div className="feature-card-icon">
|
||||
<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2"><path d="M19 11H5m14 0a2 2 0 012 2v6a2 2 0 01-2 2H5a2 2 0 01-2-2v-6a2 2 0 012-2m14 0V9a2 2 0 00-2-2M5 11V9a2 2 0 012-2m0 0V5a2 2 0 012-2h6a2 2 0 012 2v2M7 7h10" /></svg>
|
||||
</div>
|
||||
<h3>Campaign Management</h3>
|
||||
<p>Create and manage search, display, and video campaigns with an intuitive interface.</p>
|
||||
</div>
|
||||
<div className="feature-card">
|
||||
<div className="feature-card-icon">
|
||||
<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2"><path d="M9 19v-6a2 2 0 00-2-2H5a2 2 0 00-2 2v6a2 2 0 002 2h2a2 2 0 002-2zm0 0V9a2 2 0 012-2h2a2 2 0 012 2v10m-6 0a2 2 0 002 2h2a2 2 0 002-2m0 0V5a2 2 0 012-2h2a2 2 0 012 2v14a2 2 0 01-2 2h-2a2 2 0 01-2-2z" /></svg>
|
||||
</div>
|
||||
<h3>Performance Reporting</h3>
|
||||
<p>Track impressions, clicks, conversions, and spend with real-time reporting dashboards.</p>
|
||||
</div>
|
||||
<div className="feature-card">
|
||||
<div className="feature-card-icon">
|
||||
<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2"><path d="M9 12l2 2 4-4m5.618-4.016A11.955 11.955 0 0112 2.944a11.955 11.955 0 01-8.618 3.04A12.02 12.02 0 003 9c0 5.591 3.824 10.29 9 11.622 5.176-1.332 9-6.03 9-11.622 0-1.042-.133-2.052-.382-3.016z" /></svg>
|
||||
</div>
|
||||
<h3>Managed by Experts</h3>
|
||||
<p>Your campaigns run under our agency account with professional oversight and support.</p>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -1,101 +0,0 @@
|
||||
import React, { useState } from 'react';
|
||||
import { useAuth } from '../auth/AuthProvider';
|
||||
import Sidebar from './Sidebar';
|
||||
import Dashboard from './views/Dashboard';
|
||||
import Campaigns from './views/Campaigns';
|
||||
import Reporting from './views/Reporting';
|
||||
import Accounts from './views/Accounts';
|
||||
import Developer from './views/Developer';
|
||||
import Settings from './views/Settings';
|
||||
|
||||
const viewTitles = {
|
||||
dashboard: 'Dashboard',
|
||||
campaigns: 'Campaigns',
|
||||
reporting: 'Reporting',
|
||||
accounts: 'Accounts',
|
||||
developer: 'API Testing',
|
||||
settings: 'Settings'
|
||||
};
|
||||
|
||||
export default function Platform() {
|
||||
const { sessionUser, sessionToken, signOut } = useAuth();
|
||||
const [activeView, setActiveView] = useState('dashboard');
|
||||
const [sidebarCollapsed, setSidebarCollapsed] = useState(false);
|
||||
const [menuOpen, setMenuOpen] = useState(false);
|
||||
|
||||
const getInitials = (name) => {
|
||||
if (!name) return 'U';
|
||||
return name.split(' ').map(n => n[0]).join('').toUpperCase().slice(0, 2);
|
||||
};
|
||||
|
||||
const renderView = () => {
|
||||
const props = { sessionUser, sessionToken, onNavigate: setActiveView };
|
||||
switch (activeView) {
|
||||
case 'campaigns': return <Campaigns {...props} />;
|
||||
case 'reporting': return <Reporting {...props} />;
|
||||
case 'accounts': return <Accounts {...props} />;
|
||||
case 'developer': return <Developer {...props} />;
|
||||
case 'settings': return <Settings {...props} onSignOut={signOut} />;
|
||||
default: return <Dashboard {...props} />;
|
||||
}
|
||||
};
|
||||
|
||||
return (
|
||||
<div className={`platform ${sidebarCollapsed ? 'sidebar-collapsed' : ''}`}>
|
||||
<Sidebar
|
||||
activeView={activeView}
|
||||
onNavigate={setActiveView}
|
||||
collapsed={sidebarCollapsed}
|
||||
onToggle={() => setSidebarCollapsed(!sidebarCollapsed)}
|
||||
/>
|
||||
|
||||
<div className="main-area">
|
||||
{/* Top Bar */}
|
||||
<header className="top-bar">
|
||||
<nav className="breadcrumb">
|
||||
<span className="breadcrumb-item">AdPlatform</span>
|
||||
<span className="breadcrumb-separator">/</span>
|
||||
<span className="breadcrumb-current">{viewTitles[activeView]}</span>
|
||||
</nav>
|
||||
|
||||
<div className="top-bar-actions">
|
||||
{sessionUser?.clientName && (
|
||||
<span className="client-badge">{sessionUser.clientName}</span>
|
||||
)}
|
||||
|
||||
<div className="user-menu">
|
||||
<button className="user-menu-trigger" onClick={() => setMenuOpen(!menuOpen)}>
|
||||
<div className="user-avatar">
|
||||
{getInitials(sessionUser?.name || sessionUser?.email)}
|
||||
</div>
|
||||
<span className="user-name">{sessionUser?.name || sessionUser?.email || 'User'}</span>
|
||||
<svg width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2">
|
||||
<path d="M6 9l6 6 6-6" />
|
||||
</svg>
|
||||
</button>
|
||||
|
||||
{menuOpen && (
|
||||
<div className="user-dropdown">
|
||||
<button className="user-dropdown-item" onClick={() => { setActiveView('settings'); setMenuOpen(false); }}>
|
||||
<svg width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2"><path d="M10.325 4.317c.426-1.756 2.924-1.756 3.35 0a1.724 1.724 0 002.573 1.066c1.543-.94 3.31.826 2.37 2.37a1.724 1.724 0 001.065 2.572c1.756.426 1.756 2.924 0 3.35a1.724 1.724 0 00-1.066 2.573c.94 1.543-.826 3.31-2.37 2.37a1.724 1.724 0 00-2.572 1.065c-.426 1.756-2.924 1.756-3.35 0a1.724 1.724 0 00-2.573-1.066c-1.543.94-3.31-.826-2.37-2.37a1.724 1.724 0 00-1.065-2.572c-1.756-.426-1.756-2.924 0-3.35a1.724 1.724 0 001.066-2.573c-.94-1.543.826-3.31 2.37-2.37.996.608 2.296.07 2.572-1.065z" /><circle cx="12" cy="12" r="3" /></svg>
|
||||
Settings
|
||||
</button>
|
||||
<div className="user-dropdown-divider" />
|
||||
<button className="user-dropdown-item danger" onClick={() => { signOut(); setMenuOpen(false); }}>
|
||||
<svg width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2"><path d="M9 21H5a2 2 0 01-2-2V5a2 2 0 012-2h4M16 17l5-5-5-5M21 12H9" /></svg>
|
||||
Sign Out
|
||||
</button>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
</header>
|
||||
|
||||
{/* Content */}
|
||||
<main className="content-area">
|
||||
{renderView()}
|
||||
</main>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
57
Client-Client/src/components/RegistrationForm.jsx
Normal file
57
Client-Client/src/components/RegistrationForm.jsx
Normal file
@@ -0,0 +1,57 @@
|
||||
import React, { useState } from 'react';
|
||||
import { useAuth } from '../auth/AuthProvider';
|
||||
|
||||
export default function RegistrationForm() {
|
||||
const { user, completeRegistration, error: authError } = useAuth();
|
||||
const [clientName, setClientName] = useState('');
|
||||
const [loading, setLoading] = useState(false);
|
||||
const [error, setError] = useState(null);
|
||||
|
||||
const handleSubmit = async (e) => {
|
||||
e.preventDefault();
|
||||
|
||||
if (!clientName.trim()) {
|
||||
setError('Organization name is required');
|
||||
return;
|
||||
}
|
||||
|
||||
setLoading(true);
|
||||
setError(null);
|
||||
|
||||
const success = await completeRegistration(clientName.trim());
|
||||
if (!success) setLoading(false);
|
||||
};
|
||||
|
||||
return (
|
||||
<div className="registration-container">
|
||||
<div className="registration-card">
|
||||
<h1>Complete Registration</h1>
|
||||
<p className="registration-subtitle">
|
||||
Welcome, {user?.name || user?.email}! Set up your organization to get started.
|
||||
</p>
|
||||
|
||||
{(error || authError) && <div className="error-message">{error || authError}</div>}
|
||||
|
||||
<form onSubmit={handleSubmit}>
|
||||
<div className="form-group">
|
||||
<label htmlFor="clientName">Organization Name</label>
|
||||
<input
|
||||
type="text"
|
||||
id="clientName"
|
||||
value={clientName}
|
||||
onChange={(e) => setClientName(e.target.value)}
|
||||
placeholder="e.g., Acme Corporation"
|
||||
disabled={loading}
|
||||
autoFocus
|
||||
/>
|
||||
<span className="form-help">This is the name that will appear on your advertising accounts</span>
|
||||
</div>
|
||||
|
||||
<button type="submit" className="btn-primary" disabled={loading}>
|
||||
{loading ? 'Creating...' : 'Create Organization'}
|
||||
</button>
|
||||
</form>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -1,15 +0,0 @@
|
||||
import { useEffect } from 'react';
|
||||
import { REGISTRATION_URL } from '../auth/authConfig';
|
||||
|
||||
export default function RegistrationPage() {
|
||||
useEffect(() => {
|
||||
window.location.href = REGISTRATION_URL;
|
||||
}, []);
|
||||
|
||||
return (
|
||||
<div className="loading-screen">
|
||||
<div className="loading-spinner" />
|
||||
<p>Redirecting to registration…</p>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
25
Client-Client/src/components/Shell.jsx
Normal file
25
Client-Client/src/components/Shell.jsx
Normal file
@@ -0,0 +1,25 @@
|
||||
import React from 'react';
|
||||
import { useAuth } from '../auth/AuthProvider';
|
||||
|
||||
export default function Shell({ children }) {
|
||||
const { authState, sessionUser: user, signOut } = useAuth();
|
||||
const isSignedIn = authState === 'active';
|
||||
|
||||
return (
|
||||
<div className="shell">
|
||||
<header className="shell-header">
|
||||
<div className="shell-logo">
|
||||
<span className="logo-icon">◆</span>
|
||||
<span className="logo-text">AdPlatform</span>
|
||||
</div>
|
||||
{isSignedIn && user && (
|
||||
<div className="shell-user">
|
||||
<span className="user-name">{user.displayName || user.email}</span>
|
||||
<button onClick={signOut} className="btn-signout">Sign Out</button>
|
||||
</div>
|
||||
)}
|
||||
</header>
|
||||
<main className="shell-content">{children}</main>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -1,65 +1,137 @@
|
||||
import React from 'react';
|
||||
import { useAuth } from '../auth/AuthProvider';
|
||||
|
||||
const navItems = [
|
||||
{ id: 'dashboard', label: 'Dashboard', icon: 'M3 12l2-2m0 0l7-7 7 7M5 10v10a1 1 0 001 1h3m10-11l2 2m-2-2v10a1 1 0 01-1 1h-3m-6 0a1 1 0 001-1v-4a1 1 0 011-1h2a1 1 0 011 1v4a1 1 0 001 1m-6 0h6' },
|
||||
{ id: 'campaigns', label: 'Campaigns', icon: 'M19 11H5m14 0a2 2 0 012 2v6a2 2 0 01-2 2H5a2 2 0 01-2-2v-6a2 2 0 012-2m14 0V9a2 2 0 00-2-2M5 11V9a2 2 0 012-2m0 0V5a2 2 0 012-2h6a2 2 0 012 2v2M7 7h10' },
|
||||
{ id: 'reporting', label: 'Reporting', icon: 'M9 19v-6a2 2 0 00-2-2H5a2 2 0 00-2 2v6a2 2 0 002 2h2a2 2 0 002-2zm0 0V9a2 2 0 012-2h2a2 2 0 012 2v10m-6 0a2 2 0 002 2h2a2 2 0 002-2m0 0V5a2 2 0 012-2h2a2 2 0 012 2v14a2 2 0 01-2 2h-2a2 2 0 01-2-2z' },
|
||||
{ id: 'accounts', label: 'Accounts', icon: 'M17 20h5v-2a3 3 0 00-5.356-1.857M17 20H7m10 0v-2c0-.656-.126-1.283-.356-1.857M7 20H2v-2a3 3 0 015.356-1.857M7 20v-2c0-.656.126-1.283.356-1.857m0 0a5.002 5.002 0 019.288 0M15 7a3 3 0 11-6 0 3 3 0 016 0zm6 3a2 2 0 11-4 0 2 2 0 014 0zM7 10a2 2 0 11-4 0 2 2 0 014 0z' },
|
||||
{ id: 'developer', label: 'API Testing', icon: 'M10 20l4-16m4 4l4 4-4 4M6 16l-4-4 4-4' },
|
||||
{ id: 'settings', label: 'Settings', icon: 'M10.325 4.317c.426-1.756 2.924-1.756 3.35 0a1.724 1.724 0 002.573 1.066c1.543-.94 3.31.826 2.37 2.37a1.724 1.724 0 001.065 2.572c1.756.426 1.756 2.924 0 3.35a1.724 1.724 0 00-1.066 2.573c.94 1.543-.826 3.31-2.37 2.37a1.724 1.724 0 00-2.572 1.065c-.426 1.756-2.924 1.756-3.35 0a1.724 1.724 0 00-2.573-1.066c-1.543.94-3.31-.826-2.37-2.37a1.724 1.724 0 00-1.065-2.572c-1.756-.426-1.756-2.924 0-3.35a1.724 1.724 0 001.066-2.573c-.94-1.543.826-3.31 2.37-2.37.996.608 2.296.07 2.572-1.065z M15 12a3 3 0 11-6 0 3 3 0 016 0z' }
|
||||
const Icons = {
|
||||
dashboard: (
|
||||
<svg width="20" height="20" viewBox="0 0 20 20" fill="none" stroke="currentColor" strokeWidth="1.5" strokeLinecap="round" strokeLinejoin="round">
|
||||
<rect x="2" y="2" width="7" height="8" rx="1.5" />
|
||||
<rect x="11" y="2" width="7" height="5" rx="1.5" />
|
||||
<rect x="2" y="12" width="7" height="6" rx="1.5" />
|
||||
<rect x="11" y="9" width="7" height="9" rx="1.5" />
|
||||
</svg>
|
||||
),
|
||||
campaigns: (
|
||||
<svg width="20" height="20" viewBox="0 0 20 20" fill="none" stroke="currentColor" strokeWidth="1.5" strokeLinecap="round" strokeLinejoin="round">
|
||||
<path d="M16 3L8 7H4a1 1 0 00-1 1v4a1 1 0 001 1h1l2 4h2l-2-4h1l8 4V3z" />
|
||||
</svg>
|
||||
),
|
||||
performance: (
|
||||
<svg width="20" height="20" viewBox="0 0 20 20" fill="none" stroke="currentColor" strokeWidth="1.5" strokeLinecap="round" strokeLinejoin="round">
|
||||
<path d="M3 16L7 8l4 5 3-7 3 10" />
|
||||
<circle cx="7" cy="8" r="1.5" fill="currentColor" />
|
||||
<circle cx="11" cy="13" r="1.5" fill="currentColor" />
|
||||
<circle cx="14" cy="6" r="1.5" fill="currentColor" />
|
||||
</svg>
|
||||
),
|
||||
billing: (
|
||||
<svg width="20" height="20" viewBox="0 0 20 20" fill="none" stroke="currentColor" strokeWidth="1.5" strokeLinecap="round" strokeLinejoin="round">
|
||||
<rect x="2" y="4" width="16" height="12" rx="2" />
|
||||
<path d="M2 8h16M6 12h3" />
|
||||
</svg>
|
||||
),
|
||||
documents: (
|
||||
<svg width="20" height="20" viewBox="0 0 20 20" fill="none" stroke="currentColor" strokeWidth="1.5" strokeLinecap="round" strokeLinejoin="round">
|
||||
<path d="M12 2H6a2 2 0 00-2 2v12a2 2 0 002 2h8a2 2 0 002-2V7z" />
|
||||
<path d="M12 2v5h5M7 11h6M7 14h4" />
|
||||
</svg>
|
||||
),
|
||||
support: (
|
||||
<svg width="20" height="20" viewBox="0 0 20 20" fill="none" stroke="currentColor" strokeWidth="1.5" strokeLinecap="round" strokeLinejoin="round">
|
||||
<circle cx="10" cy="10" r="8" />
|
||||
<path d="M10 14v-1M10 10a2 2 0 10-2-2" />
|
||||
<circle cx="10" cy="14.5" r="0.5" fill="currentColor" />
|
||||
</svg>
|
||||
),
|
||||
chevron: (
|
||||
<svg width="14" height="14" viewBox="0 0 14 14" fill="none" stroke="currentColor" strokeWidth="1.5" strokeLinecap="round" strokeLinejoin="round">
|
||||
<path d="M5 3l4 4-4 4" />
|
||||
</svg>
|
||||
),
|
||||
};
|
||||
|
||||
const NAV_ITEMS = [
|
||||
{ id: 'dashboard', label: 'Dashboard', icon: 'dashboard' },
|
||||
{ id: 'campaigns', label: 'Campaigns', icon: 'campaigns' },
|
||||
{ id: 'performance', label: 'Performance', icon: 'performance' },
|
||||
{ divider: true },
|
||||
{ id: 'billing', label: 'Billing', icon: 'billing', disabled: true },
|
||||
{ id: 'documents', label: 'Documents', icon: 'documents' },
|
||||
{ id: 'support', label: 'Support', icon: 'support' },
|
||||
];
|
||||
|
||||
export default function Sidebar({ activeView, onNavigate, collapsed, onToggle }) {
|
||||
return (
|
||||
<aside className={`sidebar ${collapsed ? 'collapsed' : ''}`}>
|
||||
<div className="sidebar-header">
|
||||
<div className="logo">
|
||||
<div className="logo-icon">
|
||||
<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2" strokeLinecap="round" strokeLinejoin="round">
|
||||
<polygon points="13 2 3 14 12 14 11 22 21 10 12 10 13 2" />
|
||||
</svg>
|
||||
</div>
|
||||
{!collapsed && <span className="logo-text">AdPlatform</span>}
|
||||
</div>
|
||||
<button className="collapse-btn" onClick={onToggle} title={collapsed ? 'Expand' : 'Collapse'}>
|
||||
<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2">
|
||||
{collapsed ? <path d="M13 5l7 7-7 7M5 5l7 7-7 7" /> : <path d="M11 19l-7-7 7-7M19 19l-7-7 7-7" />}
|
||||
</svg>
|
||||
</button>
|
||||
</div>
|
||||
export default function Sidebar({ activeCategory, onSelectCategory, collapsed, onToggleCollapse }) {
|
||||
const { sessionUser: user, signOut } = useAuth();
|
||||
|
||||
<nav className="sidebar-nav">
|
||||
<div className="nav-section">
|
||||
{!collapsed && <span className="nav-label">Menu</span>}
|
||||
<ul className="nav-list">
|
||||
{navItems.map(item => (
|
||||
<li key={item.id}>
|
||||
<button
|
||||
className={`nav-item ${activeView === item.id ? 'active' : ''}`}
|
||||
onClick={() => onNavigate(item.id)}
|
||||
title={item.label}
|
||||
>
|
||||
<span className="nav-icon">
|
||||
<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="1.5" strokeLinecap="round" strokeLinejoin="round">
|
||||
<path d={item.icon} />
|
||||
</svg>
|
||||
</span>
|
||||
{!collapsed && <span>{item.label}</span>}
|
||||
return (
|
||||
<aside className={`sidebar ${collapsed ? 'sidebar-collapsed' : ''}`}>
|
||||
<div className="sidebar-logo">
|
||||
<span className="sidebar-logo-icon">◆</span>
|
||||
{!collapsed && <span className="sidebar-logo-text">AdPlatform</span>}
|
||||
</div>
|
||||
|
||||
{/* Client name badge */}
|
||||
{!collapsed && user?.clientName && (
|
||||
<div style={{
|
||||
padding: '6px 16px 10px',
|
||||
fontSize: 12,
|
||||
color: 'var(--sidebar-muted, #94a3b8)',
|
||||
borderBottom: '1px solid var(--sidebar-border, rgba(255,255,255,0.08))',
|
||||
marginBottom: 8,
|
||||
}}>
|
||||
{user.clientName}
|
||||
</div>
|
||||
)}
|
||||
|
||||
<nav className="sidebar-nav">
|
||||
{NAV_ITEMS.map((item, i) => {
|
||||
if (item.divider) return <div key={`d-${i}`} className="sidebar-divider" />;
|
||||
const isActive = activeCategory === item.id;
|
||||
const isDisabled = item.disabled;
|
||||
return (
|
||||
<button
|
||||
key={item.id}
|
||||
className={`sidebar-item ${isActive ? 'sidebar-item-active' : ''} ${isDisabled ? 'sidebar-item-disabled' : ''}`}
|
||||
onClick={() => !isDisabled && onSelectCategory(item.id)}
|
||||
title={collapsed ? item.label : undefined}
|
||||
>
|
||||
<span className="sidebar-item-icon">{Icons[item.icon]}</span>
|
||||
{!collapsed && (
|
||||
<>
|
||||
<span className="sidebar-item-label">{item.label}</span>
|
||||
{isDisabled && <span className="sidebar-soon">Soon</span>}
|
||||
</>
|
||||
)}
|
||||
</button>
|
||||
);
|
||||
})}
|
||||
</nav>
|
||||
|
||||
<div className="sidebar-footer">
|
||||
{!collapsed && user && (
|
||||
<div className="sidebar-user">
|
||||
<div className="sidebar-user-avatar">
|
||||
{(user.displayName || user.email || '?')[0].toUpperCase()}
|
||||
</div>
|
||||
<div className="sidebar-user-info">
|
||||
<span className="sidebar-user-name">{user.displayName || user.email}</span>
|
||||
<button onClick={signOut} className="sidebar-user-signout">Sign Out</button>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
{collapsed && user && (
|
||||
<div className="sidebar-user-collapsed" title={user.displayName || user.email}>
|
||||
<div className="sidebar-user-avatar">
|
||||
{(user.displayName || user.email || '?')[0].toUpperCase()}
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
<button className="sidebar-collapse-btn" onClick={onToggleCollapse}>
|
||||
<span className={`sidebar-collapse-icon ${collapsed ? '' : 'sidebar-collapse-icon-flip'}`}>
|
||||
{Icons.chevron}
|
||||
</span>
|
||||
{!collapsed && <span>Collapse</span>}
|
||||
</button>
|
||||
</li>
|
||||
))}
|
||||
</ul>
|
||||
</div>
|
||||
</nav>
|
||||
|
||||
<div className="sidebar-footer">
|
||||
{!collapsed && (
|
||||
<div className="sidebar-brand">
|
||||
<span>Powered by</span>
|
||||
<strong>USIM</strong>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</aside>
|
||||
);
|
||||
</div>
|
||||
</aside>
|
||||
);
|
||||
}
|
||||
|
||||
56
Client-Client/src/components/SignInOverlay.jsx
Normal file
56
Client-Client/src/components/SignInOverlay.jsx
Normal file
@@ -0,0 +1,56 @@
|
||||
import React from 'react';
|
||||
import { useAuth } from '../auth/AuthProvider';
|
||||
|
||||
export default function SignInOverlay() {
|
||||
const { authState, error, signIn, retrySignIn } = useAuth();
|
||||
|
||||
if (authState === 'needsRegistration') {
|
||||
return (
|
||||
<div className="signin-overlay">
|
||||
<div className="signin-card">
|
||||
<div className="signin-icon">◆</div>
|
||||
<h1>AdPlatform</h1>
|
||||
<p className="signin-subtitle">Account not found</p>
|
||||
<div className="error-message">
|
||||
Your account doesn't have access to AdPlatform yet.
|
||||
Please contact your administrator or complete registration
|
||||
before signing in.
|
||||
</div>
|
||||
<button onClick={retrySignIn} className="btn-signin btn-secondary">
|
||||
Try a different account
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
if (authState === 'error') {
|
||||
return (
|
||||
<div className="signin-overlay">
|
||||
<div className="signin-card">
|
||||
<div className="signin-icon">◆</div>
|
||||
<h1>AdPlatform</h1>
|
||||
<p className="signin-subtitle">Sign-in failed</p>
|
||||
{error && <div className="error-message">{error}</div>}
|
||||
<button onClick={retrySignIn} className="btn-signin">
|
||||
Try again
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
// unauthenticated — default sign-in prompt
|
||||
return (
|
||||
<div className="signin-overlay">
|
||||
<div className="signin-card">
|
||||
<div className="signin-icon">◆</div>
|
||||
<h1>AdPlatform</h1>
|
||||
<p className="signin-subtitle">Sign in to manage your advertising campaigns</p>
|
||||
<button onClick={signIn} className="btn-signin">
|
||||
Sign in with Microsoft
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
388
Client-Client/src/components/admin/CampaignsPanel.jsx
Normal file
388
Client-Client/src/components/admin/CampaignsPanel.jsx
Normal file
@@ -0,0 +1,388 @@
|
||||
import React, { useState, useEffect, useCallback } from 'react';
|
||||
import { useAdmin } from '../../context/AdminContext';
|
||||
|
||||
/**
|
||||
* CampaignsPanel — Admin view of all initiatives across all clients.
|
||||
* Each initiative can have multiple channel campaigns (Google Ads, Meta, TikTok).
|
||||
* Includes filters: status, client, date range.
|
||||
*/
|
||||
|
||||
const STATUSES = [
|
||||
{ value: '', label: 'All Statuses' },
|
||||
{ value: 'draft', label: 'Draft' },
|
||||
{ value: 'staged', label: 'Staged' },
|
||||
{ value: 'active', label: 'Active' },
|
||||
{ value: 'paused', label: 'Paused' },
|
||||
{ value: 'completed', label: 'Completed' },
|
||||
{ value: 'error', label: 'Error' },
|
||||
];
|
||||
|
||||
export default function CampaignsPanel() {
|
||||
const { data, apiCall, refresh } = useAdmin();
|
||||
|
||||
// Filter state
|
||||
const [statusFilter, setStatusFilter] = useState('');
|
||||
const [clientFilter, setClientFilter] = useState('');
|
||||
const [dateFrom, setDateFrom] = useState('');
|
||||
const [dateTo, setDateTo] = useState('');
|
||||
|
||||
// Client list for dropdown
|
||||
const [clients, setClients] = useState([]);
|
||||
|
||||
// Filtered data (null = use initial data prop)
|
||||
const [filteredData, setFilteredData] = useState(null);
|
||||
const [loading, setLoading] = useState(false);
|
||||
const [error, setError] = useState(null);
|
||||
|
||||
const hasFilters = statusFilter || clientFilter || dateFrom || dateTo;
|
||||
const activeData = hasFilters ? filteredData : data;
|
||||
const initiatives = activeData?.initiatives || [];
|
||||
const totalCount = activeData?.totalCount ?? initiatives.length;
|
||||
|
||||
// Fetch client list for dropdown on mount
|
||||
useEffect(() => {
|
||||
const loadClients = async () => {
|
||||
const result = await apiCall('/api/admin/clients');
|
||||
if (result.ok && result.clients) {
|
||||
setClients(result.clients);
|
||||
}
|
||||
};
|
||||
loadClients();
|
||||
}, [apiCall]);
|
||||
|
||||
// Fetch with filters
|
||||
const fetchFiltered = useCallback(async () => {
|
||||
setLoading(true);
|
||||
setError(null);
|
||||
|
||||
const params = new URLSearchParams();
|
||||
if (statusFilter) params.set('status', statusFilter);
|
||||
if (clientFilter) params.set('clientId', clientFilter);
|
||||
if (dateFrom) params.set('dateFrom', dateFrom);
|
||||
if (dateTo) params.set('dateTo', dateTo);
|
||||
|
||||
const qs = params.toString();
|
||||
const result = await apiCall(`/api/admin/campaigns${qs ? '?' + qs : ''}`);
|
||||
|
||||
if (result.ok) {
|
||||
setFilteredData(result);
|
||||
} else {
|
||||
setError(result.error || 'Failed to load campaigns');
|
||||
}
|
||||
setLoading(false);
|
||||
}, [apiCall, statusFilter, clientFilter, dateFrom, dateTo]);
|
||||
|
||||
// Refetch when filters change
|
||||
useEffect(() => {
|
||||
if (!hasFilters) {
|
||||
setFilteredData(null);
|
||||
return;
|
||||
}
|
||||
const timer = setTimeout(fetchFiltered, 200);
|
||||
return () => clearTimeout(timer);
|
||||
}, [hasFilters, fetchFiltered]);
|
||||
|
||||
const clearFilters = () => {
|
||||
setStatusFilter('');
|
||||
setClientFilter('');
|
||||
setDateFrom('');
|
||||
setDateTo('');
|
||||
setFilteredData(null);
|
||||
};
|
||||
|
||||
return (
|
||||
<div className="table-panel">
|
||||
<div className="panel-header">
|
||||
<h2>Campaigns ({totalCount})</h2>
|
||||
<button className="btn-action" onClick={hasFilters ? fetchFiltered : refresh}>
|
||||
↻ Refresh
|
||||
</button>
|
||||
</div>
|
||||
|
||||
{/* ─── Filter Bar ──────────────────────────────────── */}
|
||||
<div className="filter-bar">
|
||||
<div className="filter-group">
|
||||
<label className="filter-label">Status</label>
|
||||
<select
|
||||
className="filter-select"
|
||||
value={statusFilter}
|
||||
onChange={e => setStatusFilter(e.target.value)}
|
||||
>
|
||||
{STATUSES.map(s => (
|
||||
<option key={s.value} value={s.value}>{s.label}</option>
|
||||
))}
|
||||
</select>
|
||||
</div>
|
||||
|
||||
<div className="filter-group">
|
||||
<label className="filter-label">Client</label>
|
||||
<select
|
||||
className="filter-select"
|
||||
value={clientFilter}
|
||||
onChange={e => setClientFilter(e.target.value)}
|
||||
>
|
||||
<option value="">All Clients</option>
|
||||
{clients.map(c => (
|
||||
<option key={c.clientId} value={c.clientId}>
|
||||
{c.clientName}
|
||||
</option>
|
||||
))}
|
||||
</select>
|
||||
</div>
|
||||
|
||||
<div className="filter-group">
|
||||
<label className="filter-label">From</label>
|
||||
<input
|
||||
type="date"
|
||||
className="filter-input"
|
||||
value={dateFrom}
|
||||
onChange={e => setDateFrom(e.target.value)}
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div className="filter-group">
|
||||
<label className="filter-label">To</label>
|
||||
<input
|
||||
type="date"
|
||||
className="filter-input"
|
||||
value={dateTo}
|
||||
onChange={e => setDateTo(e.target.value)}
|
||||
/>
|
||||
</div>
|
||||
|
||||
{hasFilters && (
|
||||
<button className="btn-clear-filters" onClick={clearFilters}>
|
||||
✕ Clear
|
||||
</button>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* ─── Loading / Error ─────────────────────────────── */}
|
||||
{loading && (
|
||||
<div style={{ padding: '20px', textAlign: 'center', color: '#6b7280' }}>
|
||||
Loading...
|
||||
</div>
|
||||
)}
|
||||
|
||||
{error && (
|
||||
<div className="error-message" style={{ margin: '12px 0' }}>
|
||||
<strong>Error:</strong> {error}
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* ─── Table ───────────────────────────────────────── */}
|
||||
{!loading && !error && initiatives.length === 0 ? (
|
||||
<div className="empty-state">
|
||||
<div className="empty-state-icon">{hasFilters ? '🔍' : '📢'}</div>
|
||||
<h3>{hasFilters ? 'No matching campaigns' : 'No campaigns yet'}</h3>
|
||||
<p>
|
||||
{hasFilters
|
||||
? 'Try adjusting your filters to find what you\'re looking for.'
|
||||
: 'Campaigns will appear here once clients create them through the wizard.'}
|
||||
</p>
|
||||
</div>
|
||||
) : !loading && !error && (
|
||||
<table className="data-table">
|
||||
<thead>
|
||||
<tr>
|
||||
<th style={{ width: 28 }}></th>
|
||||
<th>Client</th>
|
||||
<th>Campaign</th>
|
||||
<th>Objective</th>
|
||||
<th>Budget</th>
|
||||
<th>Channels</th>
|
||||
<th>Status</th>
|
||||
<th>Dates</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
{initiatives.map(init => (
|
||||
<InitiativeRow key={init.initiativeId} initiative={init} />
|
||||
))}
|
||||
</tbody>
|
||||
</table>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
// ─── Initiative row with expandable channel details ──────────────────────────
|
||||
|
||||
function InitiativeRow({ initiative }) {
|
||||
const [expanded, setExpanded] = useState(false);
|
||||
const channels = initiative.channels || [];
|
||||
|
||||
return (
|
||||
<>
|
||||
<tr
|
||||
className="initiative-row"
|
||||
style={{ cursor: channels.length > 0 ? 'pointer' : 'default' }}
|
||||
onClick={() => channels.length > 0 && setExpanded(!expanded)}
|
||||
>
|
||||
<td style={{ textAlign: 'center', color: '#9ca3af', fontSize: 12 }}>
|
||||
{channels.length > 0 && (
|
||||
<span style={{
|
||||
display: 'inline-block',
|
||||
transition: 'transform 0.15s',
|
||||
transform: expanded ? 'rotate(90deg)' : 'rotate(0deg)',
|
||||
}}>▶</span>
|
||||
)}
|
||||
</td>
|
||||
<td>{initiative.clientName || '—'}</td>
|
||||
<td style={{ fontWeight: 500 }}>{initiative.name || '—'}</td>
|
||||
<td>
|
||||
<span className="objective-badge">
|
||||
{formatObjective(initiative.objective)}
|
||||
</span>
|
||||
</td>
|
||||
<td>{formatBudget(initiative.totalBudget, initiative.budgetPeriod)}</td>
|
||||
<td>
|
||||
<div style={{ display: 'flex', gap: 4, flexWrap: 'wrap' }}>
|
||||
{channels.map((ch, i) => (
|
||||
<ChannelBadge key={i} channel={ch} />
|
||||
))}
|
||||
{channels.length === 0 && <span style={{ color: '#9ca3af' }}>—</span>}
|
||||
</div>
|
||||
</td>
|
||||
<td><InitiativeStatusBadge status={initiative.status} /></td>
|
||||
<td style={{ fontSize: 13 }}>{formatDateRange(initiative.startDate, initiative.endDate)}</td>
|
||||
</tr>
|
||||
|
||||
{/* Expanded channel detail rows */}
|
||||
{expanded && channels.map((ch, i) => (
|
||||
<tr key={`ch-${i}`} className="channel-detail-row">
|
||||
<td></td>
|
||||
<td colSpan="7">
|
||||
<div className="channel-detail">
|
||||
<ChannelIcon type={ch.channelType} />
|
||||
<div className="channel-detail-info">
|
||||
<span className="channel-detail-type">
|
||||
{formatChannelName(ch.channelType)}
|
||||
</span>
|
||||
<span className="channel-detail-alloc">
|
||||
{ch.allocationPct != null ? `${ch.allocationPct}%` : '—'}
|
||||
{ch.channelBudget != null && (
|
||||
<> · ${Number(ch.channelBudget).toLocaleString()}</>
|
||||
)}
|
||||
</span>
|
||||
</div>
|
||||
<ChannelStatusBadge status={ch.status} />
|
||||
{ch.externalCampaignId && (
|
||||
<span className="channel-detail-ext" title={ch.externalCampaignId}>
|
||||
{truncateExtId(ch.externalCampaignId)}
|
||||
</span>
|
||||
)}
|
||||
{ch.providerStatus && ch.providerStatus !== ch.status && (
|
||||
<span className="channel-detail-provider">
|
||||
Provider: {ch.providerStatus}
|
||||
</span>
|
||||
)}
|
||||
</div>
|
||||
</td>
|
||||
</tr>
|
||||
))}
|
||||
</>
|
||||
);
|
||||
}
|
||||
|
||||
// ─── Badge components ────────────────────────────────────────────────────────
|
||||
|
||||
function ChannelBadge({ channel }) {
|
||||
const colors = {
|
||||
google_ads: { bg: '#e8f0fe', text: '#1a73e8', label: 'Google' },
|
||||
meta: { bg: '#f3e8ff', text: '#7c3aed', label: 'Meta' },
|
||||
tiktok: { bg: '#fff0f0', text: '#ff0050', label: 'TikTok' },
|
||||
};
|
||||
const c = colors[channel.channelType] || { bg: '#f3f4f6', text: '#6b7280', label: channel.channelType };
|
||||
const isError = channel.status === 'error';
|
||||
|
||||
return (
|
||||
<span
|
||||
className="channel-badge"
|
||||
style={{
|
||||
background: isError ? '#fef2f2' : c.bg,
|
||||
color: isError ? '#dc2626' : c.text,
|
||||
opacity: isError ? 0.7 : 1,
|
||||
}}
|
||||
title={`${c.label} — ${channel.status} (${channel.allocationPct || 0}%)`}
|
||||
>
|
||||
{c.label}
|
||||
{channel.allocationPct != null && (
|
||||
<span style={{ opacity: 0.6, marginLeft: 3, fontSize: 10 }}>
|
||||
{channel.allocationPct}%
|
||||
</span>
|
||||
)}
|
||||
</span>
|
||||
);
|
||||
}
|
||||
|
||||
function InitiativeStatusBadge({ status }) {
|
||||
const s = (status || '').toLowerCase();
|
||||
let className = 'status-default';
|
||||
if (s === 'active' || s === 'launched') className = 'status-active';
|
||||
else if (s === 'staged' || s === 'pending' || s === 'draft') className = 'status-pending';
|
||||
else if (s === 'paused') className = 'status-warning';
|
||||
else if (s === 'error' || s === 'failed') className = 'status-error';
|
||||
else if (s === 'completed' || s === 'ended') className = 'status-inactive';
|
||||
|
||||
return <span className={`status-badge ${className}`}>{status || '—'}</span>;
|
||||
}
|
||||
|
||||
function ChannelStatusBadge({ status }) {
|
||||
const s = (status || '').toLowerCase();
|
||||
let className = 'status-default';
|
||||
if (s === 'active' || s === 'submitted') className = 'status-active';
|
||||
else if (s === 'pending' || s === 'draft' || s === 'pending_review') className = 'status-pending';
|
||||
else if (s === 'error') className = 'status-error';
|
||||
|
||||
return <span className={`status-badge ${className}`} style={{ fontSize: 11 }}>{status || '—'}</span>;
|
||||
}
|
||||
|
||||
function ChannelIcon({ type }) {
|
||||
const icons = {
|
||||
google_ads: '🔍',
|
||||
meta: '📘',
|
||||
tiktok: '🎵',
|
||||
};
|
||||
return <span style={{ fontSize: 16, marginRight: 6 }}>{icons[type] || '📢'}</span>;
|
||||
}
|
||||
|
||||
// ─── Formatters ──────────────────────────────────────────────────────────────
|
||||
|
||||
function formatObjective(obj) {
|
||||
if (!obj) return '—';
|
||||
return obj.charAt(0).toUpperCase() + obj.slice(1).replace(/_/g, ' ');
|
||||
}
|
||||
|
||||
function formatBudget(amount, period) {
|
||||
if (amount == null) return '—';
|
||||
const formatted = '$' + Number(amount).toLocaleString(undefined, {
|
||||
minimumFractionDigits: 0,
|
||||
maximumFractionDigits: 0,
|
||||
});
|
||||
if (period === 'monthly') return `${formatted}/mo`;
|
||||
if (period === 'daily') return `${formatted}/day`;
|
||||
return formatted;
|
||||
}
|
||||
|
||||
function formatDateRange(start, end) {
|
||||
if (!start && !end) return '—';
|
||||
const fmt = (d) => d ? new Date(d).toLocaleDateString('en-US', { month: 'short', day: 'numeric' }) : '…';
|
||||
return `${fmt(start)} – ${fmt(end)}`;
|
||||
}
|
||||
|
||||
function formatChannelName(type) {
|
||||
const names = {
|
||||
google_ads: 'Google Ads',
|
||||
meta: 'Meta Ads',
|
||||
tiktok: 'TikTok Ads',
|
||||
};
|
||||
return names[type] || type;
|
||||
}
|
||||
|
||||
function truncateExtId(id) {
|
||||
if (!id) return '';
|
||||
const parts = id.split('/');
|
||||
if (parts.length >= 2) return `…/${parts.slice(-2).join('/')}`;
|
||||
return id.length > 20 ? '…' + id.slice(-18) : id;
|
||||
}
|
||||
744
Client-Client/src/components/admin/ClientManagementPanel.jsx
Normal file
744
Client-Client/src/components/admin/ClientManagementPanel.jsx
Normal file
@@ -0,0 +1,744 @@
|
||||
import React, { useState, useEffect, useCallback, memo } from 'react';
|
||||
import { useAdmin } from '../../context/AdminContext';
|
||||
import { API_BASE_URL } from '../../auth/authConfig';
|
||||
|
||||
// ─── Helpers ────────────────────────────────────────────────
|
||||
const fmt = (s) => (s || '').replace(/_/g, ' ').replace(/\b\w/g, c => c.toUpperCase());
|
||||
const daysSince = (dateStr) => {
|
||||
if (!dateStr) return '—';
|
||||
const days = Math.floor((Date.now() - new Date(dateStr).getTime()) / 86400000);
|
||||
if (days === 0) return 'Today';
|
||||
if (days === 1) return '1 day ago';
|
||||
return `${days} days ago`;
|
||||
};
|
||||
const fmtDate = (d) => d ? new Date(d).toLocaleDateString('en-US', { month: 'short', day: 'numeric', year: 'numeric' }) : '—';
|
||||
const fmtDateTime = (d) => d ? new Date(d).toLocaleString('en-US', { month: 'short', day: 'numeric', year: 'numeric', hour: 'numeric', minute: '2-digit' }) : '—';
|
||||
const fmtCurrency = (cents) => cents != null ? `$${(cents / 100).toFixed(2)}` : '—';
|
||||
|
||||
const STATUS_STYLES = {
|
||||
Active: { bg: '#dcfce7', color: '#166534', border: '#86efac' },
|
||||
Suspended: { bg: '#fef3c7', color: '#92400e', border: '#fcd34d' },
|
||||
Cancelled: { bg: '#fee2e2', color: '#991b1b', border: '#fca5a5' },
|
||||
};
|
||||
|
||||
|
||||
// ═════════════════════════════════════════════════════════════
|
||||
// MAIN COMPONENT — Switches between Pending and All Clients
|
||||
// ═════════════════════════════════════════════════════════════
|
||||
export default function ClientManagementPanel({ activeTab }) {
|
||||
if (activeTab === 'pending') return <PendingTab />;
|
||||
if (activeTab === 'allClients') return <AllClientsTab />;
|
||||
return null;
|
||||
}
|
||||
|
||||
|
||||
// ═════════════════════════════════════════════════════════════
|
||||
// PENDING TAB — Registration queue (external data source)
|
||||
// ═════════════════════════════════════════════════════════════
|
||||
function PendingTab() {
|
||||
const { apiCall, sessionToken } = useAdmin();
|
||||
const [applicants, setApplicants] = useState([]);
|
||||
const [loading, setLoading] = useState(true);
|
||||
const [error, setError] = useState(null);
|
||||
const [approving, setApproving] = useState(null); // registrationId being approved
|
||||
const [rejecting, setRejecting] = useState(null); // registrationId being rejected
|
||||
const [rejectReason, setRejectReason] = useState('');
|
||||
|
||||
const fetchPending = useCallback(async () => {
|
||||
if (!sessionToken) return;
|
||||
setLoading(true);
|
||||
setError(null);
|
||||
try {
|
||||
const res = await fetch(`${API_BASE_URL}/api/registration/pending`, {
|
||||
headers: { 'X-Session-Token': sessionToken }
|
||||
});
|
||||
if (!res.ok) throw new Error(`HTTP ${res.status}`);
|
||||
const data = await res.json();
|
||||
setApplicants(data.applicants || []);
|
||||
} catch (err) {
|
||||
console.info('[Clients] Registration endpoint not available:', err.message);
|
||||
setApplicants([]);
|
||||
// Don't show error for expected 404/connection — endpoint may not exist yet
|
||||
if (!err.message.includes('404') && !err.message.includes('Failed to fetch'))
|
||||
setError(err.message);
|
||||
} finally {
|
||||
setLoading(false);
|
||||
}
|
||||
}, [sessionToken]);
|
||||
|
||||
useEffect(() => { fetchPending(); }, [fetchPending]);
|
||||
|
||||
const handleApprove = async (applicant) => {
|
||||
setApproving(applicant.registrationId);
|
||||
const result = await apiCall('/api/admin/clients', 'POST', {
|
||||
registrationId: applicant.registrationId,
|
||||
name: applicant.businessName,
|
||||
websiteUrl: applicant.websiteUrl,
|
||||
businessCategory: applicant.businessCategory,
|
||||
description: applicant.businessDescription,
|
||||
contactName: applicant.contactName,
|
||||
contactEmail: applicant.contactEmail,
|
||||
contactPhone: applicant.contactPhone,
|
||||
entraSubjectId: applicant.entraSubjectId,
|
||||
clientCategory: applicant.clientCategory || 'General',
|
||||
});
|
||||
if (result.ok) {
|
||||
fetchPending();
|
||||
} else {
|
||||
alert(result.error || 'Approval failed');
|
||||
}
|
||||
setApproving(null);
|
||||
};
|
||||
|
||||
const handleReject = async (registrationId) => {
|
||||
const result = await apiCall(`/api/registration/${registrationId}/reject`, 'POST', {
|
||||
reason: rejectReason,
|
||||
});
|
||||
if (result.ok) {
|
||||
setRejecting(null);
|
||||
setRejectReason('');
|
||||
fetchPending();
|
||||
} else {
|
||||
alert(result.error || 'Rejection failed');
|
||||
}
|
||||
};
|
||||
|
||||
if (loading) {
|
||||
return (
|
||||
<div className="loading-container">
|
||||
<div className="spinner"></div>
|
||||
<p>Checking for pending registrations…</p>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="table-panel">
|
||||
<div className="panel-header">
|
||||
<div>
|
||||
<h2>Pending Registrations</h2>
|
||||
<span style={{ fontSize: 13, color: 'var(--text-dim)' }}>
|
||||
New client applications awaiting review
|
||||
</span>
|
||||
</div>
|
||||
<button className="btn-small" onClick={fetchPending}>↻ Refresh</button>
|
||||
</div>
|
||||
|
||||
{error && (
|
||||
<div className="error-message" style={{ marginBottom: 16 }}>
|
||||
<strong>Error:</strong> {error}
|
||||
</div>
|
||||
)}
|
||||
|
||||
{applicants.length === 0 && !error && (
|
||||
<div className="empty-state">
|
||||
<div className="empty-state-icon">✓</div>
|
||||
<h3>All caught up!</h3>
|
||||
<p>No pending registrations to review.</p>
|
||||
<p style={{ fontSize: 12, color: 'var(--text-dim)', marginTop: 8 }}>
|
||||
When the registration system sends new applicants, they'll appear here.
|
||||
</p>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{applicants.length > 0 && (
|
||||
<div className="client-cards">
|
||||
{applicants.map(app => (
|
||||
<div key={app.registrationId} className="client-card">
|
||||
<div className="client-card-header">
|
||||
<div className="client-card-title">
|
||||
<h3>{app.businessName}</h3>
|
||||
{app.businessCategory && (
|
||||
<span className="category-tag">
|
||||
{fmt(app.businessCategory)}
|
||||
</span>
|
||||
)}
|
||||
{app.clientCategory && app.clientCategory !== 'General' && (
|
||||
<span className="category-tag category-tag-type">
|
||||
{app.clientCategory === 'Franchisee' ? '🏪' : '🏗️'} {app.clientCategory}
|
||||
</span>
|
||||
)}
|
||||
</div>
|
||||
<span className="client-card-date">
|
||||
Registered {daysSince(app.registeredUtc)}
|
||||
</span>
|
||||
</div>
|
||||
|
||||
{app.businessDescription && (
|
||||
<p className="client-card-desc">{app.businessDescription}</p>
|
||||
)}
|
||||
|
||||
<div className="client-card-details">
|
||||
{app.contactName && (
|
||||
<div className="detail-item">
|
||||
<span className="detail-label">Contact</span>
|
||||
<span>{app.contactName}</span>
|
||||
</div>
|
||||
)}
|
||||
{app.contactEmail && (
|
||||
<div className="detail-item">
|
||||
<span className="detail-label">Email</span>
|
||||
<span>{app.contactEmail}</span>
|
||||
</div>
|
||||
)}
|
||||
{app.contactPhone && (
|
||||
<div className="detail-item">
|
||||
<span className="detail-label">Phone</span>
|
||||
<span>{app.contactPhone}</span>
|
||||
</div>
|
||||
)}
|
||||
{app.websiteUrl && (
|
||||
<div className="detail-item">
|
||||
<span className="detail-label">Website</span>
|
||||
<a href={app.websiteUrl} target="_blank" rel="noopener noreferrer">
|
||||
{app.websiteUrl.replace(/^https?:\/\//, '')}
|
||||
</a>
|
||||
</div>
|
||||
)}
|
||||
<div className="detail-item">
|
||||
<span className="detail-label">Payment</span>
|
||||
<span className={app.paymentVerified ? 'text-success' : 'text-warning'}>
|
||||
{app.paymentVerified ? '✓ Verified' : '⚠ Unverified'}
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="client-card-actions">
|
||||
{rejecting === app.registrationId ? (
|
||||
<div className="reject-form">
|
||||
<input
|
||||
type="text"
|
||||
placeholder="Reason for rejection…"
|
||||
value={rejectReason}
|
||||
onChange={e => setRejectReason(e.target.value)}
|
||||
autoFocus
|
||||
/>
|
||||
<button className="btn-small btn-danger"
|
||||
onClick={() => handleReject(app.registrationId)}
|
||||
disabled={!rejectReason.trim()}>
|
||||
Confirm Reject
|
||||
</button>
|
||||
<button className="btn-small"
|
||||
onClick={() => { setRejecting(null); setRejectReason(''); }}>
|
||||
Cancel
|
||||
</button>
|
||||
</div>
|
||||
) : (
|
||||
<>
|
||||
<button
|
||||
className="btn-primary"
|
||||
onClick={() => handleApprove(app)}
|
||||
disabled={approving === app.registrationId}
|
||||
style={{ minWidth: 100 }}>
|
||||
{approving === app.registrationId ? 'Approving…' : 'Approve'}
|
||||
</button>
|
||||
<button
|
||||
className="btn-small btn-danger"
|
||||
onClick={() => setRejecting(app.registrationId)}>
|
||||
Reject
|
||||
</button>
|
||||
</>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
|
||||
// ═════════════════════════════════════════════════════════════
|
||||
// ALL CLIENTS TAB — From spClientManagement.list
|
||||
// ═════════════════════════════════════════════════════════════
|
||||
function AllClientsTab() {
|
||||
const { apiCall } = useAdmin();
|
||||
const [clients, setClients] = useState([]);
|
||||
const [loading, setLoading] = useState(true);
|
||||
const [error, setError] = useState(null);
|
||||
const [statusFilter, setStatusFilter] = useState('all');
|
||||
const [search, setSearch] = useState('');
|
||||
const [expandedId, setExpandedId] = useState(null);
|
||||
const [detail, setDetail] = useState(null); // full detail for expanded client
|
||||
const [detailLoading, setDetailLoading] = useState(false);
|
||||
|
||||
const fetchClients = useCallback(async () => {
|
||||
setLoading(true);
|
||||
setError(null);
|
||||
const statusParam = statusFilter === 'all' ? '' : `?status=${statusFilter}`;
|
||||
const result = await apiCall(`/api/admin/clients${statusParam}`);
|
||||
if (result?.ok) {
|
||||
setClients(Array.isArray(result.clients) ? result.clients : []);
|
||||
} else {
|
||||
setError(result?.error || 'Failed to load clients');
|
||||
setClients([]);
|
||||
}
|
||||
setLoading(false);
|
||||
}, [apiCall, statusFilter]);
|
||||
|
||||
useEffect(() => { fetchClients(); }, [fetchClients]);
|
||||
|
||||
const fetchDetail = useCallback(async (clientId) => {
|
||||
setDetailLoading(true);
|
||||
const result = await apiCall(`/api/admin/clients/${clientId}`);
|
||||
if (result?.ok) {
|
||||
setDetail(result);
|
||||
}
|
||||
setDetailLoading(false);
|
||||
}, [apiCall]);
|
||||
|
||||
const handleExpand = (clientId) => {
|
||||
if (expandedId === clientId) {
|
||||
setExpandedId(null);
|
||||
setDetail(null);
|
||||
} else {
|
||||
setExpandedId(clientId);
|
||||
fetchDetail(clientId);
|
||||
}
|
||||
};
|
||||
|
||||
const handleStatusAction = async (action, clientId, reason) => {
|
||||
const body = reason ? { reason } : {};
|
||||
const result = await apiCall(`/api/admin/clients/${clientId}/${action}`, 'POST', body);
|
||||
if (result?.ok) {
|
||||
fetchClients();
|
||||
fetchDetail(clientId);
|
||||
} else {
|
||||
alert(result?.error || `${action} failed`);
|
||||
}
|
||||
};
|
||||
|
||||
// Client-side search filter
|
||||
const filtered = clients.filter(c => {
|
||||
if (!search.trim()) return true;
|
||||
const q = search.toLowerCase();
|
||||
return (c.clientName || '').toLowerCase().includes(q)
|
||||
|| (c.contactEmail || '').toLowerCase().includes(q)
|
||||
|| (c.contactName || '').toLowerCase().includes(q);
|
||||
});
|
||||
|
||||
return (
|
||||
<div className="table-panel">
|
||||
<div className="panel-header">
|
||||
<div>
|
||||
<h2>All Clients</h2>
|
||||
<span style={{ fontSize: 13, color: 'var(--text-dim)' }}>
|
||||
{filtered.length} client{filtered.length !== 1 ? 's' : ''}
|
||||
{statusFilter !== 'all' ? ` (${fmt(statusFilter)})` : ''}
|
||||
</span>
|
||||
</div>
|
||||
<div style={{ display: 'flex', gap: 8, alignItems: 'center' }}>
|
||||
<input
|
||||
type="text"
|
||||
placeholder="Search name or email…"
|
||||
value={search}
|
||||
onChange={e => setSearch(e.target.value)}
|
||||
className="client-search"
|
||||
/>
|
||||
<select
|
||||
value={statusFilter}
|
||||
onChange={e => setStatusFilter(e.target.value)}
|
||||
className="client-filter-select"
|
||||
>
|
||||
<option value="all">All Statuses</option>
|
||||
<option value="Active">Active</option>
|
||||
<option value="Suspended">Suspended</option>
|
||||
<option value="Cancelled">Cancelled</option>
|
||||
</select>
|
||||
<button className="btn-small" onClick={fetchClients}>↻</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{error && (
|
||||
<div className="error-message" style={{ marginBottom: 16 }}>
|
||||
<strong>Error:</strong> {error}
|
||||
</div>
|
||||
)}
|
||||
|
||||
{loading && (
|
||||
<div className="loading-container">
|
||||
<div className="spinner"></div>
|
||||
<p>Loading clients…</p>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{!loading && filtered.length === 0 && (
|
||||
<div className="empty-state">
|
||||
<div className="empty-state-icon">📋</div>
|
||||
<h3>No clients found</h3>
|
||||
<p>{search ? 'Try a different search term.' : 'Approved clients will appear here.'}</p>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{!loading && filtered.length > 0 && (
|
||||
<table className="data-table">
|
||||
<thead>
|
||||
<tr>
|
||||
<th style={{ width: 30 }}></th>
|
||||
<th>Client</th>
|
||||
<th>Category</th>
|
||||
<th>Status</th>
|
||||
<th>Contact</th>
|
||||
<th>Tier</th>
|
||||
<th>Created</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
{filtered.map(c => (
|
||||
<ClientRow
|
||||
key={c.clientId}
|
||||
client={c}
|
||||
expanded={expandedId === c.clientId}
|
||||
detail={expandedId === c.clientId ? detail : null}
|
||||
detailLoading={expandedId === c.clientId && detailLoading}
|
||||
onToggle={() => handleExpand(c.clientId)}
|
||||
onStatusAction={handleStatusAction}
|
||||
onRefresh={fetchClients}
|
||||
apiCall={apiCall}
|
||||
/>
|
||||
))}
|
||||
</tbody>
|
||||
</table>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
|
||||
// ─── Client Row (table row + expandable detail) ─────────────
|
||||
const ClientRow = memo(function ClientRow({
|
||||
client: c, expanded, detail, detailLoading,
|
||||
onToggle, onStatusAction, onRefresh, apiCall,
|
||||
}) {
|
||||
const [actionConfirm, setActionConfirm] = useState(null); // { action, label }
|
||||
const [reason, setReason] = useState('');
|
||||
const [editing, setEditing] = useState(false);
|
||||
const [editFields, setEditFields] = useState({});
|
||||
const [saving, setSaving] = useState(false);
|
||||
|
||||
const style = STATUS_STYLES[c.status] || {};
|
||||
|
||||
const handleConfirmedAction = async () => {
|
||||
await onStatusAction(actionConfirm.action, c.clientId, reason);
|
||||
setActionConfirm(null);
|
||||
setReason('');
|
||||
};
|
||||
|
||||
const handleSaveEdit = async () => {
|
||||
setSaving(true);
|
||||
const result = await apiCall(`/api/admin/clients/${c.clientId}`, 'PUT', {
|
||||
...editFields,
|
||||
clientCategory: editFields.clientCategory,
|
||||
});
|
||||
if (result?.ok) {
|
||||
setEditing(false);
|
||||
setEditFields({});
|
||||
onRefresh();
|
||||
} else {
|
||||
alert(result?.error || 'Update failed');
|
||||
}
|
||||
setSaving(false);
|
||||
};
|
||||
|
||||
const startEdit = () => {
|
||||
setEditFields({
|
||||
name: c.clientName || '',
|
||||
contactName: c.contactName || '',
|
||||
contactEmail: c.contactEmail || '',
|
||||
contactPhone: c.contactPhone || '',
|
||||
websiteUrl: c.websiteUrl || '',
|
||||
description: c.description || '',
|
||||
notes: c.notes || '',
|
||||
clientCategory: c.clientCategoryName || 'General',
|
||||
});
|
||||
setEditing(true);
|
||||
};
|
||||
|
||||
return (
|
||||
<>
|
||||
<tr className={`client-row ${expanded ? 'expanded' : ''}`} onClick={onToggle}>
|
||||
<td className="expand-toggle">{expanded ? '▾' : '▸'}</td>
|
||||
<td>
|
||||
<div className="client-name-cell">
|
||||
<strong>{c.clientName}</strong>
|
||||
{c.websiteUrl && (
|
||||
<span className="client-url">{c.websiteUrl.replace(/^https?:\/\//, '')}</span>
|
||||
)}
|
||||
</div>
|
||||
</td>
|
||||
<td>
|
||||
{c.categoryIcon && <span style={{ marginRight: 4 }}>{c.categoryIcon}</span>}
|
||||
{c.categoryName ? fmt(c.categoryName) : '—'}
|
||||
</td>
|
||||
<td>
|
||||
<span className="status-badge" style={{
|
||||
background: style.bg, color: style.color, border: `1px solid ${style.border}`
|
||||
}}>
|
||||
{c.status}
|
||||
</span>
|
||||
</td>
|
||||
<td>{c.contactEmail || '—'}</td>
|
||||
<td>{fmt(c.serviceTier || 'self_service')}</td>
|
||||
<td>{fmtDate(c.createdUtc)}</td>
|
||||
</tr>
|
||||
|
||||
{expanded && (
|
||||
<tr className="client-detail-row">
|
||||
<td colSpan="7">
|
||||
<div className="client-detail">
|
||||
{detailLoading && <p style={{ color: 'var(--text-dim)' }}>Loading detail…</p>}
|
||||
|
||||
{!detailLoading && !editing && (
|
||||
<>
|
||||
{/* Profile Section */}
|
||||
<div className="detail-section">
|
||||
<div className="detail-section-header">
|
||||
<h4>Profile</h4>
|
||||
<button className="btn-small" onClick={startEdit}>Edit</button>
|
||||
</div>
|
||||
<div className="detail-grid">
|
||||
<DetailField label="Business Name" value={c.clientName} />
|
||||
<DetailField label="Contact Name" value={c.contactName} />
|
||||
<DetailField label="Email" value={c.contactEmail} />
|
||||
<DetailField label="Phone" value={c.contactPhone} />
|
||||
<DetailField label="Website" value={c.websiteUrl} link />
|
||||
<DetailField label="Category"
|
||||
value={c.categoryName ? `${c.categoryIcon || ''} ${fmt(c.categoryName)}` : null} />
|
||||
<DetailField label="Account Type"
|
||||
value={c.clientCategoryIcon && c.clientCategoryName ? `${c.clientCategoryIcon} ${c.clientCategoryName}` : (c.clientCategoryName || 'General')} />
|
||||
<DetailField label="Description" value={c.description} wide />
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Service Config */}
|
||||
<div className="detail-section">
|
||||
<h4>Service Configuration</h4>
|
||||
<div className="detail-grid">
|
||||
<DetailField label="Tier" value={fmt(c.serviceTier || 'self_service')} />
|
||||
<DetailField label="Monthly Fee" value={fmtCurrency(c.monthlyFeeCents)} />
|
||||
<DetailField label="Ad Spend Margin"
|
||||
value={c.adSpendMarginPct != null ? `${c.adSpendMarginPct}%` : null} />
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Admin Notes */}
|
||||
<div className="detail-section">
|
||||
<h4>Admin Notes</h4>
|
||||
<p className="admin-notes">{c.notes || 'No notes.'}</p>
|
||||
</div>
|
||||
|
||||
{/* Status Actions */}
|
||||
<div className="detail-section">
|
||||
<h4>Actions</h4>
|
||||
{actionConfirm ? (
|
||||
<div className="action-confirm">
|
||||
<p>
|
||||
<strong>{actionConfirm.label}</strong> this client?
|
||||
{(actionConfirm.action === 'suspend' || actionConfirm.action === 'cancel') && (
|
||||
<span> Active campaigns will be paused.</span>
|
||||
)}
|
||||
</p>
|
||||
{(actionConfirm.action === 'suspend' || actionConfirm.action === 'cancel') && (
|
||||
<input
|
||||
type="text"
|
||||
placeholder="Reason (required)…"
|
||||
value={reason}
|
||||
onChange={e => setReason(e.target.value)}
|
||||
autoFocus
|
||||
/>
|
||||
)}
|
||||
<div style={{ display: 'flex', gap: 8, marginTop: 8 }}>
|
||||
<button className="btn-primary" onClick={handleConfirmedAction}
|
||||
disabled={(actionConfirm.action === 'suspend' || actionConfirm.action === 'cancel') && !reason.trim()}
|
||||
style={{ minWidth: 80 }}>
|
||||
Confirm
|
||||
</button>
|
||||
<button className="btn-cancel"
|
||||
onClick={() => { setActionConfirm(null); setReason(''); }}>
|
||||
Cancel
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
) : (
|
||||
<div className="action-buttons">
|
||||
{c.status === 'Active' && (
|
||||
<>
|
||||
<button className="btn-small"
|
||||
style={{ background: '#fef3c7', color: '#92400e' }}
|
||||
onClick={() => setActionConfirm({ action: 'suspend', label: 'Suspend' })}>
|
||||
Suspend
|
||||
</button>
|
||||
<button className="btn-small btn-danger"
|
||||
onClick={() => setActionConfirm({ action: 'cancel', label: 'Cancel' })}>
|
||||
Cancel Account
|
||||
</button>
|
||||
</>
|
||||
)}
|
||||
{c.status === 'Suspended' && (
|
||||
<>
|
||||
<button className="btn-primary" style={{ fontSize: 12, padding: '5px 12px' }}
|
||||
onClick={() => setActionConfirm({ action: 'reactivate', label: 'Reactivate' })}>
|
||||
Reactivate
|
||||
</button>
|
||||
<button className="btn-small btn-danger"
|
||||
onClick={() => setActionConfirm({ action: 'cancel', label: 'Cancel' })}>
|
||||
Cancel Account
|
||||
</button>
|
||||
</>
|
||||
)}
|
||||
{c.status === 'Cancelled' && (
|
||||
<p style={{ fontSize: 13, color: 'var(--text-dim)' }}>
|
||||
Account cancelled {c.cancelledUtc ? `on ${fmtDate(c.cancelledUtc)}` : ''}.
|
||||
{c.cancelledReason && <span> Reason: {c.cancelledReason}</span>}
|
||||
</p>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* Status History */}
|
||||
{detail?.statusHistory?.length > 0 && (
|
||||
<div className="detail-section">
|
||||
<h4>Status History</h4>
|
||||
<div className="status-timeline">
|
||||
{detail.statusHistory.map((entry, i) => (
|
||||
<div key={i} className="timeline-entry">
|
||||
<div className="timeline-dot" />
|
||||
<div className="timeline-content">
|
||||
<span className="timeline-action">
|
||||
{entry.fromStatus
|
||||
? `${entry.fromStatus} → ${entry.toStatus}`
|
||||
: `Created as ${entry.toStatus}`
|
||||
}
|
||||
</span>
|
||||
{entry.reason && (
|
||||
<span className="timeline-reason">— {entry.reason}</span>
|
||||
)}
|
||||
<span className="timeline-meta">
|
||||
{fmtDateTime(entry.changedUtc)}
|
||||
{entry.changedByName && ` by ${entry.changedByName}`}
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Linked Users */}
|
||||
{detail?.users?.length > 0 && (
|
||||
<div className="detail-section">
|
||||
<h4>Users ({detail.users.length})</h4>
|
||||
<table className="data-table" style={{ fontSize: 12 }}>
|
||||
<thead>
|
||||
<tr>
|
||||
<th>Name</th>
|
||||
<th>Email</th>
|
||||
<th>Role</th>
|
||||
<th>Status</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
{detail.users.map(u => (
|
||||
<tr key={u.userId}>
|
||||
<td>{u.displayName || '—'}</td>
|
||||
<td>{u.email || '—'}</td>
|
||||
<td>{u.role}</td>
|
||||
<td>{u.status}</td>
|
||||
</tr>
|
||||
))}
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
)}
|
||||
</>
|
||||
)}
|
||||
|
||||
{/* Edit Mode */}
|
||||
{!detailLoading && editing && (
|
||||
<div className="detail-section">
|
||||
<h4>Edit Client</h4>
|
||||
<div className="edit-form">
|
||||
<div className="form-row-inline">
|
||||
<EditField label="Business Name" value={editFields.name}
|
||||
onChange={v => setEditFields(f => ({ ...f, name: v }))} />
|
||||
<EditField label="Contact Name" value={editFields.contactName}
|
||||
onChange={v => setEditFields(f => ({ ...f, contactName: v }))} />
|
||||
</div>
|
||||
<div className="form-row-inline">
|
||||
<EditField label="Email" value={editFields.contactEmail}
|
||||
onChange={v => setEditFields(f => ({ ...f, contactEmail: v }))} />
|
||||
<EditField label="Phone" value={editFields.contactPhone}
|
||||
onChange={v => setEditFields(f => ({ ...f, contactPhone: v }))} />
|
||||
</div>
|
||||
<div className="form-row-inline">
|
||||
<EditField label="Website URL" value={editFields.websiteUrl}
|
||||
onChange={v => setEditFields(f => ({ ...f, websiteUrl: v }))} />
|
||||
</div>
|
||||
<div className="form-row-inline">
|
||||
<EditField label="Description" value={editFields.description}
|
||||
onChange={v => setEditFields(f => ({ ...f, description: v }))} wide />
|
||||
</div>
|
||||
<div className="form-row-inline">
|
||||
<EditField label="Admin Notes" value={editFields.notes}
|
||||
onChange={v => setEditFields(f => ({ ...f, notes: v }))} wide />
|
||||
</div>
|
||||
<div className="form-row-inline">
|
||||
<div className="form-group">
|
||||
<label>Account Type</label>
|
||||
<select
|
||||
value={editFields.clientCategory || 'General'}
|
||||
onChange={e => setEditFields(f => ({ ...f, clientCategory: e.target.value }))}
|
||||
>
|
||||
<option value="General">🏢 Independent Business</option>
|
||||
<option value="Franchisee">🏪 Franchisee</option>
|
||||
<option value="Franchisor">🏗️ Franchisor / Brand</option>
|
||||
</select>
|
||||
</div>
|
||||
</div>
|
||||
<div className="form-buttons" style={{ marginTop: 12 }}>
|
||||
<button className="btn-cancel" onClick={() => setEditing(false)} disabled={saving}>
|
||||
Cancel
|
||||
</button>
|
||||
<button className="btn-primary" onClick={handleSaveEdit} disabled={saving}>
|
||||
{saving ? 'Saving…' : 'Save Changes'}
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</td>
|
||||
</tr>
|
||||
)}
|
||||
</>
|
||||
);
|
||||
});
|
||||
|
||||
|
||||
// ─── Small helper components ────────────────────────────────
|
||||
function DetailField({ label, value, link, wide }) {
|
||||
return (
|
||||
<div className={`detail-field ${wide ? 'detail-field-wide' : ''}`}>
|
||||
<span className="detail-label">{label}</span>
|
||||
{link && value ? (
|
||||
<a href={value.startsWith('http') ? value : `https://${value}`}
|
||||
target="_blank" rel="noopener noreferrer">
|
||||
{value.replace(/^https?:\/\//, '')}
|
||||
</a>
|
||||
) : (
|
||||
<span>{value || '—'}</span>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
function EditField({ label, value, onChange, wide }) {
|
||||
return (
|
||||
<div className={`form-group ${wide ? 'form-group-wide' : ''}`}>
|
||||
<label>{label}</label>
|
||||
<input type="text" value={value || ''} onChange={e => onChange(e.target.value)} />
|
||||
</div>
|
||||
);
|
||||
}
|
||||
345
Client-Client/src/components/admin/HelpPanel.jsx
Normal file
345
Client-Client/src/components/admin/HelpPanel.jsx
Normal file
@@ -0,0 +1,345 @@
|
||||
import React, { useState, useEffect, useCallback, useRef } from 'react';
|
||||
import { useAdmin } from '../../context/AdminContext';
|
||||
|
||||
// ─── HelpPanel ────────────────────────────────────────────────
|
||||
// Admin CRUD for contextual help content.
|
||||
// Manages HelpContent table via /api/admin/help.
|
||||
//
|
||||
// Key naming convention: {app}.{section}.{element}
|
||||
// client.wizard.budget client.wizard.objective
|
||||
// client.wizard.audience client.wizard.channels
|
||||
// admin.campaigns.pacing admin.clients.approval
|
||||
// ─────────────────────────────────────────────────────────────
|
||||
export default function HelpPanel() {
|
||||
const { apiCall } = useAdmin();
|
||||
|
||||
const [items, setItems] = useState([]);
|
||||
const [loading, setLoading] = useState(true);
|
||||
const [error, setError] = useState(null);
|
||||
const [toast, setToast] = useState(null); // { msg, type }
|
||||
const [showInactive, setShowInactive] = useState(false);
|
||||
|
||||
// Edit state
|
||||
const [editKey, setEditKey] = useState(null); // helpKey being edited, or 'new'
|
||||
const [form, setForm] = useState({ helpKey: '', title: '', body: '', isActive: true });
|
||||
const [saving, setSaving] = useState(false);
|
||||
const [deleting, setDeleting] = useState(null); // helpKey being deleted
|
||||
const [confirmDelete, setConfirmDelete] = useState(null);
|
||||
|
||||
const bodyRef = useRef(null);
|
||||
|
||||
// ── Toast ──
|
||||
const showToast = useCallback((msg, type = 'success') => {
|
||||
setToast({ msg, type });
|
||||
setTimeout(() => setToast(null), 3000);
|
||||
}, []);
|
||||
|
||||
// ── Fetch list ──
|
||||
const loadItems = useCallback(async () => {
|
||||
setLoading(true);
|
||||
setError(null);
|
||||
try {
|
||||
const res = await apiCall(`/api/admin/help?includeInactive=${showInactive}`);
|
||||
if (res.ok) setItems(res.items || []);
|
||||
else setError(res.error || 'Failed to load help content');
|
||||
} catch {
|
||||
setError('Network error loading help content');
|
||||
} finally {
|
||||
setLoading(false);
|
||||
}
|
||||
}, [apiCall, showInactive]);
|
||||
|
||||
useEffect(() => { loadItems(); }, [loadItems]);
|
||||
|
||||
// ── Start editing ──
|
||||
const startEdit = (item) => {
|
||||
setEditKey(item.helpKey);
|
||||
setForm({
|
||||
helpKey: item.helpKey,
|
||||
title: item.title,
|
||||
body: item.body,
|
||||
isActive: item.isActive,
|
||||
});
|
||||
};
|
||||
|
||||
const startNew = () => {
|
||||
setEditKey('new');
|
||||
setForm({ helpKey: '', title: '', body: '', isActive: true });
|
||||
};
|
||||
|
||||
const cancelEdit = () => {
|
||||
setEditKey(null);
|
||||
setForm({ helpKey: '', title: '', body: '', isActive: true });
|
||||
};
|
||||
|
||||
// ── Save ──
|
||||
const handleSave = async () => {
|
||||
if (!form.helpKey.trim()) return showToast('Help key is required', 'error');
|
||||
if (!form.title.trim()) return showToast('Title is required', 'error');
|
||||
if (!form.body.trim()) return showToast('Body content is required', 'error');
|
||||
|
||||
setSaving(true);
|
||||
try {
|
||||
const res = await apiCall('/api/admin/help', 'POST', {
|
||||
helpKey: form.helpKey.trim().toLowerCase(),
|
||||
title: form.title.trim(),
|
||||
body: form.body.trim(),
|
||||
isActive: form.isActive,
|
||||
});
|
||||
|
||||
if (res.ok) {
|
||||
showToast(`Saved "${form.helpKey}"`);
|
||||
cancelEdit();
|
||||
loadItems();
|
||||
} else {
|
||||
showToast(res.error || 'Save failed', 'error');
|
||||
}
|
||||
} catch {
|
||||
showToast('Network error saving help content', 'error');
|
||||
} finally {
|
||||
setSaving(false);
|
||||
}
|
||||
};
|
||||
|
||||
// ── Delete ──
|
||||
const handleDelete = async (helpKey) => {
|
||||
setDeleting(helpKey);
|
||||
try {
|
||||
const res = await apiCall(`/api/admin/help/${encodeURIComponent(helpKey)}`, 'DELETE');
|
||||
if (res.ok) {
|
||||
showToast(`Deleted "${helpKey}"`);
|
||||
setConfirmDelete(null);
|
||||
loadItems();
|
||||
} else {
|
||||
showToast(res.error || 'Delete failed', 'error');
|
||||
}
|
||||
} catch {
|
||||
showToast('Network error deleting entry', 'error');
|
||||
} finally {
|
||||
setDeleting(null);
|
||||
}
|
||||
};
|
||||
|
||||
// ── Key suggestions ──
|
||||
const KEY_SUGGESTIONS = [
|
||||
'client.wizard.objective', 'client.wizard.audience',
|
||||
'client.wizard.budget', 'client.wizard.channels',
|
||||
'client.wizard.creative', 'client.wizard.review',
|
||||
'admin.campaigns.status', 'admin.campaigns.pacing',
|
||||
'admin.clients.approval', 'admin.clients.status',
|
||||
];
|
||||
|
||||
const unusedSuggestions = KEY_SUGGESTIONS.filter(
|
||||
k => !items.find(i => i.helpKey === k)
|
||||
);
|
||||
|
||||
// ── Render ──
|
||||
return (
|
||||
<div className="help-panel">
|
||||
|
||||
{/* Toast */}
|
||||
{toast && (
|
||||
<div className={`help-panel-toast ${toast.type === 'error' ? 'help-panel-toast-error' : ''}`}>
|
||||
{toast.msg}
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Header */}
|
||||
<div className="panel-toolbar">
|
||||
<div className="panel-toolbar-left">
|
||||
<h2 className="panel-title">Help Content</h2>
|
||||
<span className="panel-count">{items.length} entries</span>
|
||||
</div>
|
||||
<div className="panel-toolbar-right">
|
||||
<label className="toggle-label">
|
||||
<input
|
||||
type="checkbox"
|
||||
checked={showInactive}
|
||||
onChange={e => setShowInactive(e.target.checked)}
|
||||
/>
|
||||
Show inactive
|
||||
</label>
|
||||
<button className="btn-primary" onClick={startNew}>
|
||||
+ New Entry
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* New / edit form */}
|
||||
{editKey && (
|
||||
<div className="help-edit-card">
|
||||
<div className="help-edit-header">
|
||||
<h3>{editKey === 'new' ? 'New Help Entry' : `Editing: ${editKey}`}</h3>
|
||||
<button className="btn-ghost" onClick={cancelEdit}>Cancel</button>
|
||||
</div>
|
||||
|
||||
{/* Help key */}
|
||||
<div className="help-form-row">
|
||||
<label className="help-form-label">
|
||||
Help Key
|
||||
<span className="help-form-hint">Lowercase, dots/hyphens only</span>
|
||||
</label>
|
||||
{editKey === 'new' ? (
|
||||
<div>
|
||||
<input
|
||||
className="form-input"
|
||||
placeholder="e.g. client.wizard.budget"
|
||||
value={form.helpKey}
|
||||
onChange={e => setForm(f => ({ ...f, helpKey: e.target.value.toLowerCase() }))}
|
||||
style={{ width: '100%', marginBottom: 6 }}
|
||||
/>
|
||||
{unusedSuggestions.length > 0 && (
|
||||
<div className="help-key-suggestions">
|
||||
<span className="help-form-hint">Suggested:</span>
|
||||
{unusedSuggestions.slice(0, 5).map(k => (
|
||||
<button
|
||||
key={k}
|
||||
className="help-key-chip"
|
||||
onClick={() => setForm(f => ({ ...f, helpKey: k }))}
|
||||
>{k}</button>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
) : (
|
||||
<div className="help-key-display">{form.helpKey}</div>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* Title */}
|
||||
<div className="help-form-row">
|
||||
<label className="help-form-label">Title</label>
|
||||
<input
|
||||
className="form-input"
|
||||
placeholder="Modal heading shown to users"
|
||||
value={form.title}
|
||||
onChange={e => setForm(f => ({ ...f, title: e.target.value }))}
|
||||
style={{ width: '100%' }}
|
||||
/>
|
||||
</div>
|
||||
|
||||
{/* Body */}
|
||||
<div className="help-form-row">
|
||||
<label className="help-form-label">
|
||||
Body (HTML supported)
|
||||
<span className="help-form-hint">
|
||||
<p> <ul> <li> <strong> <h4> tags render in popup
|
||||
</span>
|
||||
</label>
|
||||
<textarea
|
||||
ref={bodyRef}
|
||||
className="form-input help-body-textarea"
|
||||
placeholder="<p>Your help text here…</p>"
|
||||
value={form.body}
|
||||
onChange={e => setForm(f => ({ ...f, body: e.target.value }))}
|
||||
rows={8}
|
||||
style={{ width: '100%', fontFamily: 'var(--font-mono, monospace)', fontSize: 13 }}
|
||||
/>
|
||||
</div>
|
||||
|
||||
{/* Active toggle */}
|
||||
<div className="help-form-row help-form-row-inline">
|
||||
<label className="toggle-label">
|
||||
<input
|
||||
type="checkbox"
|
||||
checked={form.isActive}
|
||||
onChange={e => setForm(f => ({ ...f, isActive: e.target.checked }))}
|
||||
/>
|
||||
Active (visible to users)
|
||||
</label>
|
||||
<button
|
||||
className="btn-primary"
|
||||
onClick={handleSave}
|
||||
disabled={saving}
|
||||
>
|
||||
{saving ? 'Saving…' : 'Save Entry'}
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Loading / error */}
|
||||
{loading && <div className="loading-state">Loading help content…</div>}
|
||||
{error && !loading && (
|
||||
<div className="error-message">{error}</div>
|
||||
)}
|
||||
|
||||
{/* Entries table */}
|
||||
{!loading && !error && items.length === 0 && (
|
||||
<div className="empty-state">
|
||||
<p>No help entries yet.</p>
|
||||
<p style={{ fontSize: 13, marginTop: 6, color: 'var(--text-secondary, #666)' }}>
|
||||
Click <strong>+ New Entry</strong> to create your first help entry,
|
||||
or run the seed data in HelpContent.sql to get started quickly.
|
||||
</p>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{!loading && items.length > 0 && (
|
||||
<div className="help-table-wrap">
|
||||
<table className="data-table">
|
||||
<thead>
|
||||
<tr>
|
||||
<th>Help Key</th>
|
||||
<th>Title</th>
|
||||
<th style={{ width: 70 }}>Active</th>
|
||||
<th style={{ width: 80 }}>Updated</th>
|
||||
<th style={{ width: 100 }}></th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
{items.map(item => (
|
||||
<tr key={item.helpKey}
|
||||
className={!item.isActive ? 'help-row-inactive' : ''}>
|
||||
<td>
|
||||
<code className="help-key-code">{item.helpKey}</code>
|
||||
</td>
|
||||
<td>{item.title}</td>
|
||||
<td>
|
||||
<span className={`status-badge ${item.isActive ? 'status-active' : 'status-inactive'}`}>
|
||||
{item.isActive ? 'Active' : 'Off'}
|
||||
</span>
|
||||
</td>
|
||||
<td className="text-dim">
|
||||
{item.updatedAt
|
||||
? new Date(item.updatedAt).toLocaleDateString()
|
||||
: '—'}
|
||||
</td>
|
||||
<td>
|
||||
<div className="row-actions">
|
||||
<button
|
||||
className="btn-ghost btn-sm"
|
||||
onClick={() => startEdit(item)}
|
||||
disabled={editKey === item.helpKey}
|
||||
>Edit</button>
|
||||
{confirmDelete === item.helpKey ? (
|
||||
<>
|
||||
<button
|
||||
className="btn-danger btn-sm"
|
||||
onClick={() => handleDelete(item.helpKey)}
|
||||
disabled={deleting === item.helpKey}
|
||||
>
|
||||
{deleting === item.helpKey ? '…' : 'Confirm'}
|
||||
</button>
|
||||
<button
|
||||
className="btn-ghost btn-sm"
|
||||
onClick={() => setConfirmDelete(null)}
|
||||
>✕</button>
|
||||
</>
|
||||
) : (
|
||||
<button
|
||||
className="btn-ghost btn-sm btn-ghost-danger"
|
||||
onClick={() => setConfirmDelete(item.helpKey)}
|
||||
>Del</button>
|
||||
)}
|
||||
</div>
|
||||
</td>
|
||||
</tr>
|
||||
))}
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
704
Client-Client/src/components/admin/IntelligencePanel.jsx
Normal file
704
Client-Client/src/components/admin/IntelligencePanel.jsx
Normal file
@@ -0,0 +1,704 @@
|
||||
import React, { useState, useEffect, useCallback } from 'react';
|
||||
import { useAdmin } from '../../context/AdminContext';
|
||||
import '../../styles/intelligence-panel.css';
|
||||
|
||||
/**
|
||||
* IntelligencePanel — Campaign performance monitoring, optimization insights,
|
||||
* and post-campaign analysis. Manages its own data fetching.
|
||||
*
|
||||
* Attempts live API calls to /api/admin/reporting/*; falls back to
|
||||
* emulated data when the endpoint isn't wired up yet.
|
||||
*/
|
||||
|
||||
// ─── Channel metadata ────────────────────────────────────────────────────────
|
||||
|
||||
const CHANNEL_META = {
|
||||
google_ads: { label: 'Google', color: '#1a73e8', bg: '#e8f0fe' },
|
||||
meta: { label: 'Meta', color: '#7c3aed', bg: '#f3e8ff' },
|
||||
tiktok: { label: 'TikTok', color: '#ff0050', bg: '#fff0f0' },
|
||||
};
|
||||
|
||||
const SEVERITY_META = {
|
||||
critical: { label: 'Critical', color: '#dc2626', bg: '#fef2f2', icon: '🔴' },
|
||||
warning: { label: 'Warning', color: '#d97706', bg: '#fffbeb', icon: '🟡' },
|
||||
info: { label: 'Info', color: '#2563eb', bg: '#eff6ff', icon: '🔵' },
|
||||
};
|
||||
|
||||
// ─── Formatters ──────────────────────────────────────────────────────────────
|
||||
|
||||
const fmtCurrency = (v) => v == null ? '—' : '$' + Number(v).toLocaleString(undefined, { minimumFractionDigits: 0, maximumFractionDigits: 0 });
|
||||
const fmtNumber = (v) => v == null ? '—' : Number(v).toLocaleString();
|
||||
const fmtPct = (v) => v == null ? '—' : Number(v).toFixed(2) + '%';
|
||||
const fmtDec = (v, d = 2) => v == null ? '—' : '$' + Number(v).toFixed(d);
|
||||
|
||||
// ─── Main Panel ──────────────────────────────────────────────────────────────
|
||||
|
||||
export default function IntelligencePanel({ activeTab }) {
|
||||
return (
|
||||
<div className="intel-panel">
|
||||
{activeTab === 'performance' && <PerformanceView />}
|
||||
{activeTab === 'insights' && <InsightsView />}
|
||||
{activeTab === 'analysis' && <AnalysisView />}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
|
||||
// ═════════════════════════════════════════════════════════════════════════════
|
||||
// PERFORMANCE VIEW
|
||||
// ═════════════════════════════════════════════════════════════════════════════
|
||||
|
||||
function PerformanceView() {
|
||||
const { apiCall } = useAdmin();
|
||||
const [summary, setSummary] = useState(null);
|
||||
const [campaigns, setCampaigns] = useState(null);
|
||||
const [loading, setLoading] = useState(true);
|
||||
const [error, setError] = useState(null);
|
||||
const [dateFrom, setDateFrom] = useState('');
|
||||
const [dateTo, setDateTo] = useState('');
|
||||
const [clientFilter, setClientFilter] = useState('');
|
||||
const [clients, setClients] = useState([]);
|
||||
const [emulated, setEmulated] = useState(false);
|
||||
|
||||
// Load clients for filter dropdown
|
||||
useEffect(() => {
|
||||
(async () => {
|
||||
const r = await apiCall('/api/admin/clients');
|
||||
if (r.ok && r.clients) setClients(r.clients);
|
||||
})();
|
||||
}, [apiCall]);
|
||||
|
||||
const loadData = useCallback(async () => {
|
||||
setLoading(true);
|
||||
setError(null);
|
||||
setEmulated(false);
|
||||
|
||||
try {
|
||||
const params = new URLSearchParams();
|
||||
if (dateFrom) params.set('dateFrom', dateFrom);
|
||||
if (dateTo) params.set('dateTo', dateTo);
|
||||
if (clientFilter) params.set('clientId', clientFilter);
|
||||
const qs = params.toString();
|
||||
|
||||
const [summaryRes, campaignsRes] = await Promise.all([
|
||||
apiCall(`/api/admin/reporting/summary${qs ? '?' + qs : ''}`),
|
||||
apiCall(`/api/admin/reporting/campaigns${qs ? '?' + qs : ''}`),
|
||||
]);
|
||||
|
||||
if (summaryRes.ok) {
|
||||
setSummary(summaryRes);
|
||||
setCampaigns(campaignsRes.ok ? campaignsRes : null);
|
||||
} else {
|
||||
// API not wired yet — use emulated data
|
||||
setSummary(EMULATED_SUMMARY);
|
||||
setCampaigns(EMULATED_CAMPAIGNS);
|
||||
setEmulated(true);
|
||||
}
|
||||
} catch {
|
||||
setSummary(EMULATED_SUMMARY);
|
||||
setCampaigns(EMULATED_CAMPAIGNS);
|
||||
setEmulated(true);
|
||||
}
|
||||
|
||||
setLoading(false);
|
||||
}, [apiCall, dateFrom, dateTo, clientFilter]);
|
||||
|
||||
useEffect(() => { loadData(); }, [loadData]);
|
||||
|
||||
if (loading && !summary) {
|
||||
return <div className="intel-loading"><div className="spinner"></div><p>Loading performance data...</p></div>;
|
||||
}
|
||||
|
||||
const s = summary || {};
|
||||
const campaignList = campaigns?.campaigns || [];
|
||||
|
||||
return (
|
||||
<div className="intel-performance">
|
||||
{emulated && <EmulatedBanner />}
|
||||
|
||||
{/* ── Filter Bar ── */}
|
||||
<div className="filter-bar">
|
||||
<div className="filter-group">
|
||||
<label className="filter-label">From</label>
|
||||
<input type="date" className="filter-input" value={dateFrom} onChange={e => setDateFrom(e.target.value)} />
|
||||
</div>
|
||||
<div className="filter-group">
|
||||
<label className="filter-label">To</label>
|
||||
<input type="date" className="filter-input" value={dateTo} onChange={e => setDateTo(e.target.value)} />
|
||||
</div>
|
||||
<div className="filter-group">
|
||||
<label className="filter-label">Client</label>
|
||||
<select className="filter-select" value={clientFilter} onChange={e => setClientFilter(e.target.value)}>
|
||||
<option value="">All Clients</option>
|
||||
{clients.map(c => <option key={c.clientId} value={c.clientId}>{c.clientName}</option>)}
|
||||
</select>
|
||||
</div>
|
||||
<button className="btn-action" onClick={loadData} style={{ alignSelf: 'flex-end' }}>↻ Refresh</button>
|
||||
</div>
|
||||
|
||||
{/* ── KPI Cards ── */}
|
||||
<div className="intel-kpi-grid">
|
||||
<KpiCard label="Active Campaigns" value={s.activeCampaigns} format="number" />
|
||||
<KpiCard label="Total Spend" value={s.totalSpend} format="currency" />
|
||||
<KpiCard label="Impressions" value={s.totalImpressions} format="number" />
|
||||
<KpiCard label="Clicks" value={s.totalClicks} format="number" />
|
||||
<KpiCard label="Conversions" value={s.totalConversions} format="number" />
|
||||
<KpiCard
|
||||
label="Avg CTR"
|
||||
value={s.totalImpressions > 0 ? (s.totalClicks / s.totalImpressions * 100) : 0}
|
||||
format="pct"
|
||||
/>
|
||||
</div>
|
||||
|
||||
{/* ── Channel Breakdown ── */}
|
||||
{s.channelBreakdown && s.channelBreakdown.length > 0 && (
|
||||
<div className="intel-channel-breakdown">
|
||||
<h3>Channel Breakdown</h3>
|
||||
<div className="intel-channel-cards">
|
||||
{s.channelBreakdown.map(ch => {
|
||||
const meta = CHANNEL_META[ch.channel] || { label: ch.channel, color: '#6b7280', bg: '#f3f4f6' };
|
||||
return (
|
||||
<div key={ch.channel} className="intel-channel-card" style={{ borderTopColor: meta.color }}>
|
||||
<div className="intel-channel-header">
|
||||
<span className="intel-channel-badge" style={{ background: meta.bg, color: meta.color }}>{meta.label}</span>
|
||||
<span className="intel-channel-count">{ch.campaignCount} campaign{ch.campaignCount !== 1 ? 's' : ''}</span>
|
||||
</div>
|
||||
<div className="intel-channel-metrics">
|
||||
<div><span className="metric-val">{fmtCurrency(ch.spend)}</span><span className="metric-lbl">Spend</span></div>
|
||||
<div><span className="metric-val">{fmtNumber(ch.impressions)}</span><span className="metric-lbl">Impressions</span></div>
|
||||
<div><span className="metric-val">{fmtNumber(ch.clicks)}</span><span className="metric-lbl">Clicks</span></div>
|
||||
<div><span className="metric-val">{fmtNumber(ch.conversions)}</span><span className="metric-lbl">Conversions</span></div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* ── Campaign Performance Table ── */}
|
||||
<div className="intel-campaigns-table">
|
||||
<h3>Campaign Performance</h3>
|
||||
{campaignList.length === 0 ? (
|
||||
<div className="empty-state">
|
||||
<div className="empty-state-icon">📊</div>
|
||||
<h3>No performance data yet</h3>
|
||||
<p>Metrics will appear here once campaigns are running and the polling service collects data.</p>
|
||||
</div>
|
||||
) : (
|
||||
<table className="data-table">
|
||||
<thead>
|
||||
<tr>
|
||||
<th>Campaign</th>
|
||||
<th>Client</th>
|
||||
<th>Status</th>
|
||||
<th>Channels</th>
|
||||
<th style={{ textAlign: 'right' }}>Spend</th>
|
||||
<th style={{ textAlign: 'right' }}>Impressions</th>
|
||||
<th style={{ textAlign: 'right' }}>Clicks</th>
|
||||
<th style={{ textAlign: 'right' }}>CTR</th>
|
||||
<th style={{ textAlign: 'right' }}>CPC</th>
|
||||
<th style={{ textAlign: 'right' }}>Conv.</th>
|
||||
<th>Pacing</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
{campaignList.map(c => (
|
||||
<CampaignPerfRow key={c.initiativeId} campaign={c} />
|
||||
))}
|
||||
</tbody>
|
||||
</table>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
|
||||
// ─── Campaign Performance Row ────────────────────────────────────────────────
|
||||
|
||||
function CampaignPerfRow({ campaign }) {
|
||||
const c = campaign;
|
||||
const channels = c.channels || [];
|
||||
const pacing = c.budgetPacing || 0;
|
||||
|
||||
return (
|
||||
<tr>
|
||||
<td style={{ fontWeight: 500 }}>{c.name}</td>
|
||||
<td>{c.clientName || '—'}</td>
|
||||
<td><StatusBadge status={c.status} /></td>
|
||||
<td>
|
||||
<div style={{ display: 'flex', gap: 4, flexWrap: 'wrap' }}>
|
||||
{channels.map((ch, i) => {
|
||||
const meta = CHANNEL_META[ch.channelType] || { label: ch.channelType, bg: '#f3f4f6', color: '#6b7280' };
|
||||
return (
|
||||
<span key={i} className="channel-badge" style={{ background: meta.bg, color: meta.color }}>
|
||||
{meta.label}
|
||||
</span>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
</td>
|
||||
<td style={{ textAlign: 'right' }}>{fmtCurrency(c.spend)}</td>
|
||||
<td style={{ textAlign: 'right' }}>{fmtNumber(c.impressions)}</td>
|
||||
<td style={{ textAlign: 'right' }}>{fmtNumber(c.clicks)}</td>
|
||||
<td style={{ textAlign: 'right' }}>{fmtPct(c.ctr)}</td>
|
||||
<td style={{ textAlign: 'right' }}>{fmtDec(c.cpc)}</td>
|
||||
<td style={{ textAlign: 'right' }}>{fmtNumber(c.conversions)}</td>
|
||||
<td><PacingBar pct={pacing} /></td>
|
||||
</tr>
|
||||
);
|
||||
}
|
||||
|
||||
|
||||
// ═════════════════════════════════════════════════════════════════════════════
|
||||
// INSIGHTS VIEW
|
||||
// ═════════════════════════════════════════════════════════════════════════════
|
||||
|
||||
function InsightsView() {
|
||||
const { apiCall } = useAdmin();
|
||||
const [insights, setInsights] = useState(null);
|
||||
const [loading, setLoading] = useState(true);
|
||||
const [severityFilter, setSeverityFilter] = useState('');
|
||||
const [emulated, setEmulated] = useState(false);
|
||||
|
||||
const loadData = useCallback(async () => {
|
||||
setLoading(true);
|
||||
setEmulated(false);
|
||||
|
||||
const params = new URLSearchParams();
|
||||
if (severityFilter) params.set('severity', severityFilter);
|
||||
const qs = params.toString();
|
||||
|
||||
const res = await apiCall(`/api/admin/reporting/insights${qs ? '?' + qs : ''}`);
|
||||
if (res.ok) {
|
||||
setInsights(res);
|
||||
} else {
|
||||
setInsights(EMULATED_INSIGHTS);
|
||||
setEmulated(true);
|
||||
}
|
||||
setLoading(false);
|
||||
}, [apiCall, severityFilter]);
|
||||
|
||||
useEffect(() => { loadData(); }, [loadData]);
|
||||
|
||||
const insightList = insights?.insights || [];
|
||||
const totalActive = insights?.totalActive ?? insightList.length;
|
||||
|
||||
if (loading && !insights) {
|
||||
return <div className="intel-loading"><div className="spinner"></div><p>Loading insights...</p></div>;
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="intel-insights">
|
||||
{emulated && <EmulatedBanner />}
|
||||
|
||||
<div className="intel-insights-header">
|
||||
<div className="intel-insights-summary">
|
||||
<span className="intel-insights-count">{totalActive}</span>
|
||||
<span>active insight{totalActive !== 1 ? 's' : ''}</span>
|
||||
</div>
|
||||
<div style={{ display: 'flex', gap: 8, alignItems: 'center' }}>
|
||||
<select className="filter-select" value={severityFilter} onChange={e => setSeverityFilter(e.target.value)}>
|
||||
<option value="">All Severities</option>
|
||||
<option value="critical">Critical</option>
|
||||
<option value="warning">Warning</option>
|
||||
<option value="info">Info</option>
|
||||
</select>
|
||||
<button className="btn-action" onClick={loadData}>↻ Refresh</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{insightList.length === 0 ? (
|
||||
<div className="empty-state">
|
||||
<div className="empty-state-icon">✅</div>
|
||||
<h3>All clear</h3>
|
||||
<p>No active optimization recommendations at this time.</p>
|
||||
</div>
|
||||
) : (
|
||||
<div className="intel-insights-list">
|
||||
{insightList.map(insight => (
|
||||
<InsightCard key={insight.insightId} insight={insight} />
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
|
||||
function InsightCard({ insight }) {
|
||||
const sev = SEVERITY_META[insight.severity] || SEVERITY_META.info;
|
||||
const [expanded, setExpanded] = useState(false);
|
||||
|
||||
return (
|
||||
<div className="intel-insight-card" style={{ borderLeftColor: sev.color }}>
|
||||
<div className="intel-insight-top" onClick={() => setExpanded(!expanded)} style={{ cursor: 'pointer' }}>
|
||||
<div className="intel-insight-meta">
|
||||
<span className="intel-insight-severity" style={{ background: sev.bg, color: sev.color }}>
|
||||
{sev.icon} {sev.label}
|
||||
</span>
|
||||
<span className="intel-insight-type">{formatInsightType(insight.insightType)}</span>
|
||||
</div>
|
||||
<h4 className="intel-insight-title">{insight.title}</h4>
|
||||
<div className="intel-insight-context">
|
||||
<span>{insight.clientName}</span>
|
||||
<span>·</span>
|
||||
<span>{insight.campaignName}</span>
|
||||
<span className="intel-insight-expand">{expanded ? '▾' : '▸'}</span>
|
||||
</div>
|
||||
</div>
|
||||
{expanded && (
|
||||
<div className="intel-insight-body">
|
||||
<p className="intel-insight-desc">{insight.description}</p>
|
||||
{insight.recommendation && (
|
||||
<div className="intel-insight-rec">
|
||||
<strong>Recommendation:</strong> {insight.recommendation}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
|
||||
// ═════════════════════════════════════════════════════════════════════════════
|
||||
// ANALYSIS VIEW
|
||||
// ═════════════════════════════════════════════════════════════════════════════
|
||||
|
||||
function AnalysisView() {
|
||||
const { apiCall } = useAdmin();
|
||||
const [analysis, setAnalysis] = useState(null);
|
||||
const [loading, setLoading] = useState(true);
|
||||
const [emulated, setEmulated] = useState(false);
|
||||
|
||||
const loadData = useCallback(async () => {
|
||||
setLoading(true);
|
||||
setEmulated(false);
|
||||
|
||||
const res = await apiCall('/api/admin/reporting/analysis');
|
||||
if (res.ok) {
|
||||
setAnalysis(res);
|
||||
} else {
|
||||
setAnalysis(EMULATED_ANALYSIS);
|
||||
setEmulated(true);
|
||||
}
|
||||
setLoading(false);
|
||||
}, [apiCall]);
|
||||
|
||||
useEffect(() => { loadData(); }, [loadData]);
|
||||
|
||||
const campaignList = analysis?.campaigns || [];
|
||||
|
||||
if (loading && !analysis) {
|
||||
return <div className="intel-loading"><div className="spinner"></div><p>Loading analysis...</p></div>;
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="intel-analysis">
|
||||
{emulated && <EmulatedBanner />}
|
||||
|
||||
<div className="panel-header">
|
||||
<h2>Post-Campaign Analysis ({campaignList.length})</h2>
|
||||
<button className="btn-action" onClick={loadData}>↻ Refresh</button>
|
||||
</div>
|
||||
|
||||
{campaignList.length === 0 ? (
|
||||
<div className="empty-state">
|
||||
<div className="empty-state-icon">📈</div>
|
||||
<h3>No completed campaigns</h3>
|
||||
<p>Post-campaign analysis will appear here once campaigns finish their run.</p>
|
||||
</div>
|
||||
) : (
|
||||
<div className="intel-analysis-list">
|
||||
{campaignList.map(c => (
|
||||
<AnalysisCard key={c.initiativeId} campaign={c} />
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
|
||||
function AnalysisCard({ campaign }) {
|
||||
const c = campaign;
|
||||
const channels = c.channels || [];
|
||||
const [expanded, setExpanded] = useState(false);
|
||||
|
||||
return (
|
||||
<div className="intel-analysis-card">
|
||||
<div className="intel-analysis-top" onClick={() => setExpanded(!expanded)} style={{ cursor: 'pointer' }}>
|
||||
<div className="intel-analysis-header">
|
||||
<div>
|
||||
<h4>{c.name}</h4>
|
||||
<span className="text-muted">{c.clientName} · {c.objective}</span>
|
||||
</div>
|
||||
<span className="intel-analysis-expand">{expanded ? '▾' : '▸'}</span>
|
||||
</div>
|
||||
<div className="intel-analysis-kpis">
|
||||
<div className="intel-analysis-kpi">
|
||||
<span className="metric-val">{fmtCurrency(c.totalSpend)}</span>
|
||||
<span className="metric-lbl">Spent of {fmtCurrency(c.totalBudget)}</span>
|
||||
</div>
|
||||
<div className="intel-analysis-kpi">
|
||||
<span className="metric-val">{fmtNumber(c.totalImpressions)}</span>
|
||||
<span className="metric-lbl">Impressions</span>
|
||||
</div>
|
||||
<div className="intel-analysis-kpi">
|
||||
<span className="metric-val">{fmtNumber(c.totalClicks)}</span>
|
||||
<span className="metric-lbl">Clicks</span>
|
||||
</div>
|
||||
<div className="intel-analysis-kpi">
|
||||
<span className="metric-val">{fmtPct(c.ctr)}</span>
|
||||
<span className="metric-lbl">CTR</span>
|
||||
</div>
|
||||
<div className="intel-analysis-kpi">
|
||||
<span className="metric-val">{fmtNumber(c.totalConversions)}</span>
|
||||
<span className="metric-lbl">Conversions</span>
|
||||
</div>
|
||||
<div className="intel-analysis-kpi">
|
||||
<span className="metric-val">{fmtDec(c.costPerConversion)}</span>
|
||||
<span className="metric-lbl">Cost/Conv.</span>
|
||||
</div>
|
||||
</div>
|
||||
{/* Budget utilization bar */}
|
||||
<div className="intel-analysis-budget">
|
||||
<PacingBar pct={c.budgetUtilization || 0} label="Budget Utilization" showLabel />
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{expanded && channels.length > 0 && (
|
||||
<div className="intel-analysis-channels">
|
||||
<h5>Channel Performance</h5>
|
||||
<table className="data-table">
|
||||
<thead>
|
||||
<tr>
|
||||
<th>Channel</th>
|
||||
<th style={{ textAlign: 'right' }}>Allocated</th>
|
||||
<th style={{ textAlign: 'right' }}>Actual Spend</th>
|
||||
<th style={{ textAlign: 'right' }}>Impressions</th>
|
||||
<th style={{ textAlign: 'right' }}>Clicks</th>
|
||||
<th style={{ textAlign: 'right' }}>Conv.</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
{channels.map((ch, i) => {
|
||||
const meta = CHANNEL_META[ch.channel] || { label: ch.channel, bg: '#f3f4f6', color: '#6b7280' };
|
||||
return (
|
||||
<tr key={i}>
|
||||
<td>
|
||||
<span className="channel-badge" style={{ background: meta.bg, color: meta.color }}>
|
||||
{meta.label}
|
||||
</span>
|
||||
</td>
|
||||
<td style={{ textAlign: 'right' }}>{ch.allocatedPct != null ? ch.allocatedPct + '%' : '—'}</td>
|
||||
<td style={{ textAlign: 'right' }}>{fmtCurrency(ch.spend)}</td>
|
||||
<td style={{ textAlign: 'right' }}>{fmtNumber(ch.impressions)}</td>
|
||||
<td style={{ textAlign: 'right' }}>{fmtNumber(ch.clicks)}</td>
|
||||
<td style={{ textAlign: 'right' }}>{fmtNumber(ch.conversions)}</td>
|
||||
</tr>
|
||||
);
|
||||
})}
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
|
||||
// ═════════════════════════════════════════════════════════════════════════════
|
||||
// SHARED COMPONENTS
|
||||
// ═════════════════════════════════════════════════════════════════════════════
|
||||
|
||||
function KpiCard({ label, value, format = 'number' }) {
|
||||
let display = '—';
|
||||
if (value != null) {
|
||||
if (format === 'currency') display = fmtCurrency(value);
|
||||
else if (format === 'pct') display = fmtPct(value);
|
||||
else display = fmtNumber(value);
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="intel-kpi-card">
|
||||
<div className="intel-kpi-value">{display}</div>
|
||||
<div className="intel-kpi-label">{label}</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
function StatusBadge({ status }) {
|
||||
const s = (status || '').toLowerCase();
|
||||
let className = 'status-default';
|
||||
if (s === 'active' || s === 'launched') className = 'status-active';
|
||||
else if (s === 'staged' || s === 'pending' || s === 'draft') className = 'status-pending';
|
||||
else if (s === 'paused') className = 'status-warning';
|
||||
else if (s === 'error') className = 'status-error';
|
||||
else if (s === 'completed' || s === 'ended') className = 'status-inactive';
|
||||
return <span className={`status-badge ${className}`}>{status || '—'}</span>;
|
||||
}
|
||||
|
||||
function PacingBar({ pct, label, showLabel }) {
|
||||
const clamped = Math.min(Math.max(pct, 0), 100);
|
||||
const barColor = clamped > 95 ? '#dc2626' : clamped > 75 ? '#d97706' : '#16a34a';
|
||||
|
||||
return (
|
||||
<div className="intel-pacing">
|
||||
{showLabel && label && <span className="intel-pacing-label">{label}</span>}
|
||||
<div className="intel-pacing-track">
|
||||
<div className="intel-pacing-fill" style={{ width: clamped + '%', background: barColor }} />
|
||||
</div>
|
||||
<span className="intel-pacing-pct">{clamped.toFixed(0)}%</span>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
function EmulatedBanner() {
|
||||
return (
|
||||
<div className="intel-emulated-banner">
|
||||
<strong>Preview Mode</strong> — Showing emulated data. Deploy the <code>spAdminReporting</code> stored procedure
|
||||
and connect the metrics polling service to see live campaign data.
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
|
||||
// ─── Helpers ─────────────────────────────────────────────────────────────────
|
||||
|
||||
function formatInsightType(type) {
|
||||
if (!type) return '';
|
||||
return type.replace(/_/g, ' ').replace(/\b\w/g, c => c.toUpperCase());
|
||||
}
|
||||
|
||||
|
||||
// ═════════════════════════════════════════════════════════════════════════════
|
||||
// EMULATED DATA
|
||||
// Used when the reporting API endpoints aren't connected yet.
|
||||
// ═════════════════════════════════════════════════════════════════════════════
|
||||
|
||||
const EMULATED_SUMMARY = {
|
||||
ok: true,
|
||||
activeCampaigns: 4,
|
||||
totalSpend: 12480,
|
||||
totalImpressions: 847200,
|
||||
totalClicks: 18430,
|
||||
totalConversions: 312,
|
||||
channelBreakdown: [
|
||||
{ channel: 'google_ads', campaignCount: 3, spend: 6240, impressions: 412000, clicks: 9800, conversions: 186 },
|
||||
{ channel: 'meta', campaignCount: 3, spend: 4120, impressions: 325000, clicks: 6230, conversions: 98 },
|
||||
{ channel: 'tiktok', campaignCount: 2, spend: 2120, impressions: 110200, clicks: 2400, conversions: 28 },
|
||||
],
|
||||
};
|
||||
|
||||
const EMULATED_CAMPAIGNS = {
|
||||
ok: true,
|
||||
totalCount: 4,
|
||||
campaigns: [
|
||||
{
|
||||
initiativeId: 1, name: 'Spring Product Launch', clientName: 'Acme Corp', objective: 'conversions',
|
||||
status: 'active', totalBudget: 5000, budgetPeriod: 'monthly', spend: 3820, impressions: 312000,
|
||||
clicks: 7200, ctr: 2.31, cpc: 0.53, conversions: 142, budgetPacing: 76.4,
|
||||
channels: [
|
||||
{ channelType: 'google_ads', status: 'active', allocationPct: 50, spend: 1910, impressions: 156000, clicks: 3900, conversions: 82 },
|
||||
{ channelType: 'meta', status: 'active', allocationPct: 30, spend: 1146, impressions: 112000, clicks: 2300, conversions: 42 },
|
||||
{ channelType: 'tiktok', status: 'active', allocationPct: 20, spend: 764, impressions: 44000, clicks: 1000, conversions: 18 },
|
||||
],
|
||||
},
|
||||
{
|
||||
initiativeId: 2, name: 'Brand Awareness Q1', clientName: 'TechStart Inc', objective: 'awareness',
|
||||
status: 'active', totalBudget: 3000, budgetPeriod: 'monthly', spend: 2860, impressions: 285000,
|
||||
clicks: 4800, ctr: 1.68, cpc: 0.60, conversions: 64, budgetPacing: 95.3,
|
||||
channels: [
|
||||
{ channelType: 'google_ads', status: 'active', allocationPct: 40, spend: 1144, impressions: 114000, clicks: 2100, conversions: 32 },
|
||||
{ channelType: 'meta', status: 'active', allocationPct: 60, spend: 1716, impressions: 171000, clicks: 2700, conversions: 32 },
|
||||
],
|
||||
},
|
||||
{
|
||||
initiativeId: 3, name: 'Summer Sale Traffic', clientName: 'Acme Corp', objective: 'traffic',
|
||||
status: 'active', totalBudget: 4000, budgetPeriod: 'monthly', spend: 3480, impressions: 168000,
|
||||
clicks: 4230, ctr: 2.52, cpc: 0.82, conversions: 76, budgetPacing: 87.0,
|
||||
channels: [
|
||||
{ channelType: 'google_ads', status: 'active', allocationPct: 60, spend: 2088, impressions: 100800, clicks: 2800, conversions: 52 },
|
||||
{ channelType: 'meta', status: 'active', allocationPct: 40, spend: 1392, impressions: 67200, clicks: 1430, conversions: 24 },
|
||||
],
|
||||
},
|
||||
{
|
||||
initiativeId: 4, name: 'TikTok Gen-Z Push', clientName: 'FreshBrand Co', objective: 'engagement',
|
||||
status: 'active', totalBudget: 2500, budgetPeriod: 'monthly', spend: 2320, impressions: 82200,
|
||||
clicks: 2200, ctr: 2.68, cpc: 1.05, conversions: 30, budgetPacing: 92.8,
|
||||
channels: [
|
||||
{ channelType: 'tiktok', status: 'active', allocationPct: 70, spend: 1624, impressions: 57540, clicks: 1600, conversions: 22 },
|
||||
{ channelType: 'meta', status: 'active', allocationPct: 30, spend: 696, impressions: 24660, clicks: 600, conversions: 8 },
|
||||
],
|
||||
},
|
||||
],
|
||||
};
|
||||
|
||||
const EMULATED_INSIGHTS = {
|
||||
ok: true,
|
||||
totalActive: 4,
|
||||
insights: [
|
||||
{
|
||||
insightId: 1, initiativeId: 2, campaignName: 'Brand Awareness Q1', clientName: 'TechStart Inc',
|
||||
insightType: 'budget_pacing', severity: 'critical', status: 'active',
|
||||
title: 'Budget nearly exhausted — 95% spent with 8 days remaining',
|
||||
description: 'At the current spend rate of ~$102/day, the remaining $140 budget will be depleted in approximately 1.4 days. The campaign end date is 8 days away.',
|
||||
recommendation: 'Consider increasing the monthly budget to $3,800 to maintain delivery through the end date, or pause the campaign and restart next period.',
|
||||
createdAt: new Date().toISOString(),
|
||||
},
|
||||
{
|
||||
insightId: 2, initiativeId: 4, campaignName: 'TikTok Gen-Z Push', clientName: 'FreshBrand Co',
|
||||
insightType: 'underperformer', severity: 'warning', status: 'active',
|
||||
title: 'Meta channel underperforming vs. TikTok allocation',
|
||||
description: 'The Meta channel is consuming 30% of budget but delivering only 27% of conversions with a higher CPC ($1.16 vs $1.01). TikTok is outperforming on engagement metrics.',
|
||||
recommendation: 'Consider shifting 10% of Meta allocation to TikTok to improve overall conversion efficiency for this audience segment.',
|
||||
createdAt: new Date().toISOString(),
|
||||
},
|
||||
{
|
||||
insightId: 3, initiativeId: 1, campaignName: 'Spring Product Launch', clientName: 'Acme Corp',
|
||||
insightType: 'optimization', severity: 'info', status: 'active',
|
||||
title: 'Google Ads CPC trending down — opportunity to increase volume',
|
||||
description: 'Over the past 7 days, Google Ads CPC has decreased from $0.61 to $0.49 while maintaining conversion rate. This suggests room to increase daily spend cap.',
|
||||
recommendation: 'Increase Google Ads daily budget by 15% to capture additional conversions while CPC remains favorable.',
|
||||
createdAt: new Date().toISOString(),
|
||||
},
|
||||
{
|
||||
insightId: 4, initiativeId: 3, campaignName: 'Summer Sale Traffic', clientName: 'Acme Corp',
|
||||
insightType: 'reallocation', severity: 'info', status: 'active',
|
||||
title: 'Channel allocation aligns well with performance data',
|
||||
description: 'The current 60/40 Google/Meta split is delivering proportional results. Google is slightly outperforming on conversions per dollar, consistent with search-intent traffic campaigns.',
|
||||
recommendation: 'No changes recommended. Current allocation is performing within expected parameters.',
|
||||
createdAt: new Date().toISOString(),
|
||||
},
|
||||
],
|
||||
};
|
||||
|
||||
const EMULATED_ANALYSIS = {
|
||||
ok: true,
|
||||
campaigns: [
|
||||
{
|
||||
initiativeId: 100, name: 'Holiday Campaign 2024', clientName: 'Acme Corp', objective: 'conversions',
|
||||
totalBudget: 8000, startDate: '2024-11-15', endDate: '2024-12-31',
|
||||
totalSpend: 7640, totalImpressions: 524000, totalClicks: 12800, totalConversions: 384,
|
||||
ctr: 2.44, cpc: 0.60, costPerConversion: 19.90, budgetUtilization: 95.5,
|
||||
channels: [
|
||||
{ channel: 'google_ads', allocatedPct: 50, spend: 3820, impressions: 262000, clicks: 7200, conversions: 228 },
|
||||
{ channel: 'meta', allocatedPct: 35, spend: 2674, impressions: 196000, clicks: 4200, conversions: 118 },
|
||||
{ channel: 'tiktok', allocatedPct: 15, spend: 1146, impressions: 66000, clicks: 1400, conversions: 38 },
|
||||
],
|
||||
},
|
||||
{
|
||||
initiativeId: 101, name: 'Back to School Drive', clientName: 'TechStart Inc', objective: 'traffic',
|
||||
totalBudget: 3500, startDate: '2024-08-01', endDate: '2024-09-15',
|
||||
totalSpend: 3320, totalImpressions: 198000, totalClicks: 5600, totalConversions: 89,
|
||||
ctr: 2.83, cpc: 0.59, costPerConversion: 37.30, budgetUtilization: 94.9,
|
||||
channels: [
|
||||
{ channel: 'google_ads', allocatedPct: 55, spend: 1826, impressions: 108900, clicks: 3200, conversions: 56 },
|
||||
{ channel: 'meta', allocatedPct: 45, spend: 1494, impressions: 89100, clicks: 2400, conversions: 33 },
|
||||
],
|
||||
},
|
||||
],
|
||||
};
|
||||
382
Client-Client/src/components/admin/ModifiersPanel.jsx
Normal file
382
Client-Client/src/components/admin/ModifiersPanel.jsx
Normal file
@@ -0,0 +1,382 @@
|
||||
import React, { useState, useEffect, useCallback, memo } from 'react';
|
||||
import { useAdmin } from '../../context/AdminContext';
|
||||
|
||||
// ── Constants ──
|
||||
|
||||
const CHANNEL_META = {
|
||||
google_ads: { label: 'Google Ads', color: '#4285F4' },
|
||||
meta: { label: 'Meta Ads', color: '#0668E1' },
|
||||
tiktok: { label: 'TikTok Ads', color: '#010101' },
|
||||
};
|
||||
|
||||
const FACTOR_META = {
|
||||
age_skew: {
|
||||
label: 'Age Skew',
|
||||
icon: '👤',
|
||||
description: 'Adjusts channel mix based on audience age profile',
|
||||
values: {
|
||||
young: { label: 'Young (18–34)', icon: '🎯' },
|
||||
mature: { label: 'Mature (45+)', icon: '🏛️' },
|
||||
},
|
||||
},
|
||||
market_scope: {
|
||||
label: 'Market Scope',
|
||||
icon: '📍',
|
||||
description: 'Adjusts channel mix based on geographic targeting scope',
|
||||
values: {
|
||||
local: { label: 'Local', icon: '🏘️' },
|
||||
regional: { label: 'Regional', icon: '🗺️' },
|
||||
national: { label: 'National', icon: '🌐' },
|
||||
},
|
||||
},
|
||||
};
|
||||
|
||||
const fmt = (s) => (s || '').replace(/_/g, ' ').replace(/\b\w/g, c => c.toUpperCase());
|
||||
|
||||
// ── Main Panel ──
|
||||
|
||||
export default function ModifiersPanel() {
|
||||
const { apiCall } = useAdmin();
|
||||
const [modifiers, setModifiers] = useState([]);
|
||||
const [loading, setLoading] = useState(true);
|
||||
const [error, setError] = useState(null);
|
||||
const [saving, setSaving] = useState(null);
|
||||
const [dirty, setDirty] = useState({});
|
||||
const [toast, setToast] = useState(null);
|
||||
|
||||
// Preview state
|
||||
const [previewCategory, setPreviewCategory] = useState('restaurant');
|
||||
const [previewObjective, setPreviewObjective] = useState('awareness');
|
||||
const [previewAgeSkew, setPreviewAgeSkew] = useState('young');
|
||||
const [previewMarketScope, setPreviewMarketScope] = useState('local');
|
||||
const [previewResult, setPreviewResult] = useState(null);
|
||||
const [previewLoading, setPreviewLoading] = useState(false);
|
||||
|
||||
// Toast auto-dismiss
|
||||
useEffect(() => {
|
||||
if (!toast) return;
|
||||
const t = setTimeout(() => setToast(null), 3000);
|
||||
return () => clearTimeout(t);
|
||||
}, [toast]);
|
||||
|
||||
// Load modifiers
|
||||
const loadModifiers = useCallback(async () => {
|
||||
setLoading(true);
|
||||
setError(null);
|
||||
const res = await apiCall('/api/admin/modifiers');
|
||||
if (res.ok) {
|
||||
setModifiers(res.modifiers || []);
|
||||
} else {
|
||||
setError(res.error || 'Failed to load modifiers');
|
||||
}
|
||||
setLoading(false);
|
||||
}, [apiCall]);
|
||||
|
||||
useEffect(() => { loadModifiers(); }, [loadModifiers]);
|
||||
|
||||
// Track local edits
|
||||
const updateLocal = (id, field, value) => {
|
||||
setDirty(prev => ({
|
||||
...prev,
|
||||
[id]: { ...(prev[id] || {}), [field]: value }
|
||||
}));
|
||||
};
|
||||
|
||||
// Get effective value (dirty or original)
|
||||
const getVal = (mod, field) => {
|
||||
return dirty[mod.id]?.[field] !== undefined ? dirty[mod.id][field] : mod[field];
|
||||
};
|
||||
|
||||
// Save a single modifier
|
||||
const saveMod = async (mod) => {
|
||||
const changes = dirty[mod.id];
|
||||
if (!changes) return;
|
||||
|
||||
setSaving(mod.id);
|
||||
const res = await apiCall(`/api/admin/modifiers/${mod.id}`, 'PUT', changes);
|
||||
if (res.ok) {
|
||||
setModifiers(prev => prev.map(m =>
|
||||
m.id === mod.id ? { ...m, ...changes } : m
|
||||
));
|
||||
setDirty(prev => {
|
||||
const next = { ...prev };
|
||||
delete next[mod.id];
|
||||
return next;
|
||||
});
|
||||
setToast('Modifier saved');
|
||||
} else {
|
||||
setError(res.error || 'Save failed');
|
||||
}
|
||||
setSaving(null);
|
||||
};
|
||||
|
||||
// Save all dirty modifiers in a group
|
||||
const saveGroup = async (mods) => {
|
||||
for (const m of mods) {
|
||||
if (dirty[m.id]) await saveMod(m);
|
||||
}
|
||||
};
|
||||
|
||||
// Preview recommendation
|
||||
const runPreview = async () => {
|
||||
setPreviewLoading(true);
|
||||
const res = await apiCall('/api/admin/modifiers/preview', 'POST', {
|
||||
businessCategory: previewCategory,
|
||||
objective: previewObjective,
|
||||
ageSkew: previewAgeSkew || null,
|
||||
marketScope: previewMarketScope || null,
|
||||
});
|
||||
if (res.ok) {
|
||||
setPreviewResult(res);
|
||||
} else {
|
||||
setPreviewResult({ warning: res.error || 'Preview failed' });
|
||||
}
|
||||
setPreviewLoading(false);
|
||||
};
|
||||
|
||||
// Group modifiers by factorType → factorValue
|
||||
const grouped = {};
|
||||
modifiers.forEach(mod => {
|
||||
const key = mod.factorType;
|
||||
if (!grouped[key]) grouped[key] = {};
|
||||
const subKey = mod.factorValue;
|
||||
if (!grouped[key][subKey]) grouped[key][subKey] = [];
|
||||
grouped[key][subKey].push(mod);
|
||||
});
|
||||
|
||||
const hasDirtyCount = Object.keys(dirty).length;
|
||||
|
||||
if (loading) {
|
||||
return (
|
||||
<div style={{ padding: 40, textAlign: 'center', color: '#888' }}>
|
||||
<div className="spinner"></div>
|
||||
<p>Loading modifiers…</p>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="modifiers-panel">
|
||||
{/* Toast */}
|
||||
{toast && <div className="mod-toast">{toast}</div>}
|
||||
|
||||
{error && (
|
||||
<div className="error-message" style={{ margin: '0 0 16px 0' }}>
|
||||
<strong>Error:</strong> {error}
|
||||
<button onClick={() => setError(null)} className="mod-dismiss-btn">✕</button>
|
||||
</div>
|
||||
)}
|
||||
|
||||
<div className="mod-intro">
|
||||
<p>
|
||||
Audience modifiers adjust the base allocation template percentages based on the target audience's
|
||||
age profile and geographic scope. Positive values increase a channel's share; negative values decrease it.
|
||||
Results are normalized to 100% at recommendation time.
|
||||
</p>
|
||||
{hasDirtyCount > 0 && (
|
||||
<span className="mod-dirty-badge">{hasDirtyCount} unsaved change{hasDirtyCount !== 1 ? 's' : ''}</span>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* ── Factor Groups ── */}
|
||||
{Object.entries(FACTOR_META).map(([factorType, meta]) => (
|
||||
<div key={factorType} className="mod-group">
|
||||
<div className="mod-group-header">
|
||||
<span className="mod-group-icon">{meta.icon}</span>
|
||||
<div>
|
||||
<h3 className="mod-group-title">{meta.label}</h3>
|
||||
<p className="mod-group-desc">{meta.description}</p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{Object.entries(meta.values).map(([factorValue, valueMeta]) => {
|
||||
const mods = grouped[factorType]?.[factorValue] || [];
|
||||
const groupDirty = mods.some(m => dirty[m.id]);
|
||||
|
||||
return (
|
||||
<div key={factorValue} className="mod-value-section">
|
||||
<div className="mod-value-header">
|
||||
<span className="mod-value-icon">{valueMeta.icon}</span>
|
||||
<span className="mod-value-label">{valueMeta.label}</span>
|
||||
{groupDirty && (
|
||||
<button
|
||||
className="mod-save-group-btn"
|
||||
onClick={() => saveGroup(mods)}
|
||||
disabled={saving !== null}
|
||||
>
|
||||
{saving ? 'Saving…' : 'Save Changes'}
|
||||
</button>
|
||||
)}
|
||||
</div>
|
||||
|
||||
<div className="mod-slider-list">
|
||||
{mods.map(mod => (
|
||||
<ModifierRow
|
||||
key={mod.id}
|
||||
mod={mod}
|
||||
pctAdj={getVal(mod, 'pctAdjustment')}
|
||||
rationale={getVal(mod, 'rationale')}
|
||||
isActive={getVal(mod, 'isActive')}
|
||||
isDirty={!!dirty[mod.id]}
|
||||
isSaving={saving === mod.id}
|
||||
onChangePct={(v) => updateLocal(mod.id, 'pctAdjustment', v)}
|
||||
onChangeRationale={(v) => updateLocal(mod.id, 'rationale', v)}
|
||||
onToggleActive={() => updateLocal(mod.id, 'isActive', !getVal(mod, 'isActive'))}
|
||||
onSave={() => saveMod(mod)}
|
||||
/>
|
||||
))}
|
||||
{mods.length === 0 && (
|
||||
<div className="mod-empty">No modifiers configured for {valueMeta.label}</div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
))}
|
||||
|
||||
{/* ── Preview Section ── */}
|
||||
<div className="mod-group" style={{ marginTop: 24 }}>
|
||||
<div className="mod-group-header">
|
||||
<span className="mod-group-icon">🧪</span>
|
||||
<div>
|
||||
<h3 className="mod-group-title">Preview Recommendation</h3>
|
||||
<p className="mod-group-desc">Test how modifiers affect the channel mix for a given scenario</p>
|
||||
</div>
|
||||
</div>
|
||||
<div className="mod-preview-controls">
|
||||
<div className="mod-preview-field">
|
||||
<label>Category</label>
|
||||
<input value={previewCategory} onChange={e => setPreviewCategory(e.target.value)} placeholder="restaurant" />
|
||||
</div>
|
||||
<div className="mod-preview-field">
|
||||
<label>Objective</label>
|
||||
<input value={previewObjective} onChange={e => setPreviewObjective(e.target.value)} placeholder="awareness" />
|
||||
</div>
|
||||
<div className="mod-preview-field">
|
||||
<label>Age Skew</label>
|
||||
<select value={previewAgeSkew} onChange={e => setPreviewAgeSkew(e.target.value)}>
|
||||
<option value="">None</option>
|
||||
<option value="young">Young</option>
|
||||
<option value="mature">Mature</option>
|
||||
</select>
|
||||
</div>
|
||||
<div className="mod-preview-field">
|
||||
<label>Market Scope</label>
|
||||
<select value={previewMarketScope} onChange={e => setPreviewMarketScope(e.target.value)}>
|
||||
<option value="">None</option>
|
||||
<option value="local">Local</option>
|
||||
<option value="regional">Regional</option>
|
||||
<option value="national">National</option>
|
||||
</select>
|
||||
</div>
|
||||
<button className="mod-preview-btn" onClick={runPreview} disabled={previewLoading}>
|
||||
{previewLoading ? 'Running…' : 'Run Preview'}
|
||||
</button>
|
||||
</div>
|
||||
|
||||
{previewResult && (
|
||||
<div className="mod-preview-result">
|
||||
{previewResult.warning && !previewResult.channels && (
|
||||
<div style={{ color: '#b45309', fontSize: 13, padding: '8px 0' }}>⚠ {previewResult.warning}</div>
|
||||
)}
|
||||
{previewResult.channels && (
|
||||
<div className="mod-preview-channels">
|
||||
<div className="mod-preview-row mod-preview-header-row">
|
||||
<span className="mod-preview-ch-dot-spacer" />
|
||||
<span className="mod-preview-ch-name" style={{ fontWeight: 600, fontSize: 11, textTransform: 'uppercase', color: '#64748b' }}>Channel</span>
|
||||
<span className="mod-preview-ch-val" style={{ fontWeight: 600, fontSize: 11, textTransform: 'uppercase', color: '#64748b' }}>Base</span>
|
||||
<span className="mod-preview-ch-arrow" />
|
||||
<span className="mod-preview-ch-val" style={{ fontWeight: 600, fontSize: 11, textTransform: 'uppercase', color: '#64748b' }}>Adjusted</span>
|
||||
<span className="mod-preview-ch-diff-spacer" />
|
||||
</div>
|
||||
{previewResult.channels.map((ch, i) => {
|
||||
const meta = CHANNEL_META[ch.channelType] || {};
|
||||
const diff = ch.recommendedPct - ch.basePct;
|
||||
return (
|
||||
<div key={i} className="mod-preview-row">
|
||||
<span className="mod-preview-ch-dot" style={{ background: meta.color || '#999' }} />
|
||||
<span className="mod-preview-ch-name">{meta.label || fmt(ch.channelType)}</span>
|
||||
<span className="mod-preview-ch-val">{ch.basePct}%</span>
|
||||
<span className="mod-preview-ch-arrow">→</span>
|
||||
<span className="mod-preview-ch-val" style={{ fontWeight: 700 }}>{ch.recommendedPct}%</span>
|
||||
{diff !== 0 ? (
|
||||
<span className={`mod-preview-ch-diff ${diff > 0 ? 'pos' : 'neg'}`}>
|
||||
{diff > 0 ? '+' : ''}{diff}
|
||||
</span>
|
||||
) : (
|
||||
<span className="mod-preview-ch-diff-spacer" />
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
|
||||
// ── Modifier Row with Slider ──
|
||||
|
||||
const ModifierRow = memo(function ModifierRow({
|
||||
mod, pctAdj, rationale, isActive, isDirty, isSaving,
|
||||
onChangePct, onChangeRationale, onToggleActive, onSave,
|
||||
}) {
|
||||
const meta = CHANNEL_META[mod.channelType] || {};
|
||||
const isPositive = pctAdj > 0;
|
||||
const isNegative = pctAdj < 0;
|
||||
|
||||
return (
|
||||
<div className={`mod-row ${!isActive ? 'mod-row-disabled' : ''} ${isDirty ? 'mod-row-dirty' : ''}`}>
|
||||
<div className="mod-row-top">
|
||||
<span className="mod-ch-dot" style={{ background: meta.color || '#999' }} />
|
||||
<span className="mod-ch-name">{meta.label || fmt(mod.channelType)}</span>
|
||||
|
||||
<div className="mod-slider-wrap">
|
||||
<span className="mod-slider-tick mod-slider-tick-neg">-30</span>
|
||||
<input
|
||||
type="range"
|
||||
min={-30}
|
||||
max={30}
|
||||
value={pctAdj}
|
||||
onChange={e => onChangePct(parseInt(e.target.value))}
|
||||
className="mod-slider"
|
||||
style={{
|
||||
'--slider-color': isPositive ? '#10b981' : isNegative ? '#ef4444' : '#94a3b8',
|
||||
}}
|
||||
/>
|
||||
<span className="mod-slider-tick mod-slider-tick-pos">+30</span>
|
||||
</div>
|
||||
|
||||
<span className={`mod-pct-badge ${isPositive ? 'pos' : isNegative ? 'neg' : ''}`}>
|
||||
{pctAdj > 0 ? '+' : ''}{pctAdj}%
|
||||
</span>
|
||||
|
||||
<label className="mod-toggle" title={isActive ? 'Active' : 'Disabled'}>
|
||||
<input type="checkbox" checked={isActive} onChange={onToggleActive} />
|
||||
<span className="mod-toggle-track" />
|
||||
</label>
|
||||
|
||||
{isDirty && (
|
||||
<button className="mod-save-btn" onClick={onSave} disabled={isSaving} title="Save">
|
||||
{isSaving ? '…' : '✓'}
|
||||
</button>
|
||||
)}
|
||||
</div>
|
||||
|
||||
<div className="mod-row-bottom">
|
||||
<input
|
||||
className="mod-rationale"
|
||||
value={rationale || ''}
|
||||
onChange={e => onChangeRationale(e.target.value)}
|
||||
placeholder="Rationale…"
|
||||
maxLength={200}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
});
|
||||
931
Client-Client/src/components/admin/ObjectiveMappingPanel.jsx
Normal file
931
Client-Client/src/components/admin/ObjectiveMappingPanel.jsx
Normal file
@@ -0,0 +1,931 @@
|
||||
// ============================================================
|
||||
// components/admin/ObjectiveMappingPanel.jsx
|
||||
// Redesigned: "Objective Translations" — Rosetta Stone layout
|
||||
//
|
||||
// Primary: Interactive coverage matrix (objectives × channels)
|
||||
// Secondary: Objective cards with channel translation blocks
|
||||
// ============================================================
|
||||
import React, { useState, useMemo, useCallback, memo } from 'react';
|
||||
import { useObjectiveMappings, PROVIDER_OBJECTIVES } from '../../context/ObjectiveMappingsContext';
|
||||
|
||||
// ── Styles ──
|
||||
const styles = {
|
||||
panel: {
|
||||
padding: '24px',
|
||||
maxWidth: '1200px',
|
||||
},
|
||||
header: {
|
||||
display: 'flex',
|
||||
justifyContent: 'space-between',
|
||||
alignItems: 'flex-start',
|
||||
marginBottom: '24px',
|
||||
flexWrap: 'wrap',
|
||||
gap: '12px',
|
||||
},
|
||||
headerLeft: {
|
||||
display: 'flex',
|
||||
flexDirection: 'column',
|
||||
gap: '4px',
|
||||
},
|
||||
title: {
|
||||
fontSize: '20px',
|
||||
fontWeight: 600,
|
||||
color: '#111827',
|
||||
margin: 0,
|
||||
},
|
||||
subtitle: {
|
||||
fontSize: '13px',
|
||||
color: '#6B7280',
|
||||
margin: 0,
|
||||
},
|
||||
statsRow: {
|
||||
display: 'flex',
|
||||
gap: '16px',
|
||||
alignItems: 'center',
|
||||
},
|
||||
statBadge: (color) => ({
|
||||
display: 'inline-flex',
|
||||
alignItems: 'center',
|
||||
gap: '6px',
|
||||
padding: '4px 10px',
|
||||
borderRadius: '6px',
|
||||
fontSize: '12px',
|
||||
fontWeight: 500,
|
||||
background: color + '14',
|
||||
color: color,
|
||||
}),
|
||||
|
||||
// ── Coverage matrix ──
|
||||
matrixSection: {
|
||||
marginBottom: '32px',
|
||||
},
|
||||
sectionTitle: {
|
||||
fontSize: '14px',
|
||||
fontWeight: 600,
|
||||
color: '#374151',
|
||||
marginBottom: '12px',
|
||||
display: 'flex',
|
||||
alignItems: 'center',
|
||||
gap: '8px',
|
||||
},
|
||||
matrixTable: {
|
||||
width: '100%',
|
||||
borderCollapse: 'separate',
|
||||
borderSpacing: '0',
|
||||
border: '1px solid #E5E7EB',
|
||||
borderRadius: '8px',
|
||||
overflow: 'hidden',
|
||||
},
|
||||
matrixTh: {
|
||||
padding: '10px 16px',
|
||||
fontSize: '12px',
|
||||
fontWeight: 600,
|
||||
textTransform: 'uppercase',
|
||||
letterSpacing: '0.05em',
|
||||
color: '#6B7280',
|
||||
background: '#F9FAFB',
|
||||
textAlign: 'center',
|
||||
borderBottom: '1px solid #E5E7EB',
|
||||
},
|
||||
matrixThLeft: {
|
||||
padding: '10px 16px',
|
||||
fontSize: '12px',
|
||||
fontWeight: 600,
|
||||
color: '#6B7280',
|
||||
background: '#F9FAFB',
|
||||
textAlign: 'left',
|
||||
borderBottom: '1px solid #E5E7EB',
|
||||
borderRight: '1px solid #E5E7EB',
|
||||
},
|
||||
matrixObjCell: (color) => ({
|
||||
padding: '10px 16px',
|
||||
borderBottom: '1px solid #E5E7EB',
|
||||
borderRight: '1px solid #E5E7EB',
|
||||
background: '#fff',
|
||||
display: 'flex',
|
||||
alignItems: 'center',
|
||||
gap: '8px',
|
||||
}),
|
||||
objDot: (color) => ({
|
||||
width: '8px',
|
||||
height: '8px',
|
||||
borderRadius: '50%',
|
||||
background: color,
|
||||
flexShrink: 0,
|
||||
}),
|
||||
matrixCell: (filled) => ({
|
||||
padding: '8px',
|
||||
borderBottom: '1px solid #E5E7EB',
|
||||
borderRight: '1px solid #E5E7EB',
|
||||
textAlign: 'center',
|
||||
cursor: 'pointer',
|
||||
transition: 'background 0.15s',
|
||||
background: filled ? '#F0FDF4' : '#fff',
|
||||
verticalAlign: 'middle',
|
||||
}),
|
||||
matrixCellFilled: {
|
||||
fontSize: '11px',
|
||||
fontWeight: 500,
|
||||
color: '#065F46',
|
||||
lineHeight: '1.3',
|
||||
},
|
||||
matrixCellEmpty: {
|
||||
color: '#D1D5DB',
|
||||
fontSize: '18px',
|
||||
},
|
||||
|
||||
// ── Objective cards ──
|
||||
cardsSection: {
|
||||
display: 'flex',
|
||||
flexDirection: 'column',
|
||||
gap: '16px',
|
||||
},
|
||||
card: {
|
||||
border: '1px solid #E5E7EB',
|
||||
borderRadius: '10px',
|
||||
overflow: 'hidden',
|
||||
background: '#fff',
|
||||
},
|
||||
cardHeader: (color) => ({
|
||||
display: 'flex',
|
||||
alignItems: 'center',
|
||||
justifyContent: 'space-between',
|
||||
padding: '14px 20px',
|
||||
borderBottom: '1px solid #E5E7EB',
|
||||
background: color + '08',
|
||||
borderLeft: `4px solid ${color}`,
|
||||
}),
|
||||
cardTitle: {
|
||||
display: 'flex',
|
||||
alignItems: 'center',
|
||||
gap: '10px',
|
||||
},
|
||||
cardTitleText: {
|
||||
fontSize: '15px',
|
||||
fontWeight: 600,
|
||||
color: '#111827',
|
||||
textTransform: 'capitalize',
|
||||
},
|
||||
cardCount: (color) => ({
|
||||
fontSize: '11px',
|
||||
padding: '2px 8px',
|
||||
borderRadius: '10px',
|
||||
background: color + '1A',
|
||||
color: color,
|
||||
fontWeight: 500,
|
||||
}),
|
||||
cardBody: {
|
||||
display: 'flex',
|
||||
gap: '0',
|
||||
flexWrap: 'wrap',
|
||||
},
|
||||
|
||||
// ── Translation block ──
|
||||
translationBlock: (channelColor) => ({
|
||||
flex: '1 1 280px',
|
||||
padding: '16px 20px',
|
||||
borderRight: '1px solid #F3F4F6',
|
||||
borderBottom: '1px solid #F3F4F6',
|
||||
position: 'relative',
|
||||
}),
|
||||
translationHeader: {
|
||||
display: 'flex',
|
||||
justifyContent: 'space-between',
|
||||
alignItems: 'center',
|
||||
marginBottom: '8px',
|
||||
},
|
||||
channelLabel: (color) => ({
|
||||
fontSize: '12px',
|
||||
fontWeight: 600,
|
||||
color: color,
|
||||
display: 'flex',
|
||||
alignItems: 'center',
|
||||
gap: '6px',
|
||||
}),
|
||||
providerValue: {
|
||||
fontSize: '13px',
|
||||
fontWeight: 500,
|
||||
color: '#111827',
|
||||
marginBottom: '4px',
|
||||
},
|
||||
providerLabel: {
|
||||
fontSize: '12px',
|
||||
color: '#6B7280',
|
||||
marginBottom: '10px',
|
||||
},
|
||||
capsRow: {
|
||||
display: 'flex',
|
||||
gap: '6px',
|
||||
flexWrap: 'wrap',
|
||||
marginBottom: '6px',
|
||||
},
|
||||
capBadge: (supported) => ({
|
||||
fontSize: '10px',
|
||||
fontWeight: 500,
|
||||
padding: '2px 7px',
|
||||
borderRadius: '4px',
|
||||
background: supported ? '#ECFDF5' : '#F9FAFB',
|
||||
color: supported ? '#065F46' : '#9CA3AF',
|
||||
border: `1px solid ${supported ? '#D1FAE5' : '#E5E7EB'}`,
|
||||
}),
|
||||
notes: {
|
||||
fontSize: '11px',
|
||||
color: '#9CA3AF',
|
||||
fontStyle: 'italic',
|
||||
marginTop: '4px',
|
||||
},
|
||||
blockActions: {
|
||||
display: 'flex',
|
||||
gap: '6px',
|
||||
},
|
||||
iconBtn: {
|
||||
background: 'none',
|
||||
border: 'none',
|
||||
cursor: 'pointer',
|
||||
padding: '2px 4px',
|
||||
fontSize: '13px',
|
||||
color: '#9CA3AF',
|
||||
borderRadius: '4px',
|
||||
transition: 'color 0.15s, background 0.15s',
|
||||
},
|
||||
addTranslationBlock: {
|
||||
flex: '1 1 280px',
|
||||
padding: '16px 20px',
|
||||
display: 'flex',
|
||||
alignItems: 'center',
|
||||
justifyContent: 'center',
|
||||
borderRight: '1px solid #F3F4F6',
|
||||
borderBottom: '1px solid #F3F4F6',
|
||||
minHeight: '80px',
|
||||
},
|
||||
addBtn: {
|
||||
background: 'none',
|
||||
border: '2px dashed #D1D5DB',
|
||||
borderRadius: '8px',
|
||||
padding: '8px 16px',
|
||||
color: '#9CA3AF',
|
||||
fontSize: '12px',
|
||||
fontWeight: 500,
|
||||
cursor: 'pointer',
|
||||
transition: 'all 0.15s',
|
||||
width: '100%',
|
||||
},
|
||||
|
||||
// ── Form / modal ──
|
||||
overlay: {
|
||||
position: 'fixed',
|
||||
inset: 0,
|
||||
background: 'rgba(0,0,0,0.3)',
|
||||
display: 'flex',
|
||||
alignItems: 'center',
|
||||
justifyContent: 'center',
|
||||
zIndex: 1000,
|
||||
},
|
||||
formCard: {
|
||||
background: '#fff',
|
||||
borderRadius: '12px',
|
||||
width: '520px',
|
||||
maxHeight: '85vh',
|
||||
overflowY: 'auto',
|
||||
boxShadow: '0 20px 60px rgba(0,0,0,0.15)',
|
||||
},
|
||||
formHeader: {
|
||||
padding: '20px 24px',
|
||||
borderBottom: '1px solid #E5E7EB',
|
||||
display: 'flex',
|
||||
justifyContent: 'space-between',
|
||||
alignItems: 'center',
|
||||
},
|
||||
formTitle: {
|
||||
fontSize: '16px',
|
||||
fontWeight: 600,
|
||||
color: '#111827',
|
||||
margin: 0,
|
||||
},
|
||||
formBody: {
|
||||
padding: '20px 24px',
|
||||
display: 'flex',
|
||||
flexDirection: 'column',
|
||||
gap: '16px',
|
||||
},
|
||||
formFooter: {
|
||||
padding: '16px 24px',
|
||||
borderTop: '1px solid #E5E7EB',
|
||||
display: 'flex',
|
||||
justifyContent: 'flex-end',
|
||||
gap: '10px',
|
||||
},
|
||||
fieldGroup: {
|
||||
display: 'flex',
|
||||
flexDirection: 'column',
|
||||
gap: '4px',
|
||||
},
|
||||
fieldLabel: {
|
||||
fontSize: '12px',
|
||||
fontWeight: 600,
|
||||
color: '#374151',
|
||||
},
|
||||
fieldInput: {
|
||||
padding: '8px 12px',
|
||||
border: '1px solid #D1D5DB',
|
||||
borderRadius: '6px',
|
||||
fontSize: '13px',
|
||||
outline: 'none',
|
||||
transition: 'border-color 0.15s',
|
||||
},
|
||||
fieldSelect: {
|
||||
padding: '8px 12px',
|
||||
border: '1px solid #D1D5DB',
|
||||
borderRadius: '6px',
|
||||
fontSize: '13px',
|
||||
outline: 'none',
|
||||
background: '#fff',
|
||||
},
|
||||
checkRow: {
|
||||
display: 'flex',
|
||||
alignItems: 'center',
|
||||
gap: '8px',
|
||||
},
|
||||
checkLabel: {
|
||||
fontSize: '13px',
|
||||
color: '#374151',
|
||||
},
|
||||
btnPrimary: {
|
||||
padding: '8px 18px',
|
||||
background: '#2563EB',
|
||||
color: '#fff',
|
||||
border: 'none',
|
||||
borderRadius: '6px',
|
||||
fontSize: '13px',
|
||||
fontWeight: 500,
|
||||
cursor: 'pointer',
|
||||
},
|
||||
btnSecondary: {
|
||||
padding: '8px 18px',
|
||||
background: '#fff',
|
||||
color: '#374151',
|
||||
border: '1px solid #D1D5DB',
|
||||
borderRadius: '6px',
|
||||
fontSize: '13px',
|
||||
fontWeight: 500,
|
||||
cursor: 'pointer',
|
||||
},
|
||||
btnDanger: {
|
||||
padding: '8px 18px',
|
||||
background: '#FEE2E2',
|
||||
color: '#DC2626',
|
||||
border: '1px solid #FECACA',
|
||||
borderRadius: '6px',
|
||||
fontSize: '13px',
|
||||
fontWeight: 500,
|
||||
cursor: 'pointer',
|
||||
},
|
||||
loading: {
|
||||
padding: '60px',
|
||||
textAlign: 'center',
|
||||
color: '#9CA3AF',
|
||||
fontSize: '14px',
|
||||
},
|
||||
error: {
|
||||
padding: '16px',
|
||||
background: '#FEF2F2',
|
||||
border: '1px solid #FECACA',
|
||||
borderRadius: '8px',
|
||||
color: '#DC2626',
|
||||
fontSize: '13px',
|
||||
},
|
||||
emptyState: {
|
||||
padding: '40px',
|
||||
textAlign: 'center',
|
||||
color: '#9CA3AF',
|
||||
fontSize: '14px',
|
||||
border: '2px dashed #E5E7EB',
|
||||
borderRadius: '8px',
|
||||
},
|
||||
};
|
||||
|
||||
// ── Capability labels ──
|
||||
const CAPABILITY_FLAGS = [
|
||||
{ key: 'supportsObjectiveChange', short: 'Objective', label: 'Change Objective' },
|
||||
{ key: 'supportsBudgetChange', short: 'Budget', label: 'Change Budget' },
|
||||
{ key: 'supportsTargetingChange', short: 'Targeting', label: 'Change Targeting' },
|
||||
{ key: 'supportsStatusToggle', short: 'Status', label: 'Toggle Status' },
|
||||
];
|
||||
|
||||
// ════════════════════════════════════════════════════════════
|
||||
// Main Panel
|
||||
// ════════════════════════════════════════════════════════════
|
||||
export default function ObjectiveMappingPanel() {
|
||||
const {
|
||||
channels, objectives, mappings, channelMap, objectiveColorMap,
|
||||
coverageMatrix, coverageStats, loading, metaError,
|
||||
createMapping, updateMapping, deleteMapping, getProviderObjectives,
|
||||
} = useObjectiveMappings();
|
||||
|
||||
const [formState, setFormState] = useState(null); // null | { mode: 'create'|'edit', presets?, mapping? }
|
||||
|
||||
const activeChannels = useMemo(() => channels.filter(c => c.isActive), [channels]);
|
||||
const activeObjectives = useMemo(
|
||||
() => objectives.filter(o => o.isActive).sort((a, b) => a.sortOrder - b.sortOrder),
|
||||
[objectives],
|
||||
);
|
||||
|
||||
// ── Open form helpers ──
|
||||
const openCreate = useCallback((presets = {}) => {
|
||||
setFormState({ mode: 'create', presets });
|
||||
}, []);
|
||||
|
||||
const openEdit = useCallback((mapping) => {
|
||||
setFormState({ mode: 'edit', mapping });
|
||||
}, []);
|
||||
|
||||
const closeForm = useCallback(() => setFormState(null), []);
|
||||
|
||||
// ── Loading / error ──
|
||||
if (loading) {
|
||||
return <div style={styles.loading}>Loading objective translations...</div>;
|
||||
}
|
||||
|
||||
if (metaError) {
|
||||
return <div style={styles.error}>Error loading metadata: {metaError}</div>;
|
||||
}
|
||||
|
||||
return (
|
||||
<div style={styles.panel}>
|
||||
{/* ── Header ── */}
|
||||
<div style={styles.header}>
|
||||
<div style={styles.headerLeft}>
|
||||
<h2 style={styles.title}>Objective Translations</h2>
|
||||
<p style={styles.subtitle}>
|
||||
Maps platform objectives to provider-specific campaign types across channels
|
||||
</p>
|
||||
</div>
|
||||
<div style={styles.statsRow}>
|
||||
<span style={styles.statBadge('#10B981')}>
|
||||
{coverageStats.filled} mapped
|
||||
</span>
|
||||
{coverageStats.gaps > 0 && (
|
||||
<span style={styles.statBadge('#EF4444')}>
|
||||
{coverageStats.gaps} gap{coverageStats.gaps !== 1 ? 's' : ''}
|
||||
</span>
|
||||
)}
|
||||
<span style={styles.statBadge('#6B7280')}>
|
||||
{coverageStats.pct}% coverage
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* ── Coverage Matrix ── */}
|
||||
<div style={styles.matrixSection}>
|
||||
<div style={styles.sectionTitle}>
|
||||
<span>Coverage Matrix</span>
|
||||
<span style={{ fontSize: '11px', color: '#9CA3AF', fontWeight: 400 }}>
|
||||
Click any cell to {'\u00B7'} add or edit
|
||||
</span>
|
||||
</div>
|
||||
<CoverageMatrix
|
||||
objectives={activeObjectives}
|
||||
channels={activeChannels}
|
||||
matrix={coverageMatrix}
|
||||
colorMap={objectiveColorMap}
|
||||
onClickCell={(objective, channel, mapping) => {
|
||||
if (mapping) {
|
||||
openEdit(mapping);
|
||||
} else {
|
||||
openCreate({ channelType: channel.code, platformObjective: objective.name });
|
||||
}
|
||||
}}
|
||||
/>
|
||||
</div>
|
||||
|
||||
{/* ── Objective Cards ── */}
|
||||
<div style={styles.sectionTitle}>
|
||||
<span>Translations by Objective</span>
|
||||
</div>
|
||||
<div style={styles.cardsSection}>
|
||||
{activeObjectives.map(obj => (
|
||||
<ObjectiveCard
|
||||
key={obj.name}
|
||||
objective={obj}
|
||||
channels={activeChannels}
|
||||
channelMap={channelMap}
|
||||
matrix={coverageMatrix}
|
||||
colorMap={objectiveColorMap}
|
||||
onEdit={openEdit}
|
||||
onAdd={(channelCode) => openCreate({
|
||||
platformObjective: obj.name,
|
||||
channelType: channelCode,
|
||||
})}
|
||||
onDelete={deleteMapping}
|
||||
/>
|
||||
))}
|
||||
{activeObjectives.length === 0 && (
|
||||
<div style={styles.emptyState}>
|
||||
No active objectives defined. Add objectives in the Allocation Templates panel.
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* ── Form overlay ── */}
|
||||
{formState && (
|
||||
<MappingFormOverlay
|
||||
mode={formState.mode}
|
||||
presets={formState.presets}
|
||||
mapping={formState.mapping}
|
||||
channels={activeChannels}
|
||||
objectives={activeObjectives}
|
||||
getProviderObjectives={getProviderObjectives}
|
||||
onCreate={createMapping}
|
||||
onUpdate={updateMapping}
|
||||
onClose={closeForm}
|
||||
/>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
// ════════════════════════════════════════════════════════════
|
||||
// Coverage Matrix (table)
|
||||
// ════════════════════════════════════════════════════════════
|
||||
const CoverageMatrix = memo(function CoverageMatrix({ objectives, channels, matrix, colorMap, onClickCell }) {
|
||||
if (!objectives.length || !channels.length) {
|
||||
return <div style={styles.emptyState}>Add channels and objectives to see the coverage matrix</div>;
|
||||
}
|
||||
|
||||
return (
|
||||
<table style={styles.matrixTable}>
|
||||
<thead>
|
||||
<tr>
|
||||
<th style={styles.matrixThLeft}>Objective</th>
|
||||
{channels.map(ch => (
|
||||
<th key={ch.code} style={styles.matrixTh}>
|
||||
<span style={{ color: ch.color || '#6B7280' }}>
|
||||
{ch.icon || ''} {ch.label}
|
||||
</span>
|
||||
</th>
|
||||
))}
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
{objectives.map(obj => {
|
||||
const color = colorMap[obj.name] || '#6B7280';
|
||||
return (
|
||||
<tr key={obj.name}>
|
||||
<td style={{ borderBottom: '1px solid #E5E7EB', borderRight: '1px solid #E5E7EB', padding: '10px 16px', background: '#fff' }}>
|
||||
<div style={{ display: 'flex', alignItems: 'center', gap: '8px' }}>
|
||||
<span style={styles.objDot(color)} />
|
||||
<span style={{ fontSize: '13px', fontWeight: 500, color: '#111827', textTransform: 'capitalize' }}>
|
||||
{obj.name}
|
||||
</span>
|
||||
</div>
|
||||
</td>
|
||||
{channels.map(ch => {
|
||||
const mapping = matrix[obj.name]?.[ch.code];
|
||||
return (
|
||||
<td
|
||||
key={ch.code}
|
||||
style={styles.matrixCell(!!mapping)}
|
||||
onClick={() => onClickCell(obj, ch, mapping)}
|
||||
onMouseEnter={e => { e.currentTarget.style.background = mapping ? '#DCFCE7' : '#F3F4F6'; }}
|
||||
onMouseLeave={e => { e.currentTarget.style.background = mapping ? '#F0FDF4' : '#fff'; }}
|
||||
title={mapping
|
||||
? `${mapping.providerObjectiveLabel}\nClick to edit`
|
||||
: `No mapping — click to add`
|
||||
}
|
||||
>
|
||||
{mapping ? (
|
||||
<div style={styles.matrixCellFilled}>
|
||||
{mapping.providerObjectiveLabel || mapping.providerObjective}
|
||||
</div>
|
||||
) : (
|
||||
<div style={styles.matrixCellEmpty}>+</div>
|
||||
)}
|
||||
</td>
|
||||
);
|
||||
})}
|
||||
</tr>
|
||||
);
|
||||
})}
|
||||
</tbody>
|
||||
</table>
|
||||
);
|
||||
});
|
||||
|
||||
// ════════════════════════════════════════════════════════════
|
||||
// Objective Card
|
||||
// ════════════════════════════════════════════════════════════
|
||||
const ObjectiveCard = memo(function ObjectiveCard({
|
||||
objective, channels, channelMap, matrix, colorMap, onEdit, onAdd, onDelete,
|
||||
}) {
|
||||
const color = colorMap[objective.name] || '#6B7280';
|
||||
const row = matrix[objective.name] || {};
|
||||
const filledChannels = channels.filter(ch => row[ch.code]);
|
||||
const missingChannels = channels.filter(ch => !row[ch.code]);
|
||||
const [confirmDelete, setConfirmDelete] = useState(null);
|
||||
|
||||
const handleDelete = async (mappingId) => {
|
||||
const result = await onDelete(mappingId);
|
||||
if (result?.ok) setConfirmDelete(null);
|
||||
};
|
||||
|
||||
return (
|
||||
<div style={styles.card}>
|
||||
<div style={styles.cardHeader(color)}>
|
||||
<div style={styles.cardTitle}>
|
||||
<span style={styles.objDot(color)} />
|
||||
<span style={styles.cardTitleText}>{objective.name}</span>
|
||||
<span style={styles.cardCount(color)}>
|
||||
{filledChannels.length}/{channels.length} channels
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
<div style={styles.cardBody}>
|
||||
{/* Filled translations */}
|
||||
{filledChannels.map(ch => {
|
||||
const mapping = row[ch.code];
|
||||
const chMeta = channelMap[ch.code] || {};
|
||||
const chColor = chMeta.color || '#6B7280';
|
||||
|
||||
return (
|
||||
<div key={ch.code} style={styles.translationBlock(chColor)}>
|
||||
<div style={styles.translationHeader}>
|
||||
<div style={styles.channelLabel(chColor)}>
|
||||
{chMeta.icon || ''} {chMeta.label || ch.code}
|
||||
</div>
|
||||
<div style={styles.blockActions}>
|
||||
<button
|
||||
style={styles.iconBtn}
|
||||
onClick={() => onEdit(mapping)}
|
||||
title="Edit"
|
||||
onMouseEnter={e => { e.target.style.color = '#2563EB'; }}
|
||||
onMouseLeave={e => { e.target.style.color = '#9CA3AF'; }}
|
||||
>
|
||||
✏️
|
||||
</button>
|
||||
{confirmDelete === mapping.mappingId ? (
|
||||
<>
|
||||
<button
|
||||
style={{ ...styles.iconBtn, color: '#DC2626', fontSize: '11px' }}
|
||||
onClick={() => handleDelete(mapping.mappingId)}
|
||||
>
|
||||
Confirm
|
||||
</button>
|
||||
<button
|
||||
style={{ ...styles.iconBtn, fontSize: '11px' }}
|
||||
onClick={() => setConfirmDelete(null)}
|
||||
>
|
||||
Cancel
|
||||
</button>
|
||||
</>
|
||||
) : (
|
||||
<button
|
||||
style={styles.iconBtn}
|
||||
onClick={() => setConfirmDelete(mapping.mappingId)}
|
||||
title="Delete"
|
||||
onMouseEnter={e => { e.target.style.color = '#DC2626'; }}
|
||||
onMouseLeave={e => { e.target.style.color = '#9CA3AF'; }}
|
||||
>
|
||||
🗑️
|
||||
</button>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
<div style={styles.providerValue}>
|
||||
{mapping.providerObjective}
|
||||
</div>
|
||||
<div style={styles.providerLabel}>
|
||||
{mapping.providerObjectiveLabel}
|
||||
</div>
|
||||
<div style={styles.capsRow}>
|
||||
{CAPABILITY_FLAGS.map(cap => (
|
||||
<span key={cap.key} style={styles.capBadge(mapping[cap.key])}>
|
||||
{mapping[cap.key] ? '✓' : '✗'} {cap.short}
|
||||
</span>
|
||||
))}
|
||||
</div>
|
||||
{mapping.notes && (
|
||||
<div style={styles.notes}>{mapping.notes}</div>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
})}
|
||||
|
||||
{/* Missing channel slots */}
|
||||
{missingChannels.map(ch => {
|
||||
const chMeta = channelMap[ch.code] || {};
|
||||
return (
|
||||
<div key={ch.code} style={styles.addTranslationBlock}>
|
||||
<button
|
||||
style={styles.addBtn}
|
||||
onClick={() => onAdd(ch.code)}
|
||||
onMouseEnter={e => {
|
||||
e.target.style.borderColor = '#9CA3AF';
|
||||
e.target.style.color = '#374151';
|
||||
}}
|
||||
onMouseLeave={e => {
|
||||
e.target.style.borderColor = '#D1D5DB';
|
||||
e.target.style.color = '#9CA3AF';
|
||||
}}
|
||||
>
|
||||
+ Add {chMeta.label || ch.code} translation
|
||||
</button>
|
||||
</div>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
});
|
||||
|
||||
// ════════════════════════════════════════════════════════════
|
||||
// Mapping Form Overlay (create / edit)
|
||||
// ════════════════════════════════════════════════════════════
|
||||
function MappingFormOverlay({
|
||||
mode, presets, mapping, channels, objectives,
|
||||
getProviderObjectives, onCreate, onUpdate, onClose,
|
||||
}) {
|
||||
const isEdit = mode === 'edit';
|
||||
const initial = isEdit ? mapping : (presets || {});
|
||||
|
||||
const [form, setForm] = useState({
|
||||
channelType: initial.channelType || '',
|
||||
platformObjective: initial.platformObjective || '',
|
||||
providerObjective: initial.providerObjective || '',
|
||||
providerObjectiveLabel: initial.providerObjectiveLabel || '',
|
||||
supportsObjectiveChange: initial.supportsObjectiveChange ?? false,
|
||||
supportsBudgetChange: initial.supportsBudgetChange ?? true,
|
||||
supportsTargetingChange: initial.supportsTargetingChange ?? true,
|
||||
supportsStatusToggle: initial.supportsStatusToggle ?? true,
|
||||
notes: initial.notes || '',
|
||||
});
|
||||
const [saving, setSaving] = useState(false);
|
||||
const [error, setError] = useState(null);
|
||||
|
||||
const providerOpts = useMemo(
|
||||
() => getProviderObjectives(form.channelType),
|
||||
[form.channelType, getProviderObjectives],
|
||||
);
|
||||
|
||||
const handleChange = (field, value) => {
|
||||
setForm(prev => ({ ...prev, [field]: value }));
|
||||
|
||||
// Auto-fill label when picking from provider objective dropdown
|
||||
if (field === 'providerObjective') {
|
||||
const opt = providerOpts.find(o => o.value === value);
|
||||
if (opt) {
|
||||
setForm(prev => ({ ...prev, providerObjective: value, providerObjectiveLabel: opt.label }));
|
||||
}
|
||||
}
|
||||
};
|
||||
|
||||
const handleSubmit = async () => {
|
||||
setError(null);
|
||||
if (!form.channelType || !form.platformObjective || !form.providerObjective || !form.providerObjectiveLabel) {
|
||||
setError('All required fields must be filled');
|
||||
return;
|
||||
}
|
||||
|
||||
setSaving(true);
|
||||
try {
|
||||
let result;
|
||||
if (isEdit) {
|
||||
result = await onUpdate(mapping.mappingId, form);
|
||||
} else {
|
||||
result = await onCreate(form);
|
||||
}
|
||||
if (result?.ok) {
|
||||
onClose();
|
||||
} else {
|
||||
setError(result?.error || 'Operation failed');
|
||||
}
|
||||
} catch (err) {
|
||||
setError(err.message);
|
||||
} finally {
|
||||
setSaving(false);
|
||||
}
|
||||
};
|
||||
|
||||
return (
|
||||
<div style={styles.overlay} onClick={onClose}>
|
||||
<div style={styles.formCard} onClick={e => e.stopPropagation()}>
|
||||
{/* Header */}
|
||||
<div style={styles.formHeader}>
|
||||
<h3 style={styles.formTitle}>
|
||||
{isEdit ? 'Edit Translation' : 'Add Translation'}
|
||||
</h3>
|
||||
<button style={styles.iconBtn} onClick={onClose}>✕</button>
|
||||
</div>
|
||||
|
||||
{/* Body */}
|
||||
<div style={styles.formBody}>
|
||||
{error && <div style={styles.error}>{error}</div>}
|
||||
|
||||
{/* Channel */}
|
||||
<div style={styles.fieldGroup}>
|
||||
<label style={styles.fieldLabel}>Channel *</label>
|
||||
<select
|
||||
style={styles.fieldSelect}
|
||||
value={form.channelType}
|
||||
onChange={e => handleChange('channelType', e.target.value)}
|
||||
disabled={isEdit}
|
||||
>
|
||||
<option value="">Select channel...</option>
|
||||
{channels.map(ch => (
|
||||
<option key={ch.code} value={ch.code}>{ch.label}</option>
|
||||
))}
|
||||
</select>
|
||||
</div>
|
||||
|
||||
{/* Platform Objective */}
|
||||
<div style={styles.fieldGroup}>
|
||||
<label style={styles.fieldLabel}>Platform Objective *</label>
|
||||
<select
|
||||
style={styles.fieldSelect}
|
||||
value={form.platformObjective}
|
||||
onChange={e => handleChange('platformObjective', e.target.value)}
|
||||
disabled={isEdit}
|
||||
>
|
||||
<option value="">Select objective...</option>
|
||||
{objectives.map(obj => (
|
||||
<option key={obj.name} value={obj.name}>{obj.name}</option>
|
||||
))}
|
||||
</select>
|
||||
</div>
|
||||
|
||||
{/* Provider Objective */}
|
||||
<div style={styles.fieldGroup}>
|
||||
<label style={styles.fieldLabel}>Provider Objective *</label>
|
||||
{providerOpts.length > 0 ? (
|
||||
<select
|
||||
style={styles.fieldSelect}
|
||||
value={form.providerObjective}
|
||||
onChange={e => handleChange('providerObjective', e.target.value)}
|
||||
>
|
||||
<option value="">Select provider objective...</option>
|
||||
{providerOpts.map(opt => (
|
||||
<option key={opt.value} value={opt.value}>{opt.label} ({opt.value})</option>
|
||||
))}
|
||||
</select>
|
||||
) : (
|
||||
<input
|
||||
style={styles.fieldInput}
|
||||
value={form.providerObjective}
|
||||
onChange={e => handleChange('providerObjective', e.target.value)}
|
||||
placeholder="e.g. MAXIMIZE_CONVERSIONS"
|
||||
/>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* Provider Objective Label */}
|
||||
<div style={styles.fieldGroup}>
|
||||
<label style={styles.fieldLabel}>Provider Objective Label *</label>
|
||||
<input
|
||||
style={styles.fieldInput}
|
||||
value={form.providerObjectiveLabel}
|
||||
onChange={e => handleChange('providerObjectiveLabel', e.target.value)}
|
||||
placeholder="Human-readable label"
|
||||
/>
|
||||
</div>
|
||||
|
||||
{/* Capability Flags */}
|
||||
<div style={styles.fieldGroup}>
|
||||
<label style={styles.fieldLabel}>Capability Flags</label>
|
||||
<div style={{ display: 'flex', flexDirection: 'column', gap: '8px', marginTop: '4px' }}>
|
||||
{CAPABILITY_FLAGS.map(cap => (
|
||||
<label key={cap.key} style={styles.checkRow}>
|
||||
<input
|
||||
type="checkbox"
|
||||
checked={form[cap.key] || false}
|
||||
onChange={e => handleChange(cap.key, e.target.checked)}
|
||||
/>
|
||||
<span style={styles.checkLabel}>{cap.label}</span>
|
||||
</label>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Notes */}
|
||||
<div style={styles.fieldGroup}>
|
||||
<label style={styles.fieldLabel}>Notes</label>
|
||||
<input
|
||||
style={styles.fieldInput}
|
||||
value={form.notes}
|
||||
onChange={e => handleChange('notes', e.target.value)}
|
||||
placeholder="Optional notes..."
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Footer */}
|
||||
<div style={styles.formFooter}>
|
||||
<button style={styles.btnSecondary} onClick={onClose} disabled={saving}>
|
||||
Cancel
|
||||
</button>
|
||||
<button style={styles.btnPrimary} onClick={handleSubmit} disabled={saving}>
|
||||
{saving ? 'Saving...' : isEdit ? 'Update' : 'Create'}
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
73
Client-Client/src/components/admin/SessionsPanel.jsx
Normal file
73
Client-Client/src/components/admin/SessionsPanel.jsx
Normal file
@@ -0,0 +1,73 @@
|
||||
import React from 'react';
|
||||
import { useAdmin } from '../../context/AdminContext';
|
||||
|
||||
export default function SessionsPanel() {
|
||||
const { data, apiCall, refresh } = useAdmin();
|
||||
const sessions = data.sessions || [];
|
||||
|
||||
const revokeSession = async (sessionId) => {
|
||||
if (!confirm('Are you sure you want to revoke this session?')) return;
|
||||
const result = await apiCall(`/api/admin/sessions/${sessionId}/revoke`, 'POST');
|
||||
if (result.ok) refresh();
|
||||
else alert(result.error || 'Failed to revoke session');
|
||||
};
|
||||
|
||||
const cleanupSessions = async () => {
|
||||
if (!confirm('This will delete all expired sessions older than 30 days. Continue?')) return;
|
||||
const result = await apiCall('/api/admin/sessions/cleanup?daysOld=30', 'POST');
|
||||
if (result.ok) {
|
||||
alert(`Cleaned up ${result.rowsDeleted} expired sessions`);
|
||||
refresh();
|
||||
}
|
||||
else alert(result.error || 'Failed to cleanup sessions');
|
||||
};
|
||||
|
||||
return (
|
||||
<div className="table-panel">
|
||||
<div className="panel-header">
|
||||
<h2>Active Sessions</h2>
|
||||
<button className="btn-action" onClick={cleanupSessions}>
|
||||
Cleanup Expired
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<table className="data-table">
|
||||
<thead>
|
||||
<tr>
|
||||
<th>User</th>
|
||||
<th>Client</th>
|
||||
<th>Last Activity</th>
|
||||
<th>Expires</th>
|
||||
<th>IP Address</th>
|
||||
<th>Actions</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
{sessions.map(session => (
|
||||
<tr key={session.sessionId}>
|
||||
<td>
|
||||
<div>{session.userEmail}</div>
|
||||
<small className="text-muted">{session.displayName}</small>
|
||||
</td>
|
||||
<td>{session.clientName}</td>
|
||||
<td>{new Date(session.lastActivity).toLocaleString()}</td>
|
||||
<td>{new Date(session.expiresAt).toLocaleString()}</td>
|
||||
<td>{session.ipAddress || '-'}</td>
|
||||
<td>
|
||||
<button
|
||||
className="btn-small btn-danger"
|
||||
onClick={() => revokeSession(session.sessionId)}
|
||||
>
|
||||
Revoke
|
||||
</button>
|
||||
</td>
|
||||
</tr>
|
||||
))}
|
||||
{sessions.length === 0 && (
|
||||
<tr><td colSpan="6" className="empty-row">No active sessions</td></tr>
|
||||
)}
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
74
Client-Client/src/components/admin/StaffUsers.jsx
Normal file
74
Client-Client/src/components/admin/StaffUsers.jsx
Normal file
@@ -0,0 +1,74 @@
|
||||
/**
|
||||
* StaffUsers.jsx — Staff User Management
|
||||
* Read-only view of staff users from the Entra portal.
|
||||
* Users are added directly via Entra Portal → Users → New User.
|
||||
* This panel shows who currently has access and their session activity.
|
||||
*/
|
||||
|
||||
import React, { useState, useEffect, useCallback } from 'react';
|
||||
import { useAdmin } from '../../context/AdminContext';
|
||||
|
||||
export default function StaffUsers() {
|
||||
const { apiCall } = useAdmin();
|
||||
|
||||
const [users, setUsers] = useState([]);
|
||||
const [loading, setLoading] = useState(true);
|
||||
const [error, setError] = useState(null);
|
||||
|
||||
const loadUsers = useCallback(async () => {
|
||||
setLoading(true);
|
||||
const result = await apiCall('/api/admin/users');
|
||||
if (result?.ok) setUsers(result.data ?? result.users ?? []);
|
||||
else setError('Failed to load users.');
|
||||
setLoading(false);
|
||||
}, [apiCall]);
|
||||
|
||||
useEffect(() => { loadUsers(); }, [loadUsers]);
|
||||
|
||||
return (
|
||||
<div className="panel staff-users-panel">
|
||||
<div className="panel-toolbar">
|
||||
<h3 className="panel-title">Staff Users</h3>
|
||||
<span className="panel-note">
|
||||
Users are managed via <strong>Entra Portal → Users</strong>
|
||||
</span>
|
||||
</div>
|
||||
|
||||
{error && <div className="alert alert-error">{error}</div>}
|
||||
|
||||
{loading ? (
|
||||
<div className="loading-indicator">Loading...</div>
|
||||
) : (
|
||||
<table className="data-table">
|
||||
<thead>
|
||||
<tr>
|
||||
<th>Name</th>
|
||||
<th>Email</th>
|
||||
<th>Role</th>
|
||||
<th>Status</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
{users.length === 0 ? (
|
||||
<tr><td colSpan="4" className="empty-state">No users found.</td></tr>
|
||||
) : (
|
||||
users.map((u, i) => (
|
||||
<tr key={u.userId ?? u.id ?? i}>
|
||||
<td>{u.name ?? u.clientName ?? '—'}</td>
|
||||
<td>{u.email ?? u.userEmail ?? '—'}</td>
|
||||
<td><span className="badge badge-role">{u.role ?? 'Staff'}</span></td>
|
||||
<td><span className="badge badge-active">Active</span></td>
|
||||
</tr>
|
||||
))
|
||||
)}
|
||||
</tbody>
|
||||
</table>
|
||||
)}
|
||||
|
||||
<div className="panel-footer-note">
|
||||
<p>To add a new staff user: <strong>Entra Portal → Users → New User</strong>, then assign them to the Staff app registration.</p>
|
||||
<p>To remove access: <strong>Entra Portal → Users → select user → Delete</strong> or revoke the app assignment.</p>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
666
Client-Client/src/components/admin/TemplatesPanel.jsx
Normal file
666
Client-Client/src/components/admin/TemplatesPanel.jsx
Normal file
@@ -0,0 +1,666 @@
|
||||
import React, { useState, useRef, useEffect, memo } from 'react';
|
||||
import Picker from '@emoji-mart/react';
|
||||
import emojiData from '@emoji-mart/data';
|
||||
import { useAdmin } from '../../context/AdminContext';
|
||||
import { useTemplates, CHANNELS, CHANNEL_META } from '../../context/TemplatesContext';
|
||||
import { GATEWAY_API_URL } from '../../auth/authConfig';
|
||||
|
||||
// ─── Helpers ────────────────────────────────────────────────
|
||||
const fmt = (s) => (s || '').replace(/_/g, ' ').replace(/\b\w/g, c => c.toUpperCase());
|
||||
const truncate = (str, max) => !str ? '—' : str.length > max ? str.substring(0, max) + '…' : str;
|
||||
|
||||
|
||||
// ─── Donut Chart (SVG) — memoized ────────────────────────────
|
||||
const DonutChart = memo(function DonutChart({ allocations, size = 110, thickness = 20 }) {
|
||||
const center = size / 2;
|
||||
const radius = (size - thickness) / 2;
|
||||
const total = allocations.reduce((s, a) => s + (a.recommendedPct || 0), 0);
|
||||
|
||||
if (total === 0) {
|
||||
return (
|
||||
<svg width={size} height={size}>
|
||||
<circle cx={center} cy={center} r={radius} fill="none"
|
||||
stroke="var(--border)" strokeWidth={thickness} />
|
||||
</svg>
|
||||
);
|
||||
}
|
||||
|
||||
let cumulative = 0;
|
||||
const segments = allocations.map((a) => {
|
||||
const startAngle = (cumulative / total) * 2 * Math.PI - Math.PI / 2;
|
||||
cumulative += (a.recommendedPct || 0);
|
||||
const endAngle = (cumulative / total) * 2 * Math.PI - Math.PI / 2;
|
||||
const largeArc = (a.recommendedPct / total) * 360 > 180 ? 1 : 0;
|
||||
return {
|
||||
...a,
|
||||
d: `M ${center + radius * Math.cos(startAngle)} ${center + radius * Math.sin(startAngle)} A ${radius} ${radius} 0 ${largeArc} 1 ${center + radius * Math.cos(endAngle)} ${center + radius * Math.sin(endAngle)}`
|
||||
};
|
||||
});
|
||||
|
||||
const isBalanced = Math.abs(total - 100) < 0.01;
|
||||
|
||||
return (
|
||||
<svg width={size} height={size} viewBox={`0 0 ${size} ${size}`}>
|
||||
{segments.map((seg, i) => (
|
||||
<path key={i} d={seg.d} fill="none"
|
||||
stroke={CHANNEL_META[seg.channelType]?.color || '#999'}
|
||||
strokeWidth={thickness} strokeLinecap="butt" />
|
||||
))}
|
||||
<text x={center} y={center - 4} textAnchor="middle"
|
||||
style={{ fontSize: 15, fontWeight: 700, fill: isBalanced ? 'var(--success)' : 'var(--danger)' }}>
|
||||
{total}%
|
||||
</text>
|
||||
<text x={center} y={center + 12} textAnchor="middle"
|
||||
style={{ fontSize: 10, fill: 'var(--text-dim)' }}>
|
||||
total
|
||||
</text>
|
||||
</svg>
|
||||
);
|
||||
});
|
||||
|
||||
// ─── Icon Picker Popover (emoji-mart) — memoized ─────────────
|
||||
const IconPicker = memo(function IconPicker({ value, onChange }) {
|
||||
const [open, setOpen] = useState(false);
|
||||
const ref = useRef(null);
|
||||
const btnRef = useRef(null);
|
||||
const [above, setAbove] = useState(false);
|
||||
|
||||
useEffect(() => {
|
||||
if (!open) return;
|
||||
const handler = (e) => { if (ref.current && !ref.current.contains(e.target)) setOpen(false); };
|
||||
document.addEventListener('mousedown', handler);
|
||||
return () => document.removeEventListener('mousedown', handler);
|
||||
}, [open]);
|
||||
|
||||
useEffect(() => {
|
||||
if (open && btnRef.current) {
|
||||
const rect = btnRef.current.getBoundingClientRect();
|
||||
setAbove(rect.bottom + 360 > window.innerHeight);
|
||||
}
|
||||
}, [open]);
|
||||
|
||||
return (
|
||||
<span ref={ref} style={{ position: 'relative' }}>
|
||||
<button ref={btnRef} onClick={(e) => { e.stopPropagation(); setOpen(!open); }}
|
||||
className="icon-picker-btn">{value}</button>
|
||||
{open && (
|
||||
<div className="icon-picker-popover" style={{ [above ? 'bottom' : 'top']: 34 }}>
|
||||
<Picker
|
||||
data={emojiData}
|
||||
onEmojiSelect={(emoji) => { onChange(emoji.native); setOpen(false); }}
|
||||
theme="light"
|
||||
previewPosition="none"
|
||||
skinTonePosition="search"
|
||||
maxFrequentRows={1}
|
||||
navPosition="bottom"
|
||||
perLine={7}
|
||||
emojiSize={22}
|
||||
emojiButtonSize={30}
|
||||
/>
|
||||
</div>
|
||||
)}
|
||||
</span>
|
||||
);
|
||||
});
|
||||
|
||||
// ─── Color Picker — memoized ────────────────────────────────
|
||||
const ColorPicker = memo(function ColorPicker({ value, onChange }) {
|
||||
const [open, setOpen] = useState(false);
|
||||
const ref = useRef(null);
|
||||
const presets = ['#7dca7d','#4fc3f7','#ffb74d','#ce93d8','#ef5350','#90a4ae','#4db6ac','#ff8a65','#aed581','#7986cb'];
|
||||
|
||||
useEffect(() => {
|
||||
if (!open) return;
|
||||
const handler = (e) => { if (ref.current && !ref.current.contains(e.target)) setOpen(false); };
|
||||
document.addEventListener('mousedown', handler);
|
||||
return () => document.removeEventListener('mousedown', handler);
|
||||
}, [open]);
|
||||
|
||||
return (
|
||||
<span ref={ref} style={{ position: 'relative' }}>
|
||||
<button onClick={(e) => { e.stopPropagation(); setOpen(!open); }}
|
||||
className="color-picker-btn"
|
||||
style={{ backgroundColor: value || '#999' }} />
|
||||
{open && (
|
||||
<div className="color-picker-grid">
|
||||
{presets.map(c => (
|
||||
<button key={c}
|
||||
className={`color-picker-option ${c === value ? 'active' : ''}`}
|
||||
style={{ backgroundColor: c }}
|
||||
onClick={(e) => { e.stopPropagation(); onChange(c); setOpen(false); }} />
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
</span>
|
||||
);
|
||||
});
|
||||
|
||||
// ─── Channel Badge — memoized ───────────────────────────────
|
||||
const ChannelBadge = memo(function ChannelBadge({ channel }) {
|
||||
const meta = CHANNEL_META[channel] || {};
|
||||
return (
|
||||
<span className="channel-badge" style={{ borderColor: meta.color || '#666' }}>
|
||||
{meta.label || channel}
|
||||
</span>
|
||||
);
|
||||
});
|
||||
|
||||
|
||||
// ─── Template Create/Edit Form — uses both contexts ─────────
|
||||
const TemplateForm = memo(function TemplateForm({ editData, defaultCategory, defaultObjective, availableChannels, onSuccess, onCancel }) {
|
||||
const { apiCall } = useAdmin();
|
||||
const { categories, objectives } = useTemplates();
|
||||
|
||||
const channelOptions = availableChannels || CHANNELS;
|
||||
const lockCatObj = !!availableChannels;
|
||||
const [channelType, setChannelType] = useState(editData?.channelType || (channelOptions.length === 1 ? channelOptions[0] : ''));
|
||||
const [businessCategory, setBusinessCategory] = useState(editData?.businessCategory || defaultCategory || '');
|
||||
const [objective, setObjective] = useState(editData?.objective || defaultObjective || '');
|
||||
const [recommendedPct, setRecommendedPct] = useState(editData?.recommendedPct ?? '');
|
||||
const [minBudgetRequired, setMinBudgetRequired] = useState(editData?.minBudgetRequired ?? 0);
|
||||
const [rationale, setRationale] = useState(editData?.rationale || '');
|
||||
const [isActive, setIsActive] = useState(editData?.isActive ?? true);
|
||||
const [loading, setLoading] = useState(false);
|
||||
const [error, setError] = useState(null);
|
||||
|
||||
const isEdit = !!editData;
|
||||
const formTitle = lockCatObj
|
||||
? `Add Channel to ${fmt(defaultCategory)} \u00B7 ${fmt(defaultObjective)}`
|
||||
: (isEdit ? 'Edit Template' : 'Create Template');
|
||||
|
||||
const handleSubmit = async (e) => {
|
||||
e.preventDefault();
|
||||
setLoading(true);
|
||||
setError(null);
|
||||
|
||||
const url = isEdit
|
||||
? `/api/admin/templates/${editData.templateId}`
|
||||
: `/api/admin/templates`;
|
||||
|
||||
const result = await apiCall(url, isEdit ? 'PUT' : 'POST', {
|
||||
channelType, businessCategory, objective,
|
||||
recommendedPct: parseFloat(recommendedPct),
|
||||
minBudgetRequired: parseFloat(minBudgetRequired) || 0,
|
||||
rationale: rationale || null,
|
||||
...(isEdit ? { isActive } : {})
|
||||
});
|
||||
|
||||
if (result.ok) onSuccess();
|
||||
else setError(result.error || 'Operation failed');
|
||||
setLoading(false);
|
||||
};
|
||||
|
||||
return (
|
||||
<div className="admin-form">
|
||||
<h3>{formTitle}</h3>
|
||||
{error && <div className="error-message">{error}</div>}
|
||||
<form onSubmit={handleSubmit}>
|
||||
<div className="form-row-inline">
|
||||
<div className="form-group">
|
||||
<label>Channel</label>
|
||||
<select value={channelType} onChange={e => setChannelType(e.target.value)} required disabled={loading}>
|
||||
<option value="">Select…</option>
|
||||
{channelOptions.map(c => <option key={c} value={c}>{CHANNEL_META[c]?.label || c}</option>)}
|
||||
</select>
|
||||
</div>
|
||||
{!lockCatObj && (
|
||||
<div className="form-group">
|
||||
<label>Category</label>
|
||||
<select value={businessCategory} onChange={e => setBusinessCategory(e.target.value)} required disabled={loading}>
|
||||
<option value="">Select…</option>
|
||||
{categories.map(c => <option key={c.name} value={c.name}>{c.icon} {fmt(c.name)}</option>)}
|
||||
</select>
|
||||
</div>
|
||||
)}
|
||||
{!lockCatObj && (
|
||||
<div className="form-group">
|
||||
<label>Objective</label>
|
||||
<select value={objective} onChange={e => setObjective(e.target.value)} required disabled={loading}>
|
||||
<option value="">Select…</option>
|
||||
{objectives.map(o => <option key={o.name} value={o.name}>{fmt(o.name)}</option>)}
|
||||
</select>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
<div className="form-row-inline">
|
||||
<div className="form-group">
|
||||
<label>Recommended %</label>
|
||||
<input type="number" min="0" max="100" step="0.01" value={recommendedPct}
|
||||
onChange={e => setRecommendedPct(e.target.value)} required disabled={loading} />
|
||||
</div>
|
||||
<div className="form-group">
|
||||
<label>Min Budget ($)</label>
|
||||
<input type="number" min="0" step="1" value={minBudgetRequired}
|
||||
onChange={e => setMinBudgetRequired(e.target.value)} disabled={loading} />
|
||||
</div>
|
||||
{isEdit && (
|
||||
<div className="form-group">
|
||||
<label>Active</label>
|
||||
<select value={isActive ? 'true' : 'false'} onChange={e => setIsActive(e.target.value === 'true')} disabled={loading}>
|
||||
<option value="true">Yes</option>
|
||||
<option value="false">No</option>
|
||||
</select>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
<div className="form-row-inline">
|
||||
<div className="form-group" style={{ flex: '1 1 100%' }}>
|
||||
<label>Rationale</label>
|
||||
<input type="text" maxLength="500" value={rationale}
|
||||
onChange={e => setRationale(e.target.value)} disabled={loading}
|
||||
placeholder="Why this allocation is recommended…" />
|
||||
</div>
|
||||
</div>
|
||||
<div className="form-buttons">
|
||||
<button type="button" className="btn-cancel" onClick={onCancel} disabled={loading}>Cancel</button>
|
||||
<button type="submit" className="btn-primary" disabled={loading}>
|
||||
{loading ? 'Saving...' : isEdit ? 'Update' : 'Create'}
|
||||
</button>
|
||||
</div>
|
||||
</form>
|
||||
</div>
|
||||
);
|
||||
});
|
||||
|
||||
|
||||
// =============================================================
|
||||
// MAIN COMPONENT — pure presentation, all state from context
|
||||
// =============================================================
|
||||
export default function TemplatesPanel() {
|
||||
const { refresh } = useAdmin();
|
||||
const {
|
||||
templates, categories, objectives,
|
||||
catIconMap, objColorMap, catCounts,
|
||||
filtered, grouped,
|
||||
selectedCategory, setSelectedCategory,
|
||||
selectedObjective, setSelectedObjective,
|
||||
editMode, setEditMode,
|
||||
showForm, setShowForm,
|
||||
editingId, setEditingId,
|
||||
addingToGroup, setAddingToGroup,
|
||||
showNewCat, setShowNewCat,
|
||||
newCatName, setNewCatName,
|
||||
newCatIcon, setNewCatIcon,
|
||||
handleUpdateCategory, handleDeleteCategory, handleCreateCategory,
|
||||
handleUpdateObjective, handleDeleteTemplate,
|
||||
} = useTemplates();
|
||||
|
||||
const { sessionToken } = useAdmin();
|
||||
|
||||
// ── Forecast Validation State ──
|
||||
const [validating, setValidating] = useState(null); // "category|objective" key
|
||||
const [validationResults, setValidationResults] = useState({}); // keyed by "category|objective"
|
||||
|
||||
const handleValidateWithApi = async (category, objective, currentTemplates) => {
|
||||
const key = `${category}|${objective}`;
|
||||
setValidating(key);
|
||||
|
||||
try {
|
||||
const headers = { 'Content-Type': 'application/json' };
|
||||
if (sessionToken) headers['Authorization'] = `Bearer ${sessionToken}`;
|
||||
|
||||
const res = await fetch(`${GATEWAY_API_URL}/api/forecast/channel-estimate`, {
|
||||
method: 'POST',
|
||||
headers,
|
||||
body: JSON.stringify({
|
||||
objective,
|
||||
businessCategory: category,
|
||||
keywords: [category, objective],
|
||||
monthlyBudget: 1500, // benchmark budget for normalization
|
||||
channels: currentTemplates.map(t => t.channelType),
|
||||
}),
|
||||
});
|
||||
const data = await res.json().catch(() => null);
|
||||
|
||||
if (res.ok && data?.channels) {
|
||||
const apiAllocations = {};
|
||||
data.channels.forEach(ch => {
|
||||
apiAllocations[ch.provider] = {
|
||||
pct: ch.allocationPercent,
|
||||
score: ch.qualityScore,
|
||||
label: ch.strengthLabel,
|
||||
impressions: ch.estimates?.impressions,
|
||||
clicks: ch.estimates?.clicks,
|
||||
};
|
||||
});
|
||||
setValidationResults(prev => ({ ...prev, [key]: { ok: true, apiAllocations, timestamp: Date.now() } }));
|
||||
} else {
|
||||
setValidationResults(prev => ({ ...prev, [key]: { ok: false, error: data?.error || 'Forecast unavailable' } }));
|
||||
}
|
||||
} catch (e) {
|
||||
setValidationResults(prev => ({ ...prev, [key]: { ok: false, error: e.message } }));
|
||||
}
|
||||
setValidating(null);
|
||||
};
|
||||
|
||||
return (
|
||||
<div className="templates-layout">
|
||||
{/* ═══ SIDEBAR ═══ */}
|
||||
<aside className="templates-sidebar">
|
||||
{/* ── Categories ── */}
|
||||
<div className="sidebar-section">
|
||||
<div className="sidebar-section-header">
|
||||
<span className="sidebar-section-title">Categories</span>
|
||||
<button className="btn-icon-sm" onClick={() => setEditMode(!editMode)}
|
||||
title={editMode ? 'Done editing' : 'Edit categories'}>
|
||||
{editMode ? '✓' : '✎'}
|
||||
</button>
|
||||
</div>
|
||||
<div className="sidebar-list sidebar-list-scrollable">
|
||||
{categories.map(cat => (
|
||||
<div key={cat.categoryId || cat.name} className="sidebar-item-wrapper">
|
||||
<button
|
||||
className={`sidebar-item ${selectedCategory === cat.name ? 'active' : ''}`}
|
||||
onClick={() => setSelectedCategory(cat.name)}>
|
||||
{editMode ? (
|
||||
<IconPicker value={cat.icon || '📋'}
|
||||
onChange={(icon) => handleUpdateCategory(cat.categoryId, { icon })} />
|
||||
) : (
|
||||
<span className="sidebar-item-icon">{cat.icon || '📋'}</span>
|
||||
)}
|
||||
<span className="sidebar-item-label">{fmt(cat.name)}</span>
|
||||
<span className="sidebar-item-count">{catCounts[cat.name] || 0}</span>
|
||||
</button>
|
||||
{editMode && (
|
||||
<button
|
||||
className="btn-icon-xs btn-danger-icon"
|
||||
title={catCounts[cat.name] ? `Has ${catCounts[cat.name]} templates — remove them first` : 'Delete category'}
|
||||
disabled={!!catCounts[cat.name]}
|
||||
onClick={(e) => { e.stopPropagation(); handleDeleteCategory(cat.categoryId, cat.name); }}>
|
||||
✕
|
||||
</button>
|
||||
)}
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
{/* Add New — always visible */}
|
||||
{!showNewCat ? (
|
||||
<button className="sidebar-item sidebar-add-btn" onClick={() => setShowNewCat(true)}>
|
||||
<span className="sidebar-item-icon">+</span>
|
||||
<span className="sidebar-item-label">Add Category</span>
|
||||
</button>
|
||||
) : (
|
||||
<div className="sidebar-new-item">
|
||||
<IconPicker value={newCatIcon || '📋'}
|
||||
onChange={(icon) => setNewCatIcon(icon)} />
|
||||
<input
|
||||
value={newCatName}
|
||||
onChange={e => setNewCatName(e.target.value)}
|
||||
onKeyDown={e => e.key === 'Enter' && handleCreateCategory()}
|
||||
placeholder="category_name"
|
||||
autoFocus
|
||||
/>
|
||||
<button className="btn-icon-xs" onClick={handleCreateCategory}>✓</button>
|
||||
<button className="btn-icon-xs" onClick={() => { setShowNewCat(false); setNewCatName(''); setNewCatIcon(''); }}>✕</button>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* ── Objectives ── */}
|
||||
<div className="sidebar-section">
|
||||
<div className="sidebar-section-header">
|
||||
<span className="sidebar-section-title">Objectives</span>
|
||||
</div>
|
||||
<div className="sidebar-list sidebar-list-scrollable">
|
||||
<button
|
||||
className={`sidebar-item ${selectedObjective === null ? 'active' : ''}`}
|
||||
onClick={() => setSelectedObjective(null)}>
|
||||
<span className="sidebar-color-dot" style={{ background: 'var(--text-dim)' }} />
|
||||
<span className="sidebar-item-label">All Objectives</span>
|
||||
</button>
|
||||
{objectives.map(obj => (
|
||||
<button key={obj.objectiveId || obj.name}
|
||||
className={`sidebar-item ${selectedObjective === obj.name ? 'active' : ''}`}
|
||||
onClick={() => setSelectedObjective(selectedObjective === obj.name ? null : obj.name)}>
|
||||
{editMode ? (
|
||||
<ColorPicker value={obj.color || '#999'}
|
||||
onChange={(color) => handleUpdateObjective(obj.objectiveId, { color })} />
|
||||
) : (
|
||||
<span className="sidebar-color-dot" style={{ background: obj.color || '#999' }} />
|
||||
)}
|
||||
<span className="sidebar-item-label">{fmt(obj.name)}</span>
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
</aside>
|
||||
|
||||
{/* ═══ MAIN CONTENT ═══ */}
|
||||
<div className="templates-content">
|
||||
{/* Channel legend */}
|
||||
<div className="channel-legend-bar">
|
||||
{CHANNELS.map(ch => (
|
||||
<span key={ch} className="channel-chip" style={{ borderColor: CHANNEL_META[ch].color }}>
|
||||
<span className="channel-chip-dot" style={{ background: CHANNEL_META[ch].color }} />
|
||||
{CHANNEL_META[ch].label}
|
||||
</span>
|
||||
))}
|
||||
</div>
|
||||
|
||||
{/* Header */}
|
||||
<div className="panel-header">
|
||||
<div>
|
||||
<h2>
|
||||
{selectedCategory ? fmt(selectedCategory) : 'Templates'}
|
||||
{selectedObjective && <span className="header-objective-tag"
|
||||
style={{ background: objColorMap[selectedObjective] || '#999' }}>
|
||||
{fmt(selectedObjective)}
|
||||
</span>}
|
||||
</h2>
|
||||
<span className="filter-count">{filtered.length} template{filtered.length !== 1 ? 's' : ''}</span>
|
||||
</div>
|
||||
<button className="btn-action" onClick={() => { setShowForm(!showForm); setEditingId(null); }}>
|
||||
{showForm ? 'Cancel' : '+ Add Template'}
|
||||
</button>
|
||||
</div>
|
||||
|
||||
{/* Create/Edit form */}
|
||||
{showForm && (
|
||||
<TemplateForm
|
||||
defaultCategory={selectedCategory}
|
||||
defaultObjective={selectedObjective}
|
||||
onSuccess={() => { setShowForm(false); refresh(); }}
|
||||
onCancel={() => setShowForm(false)}
|
||||
/>
|
||||
)}
|
||||
{editingId && (
|
||||
<TemplateForm
|
||||
editData={templates.find(t => t.templateId === editingId)}
|
||||
onSuccess={() => { setEditingId(null); refresh(); }}
|
||||
onCancel={() => setEditingId(null)}
|
||||
/>
|
||||
)}
|
||||
|
||||
{/* Empty state */}
|
||||
{Object.keys(grouped).length === 0 && !showForm && (
|
||||
<div className="empty-state">
|
||||
<div className="empty-state-icon">📊</div>
|
||||
<p>No templates for this combination yet.</p>
|
||||
<p className="text-dim">Click "+ Add Template" to create one{selectedObjective ? ', or deselect the objective filter.' : '.'}</p>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Grouped template cards */}
|
||||
{Object.entries(grouped).map(([key, items]) => {
|
||||
const [cat, obj] = key.split('|');
|
||||
const totalPct = items.reduce((s, t) => s + (t.recommendedPct || 0), 0);
|
||||
const isBalanced = Math.abs(totalPct - 100) < 0.01;
|
||||
|
||||
return (
|
||||
<div key={key} className="template-group">
|
||||
<div className="template-group-header">
|
||||
<div className="template-group-info">
|
||||
<DonutChart allocations={items} />
|
||||
<div className="template-group-meta">
|
||||
<div className="template-group-title">
|
||||
<span className="cat-icon">{catIconMap[cat] || '📋'}</span>
|
||||
{fmt(cat)}
|
||||
<span className="objective-tag"
|
||||
style={{ background: objColorMap[obj] || '#999' }}>
|
||||
{fmt(obj)}
|
||||
</span>
|
||||
</div>
|
||||
<div className="template-group-stats">
|
||||
{items.length} channel{items.length !== 1 ? 's' : ''}
|
||||
{' · '}
|
||||
<span className={isBalanced ? 'text-success' : 'text-danger'}>
|
||||
{totalPct}% allocated {isBalanced ? '✓' : '⚠'}
|
||||
</span>
|
||||
</div>
|
||||
<div className="template-group-channel-bars">
|
||||
{items.map(t => (
|
||||
<div key={t.templateId} className="channel-bar">
|
||||
<span className="channel-bar-color"
|
||||
style={{ background: CHANNEL_META[t.channelType]?.color || '#999' }} />
|
||||
<span className="channel-bar-label">{CHANNEL_META[t.channelType]?.label || t.channelType}</span>
|
||||
<span className="channel-bar-pct">{t.recommendedPct}%</span>
|
||||
<div className="channel-bar-track">
|
||||
<div className="channel-bar-fill"
|
||||
style={{
|
||||
width: `${t.recommendedPct}%`,
|
||||
background: CHANNEL_META[t.channelType]?.color || '#999'
|
||||
}} />
|
||||
</div>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
{/* Validate with API button */}
|
||||
<button
|
||||
className="btn-small btn-validate"
|
||||
disabled={validating === key}
|
||||
onClick={() => handleValidateWithApi(cat, obj, items)}
|
||||
style={{ marginTop: 8 }}
|
||||
>
|
||||
{validating === key ? '⏳ Checking…' : '🔬 Validate with Forecast API'}
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* API Validation Results */}
|
||||
{validationResults[key] && (
|
||||
<div className={`validation-results ${validationResults[key].ok ? '' : 'validation-error'}`}>
|
||||
{validationResults[key].ok ? (
|
||||
<>
|
||||
<div className="validation-header">
|
||||
<span>📊 API Forecast Comparison</span>
|
||||
<span className="text-dim" style={{ fontSize: 11 }}>
|
||||
at $1,500/mo benchmark
|
||||
</span>
|
||||
</div>
|
||||
<div className="validation-comparison">
|
||||
{items.map(t => {
|
||||
const api = validationResults[key].apiAllocations[t.channelType];
|
||||
const diff = api ? (api.pct - t.recommendedPct) : null;
|
||||
return (
|
||||
<div key={t.templateId} className="validation-row">
|
||||
<span className="channel-bar-color" style={{ background: CHANNEL_META[t.channelType]?.color || '#999' }} />
|
||||
<span className="validation-ch">{CHANNEL_META[t.channelType]?.label || t.channelType}</span>
|
||||
<span className="validation-template">{t.recommendedPct}%</span>
|
||||
<span className="validation-arrow">→</span>
|
||||
<span className={`validation-api ${diff && Math.abs(diff) > 5 ? 'validation-divergent' : ''}`}>
|
||||
{api ? `${api.pct}%` : '—'}
|
||||
</span>
|
||||
{diff != null && Math.abs(diff) > 0 && (
|
||||
<span className={`validation-diff ${diff > 0 ? 'text-success' : 'text-danger'}`}>
|
||||
{diff > 0 ? '+' : ''}{diff}%
|
||||
</span>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
</>
|
||||
) : (
|
||||
<div className="validation-error-msg">
|
||||
⚠️ {validationResults[key].error}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
|
||||
<table className="data-table">
|
||||
<thead>
|
||||
<tr>
|
||||
<th>Channel</th>
|
||||
<th>Allocation %</th>
|
||||
<th>Min Budget</th>
|
||||
<th>Rationale</th>
|
||||
<th>Active</th>
|
||||
<th>Actions</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
{items.sort((a, b) => (b.recommendedPct || 0) - (a.recommendedPct || 0)).map(t => (
|
||||
<tr key={t.templateId} className={!t.isActive ? 'row-inactive' : ''}>
|
||||
<td><ChannelBadge channel={t.channelType} /></td>
|
||||
<td className="number-cell">{t.recommendedPct}%</td>
|
||||
<td className="number-cell">${t.minBudgetRequired}</td>
|
||||
<td className="rationale-cell" title={t.rationale || ''}>
|
||||
{truncate(t.rationale, 50)}
|
||||
</td>
|
||||
<td>{t.isActive ? '✓' : '—'}</td>
|
||||
<td>
|
||||
<button className="btn-small" onClick={() => setEditingId(t.templateId)}>Edit</button>
|
||||
{' '}
|
||||
<button className="btn-small btn-danger"
|
||||
onClick={() => handleDeleteTemplate(t.templateId)}>Delete</button>
|
||||
</td>
|
||||
</tr>
|
||||
))}
|
||||
</tbody>
|
||||
</table>
|
||||
|
||||
{/* Add Channel */}
|
||||
{(() => {
|
||||
const usedChannels = items.map(t => t.channelType);
|
||||
const missing = CHANNELS.filter(c => !usedChannels.includes(c));
|
||||
if (missing.length === 0) return null;
|
||||
|
||||
const isAdding = addingToGroup?.category === cat && addingToGroup?.objective === obj;
|
||||
return isAdding ? (
|
||||
<TemplateForm
|
||||
defaultCategory={cat}
|
||||
defaultObjective={obj}
|
||||
availableChannels={missing}
|
||||
onSuccess={() => { setAddingToGroup(null); refresh(); }}
|
||||
onCancel={() => setAddingToGroup(null)}
|
||||
/>
|
||||
) : (
|
||||
<button className="btn-small btn-add-channel"
|
||||
onClick={() => setAddingToGroup({ category: cat, objective: obj })}>
|
||||
+ Add Channel ({missing.map(c => CHANNEL_META[c]?.label || c).join(', ')})
|
||||
</button>
|
||||
);
|
||||
})()}
|
||||
</div>
|
||||
);
|
||||
})}
|
||||
|
||||
{/* Allocation Summary */}
|
||||
{Object.keys(grouped).length > 0 && (
|
||||
<div className="allocation-summary">
|
||||
<h3>Allocation Summary</h3>
|
||||
<div className="summary-grid">
|
||||
{Object.entries(grouped).map(([key, items]) => {
|
||||
const [cat, obj] = key.split('|');
|
||||
const totalPct = items.reduce((s, t) => s + (t.recommendedPct || 0), 0);
|
||||
const isBalanced = Math.abs(totalPct - 100) < 0.01;
|
||||
return (
|
||||
<div key={key} className={`summary-item ${isBalanced ? '' : 'summary-warning'}`}>
|
||||
<span className="summary-label">
|
||||
{catIconMap[cat] || '📋'} {fmt(cat)} / {fmt(obj)}
|
||||
</span>
|
||||
<span className={`summary-pct ${isBalanced ? 'summary-ok' : 'summary-bad'}`}>
|
||||
{totalPct}%
|
||||
</span>
|
||||
</div>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
176
Client-Client/src/components/admin/UsersPanel.jsx
Normal file
176
Client-Client/src/components/admin/UsersPanel.jsx
Normal file
@@ -0,0 +1,176 @@
|
||||
import React, { useState, useEffect } from 'react';
|
||||
import { useAdmin } from '../../context/AdminContext';
|
||||
|
||||
export default function UsersPanel() {
|
||||
const { data, apiCall, refresh } = useAdmin();
|
||||
const [showForm, setShowForm] = useState(false);
|
||||
const users = data.users || [];
|
||||
|
||||
const deleteUser = async (userId) => {
|
||||
if (!confirm('Are you sure you want to deactivate this user?')) return;
|
||||
const result = await apiCall(`/api/admin/users/${userId}`, 'DELETE');
|
||||
if (result.ok) refresh();
|
||||
else alert(result.error || 'Failed to deactivate user');
|
||||
};
|
||||
|
||||
return (
|
||||
<div className="table-panel">
|
||||
<div className="panel-header">
|
||||
<h2>Users ({data.totalCount})</h2>
|
||||
<button className="btn-action" onClick={() => setShowForm(!showForm)}>
|
||||
{showForm ? 'Cancel' : '+ Add User'}
|
||||
</button>
|
||||
</div>
|
||||
|
||||
{showForm && (
|
||||
<CreateUserForm
|
||||
onSuccess={() => { setShowForm(false); refresh(); }}
|
||||
onCancel={() => setShowForm(false)}
|
||||
/>
|
||||
)}
|
||||
|
||||
<table className="data-table">
|
||||
<thead>
|
||||
<tr>
|
||||
<th>Email</th>
|
||||
<th>Name</th>
|
||||
<th>Status</th>
|
||||
<th>Clients</th>
|
||||
<th>Created</th>
|
||||
<th>Actions</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
{users.map(user => (
|
||||
<tr key={user.userId}>
|
||||
<td>{user.email}</td>
|
||||
<td>{user.displayName || '-'}</td>
|
||||
<td><StatusBadge status={user.status} /></td>
|
||||
<td>{user.clients?.map(c => `${c.clientName} (${c.role})`).join(', ') || '-'}</td>
|
||||
<td>{new Date(user.createdAt).toLocaleDateString()}</td>
|
||||
<td>
|
||||
<button
|
||||
className="btn-small btn-danger"
|
||||
onClick={() => deleteUser(user.userId)}
|
||||
>
|
||||
Deactivate
|
||||
</button>
|
||||
</td>
|
||||
</tr>
|
||||
))}
|
||||
{users.length === 0 && (
|
||||
<tr><td colSpan="6" className="empty-row">No users found</td></tr>
|
||||
)}
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
function CreateUserForm({ onSuccess, onCancel }) {
|
||||
const { apiCall } = useAdmin();
|
||||
const [email, setEmail] = useState('');
|
||||
const [displayName, setDisplayName] = useState('');
|
||||
const [clientId, setClientId] = useState('');
|
||||
const [role, setRole] = useState('User');
|
||||
const [clients, setClients] = useState([]);
|
||||
const [loading, setLoading] = useState(false);
|
||||
const [loadingClients, setLoadingClients] = useState(true);
|
||||
const [error, setError] = useState(null);
|
||||
|
||||
useEffect(() => {
|
||||
const loadClients = async () => {
|
||||
const result = await apiCall('/api/admin/clients?pageSize=100');
|
||||
if (result.ok) setClients(result.clients || []);
|
||||
setLoadingClients(false);
|
||||
};
|
||||
loadClients();
|
||||
}, [apiCall]);
|
||||
|
||||
const handleSubmit = async (e) => {
|
||||
e.preventDefault();
|
||||
setLoading(true);
|
||||
setError(null);
|
||||
|
||||
const result = await apiCall('/api/admin/users', 'POST', {
|
||||
email,
|
||||
displayName: displayName || null,
|
||||
clientId: clientId || null,
|
||||
role
|
||||
});
|
||||
if (result.ok) {
|
||||
onSuccess();
|
||||
} else {
|
||||
setError(result.error || 'Failed to create user');
|
||||
}
|
||||
setLoading(false);
|
||||
};
|
||||
|
||||
return (
|
||||
<div className="admin-form">
|
||||
<h3>Create User</h3>
|
||||
{error && <div className="error-message">{error}</div>}
|
||||
<form onSubmit={handleSubmit}>
|
||||
<div className="form-group">
|
||||
<label>Email *</label>
|
||||
<input
|
||||
type="email"
|
||||
value={email}
|
||||
onChange={(e) => setEmail(e.target.value)}
|
||||
placeholder="user@example.com"
|
||||
disabled={loading}
|
||||
required
|
||||
autoFocus
|
||||
/>
|
||||
</div>
|
||||
<div className="form-group">
|
||||
<label>Display Name</label>
|
||||
<input
|
||||
type="text"
|
||||
value={displayName}
|
||||
onChange={(e) => setDisplayName(e.target.value)}
|
||||
placeholder="John Smith"
|
||||
disabled={loading}
|
||||
/>
|
||||
</div>
|
||||
<div className="form-group">
|
||||
<label>Assign to Client</label>
|
||||
<select
|
||||
value={clientId}
|
||||
onChange={(e) => setClientId(e.target.value)}
|
||||
disabled={loading || loadingClients}
|
||||
>
|
||||
<option value="">-- No client (invite later) --</option>
|
||||
{clients.filter(c => c.status === 'Active').map(c => (
|
||||
<option key={c.clientId} value={c.clientId}>{c.clientName}</option>
|
||||
))}
|
||||
</select>
|
||||
</div>
|
||||
{clientId && (
|
||||
<div className="form-group">
|
||||
<label>Role</label>
|
||||
<select value={role} onChange={(e) => setRole(e.target.value)} disabled={loading}>
|
||||
<option value="Admin">Admin</option>
|
||||
<option value="User">User</option>
|
||||
<option value="ReadOnly">Read Only</option>
|
||||
</select>
|
||||
</div>
|
||||
)}
|
||||
<div className="form-buttons">
|
||||
<button type="button" className="btn-cancel" onClick={onCancel} disabled={loading}>Cancel</button>
|
||||
<button type="submit" className="btn-primary" disabled={loading || !email.trim()}>
|
||||
{loading ? 'Creating...' : 'Create User'}
|
||||
</button>
|
||||
</div>
|
||||
</form>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
function StatusBadge({ status }) {
|
||||
const statusClass = {
|
||||
'Active': 'status-active',
|
||||
'Inactive': 'status-inactive',
|
||||
}[status] || 'status-default';
|
||||
return <span className={`status-badge ${statusClass}`}>{status}</span>;
|
||||
}
|
||||
16
Client-Client/src/components/client/CampaignsPanel.jsx
Normal file
16
Client-Client/src/components/client/CampaignsPanel.jsx
Normal file
@@ -0,0 +1,16 @@
|
||||
import React from 'react';
|
||||
import { useAdmin } from '../../context/AdminContext';
|
||||
import CampaignsView from './CampaignsView';
|
||||
|
||||
export default function CampaignsPanel() {
|
||||
const { setActiveTab, setWizardId } = useAdmin();
|
||||
|
||||
const handleNavigate = (view, params) => {
|
||||
if (view === 'wizard') {
|
||||
setWizardId(params?.wizardId || null);
|
||||
setActiveTab('newCampaign');
|
||||
}
|
||||
};
|
||||
|
||||
return <CampaignsView onNavigate={handleNavigate} />;
|
||||
}
|
||||
782
Client-Client/src/components/client/CampaignsView.jsx
Normal file
782
Client-Client/src/components/client/CampaignsView.jsx
Normal file
@@ -0,0 +1,782 @@
|
||||
import React, { useState, useEffect, useCallback } from 'react';
|
||||
import { useAuth } from '../../auth/AuthProvider';
|
||||
import {
|
||||
listInitiatives, getInitiative, updateInitiativeStatus,
|
||||
listChannelCampaigns, launchInitiative, getBilling
|
||||
} from '../../services/initiativeApi';
|
||||
import { listWizards, deleteWizard } from '../../services/wizardApi';
|
||||
|
||||
/* ─── Status mapping ─── */
|
||||
const STATUS_MAP = {
|
||||
draft: { label: 'Draft', css: 'status-draft', color: 'gray' },
|
||||
staged: { label: 'Ready to Launch', css: 'status-staged', color: 'blue' },
|
||||
pending: { label: 'Launching', css: 'status-pending', color: 'blue' },
|
||||
submitted: { label: 'In Review', css: 'status-in-review', color: 'blue' },
|
||||
pending_review: { label: 'In Review', css: 'status-in-review', color: 'blue' },
|
||||
in_review: { label: 'In Review', css: 'status-in-review', color: 'blue' },
|
||||
active: { label: 'Active', css: 'status-active', color: 'green' },
|
||||
paused: { label: 'Paused', css: 'status-paused', color: 'orange' },
|
||||
completed: { label: 'Completed', css: 'status-completed', color: 'gray' },
|
||||
cancelled: { label: 'Cancelled', css: 'status-cancelled', color: 'red' },
|
||||
error: { label: 'Action Needed', css: 'status-action-needed', color: 'red' },
|
||||
};
|
||||
|
||||
const IN_REVIEW_STATUSES = ['submitted', 'pending_review', 'in_review'];
|
||||
|
||||
function getDisplayStatus(initiative) {
|
||||
const raw = (initiative.iniStatus || initiative.status || 'draft').toLowerCase();
|
||||
if (raw === 'staged') {
|
||||
const start = initiative.iniStartDate || initiative.startDate;
|
||||
if (start && new Date(start) > new Date()) {
|
||||
return { label: 'Scheduled', css: 'status-scheduled', color: 'blue' };
|
||||
}
|
||||
}
|
||||
return STATUS_MAP[raw] || STATUS_MAP.draft;
|
||||
}
|
||||
|
||||
function getRawStatus(initiative) {
|
||||
return (initiative.iniStatus || initiative.status || 'draft').toLowerCase();
|
||||
}
|
||||
|
||||
function isInReview(raw) {
|
||||
return IN_REVIEW_STATUSES.includes(raw);
|
||||
}
|
||||
|
||||
function getId(init) {
|
||||
return init.iniInitiativeId || init.initiativeId || init.id;
|
||||
}
|
||||
|
||||
/* ─── Filter tabs ─── */
|
||||
const FILTER_TABS = [
|
||||
{ key: 'all', label: 'All' },
|
||||
{ key: 'active', label: 'Active' },
|
||||
{ key: 'in_review', label: 'In Review' },
|
||||
{ key: 'staged', label: 'Ready' },
|
||||
{ key: 'paused', label: 'Paused' },
|
||||
{ key: 'completed', label: 'Completed' },
|
||||
];
|
||||
|
||||
const FILTER_MAP = {
|
||||
'active': ['active'],
|
||||
'in_review': ['submitted', 'pending_review', 'in_review', 'pending'],
|
||||
'staged': ['staged'],
|
||||
'paused': ['paused'],
|
||||
'completed': ['completed', 'cancelled'],
|
||||
};
|
||||
|
||||
/* ─── Helpers ─── */
|
||||
const fmtBudget = (v) => {
|
||||
const n = parseFloat(v);
|
||||
if (!n || isNaN(n)) return '$0';
|
||||
return n >= 1000 ? `$${(n / 1000).toFixed(n % 1000 === 0 ? 0 : 1)}k` : `$${n.toFixed(0)}`;
|
||||
};
|
||||
|
||||
const fmtDate = (d) => {
|
||||
if (!d) return '—';
|
||||
return new Date(d).toLocaleDateString('en-US', { month: 'short', day: 'numeric', year: 'numeric' });
|
||||
};
|
||||
|
||||
const timeAgo = (dateStr) => {
|
||||
if (!dateStr) return '';
|
||||
const diff = Math.floor((Date.now() - new Date(dateStr).getTime()) / 1000);
|
||||
if (diff < 60) return 'just now';
|
||||
if (diff < 3600) return `${Math.floor(diff / 60)}m ago`;
|
||||
if (diff < 86400) return `${Math.floor(diff / 3600)}h ago`;
|
||||
return `${Math.floor(diff / 86400)}d ago`;
|
||||
};
|
||||
|
||||
const objectiveLabel = (obj) => {
|
||||
if (!obj) return 'Campaign';
|
||||
const map = {
|
||||
awareness: 'Brand Awareness', traffic: 'Website Traffic',
|
||||
leads: 'Lead Generation', sales: 'Sales', conversions: 'Conversions',
|
||||
};
|
||||
return map[obj.toLowerCase()] || obj.charAt(0).toUpperCase() + obj.slice(1);
|
||||
};
|
||||
|
||||
const channelLabel = (ch) => {
|
||||
const map = { google_ads: 'Google Ads', google: 'Google Ads', meta: 'Meta', tiktok: 'TikTok' };
|
||||
return map[(ch || '').toLowerCase()] || ch;
|
||||
};
|
||||
|
||||
/* ─── Icons ─── */
|
||||
const I = {
|
||||
search: <svg width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2"><circle cx="11" cy="11" r="8"/><line x1="21" y1="21" x2="16.65" y2="16.65"/></svg>,
|
||||
calendar: <svg width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2"><rect x="3" y="4" width="18" height="18" rx="2"/><line x1="16" y1="2" x2="16" y2="6"/><line x1="8" y1="2" x2="8" y2="6"/><line x1="3" y1="10" x2="21" y2="10"/></svg>,
|
||||
dollar: <svg width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2"><line x1="12" y1="1" x2="12" y2="23"/><path d="M17 5H9.5a3.5 3.5 0 000 7h5a3.5 3.5 0 010 7H6"/></svg>,
|
||||
play: <svg width="14" height="14" viewBox="0 0 24 24" fill="currentColor"><polygon points="5,3 19,12 5,21"/></svg>,
|
||||
pause: <svg width="14" height="14" viewBox="0 0 24 24" fill="currentColor"><rect x="6" y="4" width="4" height="16"/><rect x="14" y="4" width="4" height="16"/></svg>,
|
||||
rocket: <svg width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2"><path d="M4.5 16.5c-1.5 1.26-2 5-2 5s3.74-.5 5-2c.71-.84.7-2.13-.09-2.91a2.18 2.18 0 00-2.91-.09z"/><path d="M12 15l-3-3a22 22 0 012-3.95A12.88 12.88 0 0122 2c0 2.72-.78 7.5-6 11a22.35 22.35 0 01-4 2z"/><path d="M9 12H4s.55-3.03 2-4c1.62-1.08 5 0 5 0"/><path d="M12 15v5s3.03-.55 4-2c1.08-1.62 0-5 0-5"/></svg>,
|
||||
eye: <svg width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2"><path d="M1 12s4-8 11-8 11 8 11 8-4 8-11 8-11-8-11-8z"/><circle cx="12" cy="12" r="3"/></svg>,
|
||||
copy: <svg width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2"><rect x="9" y="9" width="13" height="13" rx="2"/><path d="M5 15H4a2 2 0 01-2-2V4a2 2 0 012-2h9a2 2 0 012 2v1"/></svg>,
|
||||
alert: <svg width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2"><circle cx="12" cy="12" r="10"/><line x1="12" y1="8" x2="12" y2="12"/><line x1="12" y1="16" x2="12.01" y2="16"/></svg>,
|
||||
pencil: <svg width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2"><path d="M11 4H4a2 2 0 00-2 2v14a2 2 0 002 2h14a2 2 0 002-2v-7"/><path d="M18.5 2.5a2.121 2.121 0 013 3L12 15l-4 1 1-4 9.5-9.5z"/></svg>,
|
||||
plus: <svg width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2"><line x1="12" y1="5" x2="12" y2="19"/><line x1="5" y1="12" x2="19" y2="12"/></svg>,
|
||||
close: <svg width="18" height="18" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2"><line x1="18" y1="6" x2="6" y2="18"/><line x1="6" y1="6" x2="18" y2="18"/></svg>,
|
||||
chart: <svg width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2"><line x1="18" y1="20" x2="18" y2="10"/><line x1="12" y1="20" x2="12" y2="4"/><line x1="6" y1="20" x2="6" y2="14"/></svg>,
|
||||
x: <svg width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2"><line x1="18" y1="6" x2="6" y2="18"/><line x1="6" y1="6" x2="18" y2="18"/></svg>,
|
||||
check: <svg width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2"><polyline points="20 6 9 17 4 12"/></svg>,
|
||||
clock: <svg width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2"><circle cx="12" cy="12" r="10"/><polyline points="12 6 12 12 16 14"/></svg>,
|
||||
};
|
||||
|
||||
|
||||
/* ═══════════════════════════════════════════════════
|
||||
Launch Confirmation Modal
|
||||
═══════════════════════════════════════════════════ */
|
||||
function LaunchModal({ initiative, billing, loading, onConfirm, onCancel }) {
|
||||
const name = initiative?.iniName || initiative?.name || 'Campaign';
|
||||
const budget = parseFloat(initiative?.iniBudget || initiative?.totalBudget || 0);
|
||||
|
||||
return (
|
||||
<div className="modal-overlay" onClick={onCancel}>
|
||||
<div className="modal-content launch-modal" onClick={e => e.stopPropagation()}>
|
||||
<div className="launch-modal-header">
|
||||
<div className="launch-modal-icon">{I.rocket}</div>
|
||||
<h3>Launch Campaign</h3>
|
||||
<p>Ready to go live with <strong>{name}</strong>?</p>
|
||||
</div>
|
||||
|
||||
{billing && (
|
||||
<div className="launch-modal-billing">
|
||||
<div className="billing-row">
|
||||
<span>Ad Spend</span>
|
||||
<span>{fmtBudget(billing.adSpend || budget)}/mo</span>
|
||||
</div>
|
||||
{billing.managementFee > 0 && (
|
||||
<div className="billing-row">
|
||||
<span>Management Fee</span>
|
||||
<span>{fmtBudget(billing.managementFee)}/mo</span>
|
||||
</div>
|
||||
)}
|
||||
<div className="billing-row billing-total">
|
||||
<span>Total Monthly</span>
|
||||
<span>{fmtBudget(billing.totalCharge || budget)}/mo</span>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{!billing && (
|
||||
<div className="launch-modal-billing">
|
||||
<div className="billing-row billing-total">
|
||||
<span>Monthly Budget</span>
|
||||
<span>{fmtBudget(budget)}/mo</span>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
<div className="launch-modal-actions">
|
||||
<button className="btn btn-outline" onClick={onCancel} disabled={loading}>Cancel</button>
|
||||
<button className="btn btn-primary btn-launch" onClick={onConfirm} disabled={loading}>
|
||||
{loading ? (
|
||||
<><span className="mini-spinner" /> Launching…</>
|
||||
) : (
|
||||
<>{I.rocket} Launch Now</>
|
||||
)}
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
|
||||
/* ═══════════════════════════════════════════════════
|
||||
Detail Panel (slide-in)
|
||||
═══════════════════════════════════════════════════ */
|
||||
function DetailPanel({ initiative, channels, onClose, onAction, actionLoading }) {
|
||||
if (!initiative) return null;
|
||||
|
||||
const ds = getDisplayStatus(initiative);
|
||||
const raw = getRawStatus(initiative);
|
||||
const budget = parseFloat(initiative.iniBudget || initiative.totalBudget || 0);
|
||||
const id = getId(initiative);
|
||||
const reviewStatus = isInReview(raw);
|
||||
|
||||
return (
|
||||
<div className="detail-panel-overlay" onClick={onClose}>
|
||||
<div className="detail-panel" onClick={e => e.stopPropagation()}>
|
||||
<div className="detail-panel-header">
|
||||
<div>
|
||||
<h3>{initiative.iniName || initiative.name || 'Untitled'}</h3>
|
||||
<span className={`status-badge ${ds.css}`}>{ds.label}</span>
|
||||
</div>
|
||||
<button className="btn btn-ghost btn-icon" onClick={onClose}>{I.close}</button>
|
||||
</div>
|
||||
|
||||
<div className="detail-panel-body">
|
||||
{/* In Review notice */}
|
||||
{reviewStatus && (
|
||||
<div className="detail-notice notice-info">
|
||||
<span className="notice-icon">{I.clock}</span>
|
||||
<div>
|
||||
<strong>Under Review</strong>
|
||||
<p>This campaign has been submitted to the ad network and is awaiting approval. This typically takes 24–48 hours.</p>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Overview */}
|
||||
<section className="detail-section">
|
||||
<h4>Overview</h4>
|
||||
<div className="detail-grid">
|
||||
<div className="detail-item">
|
||||
<span className="detail-label">Objective</span>
|
||||
<span className="detail-value">{objectiveLabel(initiative.iniObjective || initiative.objective)}</span>
|
||||
</div>
|
||||
<div className="detail-item">
|
||||
<span className="detail-label">Budget</span>
|
||||
<span className="detail-value">{fmtBudget(budget)}/{initiative.iniBudgetPeriod || initiative.budgetPeriod || 'month'}</span>
|
||||
</div>
|
||||
<div className="detail-item">
|
||||
<span className="detail-label">Start Date</span>
|
||||
<span className="detail-value">{fmtDate(initiative.iniStartDate || initiative.startDate)}</span>
|
||||
</div>
|
||||
<div className="detail-item">
|
||||
<span className="detail-label">End Date</span>
|
||||
<span className="detail-value">{fmtDate(initiative.iniEndDate || initiative.endDate)}</span>
|
||||
</div>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
{/* Channel Allocation */}
|
||||
{channels && channels.length > 0 && (
|
||||
<section className="detail-section">
|
||||
<h4>Channel Allocation</h4>
|
||||
<div className="channel-allocation-list">
|
||||
{channels.map((ch, i) => {
|
||||
const alloc = parseFloat(ch.chcAllocPct || ch.allocPct || 0);
|
||||
const chBudget = budget * (alloc / 100);
|
||||
const chStatus = (ch.chcStatus || ch.status || '').toLowerCase();
|
||||
const chDs = STATUS_MAP[chStatus] || STATUS_MAP.pending;
|
||||
return (
|
||||
<div key={i} className="channel-alloc-row">
|
||||
<div className="channel-alloc-info">
|
||||
<span className="channel-alloc-name">{channelLabel(ch.chcChannel || ch.channel)}</span>
|
||||
<span className={`status-badge-sm ${chDs.css}`}>{chDs.label}</span>
|
||||
<span className="channel-alloc-budget">{fmtBudget(chBudget)}</span>
|
||||
</div>
|
||||
<div className="channel-alloc-bar-track">
|
||||
<div className="channel-alloc-bar" style={{ width: `${Math.min(alloc, 100)}%` }} />
|
||||
</div>
|
||||
<span className="channel-alloc-pct">{alloc.toFixed(0)}%</span>
|
||||
</div>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
</section>
|
||||
)}
|
||||
|
||||
{/* Timeline */}
|
||||
<section className="detail-section">
|
||||
<h4>Timeline</h4>
|
||||
<div className="detail-timeline">
|
||||
<div className="timeline-item">
|
||||
<span className="timeline-dot created" />
|
||||
<span>Created {fmtDate(initiative.iniCreatedAt || initiative.createdAt)}</span>
|
||||
</div>
|
||||
{reviewStatus && (
|
||||
<div className="timeline-item">
|
||||
<span className="timeline-dot submitted" />
|
||||
<span>Submitted for review</span>
|
||||
</div>
|
||||
)}
|
||||
{(raw === 'active' || raw === 'paused' || raw === 'completed') && (
|
||||
<div className="timeline-item">
|
||||
<span className="timeline-dot launched" />
|
||||
<span>Launched {fmtDate(initiative.iniStartDate || initiative.startDate)}</span>
|
||||
</div>
|
||||
)}
|
||||
{raw === 'completed' && (
|
||||
<div className="timeline-item">
|
||||
<span className="timeline-dot completed" />
|
||||
<span>Completed {fmtDate(initiative.iniEndDate || initiative.endDate)}</span>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</section>
|
||||
|
||||
{/* Actions */}
|
||||
<section className="detail-section">
|
||||
<div className="detail-actions">
|
||||
{raw === 'staged' && (
|
||||
<>
|
||||
<button
|
||||
className="btn btn-primary"
|
||||
onClick={() => onAction('launch', initiative)}
|
||||
disabled={actionLoading === id}
|
||||
>
|
||||
{I.rocket} Launch Campaign
|
||||
</button>
|
||||
<button
|
||||
className="btn btn-ghost"
|
||||
onClick={() => onAction('cancel', initiative)}
|
||||
disabled={actionLoading === id}
|
||||
>
|
||||
{I.x} Cancel
|
||||
</button>
|
||||
</>
|
||||
)}
|
||||
{reviewStatus && (
|
||||
<button
|
||||
className="btn btn-outline btn-danger-outline"
|
||||
onClick={() => onAction('cancel', initiative)}
|
||||
disabled={actionLoading === id}
|
||||
>
|
||||
{I.x} Cancel Campaign
|
||||
</button>
|
||||
)}
|
||||
{raw === 'active' && (
|
||||
<button className="btn btn-outline" onClick={() => onAction('pause', initiative)}>
|
||||
{I.pause} Pause Campaign
|
||||
</button>
|
||||
)}
|
||||
{raw === 'paused' && (
|
||||
<button className="btn btn-primary" onClick={() => onAction('resume', initiative)}>
|
||||
{I.play} Resume Campaign
|
||||
</button>
|
||||
)}
|
||||
{(raw === 'completed' || raw === 'cancelled') && (
|
||||
<button className="btn btn-outline" onClick={() => onAction('duplicate', initiative)}>
|
||||
{I.copy} Duplicate Campaign
|
||||
</button>
|
||||
)}
|
||||
{raw === 'error' && (
|
||||
<button className="btn btn-primary" onClick={() => onAction('resolve', initiative)}>
|
||||
{I.alert} Resolve Issue
|
||||
</button>
|
||||
)}
|
||||
</div>
|
||||
</section>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
|
||||
/* ═══════════════════════════════════════════════════
|
||||
Main Campaigns Component
|
||||
═══════════════════════════════════════════════════ */
|
||||
export default function Campaigns({ onNavigate }) {
|
||||
const { sessionToken } = useAuth();
|
||||
const [initiatives, setInitiatives] = useState([]);
|
||||
const [drafts, setDrafts] = useState([]);
|
||||
const [loading, setLoading] = useState(true);
|
||||
const [activeFilter, setActiveFilter] = useState('all');
|
||||
const [search, setSearch] = useState('');
|
||||
const [selectedInit, setSelectedInit] = useState(null);
|
||||
const [selectedChannels, setSelectedChannels] = useState([]);
|
||||
const [actionLoading, setActionLoading] = useState(null);
|
||||
|
||||
// Launch modal
|
||||
const [launchTarget, setLaunchTarget] = useState(null);
|
||||
const [launchBilling, setLaunchBilling] = useState(null);
|
||||
const [launching, setLaunching] = useState(false);
|
||||
|
||||
/* ─── Data loading ─── */
|
||||
const loadData = useCallback(async () => {
|
||||
setLoading(true);
|
||||
try {
|
||||
const [initRes, draftRes] = await Promise.all([
|
||||
listInitiatives(sessionToken),
|
||||
listWizards(sessionToken, 'draft', 20),
|
||||
]);
|
||||
|
||||
if (initRes.ok) {
|
||||
const list = initRes.data?.data || initRes.data?.initiatives || [];
|
||||
setInitiatives(Array.isArray(list) ? list : []);
|
||||
}
|
||||
if (draftRes.ok) {
|
||||
const list = draftRes.data?.data || draftRes.data || [];
|
||||
setDrafts(Array.isArray(list) ? list : []);
|
||||
}
|
||||
} catch (e) {
|
||||
console.error('Failed to load campaigns:', e);
|
||||
}
|
||||
setLoading(false);
|
||||
}, [sessionToken]);
|
||||
|
||||
useEffect(() => {
|
||||
if (sessionToken) loadData();
|
||||
}, [sessionToken, loadData]);
|
||||
|
||||
/* ─── Open detail panel ─── */
|
||||
const openDetail = async (init) => {
|
||||
setSelectedInit(init);
|
||||
const id = getId(init);
|
||||
if (id) {
|
||||
const chRes = await listChannelCampaigns(id, sessionToken);
|
||||
if (chRes.ok) {
|
||||
const list = chRes.data?.data || chRes.data?.channels || [];
|
||||
setSelectedChannels(Array.isArray(list) ? list : []);
|
||||
}
|
||||
}
|
||||
};
|
||||
|
||||
const closeDetail = () => {
|
||||
setSelectedInit(null);
|
||||
setSelectedChannels([]);
|
||||
};
|
||||
|
||||
/* ─── Launch flow ─── */
|
||||
const startLaunch = async (init) => {
|
||||
const id = getId(init);
|
||||
setLaunchTarget(init);
|
||||
setLaunchBilling(null);
|
||||
|
||||
try {
|
||||
const billingRes = await getBilling(id, sessionToken);
|
||||
if (billingRes.ok && billingRes.data?.billing) {
|
||||
setLaunchBilling(billingRes.data.billing);
|
||||
}
|
||||
} catch (e) {
|
||||
// Billing fetch is optional
|
||||
}
|
||||
};
|
||||
|
||||
const confirmLaunch = async () => {
|
||||
if (!launchTarget) return;
|
||||
const id = getId(launchTarget);
|
||||
setLaunching(true);
|
||||
|
||||
try {
|
||||
const res = await launchInitiative(id, sessionToken);
|
||||
if (res.ok || res.data?.channels) {
|
||||
setLaunchTarget(null);
|
||||
closeDetail();
|
||||
await loadData();
|
||||
} else {
|
||||
alert(res.error || res.data?.error || 'Launch failed');
|
||||
}
|
||||
} catch (e) {
|
||||
alert('Launch error: ' + e.message);
|
||||
}
|
||||
|
||||
setLaunching(false);
|
||||
};
|
||||
|
||||
const cancelLaunch = () => {
|
||||
setLaunchTarget(null);
|
||||
setLaunchBilling(null);
|
||||
};
|
||||
|
||||
/* ─── Actions ─── */
|
||||
const handleAction = async (action, init) => {
|
||||
const id = getId(init);
|
||||
setActionLoading(id);
|
||||
|
||||
try {
|
||||
if (action === 'launch') {
|
||||
setActionLoading(null);
|
||||
startLaunch(init);
|
||||
return;
|
||||
} else if (action === 'pause') {
|
||||
await updateInitiativeStatus(id, 'paused', sessionToken);
|
||||
await loadData();
|
||||
closeDetail();
|
||||
} else if (action === 'resume') {
|
||||
await updateInitiativeStatus(id, 'active', sessionToken);
|
||||
await loadData();
|
||||
closeDetail();
|
||||
} else if (action === 'cancel') {
|
||||
if (window.confirm('Cancel this campaign? This action cannot be undone.')) {
|
||||
await updateInitiativeStatus(id, 'cancelled', sessionToken);
|
||||
await loadData();
|
||||
closeDetail();
|
||||
}
|
||||
} else if (action === 'duplicate') {
|
||||
onNavigate?.('wizard', { duplicateFrom: id });
|
||||
} else if (action === 'resolve') {
|
||||
openDetail(init);
|
||||
}
|
||||
} catch (e) {
|
||||
console.error(`Action ${action} failed:`, e);
|
||||
}
|
||||
setActionLoading(null);
|
||||
};
|
||||
|
||||
const continueDraft = (draft) => {
|
||||
const wizId = draft.wizWizardId || draft.wizardId || draft.id;
|
||||
onNavigate?.('wizard', { wizardId: wizId });
|
||||
};
|
||||
|
||||
const removeDraft = async (e, draft) => {
|
||||
e.stopPropagation();
|
||||
const wizId = draft.wizWizardId || draft.wizardId || draft.id;
|
||||
if (wizId && window.confirm('Delete this draft?')) {
|
||||
await deleteWizard(wizId, sessionToken);
|
||||
await loadData();
|
||||
}
|
||||
};
|
||||
|
||||
/* ─── Filtering ─── */
|
||||
const filteredInitiatives = initiatives.filter(init => {
|
||||
const raw = getRawStatus(init);
|
||||
|
||||
if (activeFilter !== 'all') {
|
||||
if (!FILTER_MAP[activeFilter]?.includes(raw)) return false;
|
||||
}
|
||||
|
||||
if (search) {
|
||||
const q = search.toLowerCase();
|
||||
const name = (init.iniName || init.name || '').toLowerCase();
|
||||
const obj = (init.iniObjective || init.objective || '').toLowerCase();
|
||||
const ds = getDisplayStatus(init);
|
||||
if (!name.includes(q) && !obj.includes(q) && !ds.label.toLowerCase().includes(q)) return false;
|
||||
}
|
||||
|
||||
return true;
|
||||
});
|
||||
|
||||
/* ─── Tab counts ─── */
|
||||
const tabCounts = {};
|
||||
FILTER_TABS.forEach(t => {
|
||||
if (t.key === 'all') {
|
||||
tabCounts[t.key] = initiatives.length;
|
||||
} else {
|
||||
tabCounts[t.key] = initiatives.filter(i => FILTER_MAP[t.key]?.includes(getRawStatus(i))).length;
|
||||
}
|
||||
});
|
||||
|
||||
/* ─── Render ─── */
|
||||
if (loading) {
|
||||
return (
|
||||
<div className="view-container">
|
||||
<div style={{ textAlign: 'center', padding: '60px 0', color: 'var(--color-text-muted)' }}>
|
||||
Loading campaigns…
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="view-container">
|
||||
{/* Header */}
|
||||
<div className="view-header">
|
||||
<div>
|
||||
<h2>Campaigns</h2>
|
||||
<p className="text-muted">Manage your advertising campaigns across all channels</p>
|
||||
</div>
|
||||
<button className="btn btn-primary" onClick={() => onNavigate?.('wizard')}>
|
||||
{I.plus} New Campaign
|
||||
</button>
|
||||
</div>
|
||||
|
||||
{/* Draft Section */}
|
||||
{drafts.length > 0 && (
|
||||
<div style={{ marginBottom: '24px' }}>
|
||||
<h4 style={{ fontSize: '13px', fontWeight: 600, color: 'var(--color-text-muted)', textTransform: 'uppercase', letterSpacing: '0.5px', marginBottom: '10px' }}>
|
||||
Drafts ({drafts.length})
|
||||
</h4>
|
||||
<div className="draft-cards">
|
||||
{drafts.map((d, i) => (
|
||||
<div key={i} className="draft-card" onClick={() => continueDraft(d)}>
|
||||
<span style={{ fontSize: '20px' }}>{I.pencil}</span>
|
||||
<div className="draft-info">
|
||||
<span style={{ fontWeight: 600, fontSize: '13px' }}>
|
||||
{d.wizName || d.name || `Draft ${i + 1}`}
|
||||
</span>
|
||||
<span style={{ fontSize: '12px', color: 'var(--color-text-muted)' }}>
|
||||
{timeAgo(d.wizUpdatedAt || d.updatedAt)}
|
||||
</span>
|
||||
</div>
|
||||
<button
|
||||
className="btn btn-ghost btn-icon"
|
||||
onClick={(e) => removeDraft(e, d)}
|
||||
title="Delete draft"
|
||||
style={{ marginLeft: 'auto', fontSize: '12px', opacity: 0.5 }}
|
||||
>×</button>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* ─── Toolbar: Filter Tabs + Search ─── */}
|
||||
<div className="campaigns-toolbar">
|
||||
<div className="filter-tabs">
|
||||
{FILTER_TABS.map(tab => {
|
||||
const count = tabCounts[tab.key] || 0;
|
||||
const isActive = activeFilter === tab.key;
|
||||
if (count === 0 && tab.key !== 'all' && !isActive) return null;
|
||||
return (
|
||||
<button
|
||||
key={tab.key}
|
||||
className={`filter-tab${isActive ? ' active' : ''}`}
|
||||
onClick={() => setActiveFilter(tab.key)}
|
||||
>
|
||||
{tab.label}
|
||||
{count > 0 && <span className="tab-count">{count}</span>}
|
||||
</button>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
<div className="search-box">
|
||||
<span className="search-icon">{I.search}</span>
|
||||
<input
|
||||
type="text"
|
||||
className="search-input"
|
||||
placeholder="Search campaigns…"
|
||||
value={search}
|
||||
onChange={e => setSearch(e.target.value)}
|
||||
/>
|
||||
{search && (
|
||||
<button className="search-clear" onClick={() => setSearch('')}>{I.x}</button>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Campaign Cards */}
|
||||
{filteredInitiatives.length === 0 ? (
|
||||
<div className="campaigns-empty">
|
||||
<div className="campaigns-empty-icon">{I.chart}</div>
|
||||
<p className="campaigns-empty-title">
|
||||
{initiatives.length === 0 ? 'No campaigns yet' : 'No campaigns match your filters'}
|
||||
</p>
|
||||
<p className="campaigns-empty-sub">
|
||||
{initiatives.length === 0
|
||||
? 'Create your first campaign to get started.'
|
||||
: 'Try adjusting your search or filter.'
|
||||
}
|
||||
</p>
|
||||
</div>
|
||||
) : (
|
||||
<div className="campaign-cards">
|
||||
{filteredInitiatives.map((init, i) => {
|
||||
const ds = getDisplayStatus(init);
|
||||
const raw = getRawStatus(init);
|
||||
const id = getId(init);
|
||||
const budget = parseFloat(init.iniBudget || init.totalBudget || 0);
|
||||
const channels = init.channels || init.channelSummary || [];
|
||||
const reviewStatus = isInReview(raw);
|
||||
|
||||
return (
|
||||
<div key={id || i} className="campaign-card" onClick={() => openDetail(init)}>
|
||||
<div className="campaign-card-header">
|
||||
<span className="campaign-card-title">
|
||||
{init.iniName || init.name || 'Untitled Campaign'}
|
||||
</span>
|
||||
<span className={`status-badge ${ds.css}`}>{ds.label}</span>
|
||||
</div>
|
||||
|
||||
<div className="campaign-card-body">
|
||||
<div className="campaign-card-meta">
|
||||
<span>{I.dollar} {fmtBudget(budget)}/{init.iniBudgetPeriod || init.budgetPeriod || 'monthly'}</span>
|
||||
<span>{I.calendar} {fmtDate(init.iniStartDate || init.startDate)}</span>
|
||||
{(init.iniObjective || init.objective) && (
|
||||
<span>{objectiveLabel(init.iniObjective || init.objective)}</span>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* Channel pills */}
|
||||
{Array.isArray(channels) && channels.length > 0 && (
|
||||
<div className="campaign-card-channels">
|
||||
{channels.map((ch, j) => {
|
||||
const chName = typeof ch === 'string' ? ch : (ch.chcChannel || ch.channel || ch.name);
|
||||
const pct = typeof ch === 'object' ? (ch.chcAllocPct || ch.allocPct) : null;
|
||||
return (
|
||||
<span key={j} className="channel-pill">
|
||||
{channelLabel(chName)}
|
||||
{pct ? ` ${parseFloat(pct).toFixed(0)}%` : ''}
|
||||
</span>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Action buttons — context-aware per status */}
|
||||
<div className="campaign-card-actions">
|
||||
{raw === 'draft' && (
|
||||
<button className="btn btn-sm btn-outline" onClick={e => { e.stopPropagation(); continueDraft(init); }}>
|
||||
{I.pencil} Continue
|
||||
</button>
|
||||
)}
|
||||
{raw === 'staged' && (
|
||||
<>
|
||||
<button
|
||||
className="btn btn-sm btn-primary"
|
||||
onClick={e => { e.stopPropagation(); handleAction('launch', init); }}
|
||||
disabled={actionLoading === id}
|
||||
>
|
||||
{I.rocket} Launch
|
||||
</button>
|
||||
<button
|
||||
className="btn btn-sm btn-ghost"
|
||||
onClick={e => { e.stopPropagation(); handleAction('cancel', init); }}
|
||||
>
|
||||
{I.x} Cancel
|
||||
</button>
|
||||
</>
|
||||
)}
|
||||
{reviewStatus && (
|
||||
<>
|
||||
<button className="btn btn-sm btn-outline" onClick={e => { e.stopPropagation(); openDetail(init); }}>
|
||||
{I.eye} View Details
|
||||
</button>
|
||||
<button className="btn btn-sm btn-ghost" onClick={e => { e.stopPropagation(); handleAction('cancel', init); }}>
|
||||
{I.x} Cancel
|
||||
</button>
|
||||
</>
|
||||
)}
|
||||
{raw === 'active' && (
|
||||
<>
|
||||
<button className="btn btn-sm btn-outline" onClick={e => { e.stopPropagation(); openDetail(init); }}>
|
||||
{I.chart} Performance
|
||||
</button>
|
||||
<button className="btn btn-sm btn-ghost" onClick={e => { e.stopPropagation(); handleAction('pause', init); }}>
|
||||
{I.pause} Pause
|
||||
</button>
|
||||
</>
|
||||
)}
|
||||
{raw === 'paused' && (
|
||||
<button className="btn btn-sm btn-primary" onClick={e => { e.stopPropagation(); handleAction('resume', init); }}>
|
||||
{I.play} Resume
|
||||
</button>
|
||||
)}
|
||||
{raw === 'pending' && (
|
||||
<button className="btn btn-sm btn-outline" onClick={e => { e.stopPropagation(); openDetail(init); }}>
|
||||
{I.eye} View Details
|
||||
</button>
|
||||
)}
|
||||
{(raw === 'completed' || raw === 'cancelled') && (
|
||||
<button className="btn btn-sm btn-outline" onClick={e => { e.stopPropagation(); handleAction('duplicate', init); }}>
|
||||
{I.copy} Duplicate
|
||||
</button>
|
||||
)}
|
||||
{raw === 'error' && (
|
||||
<button className="btn btn-sm btn-primary" onClick={e => { e.stopPropagation(); handleAction('resolve', init); }}>
|
||||
{I.alert} Resolve
|
||||
</button>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Detail Panel */}
|
||||
{selectedInit && (
|
||||
<DetailPanel
|
||||
initiative={selectedInit}
|
||||
channels={selectedChannels}
|
||||
onClose={closeDetail}
|
||||
onAction={handleAction}
|
||||
actionLoading={actionLoading}
|
||||
/>
|
||||
)}
|
||||
|
||||
{/* Launch Confirmation Modal */}
|
||||
{launchTarget && (
|
||||
<LaunchModal
|
||||
initiative={launchTarget}
|
||||
billing={launchBilling}
|
||||
loading={launching}
|
||||
onConfirm={confirmLaunch}
|
||||
onCancel={cancelLaunch}
|
||||
/>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
210
Client-Client/src/components/client/DocumentsPanel.jsx
Normal file
210
Client-Client/src/components/client/DocumentsPanel.jsx
Normal file
@@ -0,0 +1,210 @@
|
||||
import React, { useState, useEffect, useCallback, useRef } from 'react';
|
||||
import { useAdmin } from '../../context/AdminContext';
|
||||
|
||||
const CATEGORIES = ['Business', 'Technical', 'Legal', 'Operations', 'Financial'];
|
||||
const CATEGORY_COLORS = {
|
||||
Business: { bg: '#dbeafe', text: '#1e40af' },
|
||||
Technical: { bg: '#dcfce7', text: '#166534' },
|
||||
Legal: { bg: '#fef9c3', text: '#854d0e' },
|
||||
Operations: { bg: '#ede9fe', text: '#5b21b6' },
|
||||
Financial: { bg: '#fce7f3', text: '#9d174d' },
|
||||
};
|
||||
|
||||
const formatBytes = (b) => !b ? '—' : b < 1048576 ? `${(b/1024).toFixed(1)} KB` : `${(b/1048576).toFixed(1)} MB`;
|
||||
const formatDate = (iso) => !iso ? '—' : new Date(iso).toLocaleString('en-US', { month: 'short', day: 'numeric', year: 'numeric', hour: 'numeric', minute: '2-digit' });
|
||||
|
||||
function FileIcon({ mimeType = '' }) {
|
||||
if (mimeType.includes('pdf')) return <span>📕</span>;
|
||||
if (mimeType.includes('word') || mimeType.includes('document')) return <span>📘</span>;
|
||||
if (mimeType.includes('sheet') || mimeType.includes('excel')) return <span>📗</span>;
|
||||
if (mimeType.includes('image')) return <span>🖼️</span>;
|
||||
return <span>📄</span>;
|
||||
}
|
||||
|
||||
function CategoryBadge({ category }) {
|
||||
const s = CATEGORY_COLORS[category] || { bg: '#f1f5f9', text: '#475569' };
|
||||
return <span style={{ background: s.bg, color: s.text, padding: '2px 8px', borderRadius: 12, fontSize: 11, fontWeight: 600 }}>{category || 'Uncategorized'}</span>;
|
||||
}
|
||||
|
||||
export default function DocumentsPanel() {
|
||||
const { apiCall } = useAdmin();
|
||||
const [docs, setDocs] = useState([]);
|
||||
const [loading, setLoading] = useState(true);
|
||||
const [error, setError] = useState(null);
|
||||
const [showUpload, setShowUpload] = useState(false);
|
||||
const [uploadFile, setUploadFile] = useState(null);
|
||||
const [uploadCat, setUploadCat] = useState('Business');
|
||||
const [uploadDesc, setUploadDesc] = useState('');
|
||||
const [uploading, setUploading] = useState(false);
|
||||
const [uploadError, setUploadError] = useState(null);
|
||||
const [downloading, setDownloading] = useState(null);
|
||||
const [deleteId, setDeleteId] = useState(null);
|
||||
const [deleting, setDeleting] = useState(false);
|
||||
const fileInputRef = useRef();
|
||||
|
||||
const load = useCallback(async () => {
|
||||
setLoading(true);
|
||||
setError(null);
|
||||
const res = await apiCall('/api/documents/list', 'POST', {});
|
||||
if (res?.ok !== false) {
|
||||
setDocs(Array.isArray(res?.documents) ? res.documents : []);
|
||||
} else {
|
||||
setError(res?.error || 'Failed to load documents');
|
||||
}
|
||||
setLoading(false);
|
||||
}, [apiCall]);
|
||||
|
||||
useEffect(() => { load(); }, [load]);
|
||||
|
||||
const handleUpload = async () => {
|
||||
if (!uploadFile) { setUploadError('Please select a file'); return; }
|
||||
setUploading(true);
|
||||
setUploadError(null);
|
||||
try {
|
||||
const fd = new FormData();
|
||||
fd.append('file', uploadFile);
|
||||
fd.append('category', uploadCat);
|
||||
fd.append('description', uploadDesc);
|
||||
const res = await apiCall('/api/documents', { method: 'POST', body: fd });
|
||||
if (!res?.ok) throw new Error(res?.error || 'Upload failed');
|
||||
setShowUpload(false);
|
||||
setUploadFile(null);
|
||||
setUploadDesc('');
|
||||
await load();
|
||||
} catch (err) {
|
||||
setUploadError(err.message);
|
||||
} finally {
|
||||
setUploading(false);
|
||||
}
|
||||
};
|
||||
|
||||
const handleDownload = async (doc) => {
|
||||
setDownloading(doc.docId);
|
||||
try {
|
||||
const res = await apiCall(`/api/documents/${doc.docId}/download`, { rawResponse: true });
|
||||
if (!res) throw new Error('No response');
|
||||
const blob = await res.blob();
|
||||
const url = URL.createObjectURL(blob);
|
||||
const a = document.createElement('a');
|
||||
a.href = url; a.download = doc.docFileName; a.click();
|
||||
URL.revokeObjectURL(url);
|
||||
} catch (err) {
|
||||
alert(`Download failed: ${err.message}`);
|
||||
} finally {
|
||||
setDownloading(null);
|
||||
}
|
||||
};
|
||||
|
||||
const handleDelete = async () => {
|
||||
if (!deleteId) return;
|
||||
setDeleting(true);
|
||||
try {
|
||||
const res = await apiCall(`/api/documents/${deleteId}`, { method: 'DELETE' });
|
||||
if (!res?.ok) throw new Error(res?.error || 'Delete failed');
|
||||
setDeleteId(null);
|
||||
await load();
|
||||
} catch (err) {
|
||||
alert(`Delete failed: ${err.message}`);
|
||||
} finally {
|
||||
setDeleting(false);
|
||||
}
|
||||
};
|
||||
|
||||
return (
|
||||
<div className="data-panel" style={{ maxWidth: 900 }}>
|
||||
<div className="panel-header">
|
||||
<div>
|
||||
<h3 className="panel-title">Documents</h3>
|
||||
<p className="panel-subtitle">{loading ? 'Loading…' : `${docs.length} document${docs.length !== 1 ? 's' : ''}`}</p>
|
||||
</div>
|
||||
<button className="btn-action" onClick={() => { setShowUpload(true); setUploadError(null); }}>↑ Upload</button>
|
||||
</div>
|
||||
|
||||
{showUpload && (
|
||||
<div className="admin-form" style={{ marginBottom: 20 }}>
|
||||
<div className="form-title">Upload Document</div>
|
||||
<div className="form-group">
|
||||
<label className="form-label">File</label>
|
||||
<div style={{ border: `2px dashed ${uploadFile ? 'var(--accent)' : '#cbd5e1'}`, borderRadius: 6, padding: '16px 20px', textAlign: 'center', cursor: 'pointer', background: uploadFile ? '#eff6ff' : '#fff' }}
|
||||
onClick={() => fileInputRef.current?.click()}>
|
||||
{uploadFile
|
||||
? <div style={{ color: 'var(--accent)', fontWeight: 600 }}>📎 {uploadFile.name} <span style={{ color: '#64748b', fontWeight: 400 }}>({formatBytes(uploadFile.size)})</span></div>
|
||||
: <div style={{ color: '#94a3b8' }}>Click to choose a file</div>}
|
||||
<input ref={fileInputRef} type="file" style={{ display: 'none' }} onChange={e => setUploadFile(e.target.files?.[0] || null)} />
|
||||
</div>
|
||||
</div>
|
||||
<div className="form-group">
|
||||
<label className="form-label">Category</label>
|
||||
<select className="form-select" value={uploadCat} onChange={e => setUploadCat(e.target.value)}>
|
||||
{CATEGORIES.map(c => <option key={c} value={c}>{c}</option>)}
|
||||
</select>
|
||||
</div>
|
||||
<div className="form-group">
|
||||
<label className="form-label">Description (optional)</label>
|
||||
<input className="form-input" type="text" placeholder="Brief description…" value={uploadDesc} onChange={e => setUploadDesc(e.target.value)} />
|
||||
</div>
|
||||
{uploadError && <div className="error-message" style={{ marginBottom: 12 }}>{uploadError}</div>}
|
||||
<div className="form-buttons">
|
||||
<button className="btn-action" onClick={handleUpload} disabled={uploading || !uploadFile}>{uploading ? 'Uploading…' : 'Upload'}</button>
|
||||
<button className="btn-cancel" onClick={() => { setShowUpload(false); setUploadFile(null); setUploadError(null); }} disabled={uploading}>Cancel</button>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{loading && <div className="loading-message">Loading documents…</div>}
|
||||
{error && <div className="error-message">{error}</div>}
|
||||
|
||||
{!loading && !error && docs.length === 0 && (
|
||||
<div className="empty-state">No documents yet. Upload your first document above.</div>
|
||||
)}
|
||||
|
||||
{!loading && !error && docs.length > 0 && (
|
||||
<table className="data-table">
|
||||
<thead>
|
||||
<tr>
|
||||
<th style={{ width: 36 }}></th>
|
||||
<th>File</th>
|
||||
<th>Category</th>
|
||||
<th>Size</th>
|
||||
<th>Uploaded</th>
|
||||
<th style={{ width: 80 }}>Actions</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
{docs.map(doc => (
|
||||
<tr key={doc.docId}>
|
||||
<td style={{ textAlign: 'center', fontSize: 18 }}><FileIcon mimeType={doc.docMimeType} /></td>
|
||||
<td>
|
||||
<div style={{ fontWeight: 500, color: '#1e293b' }}>{doc.docFileName}</div>
|
||||
{doc.docDescription && <div style={{ fontSize: 12, color: '#64748b', marginTop: 2 }}>{doc.docDescription}</div>}
|
||||
</td>
|
||||
<td><CategoryBadge category={doc.docCategory} /></td>
|
||||
<td style={{ color: '#64748b', fontSize: 13 }}>{formatBytes(doc.docFileSize)}</td>
|
||||
<td style={{ color: '#64748b', fontSize: 13 }}>{formatDate(doc.docUploadedAt)}</td>
|
||||
<td>
|
||||
<div style={{ display: 'flex', gap: 6 }}>
|
||||
<button className="btn-icon" title="Download" disabled={downloading === doc.docId} onClick={() => handleDownload(doc)}>{downloading === doc.docId ? '…' : '↓'}</button>
|
||||
<button className="btn-icon btn-icon-danger" title="Delete" onClick={() => setDeleteId(doc.docId)}>✕</button>
|
||||
</div>
|
||||
</td>
|
||||
</tr>
|
||||
))}
|
||||
</tbody>
|
||||
</table>
|
||||
)}
|
||||
|
||||
{deleteId && (
|
||||
<div className="modal-overlay">
|
||||
<div className="modal-box">
|
||||
<h4 className="modal-title">Delete Document?</h4>
|
||||
<p className="modal-body">This will permanently remove the document.</p>
|
||||
<div className="modal-buttons">
|
||||
<button className="btn-danger" onClick={handleDelete} disabled={deleting}>{deleting ? 'Deleting…' : 'Delete'}</button>
|
||||
<button className="btn-cancel" onClick={() => setDeleteId(null)} disabled={deleting}>Cancel</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
71
Client-Client/src/components/client/HelpPanel.jsx
Normal file
71
Client-Client/src/components/client/HelpPanel.jsx
Normal file
@@ -0,0 +1,71 @@
|
||||
import React, { useState, useEffect } from 'react';
|
||||
import { useAdmin } from '../../context/AdminContext';
|
||||
import { MANAGEMENT_URL } from '../../auth/authConfig';
|
||||
|
||||
export default function HelpPanel() {
|
||||
const { sessionToken } = useAdmin();
|
||||
const { apiCall } = useAdmin();
|
||||
const [items, setItems] = useState([]);
|
||||
const [loading, setLoading] = useState(true);
|
||||
const [open, setOpen] = useState(null);
|
||||
const [search, setSearch] = useState('');
|
||||
|
||||
useEffect(() => {
|
||||
(async () => {
|
||||
setLoading(true);
|
||||
// Help content lives on Management API
|
||||
const res = await apiCall(`${MANAGEMENT_URL}/api/help/list`, 'POST', { includeInactive: false });
|
||||
const list = Array.isArray(res?.items) ? res.items :
|
||||
Array.isArray(res?.data) ? res.data : [];
|
||||
setItems(list);
|
||||
setLoading(false);
|
||||
})();
|
||||
}, [apiCall]);
|
||||
|
||||
const filtered = items.filter(item => !search ||
|
||||
(item.title || item.Title || '').toLowerCase().includes(search.toLowerCase()) ||
|
||||
(item.content || item.Content || '').toLowerCase().includes(search.toLowerCase()));
|
||||
|
||||
if (loading) return <div className="loading-message">Loading help content…</div>;
|
||||
|
||||
return (
|
||||
<div style={{ maxWidth: 760 }}>
|
||||
<h2 style={{ fontSize: 20, fontWeight: 700, marginBottom: 16 }}>Help & FAQs</h2>
|
||||
<input
|
||||
className="form-input"
|
||||
style={{ marginBottom: 20, maxWidth: 400 }}
|
||||
placeholder="Search help articles…"
|
||||
value={search}
|
||||
onChange={e => setSearch(e.target.value)}
|
||||
/>
|
||||
{filtered.length === 0 ? (
|
||||
<div className="empty-state">No help articles found.</div>
|
||||
) : (
|
||||
<div style={{ display: 'flex', flexDirection: 'column', gap: 10 }}>
|
||||
{filtered.map((item, idx) => {
|
||||
const id = item.helpId || item.HelpId || idx;
|
||||
const title = item.title || item.Title || 'Article';
|
||||
const body = item.content || item.Content || '';
|
||||
const isOpen = open === id;
|
||||
return (
|
||||
<div key={id} style={{ background: '#fff', border: '1px solid #e2e8f0', borderRadius: 8, overflow: 'hidden' }}>
|
||||
<button
|
||||
onClick={() => setOpen(isOpen ? null : id)}
|
||||
style={{ width: '100%', padding: '14px 18px', display: 'flex', justifyContent: 'space-between', alignItems: 'center', background: 'none', border: 'none', cursor: 'pointer', textAlign: 'left' }}
|
||||
>
|
||||
<span style={{ fontWeight: 600, fontSize: 14, color: '#0f172a' }}>{title}</span>
|
||||
<span style={{ color: '#94a3b8', fontSize: 18, lineHeight: 1 }}>{isOpen ? '−' : '+'}</span>
|
||||
</button>
|
||||
{isOpen && (
|
||||
<div style={{ padding: '0 18px 16px', fontSize: 14, color: '#475569', lineHeight: 1.6 }}>
|
||||
{body}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
574
Client-Client/src/components/client/IntelligenceView.jsx
Normal file
574
Client-Client/src/components/client/IntelligenceView.jsx
Normal file
@@ -0,0 +1,574 @@
|
||||
import React, { useState, useEffect, useCallback } from 'react';
|
||||
import { useAuth } from '../../auth/AuthProvider';
|
||||
import {
|
||||
getCampaignHealth, getBudgetPacing, getPostCampaignReport,
|
||||
getRecommendations, getInitiativeRecommendations,
|
||||
dismissRecommendation, resolveRecommendation
|
||||
} from '../../services/intelligenceApi';
|
||||
|
||||
/* ─── Formatters ─── */
|
||||
const fmtNum = (n) => n != null ? Number(n).toLocaleString() : '—';
|
||||
const fmtCur = (n) => n != null ? '$' + Number(n).toLocaleString(undefined, { minimumFractionDigits: 2, maximumFractionDigits: 2 }) : '$0.00';
|
||||
const fmtPct = (n) => n != null ? Number(n).toFixed(1) + '%' : '—';
|
||||
const fmtDate = (d) => d ? new Date(d).toLocaleDateString('en-US', { month: 'short', day: 'numeric' }) : '—';
|
||||
const fmtDateFull = (d) => d ? new Date(d).toLocaleDateString('en-US', { month: 'short', day: 'numeric', year: 'numeric' }) : '—';
|
||||
const timeAgo = (d) => {
|
||||
if (!d) return '';
|
||||
const diff = Math.floor((Date.now() - new Date(d).getTime()) / 1000);
|
||||
if (diff < 60) return 'just now';
|
||||
if (diff < 3600) return `${Math.floor(diff / 60)}m ago`;
|
||||
if (diff < 86400) return `${Math.floor(diff / 3600)}h ago`;
|
||||
return `${Math.floor(diff / 86400)}d ago`;
|
||||
};
|
||||
|
||||
const channelLabel = (ch) => ({
|
||||
google_ads: 'Google Ads', google: 'Google Ads', meta: 'Meta', tiktok: 'TikTok'
|
||||
})[(ch || '').toLowerCase()] || ch;
|
||||
|
||||
const severityConfig = {
|
||||
critical: { label: 'Critical', css: 'sev-critical', icon: '⚠' },
|
||||
warning: { label: 'Warning', css: 'sev-warning', icon: '⚡' },
|
||||
info: { label: 'Info', css: 'sev-info', icon: 'ℹ' },
|
||||
};
|
||||
|
||||
const healthColors = {
|
||||
green: { label: 'Healthy', css: 'health-green', dot: '🟢' },
|
||||
yellow: { label: 'Needs Attention', css: 'health-yellow', dot: '🟡' },
|
||||
red: { label: 'Action Required', css: 'health-red', dot: '🔴' },
|
||||
};
|
||||
|
||||
const TABS = [
|
||||
{ id: 'health', label: 'Health Overview', icon: 'M9 12l2 2 4-4m6 2a9 9 0 11-18 0 9 9 0 0118 0z' },
|
||||
{ id: 'recommendations', label: 'Recommendations', icon: 'M13 10V3L4 14h7v7l9-11h-7z' },
|
||||
{ id: 'pacing', label: 'Budget Pacing', icon: 'M12 8c-1.657 0-3 .895-3 2s1.343 2 3 2 3 .895 3 2-1.343 2-3 2m0-8c1.11 0 2.08.402 2.599 1M12 8V7m0 1v8m0 0v1m0-1c-1.11 0-2.08-.402-2.599-1M21 12a9 9 0 11-18 0 9 9 0 0118 0z' },
|
||||
];
|
||||
|
||||
/* ═══════════════════════════════════════════════════
|
||||
Main Intelligence View
|
||||
═══════════════════════════════════════════════════ */
|
||||
export default function Intelligence({ onNavigate }) {
|
||||
const { sessionToken } = useAuth();
|
||||
const [activeTab, setActiveTab] = useState('health');
|
||||
const [health, setHealth] = useState(null);
|
||||
const [recommendations, setRecommendations] = useState(null);
|
||||
const [recCounts, setRecCounts] = useState({ critical: 0, warning: 0, info: 0 });
|
||||
const [loading, setLoading] = useState(true);
|
||||
const [selectedInitiative, setSelectedInitiative] = useState(null);
|
||||
const [pacing, setPacing] = useState(null);
|
||||
const [report, setReport] = useState(null);
|
||||
const [pacingLoading, setPacingLoading] = useState(false);
|
||||
|
||||
const loadHealth = useCallback(async () => {
|
||||
if (!sessionToken) return;
|
||||
setLoading(true);
|
||||
const res = await getCampaignHealth(sessionToken);
|
||||
if (res.ok && res.data) {
|
||||
setHealth(res.data.initiatives || []);
|
||||
}
|
||||
setLoading(false);
|
||||
}, [sessionToken]);
|
||||
|
||||
const loadRecommendations = useCallback(async () => {
|
||||
if (!sessionToken) return;
|
||||
const res = await getRecommendations(sessionToken);
|
||||
if (res.ok && res.data) {
|
||||
setRecommendations(res.data.recommendations || []);
|
||||
setRecCounts({
|
||||
critical: res.data.criticalCount || 0,
|
||||
warning: res.data.warningCount || 0,
|
||||
info: res.data.infoCount || 0,
|
||||
});
|
||||
}
|
||||
}, [sessionToken]);
|
||||
|
||||
useEffect(() => {
|
||||
loadHealth();
|
||||
loadRecommendations();
|
||||
}, [loadHealth, loadRecommendations]);
|
||||
|
||||
const loadPacing = useCallback(async (initiativeId) => {
|
||||
if (!sessionToken) return;
|
||||
setPacingLoading(true);
|
||||
setPacing(null);
|
||||
setReport(null);
|
||||
const [pacingRes, reportRes] = await Promise.all([
|
||||
getBudgetPacing(initiativeId, sessionToken),
|
||||
getPostCampaignReport(initiativeId, sessionToken)
|
||||
]);
|
||||
if (pacingRes.ok && pacingRes.data) setPacing(pacingRes.data);
|
||||
if (reportRes.ok && reportRes.data) setReport(reportRes.data);
|
||||
setPacingLoading(false);
|
||||
}, [sessionToken]);
|
||||
|
||||
const handleDismiss = async (recId) => {
|
||||
const res = await dismissRecommendation(recId, sessionToken);
|
||||
if (res.ok) {
|
||||
loadRecommendations();
|
||||
loadHealth();
|
||||
}
|
||||
};
|
||||
|
||||
const handleResolve = async (recId) => {
|
||||
const res = await resolveRecommendation(recId, sessionToken);
|
||||
if (res.ok) {
|
||||
loadRecommendations();
|
||||
loadHealth();
|
||||
}
|
||||
};
|
||||
|
||||
const selectInitiative = (init) => {
|
||||
const id = init.initiativeId || init.iniId;
|
||||
setSelectedInitiative(init);
|
||||
loadPacing(id);
|
||||
};
|
||||
|
||||
const totalAlerts = recCounts.critical + recCounts.warning + recCounts.info;
|
||||
|
||||
return (
|
||||
<div className="intelligence-view">
|
||||
<div className="view-header">
|
||||
<div>
|
||||
<h1>Campaign Intelligence</h1>
|
||||
<p className="view-subtitle">Real-time health monitoring, recommendations, and budget pacing</p>
|
||||
</div>
|
||||
{totalAlerts > 0 && (
|
||||
<div className="intel-alert-summary">
|
||||
{recCounts.critical > 0 && (
|
||||
<span className="intel-alert-badge sev-critical">{recCounts.critical} Critical</span>
|
||||
)}
|
||||
{recCounts.warning > 0 && (
|
||||
<span className="intel-alert-badge sev-warning">{recCounts.warning} Warning</span>
|
||||
)}
|
||||
{recCounts.info > 0 && (
|
||||
<span className="intel-alert-badge sev-info">{recCounts.info} Info</span>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* Tabs */}
|
||||
<div className="intel-tabs">
|
||||
{TABS.map(tab => (
|
||||
<button
|
||||
key={tab.id}
|
||||
className={`intel-tab ${activeTab === tab.id ? 'active' : ''}`}
|
||||
onClick={() => setActiveTab(tab.id)}
|
||||
>
|
||||
<svg width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2" strokeLinecap="round" strokeLinejoin="round">
|
||||
<path d={tab.icon} />
|
||||
</svg>
|
||||
{tab.label}
|
||||
{tab.id === 'recommendations' && totalAlerts > 0 && (
|
||||
<span className="tab-badge">{totalAlerts}</span>
|
||||
)}
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
|
||||
{/* Tab Content */}
|
||||
<div className="intel-content">
|
||||
{activeTab === 'health' && (
|
||||
<HealthOverview
|
||||
health={health}
|
||||
loading={loading}
|
||||
onSelectInitiative={selectInitiative}
|
||||
onRefresh={loadHealth}
|
||||
/>
|
||||
)}
|
||||
{activeTab === 'recommendations' && (
|
||||
<RecommendationsPanel
|
||||
recommendations={recommendations}
|
||||
loading={loading}
|
||||
onDismiss={handleDismiss}
|
||||
onResolve={handleResolve}
|
||||
/>
|
||||
)}
|
||||
{activeTab === 'pacing' && (
|
||||
<PacingView
|
||||
health={health}
|
||||
selectedInitiative={selectedInitiative}
|
||||
pacing={pacing}
|
||||
report={report}
|
||||
loading={pacingLoading}
|
||||
onSelectInitiative={selectInitiative}
|
||||
/>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
/* ═══════════════════════════════════════════════════
|
||||
Health Overview Tab
|
||||
═══════════════════════════════════════════════════ */
|
||||
function HealthOverview({ health, loading, onSelectInitiative, onRefresh }) {
|
||||
if (loading) {
|
||||
return (
|
||||
<div className="loading-placeholder padded">
|
||||
{[1,2,3].map(i => <div key={i} className="skeleton skeleton-row" style={{height:'80px', marginBottom:'12px'}} />)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
if (!health || health.length === 0) {
|
||||
return (
|
||||
<div className="empty-state">
|
||||
<svg width="48" height="48" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="1.5"><path d="M9 12l2 2 4-4m6 2a9 9 0 11-18 0 9 9 0 0118 0z"/></svg>
|
||||
<h3>No active campaigns</h3>
|
||||
<p>Campaign health data will appear here once you have active campaigns running.</p>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<div>
|
||||
<div className="intel-section-header">
|
||||
<h3>Active Campaigns</h3>
|
||||
<button className="btn btn-sm btn-outline" onClick={onRefresh}>
|
||||
<svg width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2"><path d="M1 4v6h6M23 20v-6h-6"/><path d="M20.49 9A9 9 0 005.64 5.64L1 10m22 4l-4.64 4.36A9 9 0 013.51 15"/></svg>
|
||||
Refresh
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<div className="health-grid">
|
||||
{health.map((init, i) => {
|
||||
const channels = init.channels ? (typeof init.channels === 'string' ? JSON.parse(init.channels) : init.channels) : [];
|
||||
const worstHealth = channels.reduce((worst, ch) => {
|
||||
const h = ch.healthStatus || 'green';
|
||||
if (h === 'red') return 'red';
|
||||
if (h === 'yellow' && worst !== 'red') return 'yellow';
|
||||
return worst;
|
||||
}, 'green');
|
||||
const hc = healthColors[worstHealth] || healthColors.green;
|
||||
|
||||
return (
|
||||
<div key={i} className={`health-card ${hc.css}`} onClick={() => onSelectInitiative(init)}>
|
||||
<div className="health-card-header">
|
||||
<div className="health-card-title">
|
||||
<span className="health-dot">{hc.dot}</span>
|
||||
<span>{init.name || 'Untitled'}</span>
|
||||
</div>
|
||||
<span className={`health-status-label ${hc.css}`}>{hc.label}</span>
|
||||
</div>
|
||||
|
||||
<div className="health-card-meta">
|
||||
<span>{init.objective || 'Campaign'}</span>
|
||||
<span>•</span>
|
||||
<span>{fmtCur(init.totalBudget)}</span>
|
||||
<span>•</span>
|
||||
<span>{fmtDateFull(init.startDate)} – {fmtDateFull(init.endDate)}</span>
|
||||
</div>
|
||||
|
||||
{channels.length > 0 && (
|
||||
<div className="health-channels">
|
||||
{channels.map((ch, j) => {
|
||||
const chHealth = healthColors[ch.healthStatus || 'green'] || healthColors.green;
|
||||
return (
|
||||
<div key={j} className={`health-channel-row ${chHealth.css}`}>
|
||||
<div className="health-channel-info">
|
||||
<span className="health-channel-dot">{chHealth.dot}</span>
|
||||
<span className="health-channel-name">{channelLabel(ch.channelType)}</span>
|
||||
</div>
|
||||
<div className="health-channel-stats">
|
||||
<span title="Impressions (7d)">{fmtNum(ch.impressions7d)} imp</span>
|
||||
<span title="Clicks (7d)">{fmtNum(ch.clicks7d)} clicks</span>
|
||||
<span title="CTR (7d)">{fmtPct(ch.ctr7d)} CTR</span>
|
||||
<span title="Spend (7d)">{fmtCur(ch.spend7d)}</span>
|
||||
</div>
|
||||
<div className="health-channel-alerts">
|
||||
{ch.criticalAlerts > 0 && <span className="alert-count sev-critical">{ch.criticalAlerts}</span>}
|
||||
{ch.warningAlerts > 0 && <span className="alert-count sev-warning">{ch.warningAlerts}</span>}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
/* ═══════════════════════════════════════════════════
|
||||
Recommendations Panel
|
||||
═══════════════════════════════════════════════════ */
|
||||
function RecommendationsPanel({ recommendations, loading, onDismiss, onResolve }) {
|
||||
const [filter, setFilter] = useState('all');
|
||||
|
||||
if (loading) {
|
||||
return (
|
||||
<div className="loading-placeholder padded">
|
||||
{[1,2,3].map(i => <div key={i} className="skeleton skeleton-row" style={{height:'72px', marginBottom:'12px'}} />)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
const filtered = filter === 'all' ? recommendations :
|
||||
recommendations?.filter(r => r.severity === filter) || [];
|
||||
|
||||
return (
|
||||
<div>
|
||||
<div className="intel-section-header">
|
||||
<h3>Active Recommendations</h3>
|
||||
<div className="rec-filters">
|
||||
{[
|
||||
{ key: 'all', label: 'All' },
|
||||
{ key: 'critical', label: 'Critical' },
|
||||
{ key: 'warning', label: 'Warning' },
|
||||
{ key: 'info', label: 'Info' },
|
||||
].map(f => (
|
||||
<button
|
||||
key={f.key}
|
||||
className={`btn btn-sm ${filter === f.key ? 'btn-primary' : 'btn-outline'}`}
|
||||
onClick={() => setFilter(f.key)}
|
||||
>
|
||||
{f.label}
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{!filtered || filtered.length === 0 ? (
|
||||
<div className="empty-state">
|
||||
<svg width="48" height="48" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="1.5"><path d="M9 12l2 2 4-4m6 2a9 9 0 11-18 0 9 9 0 0118 0z"/></svg>
|
||||
<h3>{filter === 'all' ? 'All clear!' : `No ${filter} recommendations`}</h3>
|
||||
<p>Your campaigns are performing within expected parameters.</p>
|
||||
</div>
|
||||
) : (
|
||||
<div className="rec-list">
|
||||
{filtered.map((rec, i) => {
|
||||
const sev = severityConfig[rec.severity] || severityConfig.info;
|
||||
return (
|
||||
<div key={rec.recommendationId || i} className={`rec-card ${sev.css}`}>
|
||||
<div className="rec-card-header">
|
||||
<span className={`rec-severity ${sev.css}`}>{sev.icon} {sev.label}</span>
|
||||
<span className="rec-category">{rec.category}</span>
|
||||
<span className="rec-channel">{channelLabel(rec.channelType)}</span>
|
||||
<span className="rec-time">{timeAgo(rec.createdAt)}</span>
|
||||
</div>
|
||||
<div className="rec-card-body">
|
||||
<p className="rec-initiative">{rec.initiativeName}</p>
|
||||
<p className="rec-message">{rec.message}</p>
|
||||
{rec.metricValue != null && (
|
||||
<div className="rec-metric">
|
||||
Current: <strong>{Number(rec.metricValue).toFixed(2)}</strong>
|
||||
{rec.threshold != null && <> · Threshold: <strong>{Number(rec.threshold).toFixed(2)}</strong></>}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
<div className="rec-card-actions">
|
||||
<button className="btn btn-sm btn-outline" onClick={() => onResolve(rec.recommendationId)}>
|
||||
✓ Resolve
|
||||
</button>
|
||||
<button className="btn btn-sm btn-ghost" onClick={() => onDismiss(rec.recommendationId)}>
|
||||
Dismiss
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
/* ═══════════════════════════════════════════════════
|
||||
Budget Pacing View
|
||||
═══════════════════════════════════════════════════ */
|
||||
function PacingView({ health, selectedInitiative, pacing, report, loading, onSelectInitiative }) {
|
||||
if (!selectedInitiative && health && health.length > 0) {
|
||||
return (
|
||||
<div>
|
||||
<div className="intel-section-header">
|
||||
<h3>Select a Campaign</h3>
|
||||
</div>
|
||||
<div className="pacing-select-grid">
|
||||
{health.map((init, i) => (
|
||||
<button key={i} className="pacing-select-card" onClick={() => onSelectInitiative(init)}>
|
||||
<span className="pacing-select-name">{init.name || 'Untitled'}</span>
|
||||
<span className="pacing-select-meta">{fmtCur(init.totalBudget)} · {init.objective || 'Campaign'}</span>
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
if (!selectedInitiative) {
|
||||
return (
|
||||
<div className="empty-state">
|
||||
<h3>No campaigns available</h3>
|
||||
<p>Budget pacing data will appear here once you have active campaigns.</p>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
if (loading) {
|
||||
return (
|
||||
<div className="loading-placeholder padded">
|
||||
{[1,2,3,4].map(i => <div key={i} className="skeleton skeleton-row" style={{height:'60px', marginBottom:'12px'}} />)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<div>
|
||||
<div className="intel-section-header">
|
||||
<h3>{selectedInitiative.name || 'Campaign'} — Budget Pacing</h3>
|
||||
<button className="btn btn-sm btn-outline" onClick={() => onSelectInitiative(null)}>
|
||||
← Back
|
||||
</button>
|
||||
</div>
|
||||
|
||||
{/* Pacing Overview */}
|
||||
{pacing && (
|
||||
<div className="pacing-overview">
|
||||
<div className="stats-grid" style={{ gridTemplateColumns: 'repeat(4, 1fr)' }}>
|
||||
<div className="stat-card">
|
||||
<div className="stat-label">Total Budget</div>
|
||||
<div className="stat-value">{fmtCur(pacing.totalBudget)}</div>
|
||||
</div>
|
||||
<div className="stat-card">
|
||||
<div className="stat-label">Spent to Date</div>
|
||||
<div className="stat-value">{fmtCur(pacing.actualSpendToDate)}</div>
|
||||
</div>
|
||||
<div className="stat-card">
|
||||
<div className="stat-label">Expected Spend</div>
|
||||
<div className="stat-value">{fmtCur(pacing.expectedSpendToDate)}</div>
|
||||
</div>
|
||||
<div className="stat-card">
|
||||
<div className="stat-label">Pacing</div>
|
||||
<div className={`stat-value ${
|
||||
pacing.overallPacingPct > 120 ? 'text-red' :
|
||||
pacing.overallPacingPct < 50 ? 'text-orange' : 'text-green'
|
||||
}`}>
|
||||
{fmtPct(pacing.overallPacingPct)}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Pacing Bar */}
|
||||
<div className="pacing-bar-container">
|
||||
<div className="pacing-bar-labels">
|
||||
<span>Day {pacing.elapsedDays} of {pacing.campaignDays}</span>
|
||||
<span>{fmtCur(pacing.actualSpendToDate)} of {fmtCur(pacing.totalBudget)}</span>
|
||||
</div>
|
||||
<div className="pacing-bar">
|
||||
<div className="pacing-bar-expected" style={{width: `${Math.min((pacing.elapsedDays / pacing.campaignDays) * 100, 100)}%`}} />
|
||||
<div className={`pacing-bar-actual ${
|
||||
pacing.overallPacingPct > 120 ? 'overpacing' :
|
||||
pacing.overallPacingPct < 50 ? 'underpacing' : 'on-track'
|
||||
}`} style={{width: `${Math.min((pacing.actualSpendToDate / pacing.totalBudget) * 100, 100)}%`}} />
|
||||
</div>
|
||||
<div className="pacing-bar-legend">
|
||||
<span><span className="legend-dot expected"></span> Expected</span>
|
||||
<span><span className="legend-dot actual"></span> Actual</span>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Channel Pacing */}
|
||||
{pacing.channelPacing && (
|
||||
<div className="content-card" style={{marginTop: '20px'}}>
|
||||
<div className="content-card-header"><h3>Channel Breakdown</h3></div>
|
||||
<div className="content-card-body">
|
||||
<table className="data-table">
|
||||
<thead>
|
||||
<tr>
|
||||
<th>Channel</th>
|
||||
<th>Allocated</th>
|
||||
<th>Spent</th>
|
||||
<th>Utilization</th>
|
||||
<th>Projected Total</th>
|
||||
<th>Status</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
{(typeof pacing.channelPacing === 'string' ? JSON.parse(pacing.channelPacing) : pacing.channelPacing).map((ch, i) => (
|
||||
<tr key={i}>
|
||||
<td style={{fontWeight: 500}}>{channelLabel(ch.channelType)}</td>
|
||||
<td>{fmtCur(ch.allocatedBudget)}</td>
|
||||
<td>{fmtCur(ch.totalSpend)}</td>
|
||||
<td>{fmtPct(ch.budgetUtilPct)}</td>
|
||||
<td>{fmtCur(ch.projectedTotalSpend)}</td>
|
||||
<td>
|
||||
<span className={`status-badge-sm status-${ch.pacingStatus === 'on_track' ? 'active' : ch.pacingStatus === 'overpacing' ? 'error' : 'paused'}`}>
|
||||
{ch.pacingStatus === 'on_track' ? 'On Track' : ch.pacingStatus === 'overpacing' ? 'Overpacing' : 'Underpacing'}
|
||||
</span>
|
||||
</td>
|
||||
</tr>
|
||||
))}
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Post-Campaign Report Summary */}
|
||||
{report && report.totals && (
|
||||
<div style={{marginTop: '24px'}}>
|
||||
<h3 style={{fontSize: '16px', fontWeight: 600, marginBottom: '16px'}}>Performance Summary</h3>
|
||||
<div className="stats-grid" style={{ gridTemplateColumns: 'repeat(5, 1fr)' }}>
|
||||
{(() => {
|
||||
const t = typeof report.totals === 'string' ? JSON.parse(report.totals) : report.totals;
|
||||
return [
|
||||
{ label: 'Impressions', value: fmtNum(t.totalImpressions) },
|
||||
{ label: 'Clicks', value: fmtNum(t.totalClicks) },
|
||||
{ label: 'CTR', value: fmtPct(t.overallCtr) },
|
||||
{ label: 'Avg CPC', value: fmtCur(t.overallCpc) },
|
||||
{ label: 'Conversions', value: fmtNum(t.totalConversions) },
|
||||
].map((s, i) => (
|
||||
<div key={i} className="stat-card">
|
||||
<div className="stat-label">{s.label}</div>
|
||||
<div className="stat-value" style={{fontSize: '20px'}}>{s.value}</div>
|
||||
</div>
|
||||
));
|
||||
})()}
|
||||
</div>
|
||||
|
||||
{/* Channel Comparison */}
|
||||
{report.channelComparison && (
|
||||
<div className="content-card" style={{marginTop: '20px'}}>
|
||||
<div className="content-card-header"><h3>Cross-Platform Comparison</h3></div>
|
||||
<div className="content-card-body">
|
||||
<table className="data-table">
|
||||
<thead>
|
||||
<tr>
|
||||
<th>Channel</th>
|
||||
<th>Impressions</th>
|
||||
<th>Clicks</th>
|
||||
<th>CTR</th>
|
||||
<th>CPC</th>
|
||||
<th>Spend</th>
|
||||
<th>Conv.</th>
|
||||
<th>ROAS</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
{(typeof report.channelComparison === 'string' ? JSON.parse(report.channelComparison) : report.channelComparison).map((ch, i) => (
|
||||
<tr key={i}>
|
||||
<td style={{fontWeight: 500}}>{channelLabel(ch.channelType)}</td>
|
||||
<td>{fmtNum(ch.totalImpressions)}</td>
|
||||
<td>{fmtNum(ch.totalClicks)}</td>
|
||||
<td>{fmtPct(ch.ctr)}</td>
|
||||
<td>{fmtCur(ch.cpc)}</td>
|
||||
<td>{fmtCur(ch.totalSpend)}</td>
|
||||
<td>{fmtNum(ch.totalConversions)}</td>
|
||||
<td>{ch.roas != null ? Number(ch.roas).toFixed(2) + 'x' : '—'}</td>
|
||||
</tr>
|
||||
))}
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
13
Client-Client/src/components/client/NewCampaignPanel.jsx
Normal file
13
Client-Client/src/components/client/NewCampaignPanel.jsx
Normal file
@@ -0,0 +1,13 @@
|
||||
import React from 'react';
|
||||
import { useAdmin } from '../../context/AdminContext';
|
||||
import CampaignWizard from '../wizard/CampaignWizard';
|
||||
|
||||
export default function NewCampaignPanel() {
|
||||
const { setActiveTab, wizardId } = useAdmin();
|
||||
return (
|
||||
<CampaignWizard
|
||||
wizardId={wizardId || null}
|
||||
onClose={() => setActiveTab('myCampaigns')}
|
||||
/>
|
||||
);
|
||||
}
|
||||
184
Client-Client/src/components/client/OverviewPanel.jsx
Normal file
184
Client-Client/src/components/client/OverviewPanel.jsx
Normal file
@@ -0,0 +1,184 @@
|
||||
import React, { useState, useEffect, useCallback } from 'react';
|
||||
import { useAdmin } from '../../context/AdminContext';
|
||||
|
||||
const STATUS_COLORS = {
|
||||
active: { bg: '#dcfce7', color: '#166534' },
|
||||
launched: { bg: '#dcfce7', color: '#166534' },
|
||||
staged: { bg: '#dbeafe', color: '#1e40af' },
|
||||
draft: { bg: '#f1f5f9', color: '#475569' },
|
||||
paused: { bg: '#fef9c3', color: '#854d0e' },
|
||||
completed: { bg: '#ede9fe', color: '#5b21b6' },
|
||||
cancelled: { bg: '#fee2e2', color: '#991b1b' },
|
||||
};
|
||||
|
||||
const fmtCurrency = (n) => n != null ? `$${Number(n).toLocaleString('en-US', { minimumFractionDigits: 2 })}` : '—';
|
||||
const fmtNum = (n) => n != null ? Number(n).toLocaleString() : '—';
|
||||
const fmtDate = (d) => d ? new Date(d).toLocaleDateString('en-US', { month: 'short', day: 'numeric', year: 'numeric' }) : '—';
|
||||
|
||||
function StatusBadge({ status }) {
|
||||
const s = STATUS_COLORS[status?.toLowerCase()] || { bg: '#f1f5f9', color: '#475569' };
|
||||
return (
|
||||
<span style={{ background: s.bg, color: s.color, padding: '2px 8px', borderRadius: 10, fontSize: 11, fontWeight: 600 }}>
|
||||
{status || '—'}
|
||||
</span>
|
||||
);
|
||||
}
|
||||
|
||||
function StatCard({ label, value, sub }) {
|
||||
return (
|
||||
<div style={{
|
||||
background: '#fff', border: '1px solid #e2e8f0', borderRadius: 10,
|
||||
padding: '20px 24px', flex: '1 1 180px',
|
||||
}}>
|
||||
<div style={{ fontSize: 28, fontWeight: 700, color: '#0f172a' }}>{value}</div>
|
||||
<div style={{ fontSize: 13, color: '#64748b', marginTop: 4 }}>{label}</div>
|
||||
{sub && <div style={{ fontSize: 12, color: '#94a3b8', marginTop: 2 }}>{sub}</div>}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
export default function OverviewPanel() {
|
||||
const { apiCall, clientId, clientName, setActiveCategory, setActiveTab } = useAdmin();
|
||||
const [initiatives, setInitiatives] = useState([]);
|
||||
const [recommendations, setRecommendations] = useState([]);
|
||||
const [loading, setLoading] = useState(true);
|
||||
|
||||
const load = useCallback(async () => {
|
||||
setLoading(true);
|
||||
const [iRes, rRes] = await Promise.all([
|
||||
apiCall('/api/initiative'),
|
||||
apiCall('/api/recommendations'),
|
||||
]);
|
||||
setInitiatives(Array.isArray(iRes?.initiatives) ? iRes.initiatives :
|
||||
Array.isArray(iRes?.data) ? iRes.data : []);
|
||||
setRecommendations(Array.isArray(rRes?.recommendations) ? rRes.recommendations :
|
||||
Array.isArray(rRes?.data) ? rRes.data : []);
|
||||
setLoading(false);
|
||||
}, [apiCall]);
|
||||
|
||||
useEffect(() => { load(); }, [load]);
|
||||
|
||||
const active = initiatives.filter(i => ['active','launched'].includes(i.iniStatus?.toLowerCase() || i.status?.toLowerCase()));
|
||||
const staged = initiatives.filter(i => ['staged','draft'].includes(i.iniStatus?.toLowerCase() || i.status?.toLowerCase()));
|
||||
const critical = recommendations.filter(r => r.severity === 'critical' || r.Severity === 'critical');
|
||||
|
||||
const totalSpend = active.reduce((sum, i) => sum + (i.totalSpend || 0), 0);
|
||||
|
||||
if (loading) return <div className="loading-message">Loading your dashboard…</div>;
|
||||
|
||||
return (
|
||||
<div style={{ maxWidth: 960 }}>
|
||||
{/* Welcome */}
|
||||
<div style={{ marginBottom: 24 }}>
|
||||
<h2 style={{ fontSize: 22, fontWeight: 700, color: '#0f172a', margin: 0 }}>
|
||||
Welcome back{clientName ? `, ${clientName}` : ''}
|
||||
</h2>
|
||||
<p style={{ color: '#64748b', margin: '4px 0 0' }}>
|
||||
Here's a summary of your advertising activity.
|
||||
</p>
|
||||
</div>
|
||||
|
||||
{/* Stats row */}
|
||||
<div style={{ display: 'flex', gap: 16, flexWrap: 'wrap', marginBottom: 28 }}>
|
||||
<StatCard label="Active Campaigns" value={active.length} />
|
||||
<StatCard label="Drafts / Staged" value={staged.length} />
|
||||
<StatCard label="Open Alerts" value={critical.length} sub={critical.length > 0 ? 'Needs attention' : 'All clear'} />
|
||||
<StatCard label="Total Spend (MTD)" value={totalSpend > 0 ? fmtCurrency(totalSpend) : '—'} />
|
||||
</div>
|
||||
|
||||
{/* Active campaigns */}
|
||||
{active.length > 0 && (
|
||||
<div style={{ marginBottom: 28 }}>
|
||||
<h3 style={{ fontSize: 15, fontWeight: 600, color: '#0f172a', marginBottom: 12 }}>
|
||||
Active Campaigns
|
||||
</h3>
|
||||
<div style={{ background: '#fff', border: '1px solid #e2e8f0', borderRadius: 10, overflow: 'hidden' }}>
|
||||
<table style={{ width: '100%', borderCollapse: 'collapse', fontSize: 13 }}>
|
||||
<thead>
|
||||
<tr style={{ background: '#f8fafc' }}>
|
||||
<th style={{ padding: '10px 16px', textAlign: 'left', color: '#64748b', fontWeight: 600 }}>Campaign</th>
|
||||
<th style={{ padding: '10px 16px', textAlign: 'left', color: '#64748b', fontWeight: 600 }}>Status</th>
|
||||
<th style={{ padding: '10px 16px', textAlign: 'right', color: '#64748b', fontWeight: 600 }}>Budget</th>
|
||||
<th style={{ padding: '10px 16px', textAlign: 'left', color: '#64748b', fontWeight: 600 }}>Ends</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
{active.slice(0, 5).map((ini, idx) => (
|
||||
<tr key={ini.iniId || ini.initiativeId || idx}
|
||||
style={{ borderTop: '1px solid #f1f5f9', cursor: 'pointer' }}
|
||||
onClick={() => { setActiveCategory('campaigns'); setActiveTab('myCampaigns'); }}
|
||||
>
|
||||
<td style={{ padding: '12px 16px', fontWeight: 500, color: '#0f172a' }}>
|
||||
{ini.iniName || ini.name || '—'}
|
||||
</td>
|
||||
<td style={{ padding: '12px 16px' }}>
|
||||
<StatusBadge status={ini.iniStatus || ini.status} />
|
||||
</td>
|
||||
<td style={{ padding: '12px 16px', textAlign: 'right', color: '#475569' }}>
|
||||
{fmtCurrency(ini.iniTotalBudget || ini.totalBudget)}
|
||||
</td>
|
||||
<td style={{ padding: '12px 16px', color: '#64748b' }}>
|
||||
{fmtDate(ini.iniEndDate || ini.endDate)}
|
||||
</td>
|
||||
</tr>
|
||||
))}
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
{active.length > 5 && (
|
||||
<button
|
||||
style={{ marginTop: 8, fontSize: 13, color: 'var(--accent)', background: 'none', border: 'none', cursor: 'pointer', padding: 0 }}
|
||||
onClick={() => { setActiveCategory('campaigns'); setActiveTab('myCampaigns'); }}
|
||||
>
|
||||
View all {active.length} campaigns →
|
||||
</button>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Recommendations */}
|
||||
{recommendations.length > 0 && (
|
||||
<div>
|
||||
<h3 style={{ fontSize: 15, fontWeight: 600, color: '#0f172a', marginBottom: 12 }}>
|
||||
Recommendations
|
||||
</h3>
|
||||
<div style={{ display: 'flex', flexDirection: 'column', gap: 10 }}>
|
||||
{recommendations.slice(0, 3).map((r, idx) => {
|
||||
const sev = r.severity || r.Severity || 'info';
|
||||
const border = sev === 'critical' ? '#fca5a5' : sev === 'warning' ? '#fcd34d' : '#93c5fd';
|
||||
return (
|
||||
<div key={r.recommendationId || r.RecId || idx} style={{
|
||||
background: '#fff', border: `1px solid ${border}`,
|
||||
borderRadius: 8, padding: '14px 16px',
|
||||
}}>
|
||||
<div style={{ fontWeight: 600, fontSize: 13, color: '#0f172a' }}>
|
||||
{r.title || r.Title || 'Recommendation'}
|
||||
</div>
|
||||
<div style={{ fontSize: 12, color: '#64748b', marginTop: 4 }}>
|
||||
{r.recommendation || r.Recommendation || r.description || r.Description}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{initiatives.length === 0 && recommendations.length === 0 && (
|
||||
<div style={{ textAlign: 'center', padding: '60px 20px', color: '#94a3b8' }}>
|
||||
<div style={{ fontSize: 40, marginBottom: 12 }}>📣</div>
|
||||
<div style={{ fontSize: 16, fontWeight: 600, color: '#475569', marginBottom: 8 }}>
|
||||
No campaigns yet
|
||||
</div>
|
||||
<div style={{ marginBottom: 20 }}>Create your first campaign to start advertising on Google, Meta, and TikTok.</div>
|
||||
<button
|
||||
className="btn-action"
|
||||
onClick={() => { setActiveCategory('campaigns'); setActiveTab('newCampaign'); }}
|
||||
>
|
||||
Create Campaign
|
||||
</button>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
6
Client-Client/src/components/client/PerformancePanel.jsx
Normal file
6
Client-Client/src/components/client/PerformancePanel.jsx
Normal file
@@ -0,0 +1,6 @@
|
||||
import React from 'react';
|
||||
import IntelligenceView from './IntelligenceView';
|
||||
|
||||
export default function PerformancePanel() {
|
||||
return <IntelligenceView />;
|
||||
}
|
||||
105
Client-Client/src/components/client/RecommendationsPanel.jsx
Normal file
105
Client-Client/src/components/client/RecommendationsPanel.jsx
Normal file
@@ -0,0 +1,105 @@
|
||||
import React, { useState, useEffect, useCallback } from 'react';
|
||||
import { useAdmin } from '../../context/AdminContext';
|
||||
|
||||
const SEV_STYLES = {
|
||||
critical: { border: '#fca5a5', bg: '#fff1f2', badge: { bg: '#fee2e2', color: '#991b1b' } },
|
||||
warning: { border: '#fcd34d', bg: '#fffbeb', badge: { bg: '#fef9c3', color: '#854d0e' } },
|
||||
info: { border: '#93c5fd', bg: '#eff6ff', badge: { bg: '#dbeafe', color: '#1e40af' } },
|
||||
};
|
||||
|
||||
export default function RecommendationsPanel() {
|
||||
const { apiCall } = useAdmin();
|
||||
const [recs, setRecs] = useState([]);
|
||||
const [loading, setLoading] = useState(true);
|
||||
const [acting, setActing] = useState(null);
|
||||
|
||||
const load = useCallback(async () => {
|
||||
setLoading(true);
|
||||
const res = await apiCall('/api/recommendations');
|
||||
setRecs(Array.isArray(res?.recommendations) ? res.recommendations :
|
||||
Array.isArray(res?.data) ? res.data : []);
|
||||
setLoading(false);
|
||||
}, [apiCall]);
|
||||
|
||||
useEffect(() => { load(); }, [load]);
|
||||
|
||||
const dismiss = async (id) => {
|
||||
setActing(id);
|
||||
await apiCall(`/api/recommendations/${id}/dismiss`, 'POST');
|
||||
await load();
|
||||
setActing(null);
|
||||
};
|
||||
|
||||
const resolve = async (id) => {
|
||||
setActing(id);
|
||||
await apiCall(`/api/recommendations/${id}/resolve`, 'POST');
|
||||
await load();
|
||||
setActing(null);
|
||||
};
|
||||
|
||||
if (loading) return <div className="loading-message">Loading recommendations…</div>;
|
||||
|
||||
if (recs.length === 0) {
|
||||
return (
|
||||
<div className="empty-state">
|
||||
<div style={{ fontSize: 36, marginBottom: 10 }}>✅</div>
|
||||
No active recommendations — your campaigns are looking good.
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<div style={{ maxWidth: 800 }}>
|
||||
<h2 style={{ fontSize: 18, fontWeight: 700, marginBottom: 20 }}>Recommendations</h2>
|
||||
<div style={{ display: 'flex', flexDirection: 'column', gap: 14 }}>
|
||||
{recs.map((r, idx) => {
|
||||
const id = r.recommendationId || r.RecId || idx;
|
||||
const sev = (r.severity || r.Severity || 'info').toLowerCase();
|
||||
const st = SEV_STYLES[sev] || SEV_STYLES.info;
|
||||
return (
|
||||
<div key={id} style={{ background: st.bg, border: `1px solid ${st.border}`, borderRadius: 10, padding: '16px 20px' }}>
|
||||
<div style={{ display: 'flex', justifyContent: 'space-between', alignItems: 'flex-start', gap: 12 }}>
|
||||
<div style={{ flex: 1 }}>
|
||||
<div style={{ display: 'flex', gap: 10, alignItems: 'center', marginBottom: 6 }}>
|
||||
<span style={{ background: st.badge.bg, color: st.badge.color, padding: '2px 8px', borderRadius: 10, fontSize: 11, fontWeight: 700, textTransform: 'uppercase' }}>
|
||||
{sev}
|
||||
</span>
|
||||
<span style={{ fontWeight: 600, fontSize: 14, color: '#0f172a' }}>
|
||||
{r.title || r.Title}
|
||||
</span>
|
||||
</div>
|
||||
<p style={{ margin: '0 0 8px', fontSize: 13, color: '#475569' }}>
|
||||
{r.description || r.Description}
|
||||
</p>
|
||||
{(r.recommendation || r.Recommendation) && (
|
||||
<p style={{ margin: 0, fontSize: 13, color: '#0f172a', fontWeight: 500 }}>
|
||||
💡 {r.recommendation || r.Recommendation}
|
||||
</p>
|
||||
)}
|
||||
</div>
|
||||
<div style={{ display: 'flex', gap: 8, flexShrink: 0 }}>
|
||||
<button
|
||||
className="btn-action"
|
||||
style={{ fontSize: 12, padding: '6px 12px' }}
|
||||
disabled={acting === id}
|
||||
onClick={() => resolve(id)}
|
||||
>
|
||||
Resolve
|
||||
</button>
|
||||
<button
|
||||
className="btn-cancel"
|
||||
style={{ fontSize: 12, padding: '6px 12px' }}
|
||||
disabled={acting === id}
|
||||
onClick={() => dismiss(id)}
|
||||
>
|
||||
Dismiss
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -1,125 +0,0 @@
|
||||
import React, { useState, useEffect } from 'react';
|
||||
import { callService } from '../../services/apiClient';
|
||||
|
||||
export default function Accounts({ sessionToken, sessionUser }) {
|
||||
const [accounts, setAccounts] = useState([]);
|
||||
const [loading, setLoading] = useState(true);
|
||||
const [selected, setSelected] = useState(null);
|
||||
|
||||
useEffect(() => {
|
||||
async function load() {
|
||||
setLoading(true);
|
||||
const res = await callService('google', 'ListAccounts', {}, { sessionToken });
|
||||
if (res.ok && res.data?.data) {
|
||||
setAccounts(Array.isArray(res.data.data) ? res.data.data : []);
|
||||
}
|
||||
setLoading(false);
|
||||
}
|
||||
if (sessionToken) load();
|
||||
}, [sessionToken]);
|
||||
|
||||
return (
|
||||
<div>
|
||||
<div className="view-header">
|
||||
<div>
|
||||
<h1>Accounts</h1>
|
||||
<p className="view-subtitle">Manage linked advertising accounts</p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* User Profile Card */}
|
||||
<div className="content-card" style={{ marginBottom: '24px' }}>
|
||||
<div className="content-card-body padded">
|
||||
<div style={{ display: 'flex', alignItems: 'center', gap: '20px' }}>
|
||||
<div style={{ width: '56px', height: '56px', borderRadius: '50%', background: 'var(--color-primary)', color: 'white', display: 'flex', alignItems: 'center', justifyContent: 'center', fontSize: '20px', fontWeight: '600', flexShrink: 0 }}>
|
||||
{(sessionUser?.name || sessionUser?.email || 'U').charAt(0).toUpperCase()}
|
||||
</div>
|
||||
<div>
|
||||
<div style={{ fontSize: '18px', fontWeight: '600' }}>{sessionUser?.name || 'User'}</div>
|
||||
<div style={{ fontSize: '14px', color: 'var(--color-text-muted)' }}>{sessionUser?.email || ''}</div>
|
||||
{sessionUser?.role && <div style={{ fontSize: '13px', color: 'var(--color-primary)', marginTop: '4px' }}>{sessionUser.role}</div>}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Linked Accounts */}
|
||||
<div className="content-card">
|
||||
<div className="content-card-header">
|
||||
<h3>Linked Accounts</h3>
|
||||
<span style={{ fontSize: '13px', color: 'var(--color-text-muted)' }}>
|
||||
{accounts.length} account{accounts.length !== 1 ? 's' : ''}
|
||||
</span>
|
||||
</div>
|
||||
<div className="content-card-body">
|
||||
{loading ? (
|
||||
<div className="loading-placeholder padded">
|
||||
{[1,2].map(i => <div key={i} className="skeleton skeleton-row" style={{height:'72px', marginBottom:'8px'}} />)}
|
||||
</div>
|
||||
) : accounts.length === 0 ? (
|
||||
<div className="empty-state">
|
||||
<svg width="48" height="48" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="1.5">
|
||||
<path d="M17 20h5v-2a3 3 0 00-5.356-1.857M17 20H7m10 0v-2c0-.656-.126-1.283-.356-1.857M7 20H2v-2a3 3 0 015.356-1.857M7 20v-2c0-.656.126-1.283.356-1.857m0 0a5.002 5.002 0 019.288 0M15 7a3 3 0 11-6 0 3 3 0 016 0z" />
|
||||
</svg>
|
||||
<h3>No linked accounts</h3>
|
||||
<p>Advertising accounts will appear here once linked to your profile.</p>
|
||||
</div>
|
||||
) : (
|
||||
accounts.map((acct, i) => (
|
||||
<div key={i} className="campaign-row" onClick={() => setSelected(acct)}>
|
||||
<div className="campaign-info">
|
||||
<span className="campaign-name">{acct.descriptiveName || acct.name || `Account ${i+1}`}</span>
|
||||
<span className="campaign-type mono">{acct.id || acct.customerId || '—'}</span>
|
||||
</div>
|
||||
<span className={`status-badge status-${(acct.status || 'active').toLowerCase()}`}>
|
||||
{(acct.status || 'Active').toLowerCase()}
|
||||
</span>
|
||||
</div>
|
||||
))
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Detail Panel */}
|
||||
{selected && (
|
||||
<div className="detail-panel">
|
||||
<div className="detail-panel-header">
|
||||
<h3>Account Details</h3>
|
||||
<button className="modal-close" onClick={() => setSelected(null)}>
|
||||
<svg width="20" height="20" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2">
|
||||
<line x1="18" y1="6" x2="6" y2="18" /><line x1="6" y1="6" x2="18" y2="18" />
|
||||
</svg>
|
||||
</button>
|
||||
</div>
|
||||
<div className="detail-panel-body">
|
||||
<div className="detail-section">
|
||||
<h4>Account Information</h4>
|
||||
<div className="detail-grid">
|
||||
<div className="detail-item">
|
||||
<span className="detail-label">Name</span>
|
||||
<span className="detail-value">{selected.descriptiveName || selected.name || '—'}</span>
|
||||
</div>
|
||||
<div className="detail-item">
|
||||
<span className="detail-label">Account ID</span>
|
||||
<span className="detail-value mono">{selected.id || selected.customerId || '—'}</span>
|
||||
</div>
|
||||
<div className="detail-item">
|
||||
<span className="detail-label">Status</span>
|
||||
<span className={`status-badge status-${(selected.status || 'active').toLowerCase()}`}>{selected.status || 'Active'}</span>
|
||||
</div>
|
||||
<div className="detail-item">
|
||||
<span className="detail-label">Currency</span>
|
||||
<span className="detail-value">{selected.currencyCode || 'USD'}</span>
|
||||
</div>
|
||||
<div className="detail-item">
|
||||
<span className="detail-label">Timezone</span>
|
||||
<span className="detail-value">{selected.timeZone || '—'}</span>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -1,274 +0,0 @@
|
||||
import React, { useState, useEffect } from 'react';
|
||||
import { callService } from '../../services/apiClient';
|
||||
|
||||
const formatStatus = (s) => (s || 'enabled').replace('CAMPAIGN_STATUS_', '').toLowerCase();
|
||||
const formatType = (t) => (t || 'Search').replace('ADVERTISING_CHANNEL_TYPE_', '').replace(/_/g, ' ');
|
||||
const formatBudget = (b) => {
|
||||
if (!b?.amountMicros) return '$0.00';
|
||||
return '$' + (parseInt(b.amountMicros) / 1000000).toFixed(2);
|
||||
};
|
||||
const formatNumber = (n) => n != null ? Number(n).toLocaleString() : '—';
|
||||
|
||||
export default function Campaigns({ sessionToken }) {
|
||||
const [campaigns, setCampaigns] = useState([]);
|
||||
const [loading, setLoading] = useState(true);
|
||||
const [search, setSearch] = useState('');
|
||||
const [showCreate, setShowCreate] = useState(false);
|
||||
const [selected, setSelected] = useState(null);
|
||||
const [creating, setCreating] = useState(false);
|
||||
const [createError, setCreateError] = useState(null);
|
||||
const [form, setForm] = useState({ name: '', type: 'SEARCH', budget: '', status: 'ENABLED' });
|
||||
|
||||
const loadCampaigns = async () => {
|
||||
setLoading(true);
|
||||
const res = await callService('google', 'ListCampaigns', {}, { sessionToken });
|
||||
if (res.ok && res.data?.data) {
|
||||
setCampaigns(Array.isArray(res.data.data) ? res.data.data : []);
|
||||
}
|
||||
setLoading(false);
|
||||
};
|
||||
|
||||
useEffect(() => {
|
||||
if (sessionToken) loadCampaigns();
|
||||
}, [sessionToken]);
|
||||
|
||||
const filtered = campaigns.filter(c =>
|
||||
!search || (c.name || '').toLowerCase().includes(search.toLowerCase())
|
||||
);
|
||||
|
||||
const handleCreate = async (e) => {
|
||||
e.preventDefault();
|
||||
setCreating(true);
|
||||
setCreateError(null);
|
||||
const res = await callService('google', 'CreateCampaign', {
|
||||
name: form.name,
|
||||
advertisingChannelType: form.type,
|
||||
budgetAmountMicros: String(parseFloat(form.budget || '0') * 1000000),
|
||||
status: form.status
|
||||
}, { sessionToken });
|
||||
|
||||
if (res.ok) {
|
||||
setShowCreate(false);
|
||||
setForm({ name: '', type: 'SEARCH', budget: '', status: 'ENABLED' });
|
||||
loadCampaigns();
|
||||
} else {
|
||||
setCreateError(res.error || 'Failed to create campaign');
|
||||
}
|
||||
setCreating(false);
|
||||
};
|
||||
|
||||
return (
|
||||
<div>
|
||||
<div className="view-header">
|
||||
<div>
|
||||
<h1>Campaigns</h1>
|
||||
<p className="view-subtitle">{campaigns.length} campaign{campaigns.length !== 1 ? 's' : ''}</p>
|
||||
</div>
|
||||
<button className="btn btn-primary" onClick={() => setShowCreate(true)}>
|
||||
<svg width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2"><path d="M12 5v14M5 12h14" /></svg>
|
||||
New Campaign
|
||||
</button>
|
||||
</div>
|
||||
|
||||
{/* Search / Filter Bar */}
|
||||
<div style={{ marginBottom: '20px' }}>
|
||||
<input
|
||||
className="form-input"
|
||||
type="text"
|
||||
placeholder="Search campaigns…"
|
||||
value={search}
|
||||
onChange={(e) => setSearch(e.target.value)}
|
||||
style={{ maxWidth: '360px' }}
|
||||
/>
|
||||
</div>
|
||||
|
||||
{/* Campaign List */}
|
||||
<div className="content-card">
|
||||
<div className="content-card-body">
|
||||
{loading ? (
|
||||
<div className="loading-placeholder padded">
|
||||
{[1,2,3,4].map(i => <div key={i} className="skeleton skeleton-row" style={{height:'52px', marginBottom:'8px'}} />)}
|
||||
</div>
|
||||
) : filtered.length === 0 ? (
|
||||
<div className="empty-state">
|
||||
<svg width="48" height="48" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="1.5">
|
||||
<path d="M19 11H5m14 0a2 2 0 012 2v6a2 2 0 01-2 2H5a2 2 0 01-2-2v-6a2 2 0 012-2m14 0V9a2 2 0 00-2-2M5 11V9a2 2 0 012-2m0 0V5a2 2 0 012-2h6a2 2 0 012 2v2M7 7h10" />
|
||||
</svg>
|
||||
<h3>{search ? 'No matching campaigns' : 'No campaigns yet'}</h3>
|
||||
<p>{search ? 'Try adjusting your search' : 'Create your first campaign to get started'}</p>
|
||||
{!search && <button className="btn btn-primary btn-sm" onClick={() => setShowCreate(true)}>Create Campaign</button>}
|
||||
</div>
|
||||
) : (
|
||||
<table className="data-table">
|
||||
<thead>
|
||||
<tr>
|
||||
<th>Campaign Name</th>
|
||||
<th>Type</th>
|
||||
<th>Status</th>
|
||||
<th>Daily Budget</th>
|
||||
<th>Impressions</th>
|
||||
<th>Clicks</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
{filtered.map((campaign, idx) => (
|
||||
<tr key={idx} onClick={() => setSelected(campaign)}>
|
||||
<td style={{ fontWeight: 500 }}>{campaign.name || `Campaign ${idx+1}`}</td>
|
||||
<td>{formatType(campaign.advertisingChannelType)}</td>
|
||||
<td>
|
||||
<span className={`status-badge status-${formatStatus(campaign.status)}`}>
|
||||
{formatStatus(campaign.status)}
|
||||
</span>
|
||||
</td>
|
||||
<td>{formatBudget(campaign.campaignBudget)}</td>
|
||||
<td>{formatNumber(campaign.metrics?.impressions)}</td>
|
||||
<td>{formatNumber(campaign.metrics?.clicks)}</td>
|
||||
</tr>
|
||||
))}
|
||||
</tbody>
|
||||
</table>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Create Campaign Modal */}
|
||||
{showCreate && (
|
||||
<div className="modal-overlay" onClick={(e) => { if (e.target === e.currentTarget) setShowCreate(false); }}>
|
||||
<div className="modal">
|
||||
<div className="modal-header">
|
||||
<h3>Create Campaign</h3>
|
||||
<button className="modal-close" onClick={() => setShowCreate(false)}>
|
||||
<svg width="20" height="20" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2">
|
||||
<line x1="18" y1="6" x2="6" y2="18" /><line x1="6" y1="6" x2="18" y2="18" />
|
||||
</svg>
|
||||
</button>
|
||||
</div>
|
||||
<form onSubmit={handleCreate}>
|
||||
<div className="modal-body">
|
||||
{createError && <div className="error-box">{createError}</div>}
|
||||
<div className="form-group">
|
||||
<label>Campaign Name</label>
|
||||
<input className="form-input" type="text" placeholder="e.g. Summer Sale 2025" value={form.name} onChange={e => setForm({...form, name: e.target.value})} required />
|
||||
</div>
|
||||
<div className="form-row">
|
||||
<div className="form-group">
|
||||
<label>Channel Type</label>
|
||||
<select className="form-select" value={form.type} onChange={e => setForm({...form, type: e.target.value})}>
|
||||
<option value="SEARCH">Search</option>
|
||||
<option value="DISPLAY">Display</option>
|
||||
<option value="VIDEO">Video</option>
|
||||
<option value="SHOPPING">Shopping</option>
|
||||
<option value="PERFORMANCE_MAX">Performance Max</option>
|
||||
</select>
|
||||
</div>
|
||||
<div className="form-group">
|
||||
<label>Status</label>
|
||||
<select className="form-select" value={form.status} onChange={e => setForm({...form, status: e.target.value})}>
|
||||
<option value="ENABLED">Enabled</option>
|
||||
<option value="PAUSED">Paused</option>
|
||||
</select>
|
||||
</div>
|
||||
</div>
|
||||
<div className="form-group">
|
||||
<label>Daily Budget ($)</label>
|
||||
<input className="form-input" type="number" step="0.01" min="0" placeholder="50.00" value={form.budget} onChange={e => setForm({...form, budget: e.target.value})} required />
|
||||
</div>
|
||||
</div>
|
||||
<div className="modal-footer">
|
||||
<button type="button" className="btn btn-outline" onClick={() => setShowCreate(false)}>Cancel</button>
|
||||
<button type="submit" className="btn btn-primary" disabled={creating || !form.name}>
|
||||
{creating ? <><span className="btn-spinner" /> Creating...</> : 'Create Campaign'}
|
||||
</button>
|
||||
</div>
|
||||
</form>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Detail Panel */}
|
||||
{selected && (
|
||||
<div className="detail-panel">
|
||||
<div className="detail-panel-header">
|
||||
<h3>Campaign Details</h3>
|
||||
<button className="modal-close" onClick={() => setSelected(null)}>
|
||||
<svg width="20" height="20" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2">
|
||||
<line x1="18" y1="6" x2="6" y2="18" /><line x1="6" y1="6" x2="18" y2="18" />
|
||||
</svg>
|
||||
</button>
|
||||
</div>
|
||||
<div className="detail-panel-body">
|
||||
<div className="detail-section">
|
||||
<h4>Overview</h4>
|
||||
<div className="detail-grid">
|
||||
<div className="detail-item">
|
||||
<span className="detail-label">Name</span>
|
||||
<span className="detail-value">{selected.name}</span>
|
||||
</div>
|
||||
<div className="detail-item">
|
||||
<span className="detail-label">Status</span>
|
||||
<span className={`status-badge status-${formatStatus(selected.status)}`}>{formatStatus(selected.status)}</span>
|
||||
</div>
|
||||
<div className="detail-item">
|
||||
<span className="detail-label">Type</span>
|
||||
<span className="detail-value">{formatType(selected.advertisingChannelType)}</span>
|
||||
</div>
|
||||
<div className="detail-item">
|
||||
<span className="detail-label">Daily Budget</span>
|
||||
<span className="detail-value">{formatBudget(selected.campaignBudget)}</span>
|
||||
</div>
|
||||
<div className="detail-item">
|
||||
<span className="detail-label">Campaign ID</span>
|
||||
<span className="detail-value mono">{selected.id || selected.resourceName?.split('/').pop() || '—'}</span>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="detail-section">
|
||||
<h4>Performance</h4>
|
||||
<div className="detail-grid">
|
||||
<div className="detail-item">
|
||||
<span className="detail-label">Impressions</span>
|
||||
<span className="detail-value">{formatNumber(selected.metrics?.impressions)}</span>
|
||||
</div>
|
||||
<div className="detail-item">
|
||||
<span className="detail-label">Clicks</span>
|
||||
<span className="detail-value">{formatNumber(selected.metrics?.clicks)}</span>
|
||||
</div>
|
||||
<div className="detail-item">
|
||||
<span className="detail-label">CTR</span>
|
||||
<span className="detail-value">
|
||||
{selected.metrics?.ctr
|
||||
? (parseFloat(selected.metrics.ctr) * 100).toFixed(2) + '%'
|
||||
: selected.metrics?.impressions && selected.metrics?.clicks
|
||||
? ((selected.metrics.clicks / selected.metrics.impressions) * 100).toFixed(2) + '%'
|
||||
: '—'}
|
||||
</span>
|
||||
</div>
|
||||
<div className="detail-item">
|
||||
<span className="detail-label">Cost</span>
|
||||
<span className="detail-value">
|
||||
{selected.metrics?.costMicros
|
||||
? '$' + (parseInt(selected.metrics.costMicros) / 1000000).toFixed(2)
|
||||
: '—'}
|
||||
</span>
|
||||
</div>
|
||||
<div className="detail-item">
|
||||
<span className="detail-label">Conversions</span>
|
||||
<span className="detail-value">{formatNumber(selected.metrics?.conversions)}</span>
|
||||
</div>
|
||||
<div className="detail-item">
|
||||
<span className="detail-label">Avg CPC</span>
|
||||
<span className="detail-value">
|
||||
{selected.metrics?.averageCpc
|
||||
? '$' + (parseInt(selected.metrics.averageCpc) / 1000000).toFixed(2)
|
||||
: '—'}
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -1,140 +0,0 @@
|
||||
import React, { useState, useEffect } from 'react';
|
||||
import { callService } from '../../services/apiClient';
|
||||
|
||||
const formatNumber = (n) => n != null ? Number(n).toLocaleString() : '—';
|
||||
const formatCurrency = (micros) => micros ? '$' + (parseInt(micros) / 1000000).toFixed(2) : '$0.00';
|
||||
const formatStatus = (s) => (s || 'enabled').replace('CAMPAIGN_STATUS_', '').toLowerCase();
|
||||
|
||||
export default function Dashboard({ sessionToken, onNavigate }) {
|
||||
const [campaigns, setCampaigns] = useState([]);
|
||||
const [loading, setLoading] = useState(true);
|
||||
const [stats, setStats] = useState({ impressions: 0, clicks: 0, conversions: 0, spend: 0 });
|
||||
|
||||
useEffect(() => {
|
||||
async function load() {
|
||||
setLoading(true);
|
||||
const res = await callService('google', 'ListCampaigns', {}, { sessionToken });
|
||||
if (res.ok && res.data?.data) {
|
||||
const list = Array.isArray(res.data.data) ? res.data.data : [];
|
||||
setCampaigns(list);
|
||||
// Aggregate stats
|
||||
let imp = 0, clk = 0, conv = 0, sp = 0;
|
||||
list.forEach(c => {
|
||||
const m = c.metrics || {};
|
||||
imp += parseInt(m.impressions || 0);
|
||||
clk += parseInt(m.clicks || 0);
|
||||
conv += parseInt(m.conversions || 0);
|
||||
sp += parseInt(m.costMicros || 0);
|
||||
});
|
||||
setStats({ impressions: imp, clicks: clk, conversions: conv, spend: sp });
|
||||
}
|
||||
setLoading(false);
|
||||
}
|
||||
if (sessionToken) load();
|
||||
}, [sessionToken]);
|
||||
|
||||
return (
|
||||
<div>
|
||||
<div className="view-header">
|
||||
<div>
|
||||
<h1>Dashboard</h1>
|
||||
<p className="view-subtitle">Overview of your advertising performance</p>
|
||||
</div>
|
||||
<button className="btn btn-primary" onClick={() => onNavigate('campaigns')}>
|
||||
<svg width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2"><path d="M12 5v14M5 12h14" /></svg>
|
||||
New Campaign
|
||||
</button>
|
||||
</div>
|
||||
|
||||
{/* Stats */}
|
||||
<div className="stats-grid">
|
||||
<div className="stat-card">
|
||||
<div className="stat-label">Impressions</div>
|
||||
<div className="stat-value">{loading ? '—' : formatNumber(stats.impressions)}</div>
|
||||
</div>
|
||||
<div className="stat-card">
|
||||
<div className="stat-label">Clicks</div>
|
||||
<div className="stat-value text-blue">{loading ? '—' : formatNumber(stats.clicks)}</div>
|
||||
</div>
|
||||
<div className="stat-card">
|
||||
<div className="stat-label">Conversions</div>
|
||||
<div className="stat-value text-green">{loading ? '—' : formatNumber(stats.conversions)}</div>
|
||||
</div>
|
||||
<div className="stat-card">
|
||||
<div className="stat-label">Total Spend</div>
|
||||
<div className="stat-value">{loading ? '—' : formatCurrency(stats.spend)}</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Quick Actions */}
|
||||
<div style={{ display: 'grid', gridTemplateColumns: '1fr 1fr', gap: '20px', marginBottom: '28px' }}>
|
||||
<div className="content-card">
|
||||
<div className="content-card-header">
|
||||
<h3>Quick Actions</h3>
|
||||
</div>
|
||||
<div className="content-card-body padded">
|
||||
<div style={{ display: 'flex', flexDirection: 'column', gap: '10px' }}>
|
||||
<button className="btn btn-outline" onClick={() => onNavigate('campaigns')}>View All Campaigns</button>
|
||||
<button className="btn btn-outline" onClick={() => onNavigate('reporting')}>View Reports</button>
|
||||
<button className="btn btn-outline" onClick={() => onNavigate('accounts')}>Manage Accounts</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="content-card">
|
||||
<div className="content-card-header">
|
||||
<h3>Getting Started</h3>
|
||||
</div>
|
||||
<div className="content-card-body padded">
|
||||
<div style={{ display: 'flex', flexDirection: 'column', gap: '14px' }}>
|
||||
{['Link your Google Ads account', 'Create your first campaign', 'Set a daily budget', 'Review performance metrics'].map((step, i) => (
|
||||
<div key={i} style={{ display: 'flex', alignItems: 'center', gap: '12px', fontSize: '14px' }}>
|
||||
<div style={{ width: '24px', height: '24px', borderRadius: '50%', background: 'var(--color-primary-light)', color: 'var(--color-primary)', display: 'flex', alignItems: 'center', justifyContent: 'center', fontSize: '12px', fontWeight: '600', flexShrink: 0 }}>{i + 1}</div>
|
||||
<span>{step}</span>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Recent Campaigns */}
|
||||
<div className="content-card">
|
||||
<div className="content-card-header">
|
||||
<h3>Recent Campaigns</h3>
|
||||
{campaigns.length > 0 && (
|
||||
<button className="btn btn-sm btn-outline" onClick={() => onNavigate('campaigns')}>View All</button>
|
||||
)}
|
||||
</div>
|
||||
<div className="content-card-body">
|
||||
{loading ? (
|
||||
<div className="loading-placeholder padded">
|
||||
{[1,2,3].map(i => <div key={i} className="skeleton skeleton-row" style={{width: `${100 - i*15}%`}} />)}
|
||||
</div>
|
||||
) : campaigns.length === 0 ? (
|
||||
<div className="empty-state">
|
||||
<svg width="48" height="48" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="1.5">
|
||||
<polygon points="13 2 3 14 12 14 11 22 21 10 12 10 13 2" />
|
||||
</svg>
|
||||
<h3>No campaigns yet</h3>
|
||||
<p>Create your first campaign to get started</p>
|
||||
<button className="btn btn-primary btn-sm" onClick={() => onNavigate('campaigns')}>Create Campaign</button>
|
||||
</div>
|
||||
) : (
|
||||
campaigns.slice(0, 5).map((c, i) => (
|
||||
<div key={i} className="campaign-row" onClick={() => onNavigate('campaigns')}>
|
||||
<div className="campaign-info">
|
||||
<span className="campaign-name">{c.name || `Campaign ${i+1}`}</span>
|
||||
<span className="campaign-type">{(c.advertisingChannelType || 'Search').replace('ADVERTISING_CHANNEL_TYPE_', '')}</span>
|
||||
</div>
|
||||
<span className={`status-badge status-${formatStatus(c.status)}`}>
|
||||
{formatStatus(c.status)}
|
||||
</span>
|
||||
</div>
|
||||
))
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -1,118 +0,0 @@
|
||||
import React, { useState } from 'react';
|
||||
import { callService, gatewayHealth } from '../../services/apiClient';
|
||||
|
||||
const presets = [
|
||||
{ label: 'Health Check', provider: '', operation: '' },
|
||||
{ label: 'List Campaigns', provider: 'google', operation: 'ListCampaigns' },
|
||||
{ label: 'List Accounts', provider: 'google', operation: 'ListAccounts' },
|
||||
{ label: 'Get Stats', provider: 'google', operation: 'GetCampaignStats' },
|
||||
{ label: 'Account Info', provider: 'google', operation: 'GetAccountInfo' },
|
||||
];
|
||||
|
||||
export default function Developer({ sessionToken }) {
|
||||
const [provider, setProvider] = useState('google');
|
||||
const [operation, setOperation] = useState('ListCampaigns');
|
||||
const [params, setParams] = useState('');
|
||||
const [response, setResponse] = useState(null);
|
||||
const [loading, setLoading] = useState(false);
|
||||
const [elapsed, setElapsed] = useState(null);
|
||||
|
||||
const handleSubmit = async (e) => {
|
||||
e.preventDefault();
|
||||
setLoading(true);
|
||||
setResponse(null);
|
||||
const start = Date.now();
|
||||
|
||||
let result;
|
||||
if (!provider) {
|
||||
// Health check
|
||||
result = await gatewayHealth();
|
||||
} else {
|
||||
let extra = {};
|
||||
if (params.trim()) {
|
||||
try { extra = JSON.parse(params); } catch { extra = { rawParams: params }; }
|
||||
}
|
||||
result = await callService(provider, operation, extra, { sessionToken });
|
||||
}
|
||||
|
||||
setElapsed(Date.now() - start);
|
||||
setResponse(result);
|
||||
setLoading(false);
|
||||
};
|
||||
|
||||
const applyPreset = (preset) => {
|
||||
setProvider(preset.provider);
|
||||
setOperation(preset.operation);
|
||||
setParams('');
|
||||
};
|
||||
|
||||
return (
|
||||
<div>
|
||||
<div className="view-header">
|
||||
<div>
|
||||
<h1>API Testing</h1>
|
||||
<p className="view-subtitle">Test Gateway execution endpoints directly</p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Presets */}
|
||||
<div className="preset-row">
|
||||
{presets.map((p, i) => (
|
||||
<button key={i} className="btn btn-sm btn-outline" onClick={() => applyPreset(p)}>{p.label}</button>
|
||||
))}
|
||||
</div>
|
||||
|
||||
{/* Request Form */}
|
||||
<div className="dev-form">
|
||||
<form onSubmit={handleSubmit}>
|
||||
<div className="form-row">
|
||||
<div className="form-group">
|
||||
<label>Provider</label>
|
||||
<input className="form-input" type="text" value={provider} onChange={e => setProvider(e.target.value)} placeholder="google" />
|
||||
</div>
|
||||
<div className="form-group">
|
||||
<label>Operation</label>
|
||||
<input className="form-input" type="text" value={operation} onChange={e => setOperation(e.target.value)} placeholder="ListCampaigns" />
|
||||
</div>
|
||||
</div>
|
||||
<div className="form-group">
|
||||
<label>Additional Parameters (JSON)</label>
|
||||
<textarea
|
||||
className="form-input"
|
||||
rows="3"
|
||||
value={params}
|
||||
onChange={e => setParams(e.target.value)}
|
||||
placeholder='{"customerId": "123-456-7890"}'
|
||||
style={{ fontFamily: 'var(--font-mono)', fontSize: '13px', resize: 'vertical' }}
|
||||
/>
|
||||
</div>
|
||||
<div style={{ display: 'flex', gap: '12px', alignItems: 'center' }}>
|
||||
<button className="btn btn-primary" type="submit" disabled={loading}>
|
||||
{loading ? <><span className="btn-spinner" /> Sending...</> : 'Send Request'}
|
||||
</button>
|
||||
<span style={{ fontSize: '13px', color: 'var(--color-text-muted)' }}>
|
||||
POST /api/execution/request • Authorization: Bearer {'<session_token>'}
|
||||
</span>
|
||||
</div>
|
||||
</form>
|
||||
</div>
|
||||
|
||||
{/* Response */}
|
||||
{response && (
|
||||
<div className="dev-response">
|
||||
<div className="response-header">
|
||||
<div className={`status-dot ${response.ok ? 'green' : 'red'}`} />
|
||||
<span>HTTP {response.status}</span>
|
||||
{response.data?.correlationId && (
|
||||
<span className="request-id">{response.data.correlationId}</span>
|
||||
)}
|
||||
<span className="elapsed">{elapsed}ms</span>
|
||||
</div>
|
||||
<pre className="response-body">
|
||||
{JSON.stringify(response.data || response, null, 2)}
|
||||
</pre>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -1,151 +0,0 @@
|
||||
import React, { useState, useEffect } from 'react';
|
||||
import { callService } from '../../services/apiClient';
|
||||
|
||||
const formatNumber = (n) => n != null ? Number(n).toLocaleString() : '—';
|
||||
const formatCurrency = (micros) => micros ? '$' + (parseInt(micros) / 1000000).toFixed(2) : '$0.00';
|
||||
|
||||
const datePresets = [
|
||||
{ label: 'Last 7 Days', days: 7 },
|
||||
{ label: 'Last 30 Days', days: 30 },
|
||||
{ label: 'Last 90 Days', days: 90 },
|
||||
{ label: 'This Month', days: 'month' },
|
||||
];
|
||||
|
||||
export default function Reporting({ sessionToken }) {
|
||||
const [campaigns, setCampaigns] = useState([]);
|
||||
const [loading, setLoading] = useState(true);
|
||||
const [activePreset, setActivePreset] = useState(1);
|
||||
const [sortBy, setSortBy] = useState('impressions');
|
||||
const [sortDir, setSortDir] = useState('desc');
|
||||
|
||||
useEffect(() => {
|
||||
async function load() {
|
||||
setLoading(true);
|
||||
const res = await callService('google', 'ListCampaigns', {}, { sessionToken });
|
||||
if (res.ok && res.data?.data) {
|
||||
setCampaigns(Array.isArray(res.data.data) ? res.data.data : []);
|
||||
}
|
||||
setLoading(false);
|
||||
}
|
||||
if (sessionToken) load();
|
||||
}, [sessionToken]);
|
||||
|
||||
const totals = campaigns.reduce((acc, c) => {
|
||||
const m = c.metrics || {};
|
||||
acc.impressions += parseInt(m.impressions || 0);
|
||||
acc.clicks += parseInt(m.clicks || 0);
|
||||
acc.conversions += parseInt(m.conversions || 0);
|
||||
acc.cost += parseInt(m.costMicros || 0);
|
||||
return acc;
|
||||
}, { impressions: 0, clicks: 0, conversions: 0, cost: 0 });
|
||||
|
||||
totals.ctr = totals.impressions > 0 ? ((totals.clicks / totals.impressions) * 100).toFixed(2) : '0.00';
|
||||
totals.cpc = totals.clicks > 0 ? (totals.cost / totals.clicks / 1000000).toFixed(2) : '0.00';
|
||||
|
||||
const sorted = [...campaigns].sort((a, b) => {
|
||||
const av = parseInt(a.metrics?.[sortBy] || 0);
|
||||
const bv = parseInt(b.metrics?.[sortBy] || 0);
|
||||
return sortDir === 'desc' ? bv - av : av - bv;
|
||||
});
|
||||
|
||||
const handleSort = (col) => {
|
||||
if (sortBy === col) { setSortDir(d => d === 'desc' ? 'asc' : 'desc'); }
|
||||
else { setSortBy(col); setSortDir('desc'); }
|
||||
};
|
||||
|
||||
const sortIcon = (col) => sortBy === col ? (sortDir === 'desc' ? ' ↓' : ' ↑') : '';
|
||||
|
||||
return (
|
||||
<div>
|
||||
<div className="view-header">
|
||||
<div>
|
||||
<h1>Reporting</h1>
|
||||
<p className="view-subtitle">Campaign performance overview</p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Date Range Presets */}
|
||||
<div className="preset-row">
|
||||
{datePresets.map((preset, i) => (
|
||||
<button
|
||||
key={i}
|
||||
className={`btn btn-sm ${activePreset === i ? 'btn-primary' : 'btn-outline'}`}
|
||||
onClick={() => setActivePreset(i)}
|
||||
>
|
||||
{preset.label}
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
|
||||
{/* Summary Stats */}
|
||||
<div className="stats-grid" style={{ gridTemplateColumns: 'repeat(6, 1fr)' }}>
|
||||
{[
|
||||
{ label: 'Impressions', value: formatNumber(totals.impressions) },
|
||||
{ label: 'Clicks', value: formatNumber(totals.clicks) },
|
||||
{ label: 'CTR', value: totals.ctr + '%' },
|
||||
{ label: 'Avg CPC', value: '$' + totals.cpc },
|
||||
{ label: 'Conversions', value: formatNumber(totals.conversions) },
|
||||
{ label: 'Total Spend', value: formatCurrency(totals.cost) },
|
||||
].map((stat, i) => (
|
||||
<div key={i} className="stat-card">
|
||||
<div className="stat-label">{stat.label}</div>
|
||||
<div className="stat-value" style={{ fontSize: '22px' }}>{loading ? '—' : stat.value}</div>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
|
||||
{/* Campaign Breakdown Table */}
|
||||
<div className="content-card">
|
||||
<div className="content-card-header">
|
||||
<h3>Campaign Breakdown</h3>
|
||||
<span style={{ fontSize: '13px', color: 'var(--color-text-muted)' }}>
|
||||
{campaigns.length} campaign{campaigns.length !== 1 ? 's' : ''}
|
||||
</span>
|
||||
</div>
|
||||
<div className="content-card-body">
|
||||
{loading ? (
|
||||
<div className="loading-placeholder padded">
|
||||
{[1,2,3].map(i => <div key={i} className="skeleton skeleton-row" style={{height:'52px', marginBottom:'8px'}} />)}
|
||||
</div>
|
||||
) : campaigns.length === 0 ? (
|
||||
<div className="empty-state">
|
||||
<h3>No data available</h3>
|
||||
<p>Campaign data will appear here once campaigns are running.</p>
|
||||
</div>
|
||||
) : (
|
||||
<table className="data-table">
|
||||
<thead>
|
||||
<tr>
|
||||
<th>Campaign</th>
|
||||
<th style={{cursor:'pointer'}} onClick={() => handleSort('impressions')}>Impressions{sortIcon('impressions')}</th>
|
||||
<th style={{cursor:'pointer'}} onClick={() => handleSort('clicks')}>Clicks{sortIcon('clicks')}</th>
|
||||
<th>CTR</th>
|
||||
<th style={{cursor:'pointer'}} onClick={() => handleSort('costMicros')}>Cost{sortIcon('costMicros')}</th>
|
||||
<th style={{cursor:'pointer'}} onClick={() => handleSort('conversions')}>Conv.{sortIcon('conversions')}</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
{sorted.map((c, i) => {
|
||||
const m = c.metrics || {};
|
||||
const imp = parseInt(m.impressions || 0);
|
||||
const clk = parseInt(m.clicks || 0);
|
||||
const ctr = imp > 0 ? ((clk / imp) * 100).toFixed(2) : '0.00';
|
||||
return (
|
||||
<tr key={i}>
|
||||
<td style={{ fontWeight: 500 }}>{c.name || `Campaign ${i+1}`}</td>
|
||||
<td>{formatNumber(imp)}</td>
|
||||
<td>{formatNumber(clk)}</td>
|
||||
<td>{ctr}%</td>
|
||||
<td>{formatCurrency(m.costMicros)}</td>
|
||||
<td>{formatNumber(m.conversions)}</td>
|
||||
</tr>
|
||||
);
|
||||
})}
|
||||
</tbody>
|
||||
</table>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -1,199 +0,0 @@
|
||||
import React, { useState } from 'react';
|
||||
import { gatewayHealth } from '../../services/apiClient';
|
||||
import { GATEWAY_URL } from '../../auth/authConfig';
|
||||
|
||||
const tabs = [
|
||||
{ id: 'general', label: 'General' },
|
||||
{ id: 'connection', label: 'Connection' },
|
||||
{ id: 'notifications', label: 'Notifications' },
|
||||
{ id: 'security', label: 'Security' },
|
||||
];
|
||||
|
||||
export default function Settings({ sessionUser, sessionToken, onSignOut }) {
|
||||
const [activeTab, setActiveTab] = useState('general');
|
||||
const [tenantId, setTenantId] = useState(localStorage.getItem('adplatform_tenantId') || '');
|
||||
const [healthResult, setHealthResult] = useState(null);
|
||||
const [testing, setTesting] = useState(false);
|
||||
|
||||
const saveTenantId = () => {
|
||||
if (tenantId.trim()) {
|
||||
localStorage.setItem('adplatform_tenantId', tenantId.trim());
|
||||
} else {
|
||||
localStorage.removeItem('adplatform_tenantId');
|
||||
}
|
||||
};
|
||||
|
||||
const testConnection = async () => {
|
||||
setTesting(true);
|
||||
setHealthResult(null);
|
||||
const result = await gatewayHealth();
|
||||
setHealthResult(result);
|
||||
setTesting(false);
|
||||
};
|
||||
|
||||
return (
|
||||
<div>
|
||||
<div className="view-header">
|
||||
<h1>Settings</h1>
|
||||
</div>
|
||||
|
||||
<div className="settings-layout">
|
||||
<nav className="settings-nav">
|
||||
{tabs.map(tab => (
|
||||
<button
|
||||
key={tab.id}
|
||||
className={`settings-nav-item ${activeTab === tab.id ? 'active' : ''}`}
|
||||
onClick={() => setActiveTab(tab.id)}
|
||||
>
|
||||
{tab.label}
|
||||
</button>
|
||||
))}
|
||||
</nav>
|
||||
|
||||
<div className="settings-content">
|
||||
{activeTab === 'general' && (
|
||||
<>
|
||||
<div className="settings-section">
|
||||
<h3>Profile</h3>
|
||||
<div className="setting-row">
|
||||
<div className="setting-info">
|
||||
<div className="setting-label">Display Name</div>
|
||||
<div className="setting-desc">{sessionUser?.name || 'Not set'}</div>
|
||||
</div>
|
||||
</div>
|
||||
<div className="setting-row">
|
||||
<div className="setting-info">
|
||||
<div className="setting-label">Email</div>
|
||||
<div className="setting-desc">{sessionUser?.email || 'Not set'}</div>
|
||||
</div>
|
||||
</div>
|
||||
<div className="setting-row">
|
||||
<div className="setting-info">
|
||||
<div className="setting-label">Role</div>
|
||||
<div className="setting-desc">{sessionUser?.role || 'User'}</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="settings-section">
|
||||
<h3>Tenant Configuration</h3>
|
||||
<div className="form-group">
|
||||
<label>Tenant ID (Google Ads Customer ID)</label>
|
||||
<div className="input-row">
|
||||
<input
|
||||
className="form-input"
|
||||
type="text"
|
||||
value={tenantId}
|
||||
onChange={e => setTenantId(e.target.value)}
|
||||
placeholder="e.g. 123-456-7890"
|
||||
/>
|
||||
<button className="btn btn-primary btn-sm" onClick={saveTenantId}>Save</button>
|
||||
</div>
|
||||
<div style={{ fontSize: '13px', color: 'var(--color-text-muted)', marginTop: '-12px' }}>
|
||||
This ID is sent as X-Tenant-Id header with API requests.
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</>
|
||||
)}
|
||||
|
||||
{activeTab === 'connection' && (
|
||||
<>
|
||||
<div className="settings-section">
|
||||
<h3>Gateway Connection</h3>
|
||||
<div className="setting-row">
|
||||
<div className="setting-info">
|
||||
<div className="setting-label">Gateway URL</div>
|
||||
<div className="setting-value">{GATEWAY_URL}</div>
|
||||
</div>
|
||||
</div>
|
||||
<div className="setting-row">
|
||||
<div className="setting-info">
|
||||
<div className="setting-label">Connection Test</div>
|
||||
<div className="setting-desc">Verify the gateway is reachable and responding.</div>
|
||||
</div>
|
||||
<button className="btn btn-sm btn-outline" onClick={testConnection} disabled={testing}>
|
||||
{testing ? 'Testing…' : 'Test Connection'}
|
||||
</button>
|
||||
</div>
|
||||
{healthResult && (
|
||||
<div className={healthResult.ok ? 'info-box' : 'error-box'} style={{ marginTop: '12px' }}>
|
||||
{healthResult.ok
|
||||
? '✓ Gateway is healthy and responding.'
|
||||
: `✗ Connection failed: ${healthResult.error}`}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
|
||||
<div className="settings-section">
|
||||
<h3>Session Information</h3>
|
||||
<div className="session-info-detailed">
|
||||
<div className="detail-item">
|
||||
<span className="detail-label">Session Token</span>
|
||||
<span className="detail-value mono" style={{ wordBreak: 'break-all', fontSize: '12px' }}>
|
||||
{sessionToken ? sessionToken.substring(0, 32) + '…' : 'None'}
|
||||
</span>
|
||||
</div>
|
||||
<div className="detail-item">
|
||||
<span className="detail-label">User ID</span>
|
||||
<span className="detail-value mono">{sessionUser?.userId || '—'}</span>
|
||||
</div>
|
||||
<div className="detail-item">
|
||||
<span className="detail-label">Client ID</span>
|
||||
<span className="detail-value mono">{sessionUser?.clientId || '—'}</span>
|
||||
</div>
|
||||
<div className="detail-item">
|
||||
<span className="detail-label">Session ID</span>
|
||||
<span className="detail-value mono">{sessionUser?.sessionId || '—'}</span>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</>
|
||||
)}
|
||||
|
||||
{activeTab === 'notifications' && (
|
||||
<div className="settings-section">
|
||||
<h3>Notification Preferences</h3>
|
||||
<div className="setting-row">
|
||||
<div className="setting-info">
|
||||
<div className="setting-label">Campaign Alerts</div>
|
||||
<div className="setting-desc">Receive alerts when campaigns need attention.</div>
|
||||
</div>
|
||||
<span style={{ fontSize: '13px', color: 'var(--color-text-muted)' }}>Coming soon</span>
|
||||
</div>
|
||||
<div className="setting-row">
|
||||
<div className="setting-info">
|
||||
<div className="setting-label">Budget Warnings</div>
|
||||
<div className="setting-desc">Get notified when budgets are nearly exhausted.</div>
|
||||
</div>
|
||||
<span style={{ fontSize: '13px', color: 'var(--color-text-muted)' }}>Coming soon</span>
|
||||
</div>
|
||||
<div className="setting-row">
|
||||
<div className="setting-info">
|
||||
<div className="setting-label">Weekly Summary</div>
|
||||
<div className="setting-desc">Receive a weekly performance summary email.</div>
|
||||
</div>
|
||||
<span style={{ fontSize: '13px', color: 'var(--color-text-muted)' }}>Coming soon</span>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{activeTab === 'security' && (
|
||||
<>
|
||||
<div className="settings-section">
|
||||
<h3>Authentication</h3>
|
||||
<div className="setting-row">
|
||||
<div className="setting-info">
|
||||
<div className="setting-label">Sign Out</div>
|
||||
<div className="setting-desc">End your current session and return to the login page.</div>
|
||||
</div>
|
||||
<button className="btn btn-sm btn-danger" onClick={onSignOut}>Sign Out</button>
|
||||
</div>
|
||||
</div>
|
||||
</>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
232
Client-Client/src/components/wizard/CampaignWizard.jsx
Normal file
232
Client-Client/src/components/wizard/CampaignWizard.jsx
Normal file
@@ -0,0 +1,232 @@
|
||||
import React, { useState, useCallback, useEffect, useRef } from 'react';
|
||||
import { useAuth } from '../../auth/AuthProvider';
|
||||
import WizardConfigProvider from '../../context/WizardConfigContext';
|
||||
import ObjectiveStep from './steps/ObjectiveStep';
|
||||
import AudienceStep from './steps/AudienceStep';
|
||||
import BudgetStep from './steps/BudgetStep';
|
||||
import ChannelAllocationStep from './steps/ChannelAllocationStep';
|
||||
import CreativeStep from './steps/CreativeStep';
|
||||
import ReviewStep from './steps/ReviewStep';
|
||||
import { createWizard, getWizard, updateStep } from '../../services/wizardApi';
|
||||
|
||||
// ═══════════════════════════════════════════════════════════════
|
||||
// Step order: Objective → Audience → Budget → Channels → Creative → Review
|
||||
//
|
||||
// Why budget before channels:
|
||||
// Budget constrains which channels are viable. Knowing the budget
|
||||
// first lets us disable unaffordable channels, call the forecasting
|
||||
// APIs with all inputs (objective + audience + budget) to give
|
||||
// data-driven channel recommendations, and surface budget tier
|
||||
// guidance (e.g. "your budget supports 1-2 channels").
|
||||
// ═══════════════════════════════════════════════════════════════
|
||||
|
||||
const STEPS = [
|
||||
{ num: 1, key: 'objective', label: 'Objective', icon: 'M9 12l2 2 4-4m6 2a9 9 0 11-18 0 9 9 0 0118 0z' },
|
||||
{ num: 2, key: 'audience', label: 'Audience', icon: 'M17 20h5v-2a3 3 0 00-5.356-1.857M17 20H7m10 0v-2c0-.656-.126-1.283-.356-1.857M7 20H2v-2a3 3 0 015.356-1.857M7 20v-2c0-.656.126-1.283.356-1.857m0 0a5.002 5.002 0 019.288 0M15 7a3 3 0 11-6 0 3 3 0 016 0z' },
|
||||
{ num: 3, key: 'budget', label: 'Budget', icon: 'M12 8c-1.657 0-3 .895-3 2s1.343 2 3 2 3 .895 3 2-1.343 2-3 2m0-8c1.11 0 2.08.402 2.599 1M12 8V7m0 1v8m0 0v1m0-1c-1.11 0-2.08-.402-2.599-1M21 12a9 9 0 11-18 0 9 9 0 0118 0z' },
|
||||
{ num: 4, key: 'channels', label: 'Channels', icon: 'M8.111 16.404a5.5 5.5 0 017.778 0M12 20h.01m-7.08-7.071c3.904-3.905 10.236-3.905 14.14 0M1.394 9.393c5.857-5.858 15.355-5.858 21.213 0' },
|
||||
{ num: 5, key: 'creative', label: 'Creative', icon: 'M4 16l4.586-4.586a2 2 0 012.828 0L16 16m-2-2l1.586-1.586a2 2 0 012.828 0L20 14m-6-6h.01M6 20h12a2 2 0 002-2V6a2 2 0 00-2-2H6a2 2 0 00-2 2v12a2 2 0 002 2z' },
|
||||
{ num: 6, key: 'review', label: 'Review', icon: 'M22 2L11 13M22 2l-7 20-4-9-9-4 20-7z' },
|
||||
];
|
||||
|
||||
const LAST_DATA_STEP = 5;
|
||||
|
||||
export default function CampaignWizard({ onClose, wizardId: initialWizardId }) {
|
||||
const { sessionToken } = useAuth();
|
||||
|
||||
const [currentStep, setCurrentStep] = useState(1);
|
||||
const [wizardId, setWizardId] = useState(initialWizardId || null);
|
||||
const [visitedSteps, setVisitedSteps] = useState(new Set([1]));
|
||||
const [stepData, setStepData] = useState({
|
||||
1: {}, // Objective: campaignName, url, objective, businessCategory, analysis
|
||||
2: {}, // Audience: locations[], demographics{}, ageSkew, marketScope
|
||||
3: {}, // Budget: totalBudget, budgetPeriod, startDate, endDate, hasEndDate
|
||||
4: {}, // Channels: selectedChannels[], allocations{}, allocationStrategy
|
||||
5: {}, // Creative: headlines[], descriptions[], images[]
|
||||
});
|
||||
const saveTimer = useRef(null);
|
||||
const wizardIdRef = useRef(wizardId);
|
||||
wizardIdRef.current = wizardId;
|
||||
|
||||
// ── Load existing draft ──
|
||||
useEffect(() => {
|
||||
if (!initialWizardId || !sessionToken) return;
|
||||
(async () => {
|
||||
const res = await getWizard(initialWizardId, sessionToken);
|
||||
if (res.ok && res.data) {
|
||||
const wiz = res.data.data || res.data;
|
||||
const restored = { 1: {}, 2: {}, 3: {}, 4: {}, 5: {} };
|
||||
if (wiz.wizStepData || wiz.stepData) {
|
||||
const saved = typeof (wiz.wizStepData || wiz.stepData) === 'string'
|
||||
? JSON.parse(wiz.wizStepData || wiz.stepData)
|
||||
: (wiz.wizStepData || wiz.stepData);
|
||||
Object.assign(restored, saved);
|
||||
}
|
||||
if (wiz.wizName || wiz.name) {
|
||||
restored[1] = { ...restored[1], campaignName: wiz.wizName || wiz.name };
|
||||
}
|
||||
if (wiz.wizUrl || wiz.url) {
|
||||
restored[1] = { ...restored[1], url: wiz.wizUrl || wiz.url };
|
||||
}
|
||||
setStepData(restored);
|
||||
const resumeStep = parseInt(wiz.wizCurrentStep || wiz.currentStep) || 1;
|
||||
setCurrentStep(Math.min(Math.max(resumeStep, 1), STEPS.length));
|
||||
}
|
||||
})();
|
||||
}, [initialWizardId, sessionToken]);
|
||||
|
||||
// ── Auto-save: debounced ──
|
||||
const saveToBackend = useCallback(async (step, data, allData) => {
|
||||
if (!sessionToken) return;
|
||||
try {
|
||||
let id = wizardIdRef.current;
|
||||
if (!id) {
|
||||
const name = allData[1]?.campaignName || 'Untitled Campaign';
|
||||
const url = allData[1]?.url || '';
|
||||
const createRes = await createWizard(name, url, sessionToken);
|
||||
if (createRes.ok) {
|
||||
id = createRes.data?.data?.wizardId || createRes.data?.wizardId || createRes.data?.id;
|
||||
if (id) { setWizardId(id); wizardIdRef.current = id; }
|
||||
}
|
||||
}
|
||||
if (id) {
|
||||
await updateStep(id, step, allData, sessionToken, allData[1]?.campaignName);
|
||||
}
|
||||
} catch (e) { console.warn('Auto-save failed:', e); }
|
||||
}, [sessionToken]);
|
||||
|
||||
const handleStepDataChange = useCallback((step, data) => {
|
||||
setStepData(prev => {
|
||||
const next = { ...prev, [step]: data };
|
||||
if (saveTimer.current) clearTimeout(saveTimer.current);
|
||||
saveTimer.current = setTimeout(() => saveToBackend(step, data, next), 1500);
|
||||
return next;
|
||||
});
|
||||
}, [saveToBackend]);
|
||||
|
||||
// Step validation
|
||||
const stepComplete = (num) => {
|
||||
const d = stepData[num];
|
||||
switch (num) {
|
||||
case 1: return !!(d?.campaignName && d?.objective);
|
||||
case 2: return visitedSteps.has(2) && visitedSteps.has(3);
|
||||
case 3: return !!(d?.totalBudget && parseFloat(d.totalBudget) > 0);
|
||||
case 4: {
|
||||
const hasChannels = (d?.selectedChannels?.length || 0) > 0;
|
||||
if (!hasChannels) return false;
|
||||
if (d.selectedChannels.length > 1) {
|
||||
const totalPct = Object.values(d?.allocations || {}).reduce((s, v) => s + (v || 0), 0);
|
||||
return Math.abs(totalPct - 100) < 0.01;
|
||||
}
|
||||
return true;
|
||||
}
|
||||
case 5: return !!(d?.headlines?.length > 0 || d?.descriptions?.length > 0);
|
||||
default: return false;
|
||||
}
|
||||
};
|
||||
|
||||
const goToStep = (targetStep) => {
|
||||
setVisitedSteps(prev => new Set([...prev, targetStep]));
|
||||
setCurrentStep(targetStep);
|
||||
if (wizardIdRef.current && sessionToken && targetStep <= LAST_DATA_STEP) {
|
||||
updateStep(wizardIdRef.current, targetStep, stepData, sessionToken, stepData[1]?.campaignName).catch(() => {});
|
||||
}
|
||||
};
|
||||
|
||||
const handleNext = () => goToStep(Math.min(currentStep + 1, STEPS.length));
|
||||
const handleBack = () => goToStep(Math.max(currentStep - 1, 1));
|
||||
|
||||
const campaignName = stepData[1]?.campaignName || 'New Campaign';
|
||||
const selectedChannelCount = stepData[4]?.selectedChannels?.length || 0;
|
||||
|
||||
return (
|
||||
<WizardConfigProvider>
|
||||
<div className="wizard-wrapper">
|
||||
{/* Header */}
|
||||
<div className="wizard-header">
|
||||
<div className="wizard-header-left">
|
||||
<button className="wizard-back-btn" onClick={onClose} title="Exit wizard">
|
||||
<svg width="20" height="20" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2">
|
||||
<path d="M18 6L6 18M6 6l12 12" />
|
||||
</svg>
|
||||
</button>
|
||||
<div>
|
||||
<h2 className="wizard-title">{campaignName}</h2>
|
||||
<span className="wizard-subtitle">
|
||||
{selectedChannelCount > 1
|
||||
? `Multi-channel Campaign · ${selectedChannelCount} channels`
|
||||
: 'Campaign Wizard'}
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
<div className="wizard-header-right">
|
||||
<span className="status-badge status-active">draft</span>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Step Navigation Rail */}
|
||||
<div className="wizard-rail">
|
||||
{STEPS.map(step => {
|
||||
const isActive = currentStep === step.num;
|
||||
const isComplete = step.num < STEPS.length && stepComplete(step.num);
|
||||
const isPast = step.num < currentStep;
|
||||
return (
|
||||
<button
|
||||
key={step.num}
|
||||
className={`wizard-rail-step ${isActive ? 'active' : ''} ${isComplete ? 'complete' : ''} ${isPast ? 'past' : ''}`}
|
||||
onClick={() => goToStep(step.num)}
|
||||
>
|
||||
<div className="rail-step-indicator">
|
||||
{isComplete ? (
|
||||
<svg width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="3"><path d="M20 6L9 17l-5-5" /></svg>
|
||||
) : (
|
||||
<span>{step.num}</span>
|
||||
)}
|
||||
</div>
|
||||
<span className="rail-step-label">{step.label}</span>
|
||||
</button>
|
||||
);
|
||||
})}
|
||||
<div className="wizard-rail-progress" style={{ width: `${((currentStep - 1) / (STEPS.length - 1)) * 100}%` }} />
|
||||
</div>
|
||||
|
||||
{/* Step Content */}
|
||||
<div className="wizard-body">
|
||||
{currentStep === 1 && <ObjectiveStep data={stepData[1]} onChange={(d) => handleStepDataChange(1, d)} />}
|
||||
{currentStep === 2 && <AudienceStep data={stepData[2]} onChange={(d) => handleStepDataChange(2, d)} />}
|
||||
{currentStep === 3 && <BudgetStep data={stepData[3]} onChange={(d) => handleStepDataChange(3, d)} />}
|
||||
{currentStep === 4 && (
|
||||
<ChannelAllocationStep
|
||||
data={stepData[4]}
|
||||
onChange={(d) => handleStepDataChange(4, d)}
|
||||
objectiveData={stepData[1]}
|
||||
audienceData={stepData[2]}
|
||||
budgetData={stepData[3]}
|
||||
/>
|
||||
)}
|
||||
{currentStep === 5 && <CreativeStep data={stepData[5]} onChange={(d) => handleStepDataChange(5, d)} businessData={stepData[1]} />}
|
||||
{currentStep === 6 && <ReviewStep stepData={stepData} onGoToStep={goToStep} />}
|
||||
</div>
|
||||
|
||||
{/* Footer Navigation */}
|
||||
{currentStep < STEPS.length && (
|
||||
<div className="wizard-footer">
|
||||
<button className="btn btn-outline" onClick={handleBack} disabled={currentStep === 1}>
|
||||
<svg width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2"><path d="M19 12H5M12 19l-7-7 7-7" /></svg>
|
||||
Back
|
||||
</button>
|
||||
<div className="wizard-footer-center">Step {currentStep} of {STEPS.length}</div>
|
||||
<button
|
||||
className="btn btn-primary"
|
||||
onClick={handleNext}
|
||||
disabled={currentStep === 4 && selectedChannelCount === 0}
|
||||
>
|
||||
{currentStep === 5 ? 'Review Campaign' : 'Continue'}
|
||||
<svg width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2"><path d="M5 12h14M12 5l7 7-7 7" /></svg>
|
||||
</button>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</WizardConfigProvider>
|
||||
);
|
||||
}
|
||||
552
Client-Client/src/components/wizard/steps/AudienceStep.jsx
Normal file
552
Client-Client/src/components/wizard/steps/AudienceStep.jsx
Normal file
@@ -0,0 +1,552 @@
|
||||
import React, { useState, useCallback, useRef, memo } from 'react';
|
||||
import { useAuth } from '../../../auth/AuthProvider';
|
||||
import { searchGeoTargets, getDemographics } from '../../../services/wizardApi';
|
||||
import HelpIcon from '../../../components/HelpIcon';
|
||||
|
||||
// ── Static Options ──
|
||||
|
||||
const AGE_RANGES = [
|
||||
{ id: 'AGE_18_24', label: '18–24' },
|
||||
{ id: 'AGE_25_34', label: '25–34' },
|
||||
{ id: 'AGE_35_44', label: '35–44' },
|
||||
{ id: 'AGE_45_54', label: '45–54' },
|
||||
{ id: 'AGE_55_64', label: '55–64' },
|
||||
{ id: 'AGE_65_UP', label: '65+' },
|
||||
];
|
||||
|
||||
const GENDERS = [
|
||||
{ id: 'MALE', label: 'Male' },
|
||||
{ id: 'FEMALE', label: 'Female' },
|
||||
{ id: 'UNDETERMINED', label: 'All Genders' },
|
||||
];
|
||||
|
||||
const INCOMES = [
|
||||
{ id: 'TOP_10', label: 'Top 10%' },
|
||||
{ id: 'TOP_11_20', label: '11–20%' },
|
||||
{ id: 'TOP_21_30', label: '21–30%' },
|
||||
{ id: 'TOP_31_40', label: '31–40%' },
|
||||
{ id: 'TOP_41_50', label: '41–50%' },
|
||||
{ id: 'LOWER_50', label: 'Lower 50%' },
|
||||
];
|
||||
|
||||
// ── Factor Derivation ──
|
||||
|
||||
export function deriveAgeSkew(selectedAges) {
|
||||
if (!selectedAges || selectedAges.length === 0) return null;
|
||||
|
||||
const youngSet = ['AGE_18_24', 'AGE_25_34'];
|
||||
const matureSet = ['AGE_45_54', 'AGE_55_64', 'AGE_65_UP'];
|
||||
|
||||
const youngCount = selectedAges.filter(a => youngSet.includes(a)).length;
|
||||
const matureCount = selectedAges.filter(a => matureSet.includes(a)).length;
|
||||
|
||||
if (youngCount > 0 && matureCount === 0) return 'young';
|
||||
if (matureCount > 0 && youngCount === 0) return 'mature';
|
||||
if (youngCount > matureCount) return 'young';
|
||||
if (matureCount > youngCount) return 'mature';
|
||||
return null;
|
||||
}
|
||||
|
||||
export function deriveMarketScope(locations) {
|
||||
if (!locations || locations.length === 0) return 'national';
|
||||
|
||||
const types = locations.map(l => (l.type || '').toLowerCase());
|
||||
if (types.some(t => t === 'country')) return 'national';
|
||||
|
||||
const states = new Set();
|
||||
locations.forEach(loc => {
|
||||
const parts = (loc.canonicalName || '').split(',').map(s => s.trim());
|
||||
if (parts.length >= 2) states.add(parts[parts.length - 2]);
|
||||
});
|
||||
|
||||
const stateTypes = types.filter(t => ['state', 'province', 'region'].includes(t));
|
||||
if (stateTypes.length >= 2) return 'national';
|
||||
if (stateTypes.length === 1) return 'regional';
|
||||
|
||||
if (states.size <= 1) return 'local';
|
||||
if (states.size <= 3) return 'regional';
|
||||
return 'national';
|
||||
}
|
||||
|
||||
// ── Chip Select Component ──
|
||||
|
||||
function ChipSelect({ options, selected, onToggle, censusHighlighted }) {
|
||||
return (
|
||||
<div className="chip-row">
|
||||
{options.map(opt => {
|
||||
const isSelected = selected.includes(opt.id);
|
||||
const isCensus = censusHighlighted?.includes(opt.id);
|
||||
return (
|
||||
<button
|
||||
key={opt.id}
|
||||
type="button"
|
||||
className={`chip ${isSelected ? 'active' : ''} ${isCensus && !isSelected ? 'census-suggested' : ''}`}
|
||||
onClick={() => onToggle(opt.id)}
|
||||
title={isCensus ? 'Recommended based on local demographics' : ''}
|
||||
>
|
||||
{opt.label}
|
||||
</button>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
// ── Market Insight Bar ──
|
||||
|
||||
function MarketInsightBar({ insights, census, loading }) {
|
||||
if (loading) {
|
||||
return (
|
||||
<div className="market-insight-bar loading">
|
||||
<span className="btn-spinner" style={{ width: 14, height: 14 }} />
|
||||
<span>Loading market data…</span>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
if (!insights || insights.length === 0) return null;
|
||||
|
||||
return (
|
||||
<div className="market-insight-bar">
|
||||
<div className="insight-header">
|
||||
<svg width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2">
|
||||
<path d="M3 3v18h18" />
|
||||
<path d="M18.7 8l-5.1 5.2-2.8-2.7L7 14.3" />
|
||||
</svg>
|
||||
<span className="insight-title">Market Profile</span>
|
||||
<span className="insight-source">U.S. Census ACS</span>
|
||||
</div>
|
||||
<div className="insight-chips">
|
||||
{insights.map((insight, i) => (
|
||||
<span key={i} className="insight-chip">{insight}</span>
|
||||
))}
|
||||
</div>
|
||||
{census && (
|
||||
<div className="insight-details">
|
||||
<div className="insight-grid">
|
||||
{census.medianIncome > 0 && (
|
||||
<div className="insight-metric">
|
||||
<span className="metric-value">${(census.medianIncome / 1000).toFixed(0)}K</span>
|
||||
<span className="metric-label">Median Income</span>
|
||||
</div>
|
||||
)}
|
||||
{census.medianHomeValue > 0 && (
|
||||
<div className="insight-metric">
|
||||
<span className="metric-value">${(census.medianHomeValue / 1000).toFixed(0)}K</span>
|
||||
<span className="metric-label">Home Value</span>
|
||||
</div>
|
||||
)}
|
||||
{census.pctBachelorPlus > 0 && (
|
||||
<div className="insight-metric">
|
||||
<span className="metric-value">{census.pctBachelorPlus}%</span>
|
||||
<span className="metric-label">College Educated</span>
|
||||
</div>
|
||||
)}
|
||||
{census.pctOwnerOccupied > 0 && (
|
||||
<div className="insight-metric">
|
||||
<span className="metric-value">{census.pctOwnerOccupied}%</span>
|
||||
<span className="metric-label">Homeowners</span>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
// ── Audience Step ──
|
||||
|
||||
const AudienceStep = memo(function AudienceStep({ data, onChange }) {
|
||||
const { sessionToken } = useAuth();
|
||||
|
||||
const [geoQuery, setGeoQuery] = useState('');
|
||||
const [geoResults, setGeoResults] = useState([]);
|
||||
const [geoSearching, setGeoSearching] = useState(false);
|
||||
const [censusLoading, setCensusLoading] = useState(false);
|
||||
const searchTimeout = useRef(null);
|
||||
|
||||
// Migrate old saved data: clear legacy Male/Female defaults
|
||||
// so census can set All Genders on first ZIP lookup
|
||||
const migrateData = (d) => {
|
||||
if (!d || !d.demographics) return d;
|
||||
const g = d.demographics.genders;
|
||||
// If saved data has exactly ['MALE','FEMALE'] and no census data,
|
||||
// it's the old hardcoded default — clear it
|
||||
if (g && g.length === 2
|
||||
&& g.includes('MALE') && g.includes('FEMALE')
|
||||
&& !d.censusData) {
|
||||
return { ...d, demographics: { ...d.demographics, genders: [] } };
|
||||
}
|
||||
return d;
|
||||
};
|
||||
|
||||
const migrated = migrateData(data);
|
||||
|
||||
const state = {
|
||||
locations: [],
|
||||
demographics: {
|
||||
ageRanges: [],
|
||||
genders: [],
|
||||
incomes: [],
|
||||
},
|
||||
ageSkew: null,
|
||||
marketScope: 'national',
|
||||
censusData: null,
|
||||
censusInsights: null,
|
||||
censusRecommendations: null,
|
||||
...migrated,
|
||||
};
|
||||
|
||||
const update = useCallback((patch) => {
|
||||
const next = { ...state, ...patch };
|
||||
next.ageSkew = deriveAgeSkew(next.demographics.ageRanges);
|
||||
next.marketScope = deriveMarketScope(next.locations);
|
||||
onChange(next);
|
||||
}, [state, onChange]);
|
||||
|
||||
const updateDemo = (key, value) => {
|
||||
update({ demographics: { ...state.demographics, [key]: value } });
|
||||
};
|
||||
|
||||
const toggleDemo = (key, id) => {
|
||||
const current = state.demographics[key] || [];
|
||||
const next = current.includes(id)
|
||||
? current.filter(x => x !== id)
|
||||
: [...current, id];
|
||||
updateDemo(key, next);
|
||||
};
|
||||
|
||||
// ── Census Lookup ──
|
||||
// Accepts currentLocations to avoid stale closure overwriting location state
|
||||
|
||||
const fetchCensusData = async (zcta, currentLocations) => {
|
||||
if (!zcta || zcta.length !== 5 || !sessionToken) return;
|
||||
|
||||
setCensusLoading(true);
|
||||
try {
|
||||
const res = await getDemographics(zcta, sessionToken);
|
||||
if (res.ok && res.data) {
|
||||
const d = res.data;
|
||||
const recs = d.recommendations || {};
|
||||
|
||||
// Always apply census recommendations for new ZIP data
|
||||
const newAges = recs.ageRanges || state.demographics.ageRanges;
|
||||
const newIncomes = recs.incomes || state.demographics.incomes;
|
||||
const newGenders = state.demographics.genders.length === 0
|
||||
? ['UNDETERMINED'] : state.demographics.genders;
|
||||
|
||||
update({
|
||||
// Preserve the locations that were set before this async call
|
||||
locations: currentLocations || state.locations,
|
||||
demographics: {
|
||||
...state.demographics,
|
||||
ageRanges: newAges,
|
||||
incomes: newIncomes,
|
||||
genders: newGenders,
|
||||
},
|
||||
censusData: d.census || null,
|
||||
censusInsights: d.insights || null,
|
||||
censusRecommendations: recs,
|
||||
});
|
||||
}
|
||||
} catch (e) {
|
||||
console.warn('Census lookup error:', e);
|
||||
}
|
||||
setCensusLoading(false);
|
||||
};
|
||||
|
||||
// ── Direct ZIP Lookup ──
|
||||
|
||||
const isZipInput = /^\d{5}$/.test(geoQuery.trim());
|
||||
|
||||
const addZipDirect = () => {
|
||||
const zip = geoQuery.trim();
|
||||
if (!/^\d{5}$/.test(zip)) return;
|
||||
if (state.locations.some(l => l.name === zip)) return;
|
||||
|
||||
const newLocations = [...state.locations, {
|
||||
id: `zip_${zip}`,
|
||||
name: zip,
|
||||
type: 'Zip Code',
|
||||
canonicalName: `${zip}, United States`,
|
||||
}];
|
||||
|
||||
update({ locations: newLocations });
|
||||
setGeoQuery('');
|
||||
setGeoResults([]);
|
||||
fetchCensusData(zip, newLocations);
|
||||
};
|
||||
|
||||
const onGeoKeyDown = (e) => {
|
||||
if (e.key === 'Enter' && isZipInput) {
|
||||
e.preventDefault();
|
||||
addZipDirect();
|
||||
}
|
||||
};
|
||||
|
||||
// ── Geo Search ──
|
||||
|
||||
const handleGeoSearch = async (query) => {
|
||||
if (!query || query.length < 2) { setGeoResults([]); return; }
|
||||
setGeoSearching(true);
|
||||
try {
|
||||
const res = await searchGeoTargets(query, sessionToken);
|
||||
if (res.ok) {
|
||||
const results = res.data?.result?.data?.results
|
||||
|| res.data?.data?.results
|
||||
|| res.data?.results || [];
|
||||
setGeoResults(results);
|
||||
}
|
||||
} catch (e) {
|
||||
console.warn('Geo search error:', e);
|
||||
}
|
||||
setGeoSearching(false);
|
||||
};
|
||||
|
||||
const onGeoInput = (val) => {
|
||||
setGeoQuery(val);
|
||||
clearTimeout(searchTimeout.current);
|
||||
// Only fire geo search for non-ZIP queries
|
||||
if (!/^\d{3,5}$/.test(val.trim())) {
|
||||
searchTimeout.current = setTimeout(() => handleGeoSearch(val), 400);
|
||||
} else {
|
||||
setGeoResults([]);
|
||||
}
|
||||
};
|
||||
|
||||
const addLocation = (geo) => {
|
||||
if (state.locations.some(l => l.id === geo.id)) return;
|
||||
|
||||
const newLocations = [...state.locations, {
|
||||
id: geo.id,
|
||||
name: geo.name,
|
||||
type: geo.targetType || geo.type || 'Location',
|
||||
canonicalName: geo.canonicalName,
|
||||
}];
|
||||
|
||||
update({ locations: newLocations });
|
||||
setGeoQuery('');
|
||||
setGeoResults([]);
|
||||
|
||||
const zcta = extractZcta(geo);
|
||||
if (zcta) fetchCensusData(zcta, newLocations);
|
||||
};
|
||||
|
||||
const removeLocation = (id) => {
|
||||
const remaining = state.locations.filter(l => l.id !== id);
|
||||
const patch = { locations: remaining };
|
||||
|
||||
if (!remaining.some(l => extractZcta(l))) {
|
||||
patch.censusData = null;
|
||||
patch.censusInsights = null;
|
||||
patch.censusRecommendations = null;
|
||||
patch.demographics = {
|
||||
...state.demographics,
|
||||
ageRanges: [],
|
||||
genders: [],
|
||||
incomes: [],
|
||||
};
|
||||
}
|
||||
|
||||
update(patch);
|
||||
};
|
||||
|
||||
const factorLabels = {
|
||||
ageSkew: { young: 'Younger audience', mature: 'Mature audience', null: 'Balanced' },
|
||||
marketScope: { local: 'Local market', regional: 'Regional', national: 'National' },
|
||||
};
|
||||
|
||||
return (
|
||||
<div className="wizard-step-content">
|
||||
<div className="step-intro">
|
||||
<div className="step-intro-header">
|
||||
<h2>Who is your audience?</h2>
|
||||
<HelpIcon helpKey="client.wizard.audience" label="About this step" />
|
||||
</div>
|
||||
<p>Add your target location and we'll analyze the local market to recommend the best audience targeting for your campaign.</p>
|
||||
</div>
|
||||
|
||||
{/* ── Location Targeting ── */}
|
||||
<div className="audience-section">
|
||||
<h3>
|
||||
<svg width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2">
|
||||
<path d="M21 10c0 7-9 13-9 13s-9-6-9-13a9 9 0 0118 0z" />
|
||||
<circle cx="12" cy="10" r="3" />
|
||||
</svg>
|
||||
Target Locations
|
||||
</h3>
|
||||
<div className="geo-search-wrap">
|
||||
<div className="geo-input-row">
|
||||
<input
|
||||
className="form-input"
|
||||
placeholder="Enter a ZIP code or search cities, states…"
|
||||
value={geoQuery}
|
||||
onChange={e => onGeoInput(e.target.value)}
|
||||
onKeyDown={onGeoKeyDown}
|
||||
/>
|
||||
{isZipInput && (
|
||||
<button
|
||||
type="button"
|
||||
className="btn btn-primary btn-sm zip-lookup-btn"
|
||||
onClick={addZipDirect}
|
||||
>
|
||||
Look Up
|
||||
</button>
|
||||
)}
|
||||
</div>
|
||||
{geoSearching && <span className="geo-search-spinner"><span className="btn-spinner" /></span>}
|
||||
{geoResults.length > 0 && (
|
||||
<div className="geo-dropdown">
|
||||
{geoResults.map(geo => (
|
||||
<button
|
||||
key={geo.id}
|
||||
type="button"
|
||||
className="geo-result"
|
||||
onClick={() => addLocation(geo)}
|
||||
disabled={state.locations.some(l => l.id === geo.id)}
|
||||
>
|
||||
<span className="geo-result-name">{geo.name}</span>
|
||||
<span className="geo-result-type">{geo.targetType || geo.type}</span>
|
||||
<span className="geo-result-canonical">{geo.canonicalName}</span>
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{state.locations.length > 0 && (
|
||||
<div className="location-chips">
|
||||
{state.locations.map(loc => (
|
||||
<span key={loc.id} className="location-chip">
|
||||
<svg width="12" height="12" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2">
|
||||
<path d="M21 10c0 7-9 13-9 13s-9-6-9-13a9 9 0 0118 0z" />
|
||||
<circle cx="12" cy="10" r="3" />
|
||||
</svg>
|
||||
{loc.name}
|
||||
<button type="button" onClick={() => removeLocation(loc.id)} className="chip-remove">×</button>
|
||||
</span>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
|
||||
{state.locations.length === 0 && (
|
||||
<div className="field-hint" style={{ marginTop: 8 }}>
|
||||
Enter a ZIP code to see local market demographics and auto-fill targeting recommendations.
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* ── Market Insight Bar (census-powered) ── */}
|
||||
<MarketInsightBar
|
||||
insights={state.censusInsights}
|
||||
census={state.censusData}
|
||||
loading={censusLoading}
|
||||
/>
|
||||
|
||||
{/* ── Demographics ── */}
|
||||
<div className="audience-section">
|
||||
<h3>
|
||||
<svg width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2">
|
||||
<path d="M17 21v-2a4 4 0 00-4-4H5a4 4 0 00-4 4v2" />
|
||||
<circle cx="9" cy="7" r="4" />
|
||||
<path d="M23 21v-2a4 4 0 00-3-3.87" />
|
||||
<path d="M16 3.13a4 4 0 010 7.75" />
|
||||
</svg>
|
||||
Demographics
|
||||
{state.censusData && (
|
||||
<span className="census-badge">Auto-populated from market data</span>
|
||||
)}
|
||||
</h3>
|
||||
|
||||
<div className="demo-group">
|
||||
<label>Age Ranges</label>
|
||||
<ChipSelect
|
||||
options={AGE_RANGES}
|
||||
selected={state.demographics.ageRanges}
|
||||
onToggle={(id) => toggleDemo('ageRanges', id)}
|
||||
censusHighlighted={state.censusRecommendations?.ageRanges}
|
||||
/>
|
||||
{state.demographics.ageRanges.length === 0 && !state.censusData && (
|
||||
<span className="field-hint">Add a ZIP code above to auto-fill, or select manually</span>
|
||||
)}
|
||||
</div>
|
||||
|
||||
<div className="demo-group">
|
||||
<label>Gender</label>
|
||||
<ChipSelect
|
||||
options={GENDERS}
|
||||
selected={state.demographics.genders}
|
||||
onToggle={(id) => toggleDemo('genders', id)}
|
||||
/>
|
||||
{state.demographics.genders.length === 0 && (
|
||||
<span className="field-hint">Will default to All Genders when market data loads</span>
|
||||
)}
|
||||
</div>
|
||||
|
||||
<div className="demo-group">
|
||||
<label>Household Income
|
||||
{state.censusData
|
||||
? <span className="census-badge small">Market-informed</span>
|
||||
: <span className="optional-tag">Optional</span>
|
||||
}
|
||||
</label>
|
||||
<ChipSelect
|
||||
options={INCOMES}
|
||||
selected={state.demographics.incomes}
|
||||
onToggle={(id) => toggleDemo('incomes', id)}
|
||||
censusHighlighted={state.censusRecommendations?.incomes}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* ── Audience Profile Summary ── */}
|
||||
<div className="audience-profile-summary">
|
||||
<div className="audience-profile-header">
|
||||
<svg width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2">
|
||||
<path d="M9.663 17h4.673M12 3v1m6.364 1.636l-.707.707M21 12h-1M4 12H3m3.343-5.657l-.707-.707m2.828 9.9a5 5 0 117.072 0l-.548.547A3.374 3.374 0 0014 18.469V19a2 2 0 11-4 0v-.531c0-.895-.356-1.754-.988-2.386l-.548-.547z" />
|
||||
</svg>
|
||||
<span className="audience-profile-title">
|
||||
{state.censusData ? 'Market-Informed Profile' : 'Audience Profile'}
|
||||
</span>
|
||||
<span className="audience-profile-hint">
|
||||
{state.censusData
|
||||
? 'Based on U.S. Census data for your target area — adjust above to override'
|
||||
: 'Derived from your selections — used for channel recommendations'}
|
||||
</span>
|
||||
</div>
|
||||
<div className="profile-factors">
|
||||
<div className="profile-factor">
|
||||
<span className="factor-label">Age Profile</span>
|
||||
<span className={`factor-value ${state.ageSkew ? 'has-value' : 'neutral'}`}>
|
||||
{factorLabels.ageSkew[state.ageSkew] || 'Balanced'}
|
||||
</span>
|
||||
</div>
|
||||
<div className="profile-factor">
|
||||
<span className="factor-label">Market Scope</span>
|
||||
<span className={`factor-value has-value`}>
|
||||
{factorLabels.marketScope[state.marketScope] || 'National'}
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
});
|
||||
|
||||
// ── Helpers ──
|
||||
|
||||
function extractZcta(geo) {
|
||||
const geoType = (geo.type || geo.targetType || '').toLowerCase();
|
||||
if (geoType === 'zip code' || geoType === 'postal code') {
|
||||
const match = (geo.name || '').match(/\b(\d{5})\b/);
|
||||
return match ? match[1] : null;
|
||||
}
|
||||
const nameMatch = (geo.name || '').match(/^\d{5}$/);
|
||||
if (nameMatch) return nameMatch[0];
|
||||
const canonMatch = (geo.canonicalName || '').match(/\b(\d{5})\b/);
|
||||
if (canonMatch) return canonMatch[1];
|
||||
return null;
|
||||
}
|
||||
|
||||
export default AudienceStep;
|
||||
186
Client-Client/src/components/wizard/steps/BudgetStep.jsx
Normal file
186
Client-Client/src/components/wizard/steps/BudgetStep.jsx
Normal file
@@ -0,0 +1,186 @@
|
||||
import React, { useCallback, memo } from 'react';
|
||||
import HelpIcon from '../../../components/HelpIcon';
|
||||
|
||||
const today = () => new Date().toISOString().split('T')[0];
|
||||
const inDays = (n) => {
|
||||
const d = new Date();
|
||||
d.setDate(d.getDate() + n);
|
||||
return d.toISOString().split('T')[0];
|
||||
};
|
||||
|
||||
const BudgetStep = memo(function BudgetStep({ data, onChange }) {
|
||||
|
||||
const state = {
|
||||
totalBudget: '',
|
||||
budgetPeriod: 'monthly',
|
||||
startDate: today(),
|
||||
endDate: inDays(30),
|
||||
hasEndDate: true,
|
||||
...data
|
||||
};
|
||||
|
||||
const update = useCallback((patch) => {
|
||||
onChange({ ...state, ...patch });
|
||||
}, [state, onChange]);
|
||||
|
||||
const totalBudget = parseFloat(state.totalBudget) || 0;
|
||||
const dailyBudget = state.budgetPeriod === 'daily' ? totalBudget : totalBudget / 30;
|
||||
const days = state.hasEndDate && state.startDate && state.endDate
|
||||
? Math.max(1, Math.ceil((new Date(state.endDate) - new Date(state.startDate)) / 86400000))
|
||||
: 30;
|
||||
|
||||
const presetBudgets = state.budgetPeriod === 'daily'
|
||||
? [25, 50, 100, 250]
|
||||
: [500, 1000, 2500, 5000];
|
||||
|
||||
// Budget tier description
|
||||
const getBudgetTier = () => {
|
||||
const monthly = state.budgetPeriod === 'daily' ? totalBudget * 30 : totalBudget;
|
||||
if (monthly <= 0) return null;
|
||||
if (monthly < 500) return { label: 'Starter', desc: 'Best suited for a single channel', color: '#94A3B8', channels: 1 };
|
||||
if (monthly < 1500) return { label: 'Growth', desc: 'Enough for 1–2 channels', color: '#3B82F6', channels: 2 };
|
||||
if (monthly < 3000) return { label: 'Accelerate', desc: 'Supports 2–3 channels effectively', color: '#8B5CF6', channels: 3 };
|
||||
return { label: 'Scale', desc: 'Full multi-channel coverage', color: '#10B981', channels: 3 };
|
||||
};
|
||||
|
||||
const tier = getBudgetTier();
|
||||
|
||||
return (
|
||||
<div className="wizard-step-content">
|
||||
<div className="step-intro">
|
||||
<div className="step-intro-header">
|
||||
<h2>What's your advertising budget?</h2>
|
||||
<HelpIcon helpKey="client.wizard.budget" label="About this step" />
|
||||
</div>
|
||||
<p>
|
||||
Set your total budget and schedule. We'll use this to recommend the best
|
||||
channel mix and show you estimated performance in the next step.
|
||||
</p>
|
||||
</div>
|
||||
|
||||
{/* Budget Period Toggle */}
|
||||
<div className="form-group">
|
||||
<label>Budget Period</label>
|
||||
<div className="period-toggle">
|
||||
<button
|
||||
className={`toggle-btn ${state.budgetPeriod === 'monthly' ? 'active' : ''}`}
|
||||
onClick={() => update({ budgetPeriod: 'monthly' })}
|
||||
>Monthly</button>
|
||||
<button
|
||||
className={`toggle-btn ${state.budgetPeriod === 'daily' ? 'active' : ''}`}
|
||||
onClick={() => update({ budgetPeriod: 'daily' })}
|
||||
>Daily</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Budget Input */}
|
||||
<div className="form-group">
|
||||
<label>Budget Amount</label>
|
||||
<div className="budget-input-row">
|
||||
<span className="budget-prefix">$</span>
|
||||
<input
|
||||
className="form-input budget-input"
|
||||
type="number"
|
||||
min="1"
|
||||
step="1"
|
||||
placeholder={state.budgetPeriod === 'daily' ? '50' : '1500'}
|
||||
value={state.totalBudget}
|
||||
onChange={e => update({ totalBudget: e.target.value })}
|
||||
/>
|
||||
<span className="budget-suffix">/ {state.budgetPeriod === 'daily' ? 'day' : 'month'}</span>
|
||||
</div>
|
||||
<div className="budget-presets">
|
||||
{presetBudgets.map(amt => (
|
||||
<button
|
||||
key={amt}
|
||||
className={`preset-chip ${parseFloat(state.totalBudget) === amt ? 'active' : ''}`}
|
||||
onClick={() => update({ totalBudget: String(amt) })}
|
||||
>
|
||||
${amt.toLocaleString()}
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Estimated spend summary */}
|
||||
{totalBudget > 0 && (
|
||||
<div className="estimate-banner">
|
||||
<div className="estimate-row">
|
||||
<span>
|
||||
{state.budgetPeriod === 'daily'
|
||||
? `~$${(totalBudget * 30).toLocaleString()} / month`
|
||||
: `~$${dailyBudget.toFixed(2)} / day`}
|
||||
</span>
|
||||
{state.hasEndDate && (
|
||||
<strong>
|
||||
${(dailyBudget * days).toLocaleString(undefined, { minimumFractionDigits: 2, maximumFractionDigits: 2 })} estimated total ({days} days)
|
||||
</strong>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Budget tier indicator */}
|
||||
{tier && (
|
||||
<div className="budget-tier-card" style={{ '--tier-color': tier.color }}>
|
||||
<div className="budget-tier-header">
|
||||
<span className="budget-tier-label" style={{ color: tier.color }}>{tier.label}</span>
|
||||
<span className="budget-tier-channels">
|
||||
{tier.channels === 1 ? '1 channel recommended' : `Up to ${tier.channels} channels`}
|
||||
</span>
|
||||
</div>
|
||||
<div className="budget-tier-desc">{tier.desc}</div>
|
||||
<div className="budget-tier-bar">
|
||||
{['Starter', 'Growth', 'Accelerate', 'Scale'].map((t, i) => (
|
||||
<div
|
||||
key={t}
|
||||
className={`tier-segment ${t === tier.label ? 'active' : ''}`}
|
||||
style={t === tier.label ? { background: tier.color } : undefined}
|
||||
/>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Date Range / Schedule */}
|
||||
<div className="form-group">
|
||||
<label>Schedule</label>
|
||||
<div className="form-row">
|
||||
<div className="form-group" style={{ marginBottom: 0 }}>
|
||||
<label className="field-sub-label">Start Date</label>
|
||||
<input
|
||||
className="form-input"
|
||||
type="date"
|
||||
value={state.startDate}
|
||||
min={today()}
|
||||
onChange={e => update({ startDate: e.target.value })}
|
||||
/>
|
||||
</div>
|
||||
<div className="form-group" style={{ marginBottom: 0 }}>
|
||||
<div style={{ display: 'flex', alignItems: 'center', justifyContent: 'space-between' }}>
|
||||
<label className="field-sub-label">End Date</label>
|
||||
<label className="toggle-label">
|
||||
<input
|
||||
type="checkbox"
|
||||
checked={state.hasEndDate}
|
||||
onChange={e => update({ hasEndDate: e.target.checked })}
|
||||
/>
|
||||
<span className="toggle-text">{state.hasEndDate ? 'Set' : 'No end date'}</span>
|
||||
</label>
|
||||
</div>
|
||||
<input
|
||||
className="form-input"
|
||||
type="date"
|
||||
value={state.endDate}
|
||||
min={state.startDate || today()}
|
||||
disabled={!state.hasEndDate}
|
||||
onChange={e => update({ endDate: e.target.value })}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
});
|
||||
|
||||
export default BudgetStep;
|
||||
@@ -0,0 +1,758 @@
|
||||
import React, { useState, useEffect, useCallback, useRef, memo } from 'react';
|
||||
import { useAuth } from '../../../auth/AuthProvider';
|
||||
import { useWizardConfig } from '../../../context/WizardConfigContext';
|
||||
import { getTemplates } from '../../../services/initiativeApi';
|
||||
import { getAudienceRecommendation } from '../../../services/wizardApi';
|
||||
import { getChannelForecast } from '../../../services/forecastApi';
|
||||
import HelpIcon from '../../../components/HelpIcon';
|
||||
|
||||
const fmt = (n) => n != null ? Number(n).toLocaleString(undefined, { maximumFractionDigits: 0 }) : '—';
|
||||
const fmtDec = (n, d = 2) => n != null ? Number(n).toLocaleString(undefined, { minimumFractionDigits: d, maximumFractionDigits: d }) : '—';
|
||||
|
||||
const channelIcons = {
|
||||
google_ads: (
|
||||
<svg width="24" height="24" viewBox="0 0 24 24" fill="none">
|
||||
<circle cx="12" cy="12" r="10" stroke="#4285F4" strokeWidth="2"/>
|
||||
<text x="12" y="16" textAnchor="middle" fontSize="12" fontWeight="bold" fill="#4285F4">G</text>
|
||||
</svg>
|
||||
),
|
||||
meta: (
|
||||
<svg width="24" height="24" viewBox="0 0 24 24" fill="none">
|
||||
<circle cx="12" cy="12" r="10" stroke="#0668E1" strokeWidth="2"/>
|
||||
<text x="12" y="16" textAnchor="middle" fontSize="12" fontWeight="bold" fill="#0668E1">M</text>
|
||||
</svg>
|
||||
),
|
||||
tiktok: (
|
||||
<svg width="24" height="24" viewBox="0 0 24 24" fill="none">
|
||||
<circle cx="12" cy="12" r="10" stroke="#000000" strokeWidth="2"/>
|
||||
<text x="12" y="16" textAnchor="middle" fontSize="12" fontWeight="bold" fill="#000000">T</text>
|
||||
</svg>
|
||||
),
|
||||
};
|
||||
|
||||
const ChannelAllocationStep = memo(function ChannelAllocationStep({
|
||||
data, onChange, objectiveData, audienceData, budgetData
|
||||
}) {
|
||||
const { sessionToken } = useAuth();
|
||||
const { getChannelLabel, getChannelColor, channels, allocationRules, loading } = useWizardConfig();
|
||||
|
||||
// ── Template state (fallback) ──
|
||||
const [templates, setTemplates] = useState([]);
|
||||
const [loadingTemplates, setLoadingTemplates] = useState(false);
|
||||
|
||||
// ── Forecast state ──
|
||||
const [forecast, setForecast] = useState(null);
|
||||
const [forecastLoading, setForecastLoading] = useState(false);
|
||||
const [forecastError, setForecastError] = useState(null);
|
||||
const forecastTimer = useRef(null);
|
||||
|
||||
const state = {
|
||||
selectedChannels: [],
|
||||
allocations: {},
|
||||
allocationStrategy: 'manual',
|
||||
...data
|
||||
};
|
||||
|
||||
const selectedChannels = state.selectedChannels || [];
|
||||
const isMultiChannel = selectedChannels.length > 1;
|
||||
|
||||
// Budget comes from step 3 now
|
||||
const totalBudget = parseFloat(budgetData?.totalBudget) || 0;
|
||||
const budgetPeriod = budgetData?.budgetPeriod || 'monthly';
|
||||
const monthlyBudget = budgetPeriod === 'daily' ? totalBudget * 30 : totalBudget;
|
||||
|
||||
const update = useCallback((patch) => {
|
||||
onChange({ ...state, ...patch });
|
||||
}, [state, onChange]);
|
||||
|
||||
// ── Channel toggle ──
|
||||
const toggleChannel = (channelType) => {
|
||||
const current = state.selectedChannels;
|
||||
const updated = current.includes(channelType)
|
||||
? current.filter(c => c !== channelType)
|
||||
: [...current, channelType];
|
||||
update({ selectedChannels: updated });
|
||||
};
|
||||
|
||||
const isSelected = (channelType) => state.selectedChannels.includes(channelType);
|
||||
const objective = objectiveData?.objective;
|
||||
const selectedCount = state.selectedChannels.length;
|
||||
|
||||
// ── Initialize allocations for selected channels ──
|
||||
useEffect(() => {
|
||||
const current = state.allocations || {};
|
||||
const needsInit = selectedChannels.some(ch => !(ch in current))
|
||||
|| selectedChannels.length !== Object.keys(current).length;
|
||||
|
||||
if (needsInit && selectedChannels.length > 0) {
|
||||
const evenSplit = Math.floor(100 / selectedChannels.length);
|
||||
const remainder = 100 - (evenSplit * selectedChannels.length);
|
||||
const updated = {};
|
||||
selectedChannels.forEach((ch, idx) => {
|
||||
updated[ch] = current[ch] || (evenSplit + (idx === 0 ? remainder : 0));
|
||||
});
|
||||
update({ allocations: updated });
|
||||
}
|
||||
}, [selectedChannels.join(',')]);
|
||||
|
||||
// ── Channel strength descriptions ──
|
||||
const getChannelStrength = (channelType) => {
|
||||
const strengths = {
|
||||
google_ads: {
|
||||
awareness: 'Strong reach via Display & YouTube',
|
||||
traffic: 'High-intent search traffic',
|
||||
leads: 'Search captures active prospects',
|
||||
conversions: 'Intent-driven conversion engine',
|
||||
sales: 'Shopping & search drive purchases',
|
||||
},
|
||||
meta: {
|
||||
awareness: 'Massive reach on Facebook & Instagram',
|
||||
traffic: 'Visual storytelling drives clicks',
|
||||
leads: 'Lead forms built into the platform',
|
||||
conversions: 'Advanced pixel tracking & lookalikes',
|
||||
sales: 'Shop integration & dynamic product ads',
|
||||
},
|
||||
tiktok: {
|
||||
awareness: 'Viral potential with younger audiences',
|
||||
traffic: 'Engaging video drives curiosity clicks',
|
||||
leads: 'Growing lead gen capabilities',
|
||||
conversions: 'Strong for impulse & discovery',
|
||||
sales: 'TikTok Shop integration',
|
||||
},
|
||||
};
|
||||
return strengths[channelType]?.[objective] || null;
|
||||
};
|
||||
|
||||
const supportsObjective = (channel) => {
|
||||
if (!objective || !channel.supportedObjectives) return true;
|
||||
return channel.supportedObjectives.includes(objective);
|
||||
};
|
||||
|
||||
const isRecommended = (channelType) => {
|
||||
const recs = {
|
||||
awareness: ['meta', 'tiktok', 'google_ads'],
|
||||
traffic: ['google_ads', 'meta'],
|
||||
leads: ['google_ads', 'meta'],
|
||||
conversions: ['google_ads', 'meta'],
|
||||
sales: ['google_ads', 'meta'],
|
||||
};
|
||||
return objective ? (recs[objective] || []).includes(channelType) : false;
|
||||
};
|
||||
|
||||
// Budget-aware channel filtering
|
||||
const isAffordable = (channel) => {
|
||||
if (monthlyBudget <= 0) return true; // Don't filter if no budget set
|
||||
return monthlyBudget >= (channel.minMonthlyBudget || 0);
|
||||
};
|
||||
|
||||
// Audience context
|
||||
const audienceSummary = [];
|
||||
if (audienceData?.marketScope && audienceData.marketScope !== 'national') {
|
||||
audienceSummary.push(audienceData.marketScope + ' market');
|
||||
}
|
||||
if (audienceData?.ageSkew) {
|
||||
audienceSummary.push(audienceData.ageSkew === 'young' ? 'younger audience' : 'mature audience');
|
||||
}
|
||||
|
||||
// ── Load template recommendations (fallback) ──
|
||||
useEffect(() => {
|
||||
if (!isMultiChannel) return;
|
||||
async function loadTemplates() {
|
||||
setLoadingTemplates(true);
|
||||
const ageSkew = audienceData?.ageSkew || null;
|
||||
const marketScope = audienceData?.marketScope || null;
|
||||
|
||||
if (ageSkew || marketScope) {
|
||||
const recRes = await getAudienceRecommendation(
|
||||
objectiveData?.businessCategory, objectiveData?.objective,
|
||||
ageSkew, marketScope, sessionToken
|
||||
);
|
||||
if (recRes.ok && recRes.data?.channels) {
|
||||
const chs = recRes.data.channels || recRes.data.data?.channels || [];
|
||||
setTemplates(chs.map(ch => ({
|
||||
channelType: ch.channelType,
|
||||
allocationPct: ch.recommendedPct,
|
||||
rationale: ch.baseRationale,
|
||||
})));
|
||||
setLoadingTemplates(false);
|
||||
return;
|
||||
}
|
||||
}
|
||||
const res = await getTemplates(sessionToken, objectiveData?.businessCategory, objectiveData?.objective);
|
||||
if (res.ok) {
|
||||
const list = res.data?.data || res.data?.templates || [];
|
||||
setTemplates(Array.isArray(list) ? list : []);
|
||||
}
|
||||
setLoadingTemplates(false);
|
||||
}
|
||||
loadTemplates();
|
||||
}, [sessionToken, objectiveData?.businessCategory, objectiveData?.objective, isMultiChannel, audienceData?.ageSkew, audienceData?.marketScope]);
|
||||
|
||||
// ════════════════════════════════════════════════
|
||||
// Forecast: debounced call when budget/channels change
|
||||
// ════════════════════════════════════════════════
|
||||
|
||||
const fetchForecast = useCallback(async (budget) => {
|
||||
if (!budget || budget <= 0 || selectedChannels.length === 0) {
|
||||
setForecast(null);
|
||||
return;
|
||||
}
|
||||
|
||||
const analysis = objectiveData?.analysis;
|
||||
const keywords = [];
|
||||
if (analysis?.keywords) keywords.push(...analysis.keywords);
|
||||
if (analysis?.suggestedKeywords) keywords.push(...analysis.suggestedKeywords);
|
||||
if (keywords.length === 0) {
|
||||
if (analysis?.title) keywords.push(analysis.title);
|
||||
if (objectiveData?.businessCategory) keywords.push(objectiveData.businessCategory);
|
||||
if (analysis?.metaDescription) {
|
||||
const words = analysis.metaDescription.split(/\s+/).slice(0, 6).join(' ');
|
||||
if (words) keywords.push(words);
|
||||
}
|
||||
}
|
||||
|
||||
if (keywords.length === 0) {
|
||||
setForecastError('No keywords available — analyze your URL in Step 1');
|
||||
return;
|
||||
}
|
||||
|
||||
const geoTargeting = {};
|
||||
if (audienceData?.locations?.length > 0) {
|
||||
geoTargeting.zipCodes = audienceData.locations
|
||||
.filter(l => l.type === 'zip' || l.zipCode)
|
||||
.map(l => l.zipCode || l.value);
|
||||
geoTargeting.geoTargetIds = audienceData.locations
|
||||
.filter(l => l.geoTargetId)
|
||||
.map(l => l.geoTargetId);
|
||||
}
|
||||
|
||||
const audience = {
|
||||
ageMin: audienceData?.demographics?.ageMin,
|
||||
ageMax: audienceData?.demographics?.ageMax,
|
||||
};
|
||||
|
||||
setForecastLoading(true);
|
||||
setForecastError(null);
|
||||
|
||||
const res = await getChannelForecast({
|
||||
objective: objectiveData?.objective || 'traffic',
|
||||
businessCategory: objectiveData?.businessCategory,
|
||||
keywords,
|
||||
geoTargeting,
|
||||
audience,
|
||||
monthlyBudget: budget,
|
||||
channels: selectedChannels,
|
||||
}, sessionToken);
|
||||
|
||||
setForecastLoading(false);
|
||||
|
||||
if (res.ok && res.data?.channels) {
|
||||
setForecast(res.data);
|
||||
|
||||
if (isMultiChannel && res.data.channels.length > 0) {
|
||||
const newAllocations = {};
|
||||
res.data.channels.forEach(ch => {
|
||||
if (selectedChannels.includes(ch.provider)) {
|
||||
newAllocations[ch.provider] = ch.allocationPercent;
|
||||
}
|
||||
});
|
||||
if (state.allocationStrategy !== 'manual-adjusted') {
|
||||
update({
|
||||
allocations: newAllocations,
|
||||
allocationStrategy: 'forecast'
|
||||
});
|
||||
}
|
||||
}
|
||||
} else {
|
||||
setForecastError(res.error || 'Could not load estimates');
|
||||
}
|
||||
}, [selectedChannels.join(','), objectiveData?.objective, objectiveData?.businessCategory, objectiveData?.analysis, audienceData, sessionToken]);
|
||||
|
||||
// Debounce: re-forecast when budget or channels change
|
||||
useEffect(() => {
|
||||
if (forecastTimer.current) clearTimeout(forecastTimer.current);
|
||||
if (monthlyBudget > 0 && selectedChannels.length > 0) {
|
||||
forecastTimer.current = setTimeout(() => fetchForecast(monthlyBudget), 800);
|
||||
} else {
|
||||
setForecast(null);
|
||||
}
|
||||
return () => { if (forecastTimer.current) clearTimeout(forecastTimer.current); };
|
||||
}, [monthlyBudget, selectedChannels.join(','), fetchForecast]);
|
||||
|
||||
// ── Derived allocation values ──
|
||||
const totalPct = Object.values(state.allocations).reduce((sum, v) => sum + (v || 0), 0);
|
||||
const isBalanced = Math.abs(totalPct - 100) < 0.01;
|
||||
|
||||
const handleAllocationChange = (channel, value) => {
|
||||
const pct = Math.max(0, Math.min(100, parseInt(value) || 0));
|
||||
update({
|
||||
allocations: { ...state.allocations, [channel]: pct },
|
||||
allocationStrategy: 'manual-adjusted'
|
||||
});
|
||||
};
|
||||
|
||||
const applyForecastAllocations = () => {
|
||||
if (!forecast?.channels) return;
|
||||
const newAllocations = {};
|
||||
forecast.channels.forEach(ch => {
|
||||
if (selectedChannels.includes(ch.provider)) {
|
||||
newAllocations[ch.provider] = ch.allocationPercent;
|
||||
}
|
||||
});
|
||||
update({ allocations: newAllocations, allocationStrategy: 'forecast' });
|
||||
};
|
||||
|
||||
const applyTemplate = (template) => {
|
||||
const newAllocations = {};
|
||||
selectedChannels.forEach(ch => {
|
||||
const match = template.find(t => (t.channelType || t.atlChannelType) === ch);
|
||||
newAllocations[ch] = match ? parseFloat(match.allocationPct || match.atlAllocationPct || 0) : 0;
|
||||
});
|
||||
update({ allocations: newAllocations, allocationStrategy: 'template' });
|
||||
};
|
||||
|
||||
const getChannelEstimate = (channelType) => {
|
||||
if (!forecast?.channels) return null;
|
||||
const fc = forecast.channels.find(c => c.provider === channelType);
|
||||
if (!fc) return null;
|
||||
const forecastPct = fc.allocationPercent || 1;
|
||||
const currentPct = state.allocations[channelType] || forecastPct;
|
||||
const scale = currentPct / forecastPct;
|
||||
return {
|
||||
...fc,
|
||||
scaled: {
|
||||
impressions: Math.round((fc.estimates?.impressions || 0) * scale),
|
||||
reach: fc.estimates?.reach ? Math.round(fc.estimates.reach * scale) : null,
|
||||
clicks: Math.round((fc.estimates?.clicks || 0) * scale),
|
||||
conversions: Math.round(((fc.estimates?.conversions || 0) * scale) * 10) / 10,
|
||||
},
|
||||
allocatedBudget: Math.round(monthlyBudget * currentPct / 100),
|
||||
};
|
||||
};
|
||||
|
||||
// ════════════════════════════════════════════════
|
||||
// Render
|
||||
// ════════════════════════════════════════════════
|
||||
|
||||
return (
|
||||
<div className="wizard-step-content">
|
||||
<div className="step-intro">
|
||||
<div className="step-intro-header">
|
||||
<h2>Choose your channels{isMultiChannel ? ' & allocation' : ''}</h2>
|
||||
<HelpIcon helpKey="client.wizard.channels" label="About this step" />
|
||||
</div>
|
||||
<p>
|
||||
{monthlyBudget > 0
|
||||
? `With your $${monthlyBudget.toLocaleString()}/mo budget, select the channels where you want to advertise.`
|
||||
: 'Select one or more advertising channels.'}
|
||||
{selectedCount > 1 ? ` We'll show estimated performance and help you split the budget.` : ''}
|
||||
{objective ? ` All optimized for your ${objective} objective.` : ''}
|
||||
</p>
|
||||
</div>
|
||||
|
||||
{/* Budget & Audience Context Bar */}
|
||||
<div className="channel-context-bar">
|
||||
<svg width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2">
|
||||
<path d="M9.663 17h4.673M12 3v1m6.364 1.636l-.707.707M21 12h-1M4 12H3m3.343-5.657l-.707-.707m2.828 9.9a5 5 0 117.072 0l-.548.547A3.374 3.374 0 0014 18.469V19a2 2 0 11-4 0v-.531c0-.895-.356-1.754-.988-2.386l-.548-.547z" />
|
||||
</svg>
|
||||
<span>
|
||||
{monthlyBudget > 0 && <><strong>${monthlyBudget.toLocaleString()}/mo</strong> budget</>}
|
||||
{objective && <>{monthlyBudget > 0 ? ' · ' : ''}Optimized for <strong>{objective}</strong></>}
|
||||
{audienceSummary.length > 0 && <> · {audienceSummary.join(' and ')}</>}
|
||||
{objectiveData?.businessCategory && <> · <strong>{objectiveData.businessCategory}</strong></>}
|
||||
</span>
|
||||
</div>
|
||||
|
||||
{/* ── Channel Selection Grid ── */}
|
||||
{loading ? (
|
||||
<div className="loading-placeholder padded">
|
||||
{[1, 2, 3].map(i => (
|
||||
<div key={i} className="skeleton skeleton-row" style={{ height: 120, marginBottom: 16 }} />
|
||||
))}
|
||||
</div>
|
||||
) : (
|
||||
<>
|
||||
<div className="channel-grid">
|
||||
{channels.map(channel => {
|
||||
const selected = isSelected(channel.channelType);
|
||||
const supported = supportsObjective(channel);
|
||||
const affordable = isAffordable(channel);
|
||||
const recommended = isRecommended(channel.channelType);
|
||||
const strength = getChannelStrength(channel.channelType);
|
||||
const borderColor = selected ? (channel.color || '#4F46E5') : 'transparent';
|
||||
const disabled = !supported || !affordable;
|
||||
|
||||
return (
|
||||
<button
|
||||
key={channel.channelType}
|
||||
className={`channel-card ${selected ? 'selected' : ''} ${disabled ? 'unsupported' : ''}`}
|
||||
style={{ '--channel-color': channel.color || '#4F46E5', borderColor }}
|
||||
onClick={() => !disabled && toggleChannel(channel.channelType)}
|
||||
disabled={disabled}
|
||||
>
|
||||
<div className="channel-card-header">
|
||||
<div className="channel-icon">
|
||||
{channelIcons[channel.channelType] || (
|
||||
<div className="channel-icon-fallback" style={{ background: channel.color || '#4F46E5' }}>
|
||||
{channel.displayName?.charAt(0)}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
<div style={{ display: 'flex', alignItems: 'center', gap: 8 }}>
|
||||
{recommended && supported && affordable && (
|
||||
<span className="channel-rec-badge">
|
||||
<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2.5"><path d="M20 6L9 17l-5-5" /></svg>
|
||||
Recommended
|
||||
</span>
|
||||
)}
|
||||
<div className="channel-card-check">
|
||||
{selected ? (
|
||||
<svg width="20" height="20" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2.5">
|
||||
<path d="M20 6L9 17l-5-5" />
|
||||
</svg>
|
||||
) : (
|
||||
<div className="check-empty" />
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="channel-card-body">
|
||||
<div className="channel-name">
|
||||
{channel.displayName}
|
||||
{channel.isStub && <span className="stub-badge">Preview</span>}
|
||||
</div>
|
||||
<div className="channel-desc">{channel.description}</div>
|
||||
{strength && (
|
||||
<div className="channel-strength">
|
||||
<svg width="12" height="12" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2"><path d="M13 2L3 14h9l-1 8 10-12h-9l1-8z" /></svg>
|
||||
{strength}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
|
||||
<div className="channel-card-footer">
|
||||
<span className="channel-min-budget">
|
||||
Min ${channel.minMonthlyBudget || 0}/mo
|
||||
</span>
|
||||
{!supported && (
|
||||
<span className="channel-unsupported-label">
|
||||
Not available for {objective}
|
||||
</span>
|
||||
)}
|
||||
{supported && !affordable && (
|
||||
<span className="channel-unsupported-label">
|
||||
Requires ${channel.minMonthlyBudget}/mo minimum
|
||||
</span>
|
||||
)}
|
||||
</div>
|
||||
</button>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
|
||||
{/* Selection summary */}
|
||||
{selectedCount > 0 && (
|
||||
<div className="channel-summary">
|
||||
<div className="channel-summary-chips">
|
||||
{state.selectedChannels.map(ct => {
|
||||
const ch = channels.find(c => c.channelType === ct);
|
||||
return (
|
||||
<span key={ct} className="channel-chip" style={{ borderColor: ch?.color || '#4F46E5' }}>
|
||||
{ch?.displayName || ct}
|
||||
<button onClick={() => toggleChannel(ct)} className="chip-remove">×</button>
|
||||
</span>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
<div className="channel-summary-info">
|
||||
{selectedCount === 1
|
||||
? 'Single-channel campaign'
|
||||
: `Multi-channel campaign across ${selectedCount} platforms`}
|
||||
{selectedCount > 1 && allocationRules && (
|
||||
<span className="channel-summary-note">
|
||||
{' '}· Minimum total budget: ${allocationRules.minMultiChannelMonthlyBudget}/mo
|
||||
</span>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{selectedCount === 0 && (
|
||||
<div className="channel-empty-hint">
|
||||
<svg width="20" height="20" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2">
|
||||
<path d="M12 9v2m0 4h.01M21 12a9 9 0 11-18 0 9 9 0 0118 0z" />
|
||||
</svg>
|
||||
<span>Select at least one channel to continue</span>
|
||||
</div>
|
||||
)}
|
||||
</>
|
||||
)}
|
||||
|
||||
{/* ════════════════════════════════════════════════ */}
|
||||
{/* Forecast + Allocation (appears after channels selected + budget set) */}
|
||||
{/* ════════════════════════════════════════════════ */}
|
||||
|
||||
{selectedCount > 0 && monthlyBudget > 0 && (
|
||||
<div className="forecast-section">
|
||||
|
||||
{/* Loading indicator */}
|
||||
{forecastLoading && (
|
||||
<div className="forecast-loading">
|
||||
<span className="btn-spinner" />
|
||||
<span>Getting performance estimates from ad platforms…</span>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Forecast card */}
|
||||
{forecast && !forecastLoading && (
|
||||
<div className="forecast-card">
|
||||
<div className="forecast-card-header">
|
||||
<div className="forecast-title">
|
||||
<svg width="18" height="18" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2">
|
||||
<path d="M9 19v-6a2 2 0 00-2-2H5a2 2 0 00-2 2v6a2 2 0 002 2h2a2 2 0 002-2zm0 0V9a2 2 0 012-2h2a2 2 0 012 2v10m-6 0a2 2 0 002 2h2a2 2 0 002-2m0 0V5a2 2 0 012-2h2a2 2 0 012 2v14a2 2 0 01-2 2h-2a2 2 0 01-2-2z" />
|
||||
</svg>
|
||||
<span>Estimated Performance at ${monthlyBudget.toLocaleString()}/mo</span>
|
||||
</div>
|
||||
{forecast.recommendation && (
|
||||
<span className="forecast-objective-tag">
|
||||
Optimized for {objectiveData?.objective || 'traffic'}
|
||||
</span>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* Side-by-side channel estimates */}
|
||||
<div className="forecast-channels">
|
||||
{selectedChannels.map(ch => {
|
||||
const est = getChannelEstimate(ch);
|
||||
const color = getChannelColor(ch);
|
||||
const label = getChannelLabel(ch);
|
||||
const pct = state.allocations[ch] || 0;
|
||||
|
||||
if (!est || est.dataSource === 'template') {
|
||||
return (
|
||||
<div key={ch} className="forecast-channel forecast-channel-template">
|
||||
<div className="forecast-ch-header">
|
||||
<span className="forecast-ch-dot" style={{ background: color }} />
|
||||
<span className="forecast-ch-name">{label} ({pct}%)</span>
|
||||
<span className="forecast-ch-budget">${Math.round(monthlyBudget * pct / 100).toLocaleString()}/mo</span>
|
||||
</div>
|
||||
<div className="forecast-ch-template-note">
|
||||
<svg width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2">
|
||||
<path d="M13 16h-1v-4h-1m1-4h.01M21 12a9 9 0 11-18 0 9 9 0 0118 0z" />
|
||||
</svg>
|
||||
<span>Estimates not yet available. Allocation based on category benchmarks.</span>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<div key={ch} className="forecast-channel">
|
||||
<div className="forecast-ch-header">
|
||||
<span className="forecast-ch-dot" style={{ background: color }} />
|
||||
<span className="forecast-ch-name">{label} ({pct}%)</span>
|
||||
<span className="forecast-ch-budget">${est.allocatedBudget.toLocaleString()}/mo</span>
|
||||
</div>
|
||||
<div className="forecast-ch-metrics">
|
||||
{est.scaled.reach && (
|
||||
<div className="forecast-metric">
|
||||
<span className="forecast-metric-value">~{fmt(est.scaled.reach)}</span>
|
||||
<span className="forecast-metric-label">reach</span>
|
||||
</div>
|
||||
)}
|
||||
<div className="forecast-metric">
|
||||
<span className="forecast-metric-value">~{fmt(est.scaled.impressions)}</span>
|
||||
<span className="forecast-metric-label">impressions</span>
|
||||
</div>
|
||||
<div className="forecast-metric">
|
||||
<span className="forecast-metric-value">~{fmt(est.scaled.clicks)}</span>
|
||||
<span className="forecast-metric-label">clicks</span>
|
||||
</div>
|
||||
<div className="forecast-metric">
|
||||
<span className="forecast-metric-value">~{fmt(est.scaled.conversions)}</span>
|
||||
<span className="forecast-metric-label">est. conv.</span>
|
||||
</div>
|
||||
</div>
|
||||
<div className="forecast-ch-detail">
|
||||
<span>${fmtDec(est.estimates?.avgCpc)} avg CPC</span>
|
||||
<span>${fmtDec(est.estimates?.avgCpm)} avg CPM</span>
|
||||
{est.estimates?.estimatedCpa && (
|
||||
<span>${fmtDec(est.estimates.estimatedCpa)} est. CPA</span>
|
||||
)}
|
||||
</div>
|
||||
<div className="forecast-ch-strength">
|
||||
<span className="forecast-strength-dot" style={{ background: color }} />
|
||||
{est.strengthLabel}
|
||||
</div>
|
||||
{est.confidence !== 'medium' && est.confidence !== 'high' && (
|
||||
<div className="forecast-ch-confidence">
|
||||
{est.dataSource === 'emulated' ? '○ Estimated' : `○ ${est.confidence}`}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
|
||||
{/* Recommendation highlights */}
|
||||
{forecast.recommendation?.highlights?.length > 0 && (
|
||||
<div className="forecast-highlights">
|
||||
{forecast.recommendation.highlights.map((h, i) => (
|
||||
<span key={i} className="forecast-highlight">{h}</span>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Forecast error — fall back to template */}
|
||||
{forecastError && !forecastLoading && (
|
||||
<div className="forecast-error">
|
||||
<svg width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2">
|
||||
<path d="M12 9v2m0 4h.01M21 12a9 9 0 11-18 0 9 9 0 0118 0z" />
|
||||
</svg>
|
||||
<span>{forecastError}</span>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* ── Multi-Channel Allocation Sliders ── */}
|
||||
{isMultiChannel && selectedCount > 0 && (
|
||||
<div className="allocation-section">
|
||||
<div style={{ display: 'flex', alignItems: 'center', justifyContent: 'space-between', marginBottom: 8 }}>
|
||||
<label style={{ margin: 0, fontWeight: 600 }}>Channel Allocation</label>
|
||||
<div style={{ display: 'flex', gap: 8 }}>
|
||||
{/* Template fallback button (if no forecast) */}
|
||||
{!forecast && templates.length > 0 && (
|
||||
<button className="btn btn-sm btn-outline" onClick={() => applyTemplate(templates)}>
|
||||
<svg width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2">
|
||||
<path d="M9.663 17h4.673M12 3v1m6.364 1.636l-.707.707M21 12h-1M4 12H3m3.343-5.657l-.707-.707m2.828 9.9a5 5 0 117.072 0l-.548.547A3.374 3.374 0 0014 18.469V19a2 2 0 11-4 0v-.531c0-.895-.356-1.754-.988-2.386l-.548-.547z" />
|
||||
</svg>
|
||||
Apply Template
|
||||
</button>
|
||||
)}
|
||||
{state.allocationStrategy === 'manual-adjusted' && forecast && (
|
||||
<button className="btn btn-sm btn-outline forecast-reset-btn" onClick={applyForecastAllocations}>
|
||||
<svg width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2">
|
||||
<path d="M4 4v5h.582m15.356 2A8.001 8.001 0 004.582 9m0 0H9m11 11v-5h-.581m0 0a8.003 8.003 0 01-15.357-2m15.357 2H15" />
|
||||
</svg>
|
||||
Reset to recommended
|
||||
</button>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="allocation-sliders">
|
||||
{selectedChannels.map(ch => {
|
||||
const pct = state.allocations[ch] || 0;
|
||||
const amount = (monthlyBudget * pct / 100);
|
||||
const color = getChannelColor(ch);
|
||||
|
||||
return (
|
||||
<div key={ch} className="allocation-row">
|
||||
<div className="allocation-label">
|
||||
<span className="alloc-dot" style={{ background: color }} />
|
||||
<span className="alloc-name">{getChannelLabel(ch)}</span>
|
||||
</div>
|
||||
<div className="allocation-controls">
|
||||
<input
|
||||
type="range"
|
||||
min="0"
|
||||
max="100"
|
||||
value={pct}
|
||||
onChange={e => handleAllocationChange(ch, e.target.value)}
|
||||
className="alloc-slider"
|
||||
style={{ '--slider-color': color }}
|
||||
/>
|
||||
<div className="alloc-values">
|
||||
<input
|
||||
type="number"
|
||||
className="alloc-pct-input"
|
||||
value={pct}
|
||||
min="0"
|
||||
max="100"
|
||||
onChange={e => handleAllocationChange(ch, e.target.value)}
|
||||
/>
|
||||
<span className="alloc-pct-sign">%</span>
|
||||
<span className="alloc-amount">
|
||||
${amount.toFixed(0)}/{budgetPeriod === 'daily' ? 'day' : 'mo'}
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
|
||||
{/* Balance indicator */}
|
||||
<div className={`allocation-balance ${isBalanced ? 'balanced' : 'unbalanced'}`}>
|
||||
{isBalanced ? (
|
||||
<><svg width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2"><path d="M20 6L9 17l-5-5" /></svg> Allocations total 100%</>
|
||||
) : (
|
||||
<><svg width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2"><path d="M12 9v2m0 4h.01M21 12a9 9 0 11-18 0 9 9 0 0118 0z" /></svg> Allocations total {totalPct}% — must equal 100%</>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* Visual bar */}
|
||||
<div className="allocation-bar">
|
||||
{selectedChannels.map(ch => {
|
||||
const pct = state.allocations[ch] || 0;
|
||||
return (
|
||||
<div
|
||||
key={ch}
|
||||
className="alloc-bar-segment"
|
||||
style={{ width: `${pct}%`, background: getChannelColor(ch) }}
|
||||
title={`${getChannelLabel(ch)}: ${pct}%`}
|
||||
/>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Single channel — show forecast if available */}
|
||||
{!isMultiChannel && selectedCount === 1 && monthlyBudget > 0 && forecast && !forecastLoading && (
|
||||
<div className="forecast-section">
|
||||
<div className="forecast-card forecast-card-single">
|
||||
<div className="forecast-card-header">
|
||||
<div className="forecast-title">
|
||||
<svg width="18" height="18" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2">
|
||||
<path d="M9 19v-6a2 2 0 00-2-2H5a2 2 0 00-2 2v6a2 2 0 002 2h2a2 2 0 002-2zm0 0V9a2 2 0 012-2h2a2 2 0 012 2v10m-6 0a2 2 0 002 2h2a2 2 0 002-2m0 0V5a2 2 0 012-2h2a2 2 0 012 2v14a2 2 0 01-2 2h-2a2 2 0 01-2-2z" />
|
||||
</svg>
|
||||
<span>Estimated Performance</span>
|
||||
</div>
|
||||
</div>
|
||||
<div className="forecast-channels">
|
||||
{forecast.channels.filter(c => c.dataSource !== 'template').map(ch => (
|
||||
<div key={ch.provider} className="forecast-channel">
|
||||
<div className="forecast-ch-metrics">
|
||||
<div className="forecast-metric">
|
||||
<span className="forecast-metric-value">~{fmt(ch.estimates?.impressions)}</span>
|
||||
<span className="forecast-metric-label">impressions</span>
|
||||
</div>
|
||||
<div className="forecast-metric">
|
||||
<span className="forecast-metric-value">~{fmt(ch.estimates?.clicks)}</span>
|
||||
<span className="forecast-metric-label">clicks</span>
|
||||
</div>
|
||||
<div className="forecast-metric">
|
||||
<span className="forecast-metric-value">~{fmt(ch.estimates?.conversions)}</span>
|
||||
<span className="forecast-metric-label">est. conv.</span>
|
||||
</div>
|
||||
</div>
|
||||
<div className="forecast-ch-detail">
|
||||
<span>${fmtDec(ch.estimates?.avgCpc)} avg CPC</span>
|
||||
<span>${fmtDec(ch.estimates?.avgCpm)} avg CPM</span>
|
||||
</div>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
});
|
||||
|
||||
export default ChannelAllocationStep;
|
||||
284
Client-Client/src/components/wizard/steps/CreativeStep.jsx
Normal file
284
Client-Client/src/components/wizard/steps/CreativeStep.jsx
Normal file
@@ -0,0 +1,284 @@
|
||||
import React, { useState, useCallback, memo } from 'react';
|
||||
import { useAuth } from '../../../auth/AuthProvider';
|
||||
import { createDraft, generateAssets, getImages } from '../../../services/wizardApi';
|
||||
import HelpIcon from '../../../components/HelpIcon';
|
||||
|
||||
const MAX_HEADLINE = 30;
|
||||
const MAX_DESC = 90;
|
||||
|
||||
function AssetEditor({ items, maxLen, type, onUpdate }) {
|
||||
const [editIdx, setEditIdx] = useState(null);
|
||||
const [editText, setEditText] = useState('');
|
||||
|
||||
const startEdit = (idx) => {
|
||||
setEditIdx(idx);
|
||||
setEditText(items[idx]?.text || '');
|
||||
};
|
||||
|
||||
const saveEdit = () => {
|
||||
if (editIdx === null) return;
|
||||
const updated = [...items];
|
||||
updated[editIdx] = { ...updated[editIdx], text: editText, charCount: editText.length };
|
||||
onUpdate(updated);
|
||||
setEditIdx(null);
|
||||
};
|
||||
|
||||
const removeItem = (idx) => {
|
||||
onUpdate(items.filter((_, i) => i !== idx));
|
||||
};
|
||||
|
||||
const addItem = () => {
|
||||
onUpdate([...items, { text: '', charCount: 0 }]);
|
||||
setEditIdx(items.length);
|
||||
setEditText('');
|
||||
};
|
||||
|
||||
return (
|
||||
<div className="asset-editor">
|
||||
{items.map((item, idx) => (
|
||||
<div key={idx} className={`asset-row ${editIdx === idx ? 'editing' : ''}`}>
|
||||
{editIdx === idx ? (
|
||||
<div className="asset-edit-row">
|
||||
<input
|
||||
className="form-input"
|
||||
value={editText}
|
||||
maxLength={maxLen}
|
||||
onChange={e => setEditText(e.target.value)}
|
||||
onKeyDown={e => e.key === 'Enter' && saveEdit()}
|
||||
autoFocus
|
||||
/>
|
||||
<span className={`char-counter ${editText.length > maxLen ? 'over' : ''}`}>
|
||||
{editText.length}/{maxLen}
|
||||
</span>
|
||||
<button className="btn btn-sm btn-primary" onClick={saveEdit}>Save</button>
|
||||
<button className="btn btn-sm btn-outline" onClick={() => setEditIdx(null)}>Cancel</button>
|
||||
</div>
|
||||
) : (
|
||||
<>
|
||||
<span className="asset-text">{item.text || <em className="text-muted">Empty {type}</em>}</span>
|
||||
<span className="char-counter">{item.charCount || item.text?.length || 0}/{maxLen}</span>
|
||||
<button className="asset-action" onClick={() => startEdit(idx)} title="Edit">
|
||||
<svg width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2"><path d="M11 4H4a2 2 0 00-2 2v14a2 2 0 002 2h14a2 2 0 002-2v-7" /><path d="M18.5 2.5a2.121 2.121 0 013 3L12 15l-4 1 1-4 9.5-9.5z" /></svg>
|
||||
</button>
|
||||
<button className="asset-action danger" onClick={() => removeItem(idx)} title="Remove">
|
||||
<svg width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2"><path d="M18 6L6 18M6 6l12 12" /></svg>
|
||||
</button>
|
||||
</>
|
||||
)}
|
||||
</div>
|
||||
))}
|
||||
<button className="btn btn-sm btn-outline add-asset-btn" onClick={addItem}>
|
||||
<svg width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2"><path d="M12 5v14M5 12h14" /></svg>
|
||||
Add {type}
|
||||
</button>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
const CreativeStep = memo(function CreativeStep({ data, onChange, businessData }) {
|
||||
// ── Auth from context (no more prop) ──
|
||||
const { sessionToken } = useAuth();
|
||||
|
||||
const [generating, setGenerating] = useState(false);
|
||||
const [genPhase, setGenPhase] = useState('');
|
||||
const [error, setError] = useState(null);
|
||||
|
||||
const state = {
|
||||
headlines: [],
|
||||
descriptions: [],
|
||||
images: [],
|
||||
analysis: businessData?.analysis || null,
|
||||
source: null,
|
||||
imageSource: null,
|
||||
...data
|
||||
};
|
||||
|
||||
const update = useCallback((patch) => {
|
||||
onChange({ ...state, ...patch });
|
||||
}, [state, onChange]);
|
||||
|
||||
// Auto-generate if we have a URL but no assets yet
|
||||
const hasAssets = state.headlines.length > 0 || state.images.length > 0;
|
||||
const url = businessData?.url;
|
||||
|
||||
const handleGenerate = async () => {
|
||||
if (!url && !state.analysis) return;
|
||||
setGenerating(true);
|
||||
setError(null);
|
||||
|
||||
try {
|
||||
if (url) {
|
||||
// Full pipeline: URL → draft with images
|
||||
setGenPhase('Analyzing website and generating creative…');
|
||||
const res = await createDraft(url, sessionToken);
|
||||
if (res.ok) {
|
||||
const draft = res.data?.result?.data || res.data?.data || res.data;
|
||||
update({
|
||||
headlines: draft?.headlines || [],
|
||||
descriptions: draft?.descriptions || [],
|
||||
images: draft?.images || [],
|
||||
analysis: draft?.analysis || state.analysis,
|
||||
source: draft?.source || 'generated',
|
||||
imageSource: draft?.imageSource || 'generated'
|
||||
});
|
||||
} else {
|
||||
setError(res.error || 'Failed to generate creative');
|
||||
}
|
||||
} else if (state.analysis) {
|
||||
// Generate from existing analysis
|
||||
setGenPhase('Generating ad copy…');
|
||||
const copyRes = await generateAssets(state.analysis, sessionToken);
|
||||
if (copyRes.ok) {
|
||||
const copyData = copyRes.data?.result?.data || copyRes.data?.data || copyRes.data;
|
||||
update({
|
||||
headlines: copyData?.headlines || [],
|
||||
descriptions: copyData?.descriptions || [],
|
||||
});
|
||||
}
|
||||
setGenPhase('Finding images…');
|
||||
const imgRes = await getImages(state.analysis, sessionToken);
|
||||
if (imgRes.ok) {
|
||||
const imgData = imgRes.data?.result?.data || imgRes.data?.data || imgRes.data;
|
||||
update({ images: imgData?.images || [] });
|
||||
}
|
||||
}
|
||||
} catch (e) {
|
||||
setError(e.message || 'Generation failed');
|
||||
}
|
||||
|
||||
setGenPhase('');
|
||||
setGenerating(false);
|
||||
};
|
||||
|
||||
return (
|
||||
<div className="wizard-step-content">
|
||||
<div className="step-intro">
|
||||
<div className="step-intro-header">
|
||||
<h2>Create your ad</h2>
|
||||
<HelpIcon helpKey="client.wizard.creative" label="About this step" />
|
||||
</div>
|
||||
<p>Generate headlines, descriptions, and images for your campaign — or write your own.</p>
|
||||
</div>
|
||||
|
||||
{/* Generate Button - Enhanced */}
|
||||
{url && (
|
||||
<div className={`generate-bar ${hasAssets ? 'has-assets' : 'empty'}`}>
|
||||
<div className="generate-bar-content">
|
||||
<div className="generate-bar-icon">
|
||||
{generating ? (
|
||||
<span className="btn-spinner" style={{ width: 24, height: 24 }} />
|
||||
) : hasAssets ? (
|
||||
<svg width="24" height="24" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2"><path d="M1 4v6h6M23 20v-6h-6" /><path d="M20.49 9A9 9 0 005.64 5.64L1 10m22 4l-4.64 4.36A9 9 0 013.51 15" /></svg>
|
||||
) : (
|
||||
<svg width="24" height="24" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2"><polygon points="13 2 3 14 12 14 11 22 21 10 12 10 13 2" /></svg>
|
||||
)}
|
||||
</div>
|
||||
<div className="generate-bar-text">
|
||||
<strong>{generating ? genPhase || 'Generating…' : hasAssets ? 'Regenerate creative' : 'Generate from your website'}</strong>
|
||||
{!generating && !hasAssets && (
|
||||
<span>AI will analyze your site and create ad copy + find matching images</span>
|
||||
)}
|
||||
{!generating && hasAssets && (
|
||||
<span>Create a fresh set of headlines, descriptions, and images</span>
|
||||
)}
|
||||
</div>
|
||||
<button
|
||||
className="btn btn-primary"
|
||||
onClick={handleGenerate}
|
||||
disabled={generating}
|
||||
>
|
||||
{generating ? 'Working…' : hasAssets ? 'Regenerate' : 'Generate'}
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{error && <div className="error-box">{error}</div>}
|
||||
|
||||
{/* Headlines */}
|
||||
<div className="creative-section">
|
||||
<div className="creative-section-header">
|
||||
<h3>Headlines</h3>
|
||||
<span className="asset-count">{state.headlines.length} / 15 max</span>
|
||||
</div>
|
||||
<p className="creative-section-hint">Short, punchy text shown at the top of your ad. Max {MAX_HEADLINE} characters each.</p>
|
||||
<AssetEditor
|
||||
items={state.headlines}
|
||||
maxLen={MAX_HEADLINE}
|
||||
type="headline"
|
||||
onUpdate={(h) => update({ headlines: h })}
|
||||
/>
|
||||
</div>
|
||||
|
||||
{/* Descriptions */}
|
||||
<div className="creative-section">
|
||||
<div className="creative-section-header">
|
||||
<h3>Descriptions</h3>
|
||||
<span className="asset-count">{state.descriptions.length} / 4 max</span>
|
||||
</div>
|
||||
<p className="creative-section-hint">Longer text that appears below the headline. Max {MAX_DESC} characters each.</p>
|
||||
<AssetEditor
|
||||
items={state.descriptions}
|
||||
maxLen={MAX_DESC}
|
||||
type="description"
|
||||
onUpdate={(d) => update({ descriptions: d })}
|
||||
/>
|
||||
</div>
|
||||
|
||||
{/* Images */}
|
||||
<div className="creative-section">
|
||||
<div className="creative-section-header">
|
||||
<h3>Images</h3>
|
||||
<span className="asset-count">{state.images.length} image{state.images.length !== 1 ? 's' : ''}</span>
|
||||
</div>
|
||||
{state.images.length > 0 ? (
|
||||
<div className="image-grid">
|
||||
{state.images.map((img, idx) => (
|
||||
<div key={img.imageId || idx} className="image-thumb">
|
||||
<img
|
||||
src={img.url}
|
||||
alt={img.altText || `Image ${idx + 1}`}
|
||||
loading="lazy"
|
||||
onError={e => { e.target.style.display = 'none'; }}
|
||||
/>
|
||||
<div className="image-thumb-meta">
|
||||
<span className="orientation-tag">{img.orientation}</span>
|
||||
{img.width && <span className="dim-tag">{img.width}×{img.height}</span>}
|
||||
</div>
|
||||
<button
|
||||
className="image-remove"
|
||||
onClick={() => update({ images: state.images.filter((_, i) => i !== idx) })}
|
||||
title="Remove image"
|
||||
>
|
||||
<svg width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2"><path d="M18 6L6 18M6 6l12 12" /></svg>
|
||||
</button>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
) : (
|
||||
<div className="empty-images">
|
||||
<svg width="32" height="32" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="1.5"><rect x="3" y="3" width="18" height="18" rx="2" /><circle cx="8.5" cy="8.5" r="1.5" /><path d="M21 15l-5-5L5 21" /></svg>
|
||||
<span>{url ? 'Click "Generate" above to find images for your ad' : 'No images added yet'}</span>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* Ad Preview */}
|
||||
{state.headlines.length > 0 && (
|
||||
<div className="creative-section">
|
||||
<div className="creative-section-header">
|
||||
<h3>Ad Preview</h3>
|
||||
<span className="asset-count" style={{ fontStyle: 'italic' }}>Approximate appearance</span>
|
||||
</div>
|
||||
<div className="ad-preview-card">
|
||||
<div className="ad-preview-label">Ad · {businessData?.url ? new URL(businessData.url).hostname : 'yoursite.com'}</div>
|
||||
<div className="ad-preview-headline">{state.headlines[0]?.text || 'Your Headline'}</div>
|
||||
<div className="ad-preview-desc">{state.descriptions[0]?.text || 'Your description will appear here.'}</div>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
});
|
||||
|
||||
export default CreativeStep;
|
||||
233
Client-Client/src/components/wizard/steps/ObjectiveStep.jsx
Normal file
233
Client-Client/src/components/wizard/steps/ObjectiveStep.jsx
Normal file
@@ -0,0 +1,233 @@
|
||||
import React, { useState, useCallback, memo } from 'react';
|
||||
import { useAuth } from '../../../auth/AuthProvider';
|
||||
import { useWizardConfig } from '../../../context/WizardConfigContext';
|
||||
import { analyzeUrl } from '../../../services/wizardApi';
|
||||
import HelpIcon from '../../../components/HelpIcon';
|
||||
|
||||
// ── Component (memoized — only re-renders when data/onChange actually change) ──
|
||||
const ObjectiveStep = memo(function ObjectiveStep({ data, onChange }) {
|
||||
// ── Pull auth + config from context (no more prop-drilling) ──
|
||||
const { sessionToken } = useAuth();
|
||||
const {
|
||||
categories, objectives, loading,
|
||||
toDisplayLabel, objectiveDescriptions, objectiveIcons
|
||||
} = useWizardConfig();
|
||||
|
||||
const [analyzing, setAnalyzing] = useState(false);
|
||||
const [error, setError] = useState(null);
|
||||
|
||||
const state = {
|
||||
url: '',
|
||||
campaignName: '',
|
||||
objective: '',
|
||||
businessCategory: '',
|
||||
analysis: null,
|
||||
...data
|
||||
};
|
||||
|
||||
const update = useCallback((patch) => {
|
||||
onChange({ ...state, ...patch });
|
||||
}, [state, onChange]);
|
||||
|
||||
// Auto-default category to 'general' when categories load
|
||||
React.useEffect(() => {
|
||||
if (!loading && categories.length > 0 && !state.businessCategory) {
|
||||
const general = categories.find(c => c.name.toLowerCase() === 'general');
|
||||
if (general) {
|
||||
update({ businessCategory: general.name });
|
||||
}
|
||||
}
|
||||
}, [loading, categories.length]);
|
||||
|
||||
const handleAnalyze = async () => {
|
||||
if (!state.url) return;
|
||||
setAnalyzing(true);
|
||||
setError(null);
|
||||
|
||||
const res = await analyzeUrl(state.url, sessionToken);
|
||||
if (res.ok) {
|
||||
const analysis = res.data?.result?.data || res.data?.data || res.data;
|
||||
const suggestedName = analysis?.title
|
||||
? `${analysis.title.slice(0, 40)} Campaign`
|
||||
: state.campaignName;
|
||||
|
||||
// Try to auto-detect category from analysis
|
||||
const inferredCat = mapCategoryFromAnalysis(analysis?.inferredCategory, categories);
|
||||
|
||||
update({
|
||||
analysis,
|
||||
campaignName: state.campaignName || suggestedName,
|
||||
businessCategory: state.businessCategory || inferredCat
|
||||
});
|
||||
} else {
|
||||
setError(res.error || 'Failed to analyze URL');
|
||||
}
|
||||
setAnalyzing(false);
|
||||
};
|
||||
|
||||
return (
|
||||
<div className="wizard-step-content">
|
||||
<div className="step-intro">
|
||||
<div className="step-intro-header">
|
||||
<h2>What's your advertising goal?</h2>
|
||||
<HelpIcon helpKey="client.wizard.objective" label="About this step" />
|
||||
</div>
|
||||
<p>Tell us about your business and what you want to achieve. We'll help you reach the right audience across multiple channels.</p>
|
||||
</div>
|
||||
|
||||
{/* URL Input */}
|
||||
<div className="form-group">
|
||||
<label>Website URL</label>
|
||||
<div className="url-input-row">
|
||||
<input
|
||||
className="form-input"
|
||||
type="url"
|
||||
placeholder="https://yourwebsite.com"
|
||||
value={state.url}
|
||||
onChange={e => update({ url: e.target.value })}
|
||||
/>
|
||||
<button
|
||||
className="btn btn-primary"
|
||||
onClick={handleAnalyze}
|
||||
disabled={analyzing || !state.url}
|
||||
>
|
||||
{analyzing
|
||||
? <><span className="btn-spinner" /> Analyzing…</>
|
||||
: state.analysis ? 'Re-analyze' : 'Analyze'}
|
||||
</button>
|
||||
</div>
|
||||
{error && <div className="field-error">{error}</div>}
|
||||
</div>
|
||||
|
||||
{/* Analysis Results */}
|
||||
{state.analysis && (
|
||||
<div className="analysis-card">
|
||||
<div className="analysis-card-header">
|
||||
<svg width="18" height="18" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2"><path d="M9 12l2 2 4-4m6 2a9 9 0 11-18 0 9 9 0 0118 0z" /></svg>
|
||||
<span>Site analyzed successfully</span>
|
||||
</div>
|
||||
<div className="analysis-card-body">
|
||||
<div className="analysis-detail">
|
||||
<span className="analysis-label">Title</span>
|
||||
<span className="analysis-value">{state.analysis.title || '—'}</span>
|
||||
</div>
|
||||
{state.analysis.metaDescription && (
|
||||
<div className="analysis-detail">
|
||||
<span className="analysis-label">Description</span>
|
||||
<span className="analysis-value">{state.analysis.metaDescription}</span>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Campaign Name */}
|
||||
<div className="form-group">
|
||||
<label>Campaign Name</label>
|
||||
<input
|
||||
className="form-input"
|
||||
type="text"
|
||||
placeholder="e.g. Summer Sale 2025"
|
||||
value={state.campaignName}
|
||||
onChange={e => update({ campaignName: e.target.value })}
|
||||
/>
|
||||
<span className="field-hint">A name to identify this initiative in your dashboard</span>
|
||||
</div>
|
||||
|
||||
{/* Business Category */}
|
||||
<div className="form-group">
|
||||
<label>Business Category</label>
|
||||
<span className="field-hint" style={{ marginBottom: 8, display: 'block' }}>
|
||||
This helps us recommend the best channel mix and budget allocation for your industry.
|
||||
</span>
|
||||
{loading ? (
|
||||
<div className="skeleton" style={{ height: 48, borderRadius: 8 }} />
|
||||
) : (
|
||||
<select
|
||||
className="form-select form-select-lg"
|
||||
value={state.businessCategory}
|
||||
onChange={e => update({ businessCategory: e.target.value })}
|
||||
>
|
||||
<option value="">Select your industry…</option>
|
||||
{categories.map(cat => (
|
||||
<option key={cat.categoryId || cat.name} value={cat.name}>
|
||||
{cat.icon || '📦'} {toDisplayLabel(cat.name)}
|
||||
</option>
|
||||
))}
|
||||
</select>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* Objective */}
|
||||
<div className="form-group">
|
||||
<label>Advertising Objective</label>
|
||||
{loading ? (
|
||||
<div className="loading-placeholder">
|
||||
<div className="objective-grid">
|
||||
{[1, 2, 3, 4, 5].map(i => (
|
||||
<div key={i} className="skeleton" style={{ height: 80, borderRadius: 10 }} />
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
) : (
|
||||
<div className="objective-grid">
|
||||
{objectives.map(obj => (
|
||||
<button
|
||||
key={obj.objectiveId || obj.name}
|
||||
className={`objective-card ${state.objective === obj.name ? 'selected' : ''}`}
|
||||
onClick={() => update({ objective: obj.name })}
|
||||
style={obj.color ? { '--objective-accent': obj.color } : undefined}
|
||||
>
|
||||
<span className="objective-icon">{objectiveIcons[obj.name] || '🎯'}</span>
|
||||
<span className="objective-label">{toDisplayLabel(obj.name)}</span>
|
||||
{objectiveDescriptions[obj.name] && (
|
||||
<span className="objective-desc">{objectiveDescriptions[obj.name]}</span>
|
||||
)}
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
});
|
||||
|
||||
export default ObjectiveStep;
|
||||
|
||||
|
||||
/**
|
||||
* Map an inferred category string from URL analysis to the closest
|
||||
* category name in the loaded list. Falls back to '' if no match.
|
||||
*/
|
||||
function mapCategoryFromAnalysis(inferredCategory, loadedCategories) {
|
||||
if (!inferredCategory || !loadedCategories?.length) return '';
|
||||
const lower = inferredCategory.toLowerCase();
|
||||
|
||||
const patterns = [
|
||||
{ regex: /restaurant|food|dining|cafe|bar|bakery|pizza/i, match: 'restaurant' },
|
||||
{ regex: /retail|shop|store|ecommerce|fashion|boutique/i, match: 'retail' },
|
||||
{ regex: /b2b|saas|consult|agency|enterprise|software/i, match: 'b2b services' },
|
||||
{ regex: /plumb|electric|clean|landscap|repair|local|hvac/i, match: 'local services' },
|
||||
{ regex: /health|medical|dental|fitness|wellness|gym|therapy/i, match: 'health' },
|
||||
];
|
||||
|
||||
for (const { regex, match } of patterns) {
|
||||
if (regex.test(lower)) {
|
||||
const found = loadedCategories.find(c =>
|
||||
c.name.toLowerCase().includes(match)
|
||||
);
|
||||
if (found) return found.name;
|
||||
}
|
||||
}
|
||||
|
||||
const direct = loadedCategories.find(c =>
|
||||
c.name.toLowerCase() === lower ||
|
||||
lower.includes(c.name.toLowerCase())
|
||||
);
|
||||
if (direct) return direct.name;
|
||||
|
||||
const general = loadedCategories.find(c =>
|
||||
c.name.toLowerCase() === 'general'
|
||||
);
|
||||
return general ? general.name : '';
|
||||
}
|
||||
564
Client-Client/src/components/wizard/steps/ReviewStep.jsx
Normal file
564
Client-Client/src/components/wizard/steps/ReviewStep.jsx
Normal file
@@ -0,0 +1,564 @@
|
||||
import React, { useState, memo } from 'react';
|
||||
import { useAuth } from '../../../auth/AuthProvider';
|
||||
import { useWizardConfig } from '../../../context/WizardConfigContext';
|
||||
import { stageInitiative, launchInitiative } from '../../../services/initiativeApi';
|
||||
import HelpIcon from '../../../components/HelpIcon';
|
||||
|
||||
const statusIcons = {
|
||||
submitted: '✓', pending_review: '⏳', error: '✗', pending: '…',
|
||||
};
|
||||
const statusLabels = {
|
||||
submitted: 'Submitted', pending_review: 'Queued for Review',
|
||||
error: 'Failed', pending: 'Pending',
|
||||
};
|
||||
|
||||
const ageLabels = {
|
||||
AGE_18_24: '18–24', AGE_25_34: '25–34', AGE_35_44: '35–44',
|
||||
AGE_45_54: '45–54', AGE_55_64: '55–64', AGE_65_UP: '65+',
|
||||
};
|
||||
const genderLabels = { MALE: 'Male', FEMALE: 'Female', UNDETERMINED: 'All Genders' };
|
||||
const scopeLabels = { local: 'Local', regional: 'Regional', national: 'National' };
|
||||
const skewLabels = { young: 'Younger', mature: 'Mature' };
|
||||
|
||||
function SectionCard({ title, icon, children, onEdit, issues, onFix }) {
|
||||
const hasIssues = issues && issues.length > 0;
|
||||
return (
|
||||
<div className={`review-section ${hasIssues ? 'has-issues' : ''}`}>
|
||||
<div className="review-section-header">
|
||||
<div className="review-section-title">
|
||||
<span className="review-icon">{icon}</span>
|
||||
<h3>{title}</h3>
|
||||
</div>
|
||||
<div className="review-section-actions">
|
||||
{hasIssues && (
|
||||
<button className="btn btn-sm btn-fix" onClick={onFix || onEdit}>
|
||||
<svg width="12" height="12" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2.5">
|
||||
<path d="M12 9v2m0 4h.01M21 12a9 9 0 11-18 0 9 9 0 0118 0z" />
|
||||
</svg>
|
||||
Fix
|
||||
</button>
|
||||
)}
|
||||
{onEdit && (
|
||||
<button className="btn btn-sm btn-outline" onClick={onEdit}>Edit</button>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
{hasIssues && (
|
||||
<div className="review-section-issues">
|
||||
{issues.map((msg, idx) => (
|
||||
<span key={idx} className="review-issue-tag">{msg}</span>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
<div className="review-section-body">{children}</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
function ReviewRow({ label, value, mono }) {
|
||||
return (
|
||||
<div className="review-row">
|
||||
<span className="review-label">{label}</span>
|
||||
<span className={`review-value ${mono ? 'mono' : ''}`}>{value || '—'}</span>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
const ReviewStep = memo(function ReviewStep({ stepData, onGoToStep }) {
|
||||
const { sessionToken } = useAuth();
|
||||
const { toDisplayLabel, getChannelLabel, getChannelColor } = useWizardConfig();
|
||||
|
||||
const [phase, setPhase] = useState('review');
|
||||
const [error, setError] = useState(null);
|
||||
const [initiativeId, setInitiativeId] = useState(null);
|
||||
const [billing, setBilling] = useState(null);
|
||||
const [launchResults, setLaunchResults] = useState(null);
|
||||
const [showConfirm, setShowConfirm] = useState(false);
|
||||
const [termsAccepted, setTermsAccepted] = useState(false);
|
||||
|
||||
// ── Step data: 1=Objective, 2=Audience, 3=Budget, 4=Channels+Allocation, 5=Creative ──
|
||||
const objective = stepData?.[1] || {};
|
||||
const audience = stepData?.[2] || {};
|
||||
const budget = stepData?.[3] || {};
|
||||
const channelsAlloc = stepData?.[4] || {};
|
||||
const creative = stepData?.[5] || {};
|
||||
|
||||
const selectedChannels = channelsAlloc.selectedChannels || [];
|
||||
const isMultiChannel = selectedChannels.length > 1;
|
||||
const totalBudget = parseFloat(budget.totalBudget) || 0;
|
||||
const allocations = channelsAlloc.allocations || {};
|
||||
|
||||
const objectiveLabel = toDisplayLabel(objective.objective);
|
||||
const categoryLabel = toDisplayLabel(objective.businessCategory);
|
||||
|
||||
const formatBudget = (val) => {
|
||||
const n = parseFloat(val);
|
||||
return isNaN(n) ? '—' : `$${n.toLocaleString(undefined, { minimumFractionDigits: 2, maximumFractionDigits: 2 })}`;
|
||||
};
|
||||
|
||||
// Validation
|
||||
const issues = [];
|
||||
if (!objective.campaignName) issues.push({ step: 1, msg: 'Campaign name is required' });
|
||||
if (!objective.objective) issues.push({ step: 1, msg: 'Objective is required' });
|
||||
if (selectedChannels.length === 0) issues.push({ step: 4, msg: 'Select at least one channel' });
|
||||
if (!totalBudget || totalBudget <= 0) issues.push({ step: 3, msg: 'Budget must be set' });
|
||||
if (isMultiChannel) {
|
||||
const totalPct = Object.values(allocations).reduce((s, v) => s + (v || 0), 0);
|
||||
if (Math.abs(totalPct - 100) > 0.01) issues.push({ step: 4, msg: 'Channel allocations must total 100%' });
|
||||
}
|
||||
|
||||
// ── Stage ──
|
||||
const handleStage = async () => {
|
||||
if (issues.length > 0) return;
|
||||
setError(null);
|
||||
setPhase('staging');
|
||||
|
||||
const channelPayloads = selectedChannels.map(ch => ({
|
||||
channelType: ch,
|
||||
allocationPct: isMultiChannel ? (allocations[ch] || 0) : 100,
|
||||
}));
|
||||
|
||||
const payload = {
|
||||
name: objective.campaignName,
|
||||
objective: objective.objective,
|
||||
totalBudget: totalBudget,
|
||||
budgetPeriod: budget.budgetPeriod || 'monthly',
|
||||
startDate: budget.startDate,
|
||||
endDate: budget.hasEndDate ? budget.endDate : null,
|
||||
allocationStrategy: channelsAlloc.allocationStrategy || 'manual',
|
||||
businessCategory: objective.businessCategory,
|
||||
channels: channelPayloads,
|
||||
// Audience factors
|
||||
ageSkew: audience.ageSkew || null,
|
||||
marketScope: audience.marketScope || null,
|
||||
locations: audience.locations || [],
|
||||
demographics: audience.demographics || {},
|
||||
...(initiativeId ? { initiativeId } : {}),
|
||||
};
|
||||
|
||||
const stageRes = await stageInitiative(payload, sessionToken);
|
||||
|
||||
if (!stageRes.ok) {
|
||||
setPhase('error');
|
||||
setError(stageRes.error || stageRes.data?.error || 'Failed to stage initiative');
|
||||
return;
|
||||
}
|
||||
|
||||
const newId = stageRes.data?.initiativeId;
|
||||
if (!newId) {
|
||||
setPhase('error');
|
||||
setError('Initiative staged but no ID returned');
|
||||
return;
|
||||
}
|
||||
|
||||
setInitiativeId(newId);
|
||||
setBilling(stageRes.data?.billing || null);
|
||||
setTermsAccepted(false);
|
||||
setShowConfirm(true);
|
||||
setPhase('confirm');
|
||||
};
|
||||
|
||||
// ── Launch ──
|
||||
const handleConfirmLaunch = async () => {
|
||||
setShowConfirm(false);
|
||||
setError(null);
|
||||
setPhase('launching');
|
||||
|
||||
const launchRes = await launchInitiative(initiativeId, sessionToken);
|
||||
|
||||
if (launchRes.ok || launchRes.data?.channels) {
|
||||
setLaunchResults(launchRes.data);
|
||||
setPhase('done');
|
||||
} else {
|
||||
setLaunchResults(launchRes.data);
|
||||
setPhase('done');
|
||||
if (!launchRes.data?.channels?.length) {
|
||||
setError(launchRes.error || launchRes.data?.error || 'Launch failed — campaigns saved but not dispatched');
|
||||
}
|
||||
}
|
||||
};
|
||||
|
||||
const handleCancelConfirm = () => {
|
||||
setShowConfirm(false);
|
||||
setPhase('review');
|
||||
};
|
||||
|
||||
// ── Success / Results Screen ──
|
||||
if (phase === 'done') {
|
||||
const channelResults = launchResults?.channels || [];
|
||||
const successCount = channelResults.filter(c => c.status === 'submitted' || c.status === 'pending_review').length;
|
||||
const failCount = channelResults.filter(c => c.status === 'error').length;
|
||||
|
||||
return (
|
||||
<div className="wizard-step-content">
|
||||
<div className="submit-success">
|
||||
<div className="success-icon">
|
||||
{failCount === 0 ? (
|
||||
<svg width="48" height="48" viewBox="0 0 24 24" fill="none" stroke="#10B981" strokeWidth="2">
|
||||
<path d="M22 11.08V12a10 10 0 11-5.93-9.14" />
|
||||
<path d="M22 4L12 14.01l-3-3" />
|
||||
</svg>
|
||||
) : successCount > 0 ? (
|
||||
<svg width="48" height="48" viewBox="0 0 24 24" fill="none" stroke="#F59E0B" strokeWidth="2">
|
||||
<circle cx="12" cy="12" r="10" />
|
||||
<line x1="12" y1="8" x2="12" y2="12" />
|
||||
<line x1="12" y1="16" x2="12.01" y2="16" />
|
||||
</svg>
|
||||
) : (
|
||||
<svg width="48" height="48" viewBox="0 0 24 24" fill="none" stroke="#EF4444" strokeWidth="2">
|
||||
<circle cx="12" cy="12" r="10" />
|
||||
<line x1="15" y1="9" x2="9" y2="15" />
|
||||
<line x1="9" y1="9" x2="15" y2="15" />
|
||||
</svg>
|
||||
)}
|
||||
</div>
|
||||
|
||||
<h2>
|
||||
{failCount === 0
|
||||
? `Campaign ${isMultiChannel ? 'Initiative ' : ''}Launched!`
|
||||
: successCount > 0
|
||||
? 'Partially Launched'
|
||||
: 'Launch Failed'}
|
||||
</h2>
|
||||
|
||||
<p>
|
||||
{failCount === 0
|
||||
? `Your campaign "${objective.campaignName}" has been submitted${isMultiChannel ? ` across ${selectedChannels.length} channels` : ''}.`
|
||||
: successCount > 0
|
||||
? `${successCount} of ${channelResults.length} channels submitted successfully.`
|
||||
: 'All channel dispatches failed. Your campaign has been saved — you can retry from the Campaigns view.'}
|
||||
</p>
|
||||
|
||||
{channelResults.length > 0 && (
|
||||
<div className="launch-results">
|
||||
{channelResults.map((ch, idx) => (
|
||||
<div key={idx} className={`launch-result-row ${ch.status}`}>
|
||||
<span className="launch-ch-dot" style={{ background: getChannelColor(ch.channelType) }} />
|
||||
<span className="launch-ch-name">{getChannelLabel(ch.channelType)}</span>
|
||||
<span className={`launch-status ${ch.status}`}>
|
||||
{statusIcons[ch.status] || '?'} {statusLabels[ch.status] || ch.status}
|
||||
</span>
|
||||
{ch.error && <span className="launch-error">{ch.error}</span>}
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
// ── Loading states ──
|
||||
if (phase === 'staging') {
|
||||
return (
|
||||
<div className="wizard-step-content">
|
||||
<div className="phase-loading">
|
||||
<div className="loading-spinner" />
|
||||
<h3>Preparing your campaign…</h3>
|
||||
<p>Calculating fees and validating configuration</p>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
if (phase === 'launching') {
|
||||
return (
|
||||
<div className="wizard-step-content">
|
||||
<div className="phase-loading">
|
||||
<div className="loading-spinner" />
|
||||
<h3>Launching campaign…</h3>
|
||||
<p>Submitting to {isMultiChannel ? `${selectedChannels.length} ad networks` : 'ad network'}</p>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
// ── Audience summary helpers ──
|
||||
const locationSummary = audience.locations?.length > 0
|
||||
? audience.locations.map(l => l.name).join(', ')
|
||||
: 'All regions (national)';
|
||||
const ageSummary = audience.demographics?.ageRanges?.length > 0
|
||||
? audience.demographics.ageRanges.map(a => ageLabels[a] || a).join(', ')
|
||||
: 'All ages';
|
||||
const genderSummary = audience.demographics?.genders?.length > 0
|
||||
? audience.demographics.genders.map(g => genderLabels[g] || g).join(', ')
|
||||
: 'All genders';
|
||||
|
||||
// Group issues by step for inline display
|
||||
const issuesByStep = {};
|
||||
issues.forEach(issue => {
|
||||
if (!issuesByStep[issue.step]) issuesByStep[issue.step] = [];
|
||||
issuesByStep[issue.step].push(issue.msg);
|
||||
});
|
||||
|
||||
// ── Review Layout ──
|
||||
return (
|
||||
<div className="wizard-step-content">
|
||||
<div className="step-intro">
|
||||
<div className="step-intro-header">
|
||||
<h2>Review your campaign</h2>
|
||||
<HelpIcon helpKey="client.wizard.review" label="About this step" />
|
||||
</div>
|
||||
<p>Double-check everything below before launching.</p>
|
||||
</div>
|
||||
|
||||
{error && <div className="error-box">{error}</div>}
|
||||
|
||||
{/* Campaign Summary - Compact Two-Column */}
|
||||
<div className="review-grid">
|
||||
{/* Left Column: Campaign + Audience */}
|
||||
<div className="review-grid-col">
|
||||
<SectionCard
|
||||
title="Campaign" icon="🎯"
|
||||
onEdit={() => onGoToStep(1)}
|
||||
issues={issuesByStep[1]}
|
||||
onFix={() => onGoToStep(1)}
|
||||
>
|
||||
<ReviewRow label="Name" value={objective.campaignName} />
|
||||
<ReviewRow label="Objective" value={objectiveLabel} />
|
||||
<ReviewRow label="Category" value={categoryLabel} />
|
||||
</SectionCard>
|
||||
|
||||
<SectionCard title="Audience" icon="👥" onEdit={() => onGoToStep(2)}>
|
||||
<ReviewRow label="Locations" value={locationSummary} />
|
||||
<ReviewRow label="Age" value={ageSummary} />
|
||||
<ReviewRow label="Gender" value={genderSummary} />
|
||||
<div className="review-factors" style={{ marginTop: 8 }}>
|
||||
<span className="review-factor-chip">
|
||||
{scopeLabels[audience.marketScope] || 'National'}
|
||||
</span>
|
||||
<span className="review-factor-chip">
|
||||
{audience.ageSkew ? skewLabels[audience.ageSkew] : 'Balanced'}
|
||||
</span>
|
||||
</div>
|
||||
</SectionCard>
|
||||
</div>
|
||||
|
||||
{/* Right Column: Budget + Channels + Creative */}
|
||||
<div className="review-grid-col">
|
||||
<SectionCard
|
||||
title="Budget" icon="💰"
|
||||
onEdit={() => onGoToStep(3)}
|
||||
issues={issuesByStep[3]}
|
||||
onFix={() => onGoToStep(3)}
|
||||
>
|
||||
<ReviewRow
|
||||
label={budget.budgetPeriod === 'daily' ? 'Daily' : 'Monthly'}
|
||||
value={formatBudget(totalBudget)}
|
||||
/>
|
||||
<ReviewRow label="Start" value={budget.startDate} />
|
||||
{budget.hasEndDate && <ReviewRow label="End" value={budget.endDate} />}
|
||||
</SectionCard>
|
||||
|
||||
<SectionCard
|
||||
title="Channels" icon="📡"
|
||||
onEdit={() => onGoToStep(4)}
|
||||
issues={issuesByStep[4]}
|
||||
onFix={() => onGoToStep(4)}
|
||||
>
|
||||
<div className="review-channels">
|
||||
{selectedChannels.length > 0 ? selectedChannels.map(ch => (
|
||||
<span key={ch} className="review-channel-chip" style={{ borderColor: getChannelColor(ch) }}>
|
||||
<span className="review-ch-dot" style={{ background: getChannelColor(ch) }} />
|
||||
{getChannelLabel(ch)}
|
||||
{isMultiChannel && <span style={{ marginLeft: 'auto', fontSize: 12, color: 'var(--color-text-muted)' }}>{allocations[ch] || 0}%</span>}
|
||||
</span>
|
||||
)) : (
|
||||
<div className="review-empty-note">No channels selected</div>
|
||||
)}
|
||||
</div>
|
||||
</SectionCard>
|
||||
|
||||
<SectionCard title="Creative" icon="🖼️" onEdit={() => onGoToStep(5)}>
|
||||
{(creative.headlines?.length > 0 || creative.descriptions?.length > 0) ? (
|
||||
<div className="review-creative-summary">
|
||||
<span>{creative.headlines?.length || 0} headline{(creative.headlines?.length || 0) !== 1 ? 's' : ''}</span>
|
||||
<span className="review-dot-sep">·</span>
|
||||
<span>{creative.descriptions?.length || 0} description{(creative.descriptions?.length || 0) !== 1 ? 's' : ''}</span>
|
||||
<span className="review-dot-sep">·</span>
|
||||
<span>{creative.images?.length || 0} image{(creative.images?.length || 0) !== 1 ? 's' : ''}</span>
|
||||
</div>
|
||||
) : (
|
||||
<div className="review-empty-note">No creative assets added yet</div>
|
||||
)}
|
||||
</SectionCard>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Submit */}
|
||||
<div className="submit-bar">
|
||||
<div className="submit-bar-info">
|
||||
<strong>{issues.length > 0 ? 'Almost there' : 'Ready to launch?'}</strong>
|
||||
<span>
|
||||
{issues.length > 0
|
||||
? `Fix ${issues.length} issue${issues.length > 1 ? 's' : ''} above to enable launch.`
|
||||
: isMultiChannel
|
||||
? `Your campaign will be submitted to ${selectedChannels.map(ch => getChannelLabel(ch)).join(' and ')} for review.`
|
||||
: `Your campaign will be submitted to ${getChannelLabel(selectedChannels[0])} for review.`}
|
||||
</span>
|
||||
</div>
|
||||
<button
|
||||
className="btn btn-primary btn-lg"
|
||||
onClick={handleStage}
|
||||
disabled={issues.length > 0}
|
||||
>
|
||||
<svg width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2">
|
||||
<path d="M22 2L11 13M22 2l-7 20-4-9-9-4 20-7z" />
|
||||
</svg>
|
||||
{' '}Launch Campaign
|
||||
</button>
|
||||
</div>
|
||||
|
||||
{/* Confirmation Modal */}
|
||||
{showConfirm && billing && (
|
||||
<ConfirmLaunchModal
|
||||
campaignName={objective.campaignName}
|
||||
objectiveLabel={objectiveLabel}
|
||||
billing={billing}
|
||||
selectedChannels={selectedChannels}
|
||||
isMultiChannel={isMultiChannel}
|
||||
termsAccepted={termsAccepted}
|
||||
onToggleTerms={() => setTermsAccepted(!termsAccepted)}
|
||||
onConfirm={handleConfirmLaunch}
|
||||
onCancel={handleCancelConfirm}
|
||||
formatBudget={formatBudget}
|
||||
getChannelLabel={getChannelLabel}
|
||||
getChannelColor={getChannelColor}
|
||||
/>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
});
|
||||
|
||||
export default ReviewStep;
|
||||
|
||||
// ────────────────────────────────────────────────
|
||||
// Confirmation Modal
|
||||
// ────────────────────────────────────────────────
|
||||
|
||||
function ConfirmLaunchModal({
|
||||
campaignName, objectiveLabel, billing,
|
||||
selectedChannels, isMultiChannel,
|
||||
termsAccepted, onToggleTerms, onConfirm, onCancel, formatBudget,
|
||||
getChannelLabel, getChannelColor,
|
||||
}) {
|
||||
const periodLabel = billing.budgetPeriod === 'daily' ? 'day' : 'month';
|
||||
const marginPct = ((billing.marginRate || 0) * 100).toFixed(0);
|
||||
const channelBilling = billing.channels || [];
|
||||
|
||||
return (
|
||||
<div className="confirm-overlay" onClick={onCancel}>
|
||||
<div className="confirm-modal" onClick={e => e.stopPropagation()}>
|
||||
<div className="confirm-header">
|
||||
<div className="confirm-icon-wrap">
|
||||
<svg width="28" height="28" viewBox="0 0 24 24" fill="none" stroke="#d97706" strokeWidth="2">
|
||||
<path d="M10.29 3.86L1.82 18a2 2 0 001.71 3h16.94a2 2 0 001.71-3L13.71 3.86a2 2 0 00-3.42 0z" />
|
||||
<line x1="12" y1="9" x2="12" y2="13" />
|
||||
<line x1="12" y1="17" x2="12.01" y2="17" />
|
||||
</svg>
|
||||
</div>
|
||||
<h2>Confirm Campaign Launch</h2>
|
||||
</div>
|
||||
|
||||
<p className="confirm-subtitle">
|
||||
You are about to launch <strong>{campaignName}</strong>. By proceeding,
|
||||
you authorize AdPlatform to charge your payment method on file for the
|
||||
advertising spend and management fees described below.
|
||||
</p>
|
||||
|
||||
<div className="confirm-billing">
|
||||
<div className="confirm-billing-row">
|
||||
<span>Campaign objective</span>
|
||||
<span>{objectiveLabel}</span>
|
||||
</div>
|
||||
|
||||
<div className="confirm-billing-row">
|
||||
<span>Ad spend</span>
|
||||
<span>{formatBudget(billing.adSpend)} / {periodLabel}</span>
|
||||
</div>
|
||||
|
||||
{isMultiChannel && channelBilling.map((ch, idx) => (
|
||||
<div key={idx} className="confirm-billing-row confirm-billing-sub">
|
||||
<span>
|
||||
<span className="confirm-ch-dot" style={{ background: getChannelColor(ch.channelType) }} />
|
||||
{getChannelLabel(ch.channelType)} ({ch.allocationPct}%)
|
||||
</span>
|
||||
<span>{formatBudget(ch.adSpend)} / {periodLabel}</span>
|
||||
</div>
|
||||
))}
|
||||
|
||||
<div className="confirm-billing-row">
|
||||
<span>Management fee ({marginPct}%)</span>
|
||||
<span>{formatBudget(billing.managementFee)} / {periodLabel}</span>
|
||||
</div>
|
||||
|
||||
{isMultiChannel && channelBilling.map((ch, idx) => (
|
||||
<div key={`fee-${idx}`} className="confirm-billing-row confirm-billing-sub">
|
||||
<span>
|
||||
<span className="confirm-ch-dot" style={{ background: getChannelColor(ch.channelType) }} />
|
||||
{getChannelLabel(ch.channelType)}
|
||||
</span>
|
||||
<span>{formatBudget(ch.managementFee)} / {periodLabel}</span>
|
||||
</div>
|
||||
))}
|
||||
|
||||
{billing.platformFee > 0 && (
|
||||
<div className="confirm-billing-row">
|
||||
<span>Platform fee</span>
|
||||
<span>{formatBudget(billing.platformFee)} / {periodLabel}</span>
|
||||
</div>
|
||||
)}
|
||||
|
||||
<div className="confirm-billing-row confirm-billing-total">
|
||||
<span>Total recurring charge</span>
|
||||
<span className="confirm-amount">{formatBudget(billing.totalCharge)} / {periodLabel}</span>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="confirm-note">
|
||||
Ad spend will be billed to your payment method on file. Actual ad network
|
||||
charges may vary based on delivery and auction dynamics. Management fees
|
||||
are calculated at {marginPct}% of ad spend
|
||||
{billing.minManagementFee > 0 && ` (${formatBudget(billing.minManagementFee)}/mo minimum)`}.
|
||||
You can pause or cancel campaigns at any time from the Campaigns dashboard.
|
||||
{billing.pricingSource === 'client' && (
|
||||
<span className="confirm-pricing-source" title="This client has a negotiated rate">
|
||||
{' '}— Custom rate applied
|
||||
</span>
|
||||
)}
|
||||
</div>
|
||||
|
||||
<label className="confirm-terms">
|
||||
<input
|
||||
type="checkbox"
|
||||
checked={termsAccepted}
|
||||
onChange={onToggleTerms}
|
||||
/>
|
||||
<span>
|
||||
I authorize AdPlatform to charge up to <strong>{formatBudget(billing.totalCharge)} / {periodLabel}</strong> to
|
||||
my payment method on file. I understand that ad networks may take up
|
||||
to 48 hours to review and approve my campaigns, and that actual
|
||||
delivery and charges may vary.
|
||||
</span>
|
||||
</label>
|
||||
|
||||
<div className="confirm-actions">
|
||||
<button className="btn btn-outline" onClick={onCancel}>
|
||||
Cancel
|
||||
</button>
|
||||
<button
|
||||
className="btn btn-primary btn-lg"
|
||||
onClick={onConfirm}
|
||||
disabled={!termsAccepted}
|
||||
>
|
||||
<svg width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2">
|
||||
<rect x="1" y="4" width="22" height="16" rx="2" ry="2" />
|
||||
<line x1="1" y1="10" x2="23" y2="10" />
|
||||
</svg>
|
||||
{' '}Confirm & Launch
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
149
Client-Client/src/context/AdminContext.jsx
Normal file
149
Client-Client/src/context/AdminContext.jsx
Normal file
@@ -0,0 +1,149 @@
|
||||
import React, { createContext, useContext, useState, useCallback, useEffect, useMemo } from 'react';
|
||||
import { useAuth } from '../auth/AuthProvider';
|
||||
import { GATEWAY_URL, MANAGEMENT_URL } from '../auth/authConfig';
|
||||
|
||||
// ─── Navigation structure ─────────────────────────────────────────────────────
|
||||
export const CATEGORY_TABS = {
|
||||
dashboard: [
|
||||
{ id: 'overview', label: 'Overview' },
|
||||
],
|
||||
campaigns: [
|
||||
{ id: 'myCampaigns', label: 'My Campaigns' },
|
||||
{ id: 'newCampaign', label: '+ New Campaign' },
|
||||
],
|
||||
performance: [
|
||||
{ id: 'metrics', label: 'Metrics' },
|
||||
{ id: 'recommendations', label: 'Recommendations' },
|
||||
],
|
||||
billing: [
|
||||
{ id: 'invoices', label: 'Invoices' },
|
||||
],
|
||||
documents: [
|
||||
{ id: 'myDocuments', label: 'My Documents' },
|
||||
],
|
||||
support: [
|
||||
{ id: 'help', label: 'Help & FAQs' },
|
||||
],
|
||||
};
|
||||
|
||||
export const CATEGORY_LABELS = {
|
||||
dashboard: 'Dashboard',
|
||||
campaigns: 'Campaigns',
|
||||
performance: 'Performance',
|
||||
billing: 'Billing',
|
||||
documents: 'Documents',
|
||||
support: 'Support',
|
||||
};
|
||||
|
||||
// ─── Context ──────────────────────────────────────────────────────────────────
|
||||
const ClientContext = createContext(null);
|
||||
|
||||
export function useAdmin() {
|
||||
const ctx = useContext(ClientContext);
|
||||
if (!ctx) throw new Error('useAdmin must be used within ClientProvider');
|
||||
return ctx;
|
||||
}
|
||||
|
||||
// ─── Provider ─────────────────────────────────────────────────────────────────
|
||||
export function AdminProvider({ children }) {
|
||||
const { sessionUser, sessionToken, signOut } = useAuth();
|
||||
|
||||
const [activeCategory, setActiveCategory] = useState('dashboard');
|
||||
const [activeTab, setActiveTab] = useState('overview');
|
||||
const [collapsed, setCollapsed] = useState(false);
|
||||
const [data, setData] = useState(null);
|
||||
const [loading, setLoading] = useState(false);
|
||||
const [error, setError] = useState(null);
|
||||
const [refreshKey, setRefreshKey] = useState(0);
|
||||
const [wizardId, setWizardId] = useState(null);
|
||||
|
||||
// ─── API call — session token Bearer to Gateway ───────────────────────────
|
||||
const apiCall = useCallback(async (urlOrPath, methodOrOpts = 'GET', body = null) => {
|
||||
if (!sessionToken) return { ok: false, error: 'Not authenticated' };
|
||||
|
||||
// Support options-object form for FormData / rawResponse
|
||||
let method, reqBody, isFormData, rawResponse, base;
|
||||
if (methodOrOpts !== null && typeof methodOrOpts === 'object') {
|
||||
method = methodOrOpts.method || 'GET';
|
||||
rawResponse = !!methodOrOpts.rawResponse;
|
||||
const b = methodOrOpts.body ?? null;
|
||||
isFormData = b instanceof FormData;
|
||||
reqBody = isFormData ? b : (b ? JSON.stringify(b) : null);
|
||||
base = methodOrOpts.mgmt ? MANAGEMENT_URL : GATEWAY_URL;
|
||||
} else {
|
||||
method = methodOrOpts;
|
||||
rawResponse = false;
|
||||
isFormData = false;
|
||||
reqBody = body ? JSON.stringify(body) : null;
|
||||
base = GATEWAY_URL;
|
||||
}
|
||||
|
||||
const url = urlOrPath.startsWith('http') ? urlOrPath : `${base}${urlOrPath}`;
|
||||
const headers = { 'Authorization': `Bearer ${sessionToken}` };
|
||||
if (!isFormData) headers['Content-Type'] = 'application/json';
|
||||
|
||||
try {
|
||||
const res = await fetch(url, { method, headers, body: reqBody || undefined });
|
||||
|
||||
if (!res.ok) {
|
||||
if (rawResponse) return null;
|
||||
const text = await res.text();
|
||||
if (res.status !== 404) console.error(`[Client] HTTP ${res.status}:`, text.substring(0, 200));
|
||||
return { ok: false, error: `HTTP ${res.status}` };
|
||||
}
|
||||
|
||||
if (rawResponse) return res;
|
||||
return await res.json();
|
||||
} catch (err) {
|
||||
console.error('[Client] Network error:', err.message);
|
||||
return rawResponse ? null : { ok: false, error: 'Network error' };
|
||||
}
|
||||
}, [sessionToken]);
|
||||
|
||||
const handleSelectCategory = useCallback((catId) => {
|
||||
setActiveCategory(catId);
|
||||
const tabs = CATEGORY_TABS[catId];
|
||||
if (tabs?.length > 0) setActiveTab(tabs[0].id);
|
||||
setData(null);
|
||||
setError(null);
|
||||
}, []);
|
||||
|
||||
const handleSelectTab = useCallback((tabId) => {
|
||||
setActiveTab(tabId);
|
||||
setData(null);
|
||||
setError(null);
|
||||
}, []);
|
||||
|
||||
const refresh = useCallback(() => setRefreshKey(k => k + 1), []);
|
||||
|
||||
const value = useMemo(() => ({
|
||||
session: sessionUser,
|
||||
user: sessionUser,
|
||||
signOut,
|
||||
|
||||
activeCategory, activeTab, collapsed,
|
||||
setActiveCategory: handleSelectCategory,
|
||||
setActiveTab: handleSelectTab,
|
||||
setCollapsed,
|
||||
tabs: CATEGORY_TABS[activeCategory] || [],
|
||||
|
||||
data, loading, error, refresh,
|
||||
apiCall,
|
||||
|
||||
// Convenience: client identity from session
|
||||
clientId: sessionUser?.clientId,
|
||||
clientName: sessionUser?.clientName,
|
||||
|
||||
// Wizard routing
|
||||
wizardId, setWizardId,
|
||||
}), [
|
||||
sessionUser, signOut,
|
||||
activeCategory, activeTab, collapsed,
|
||||
handleSelectCategory, handleSelectTab,
|
||||
data, loading, error, refresh,
|
||||
apiCall,
|
||||
wizardId,
|
||||
]);
|
||||
|
||||
return <ClientContext.Provider value={value}>{children}</ClientContext.Provider>;
|
||||
}
|
||||
273
Client-Client/src/context/ObjectiveMappingsContext.jsx
Normal file
273
Client-Client/src/context/ObjectiveMappingsContext.jsx
Normal file
@@ -0,0 +1,273 @@
|
||||
// ============================================================
|
||||
// context/ObjectiveMappingsContext.jsx
|
||||
// Provides channels, objectives, and mappings state for
|
||||
// the ObjectiveMappingPanel. Follows TemplatesContext pattern.
|
||||
// ============================================================
|
||||
import React, { createContext, useContext, useState, useCallback, useEffect, useMemo } from 'react';
|
||||
import { useAdmin } from './AdminContext';
|
||||
|
||||
// ── Provider-specific objective options ──
|
||||
// These stay as client-side helpers for the mapping form.
|
||||
// Could move to a tbProviderObjective table later.
|
||||
export const PROVIDER_OBJECTIVES = {
|
||||
google_ads: [
|
||||
{ value: 'MAXIMIZE_CONVERSIONS', label: 'Maximize Conversions' },
|
||||
{ value: 'MAXIMIZE_CONVERSION_VALUE', label: 'Maximize Conversion Value' },
|
||||
{ value: 'TARGET_CPA', label: 'Target CPA' },
|
||||
{ value: 'TARGET_ROAS', label: 'Target ROAS' },
|
||||
{ value: 'MAXIMIZE_CLICKS', label: 'Maximize Clicks' },
|
||||
{ value: 'TARGET_IMPRESSION_SHARE', label: 'Target Impression Share' },
|
||||
{ value: 'MANUAL_CPC', label: 'Manual CPC' },
|
||||
],
|
||||
meta: [
|
||||
{ value: 'OUTCOME_AWARENESS', label: 'Awareness' },
|
||||
{ value: 'OUTCOME_TRAFFIC', label: 'Traffic' },
|
||||
{ value: 'OUTCOME_ENGAGEMENT', label: 'Engagement' },
|
||||
{ value: 'OUTCOME_LEADS', label: 'Leads' },
|
||||
{ value: 'OUTCOME_SALES', label: 'Sales' },
|
||||
{ value: 'CONVERSIONS', label: 'Conversions' },
|
||||
],
|
||||
tiktok: [
|
||||
{ value: 'REACH', label: 'Reach' },
|
||||
{ value: 'TRAFFIC', label: 'Traffic' },
|
||||
{ value: 'VIDEO_VIEWS', label: 'Video Views' },
|
||||
{ value: 'CONVERSION', label: 'Conversion' },
|
||||
{ value: 'APP_INSTALL', label: 'App Install' },
|
||||
],
|
||||
};
|
||||
|
||||
const ObjectiveMappingsContext = createContext(null);
|
||||
|
||||
export function useObjectiveMappings() {
|
||||
const ctx = useContext(ObjectiveMappingsContext);
|
||||
if (!ctx) throw new Error('useObjectiveMappings must be used within <ObjectiveMappingsProvider>');
|
||||
return ctx;
|
||||
}
|
||||
|
||||
export function ObjectiveMappingsProvider({ children }) {
|
||||
const { data, apiCall, refresh, loading: adminLoading } = useAdmin();
|
||||
|
||||
// ── Metadata state ──
|
||||
const [channels, setChannels] = useState([]);
|
||||
const [objectives, setObjectives] = useState([]);
|
||||
const [metaLoading, setMetaLoading] = useState(true);
|
||||
const [metaError, setMetaError] = useState(null);
|
||||
|
||||
// ── Track whether API endpoints returned data or need fallback ──
|
||||
const [needsChannelFallback, setNeedsChannelFallback] = useState(false);
|
||||
const [needsObjectiveFallback, setNeedsObjectiveFallback] = useState(false);
|
||||
|
||||
// ── Fetch channels + objectives once on mount ──
|
||||
useEffect(() => {
|
||||
let cancelled = false;
|
||||
|
||||
const fetchMeta = async () => {
|
||||
setMetaLoading(true);
|
||||
setMetaError(null);
|
||||
|
||||
try {
|
||||
const [chRes, objRes] = await Promise.all([
|
||||
apiCall('/api/admin/template-config/channels', 'GET'),
|
||||
apiCall('/api/admin/template-config/objectives', 'GET'),
|
||||
]);
|
||||
|
||||
if (!cancelled) {
|
||||
// Channels
|
||||
if (chRes?.ok && chRes.channels) {
|
||||
setChannels(chRes.channels);
|
||||
} else {
|
||||
setNeedsChannelFallback(true);
|
||||
}
|
||||
|
||||
// Objectives
|
||||
if (objRes?.ok && objRes.objectives) {
|
||||
setObjectives(objRes.objectives);
|
||||
} else {
|
||||
setNeedsObjectiveFallback(true);
|
||||
}
|
||||
}
|
||||
} catch (err) {
|
||||
if (!cancelled) setMetaError(err.message);
|
||||
} finally {
|
||||
if (!cancelled) setMetaLoading(false);
|
||||
}
|
||||
};
|
||||
|
||||
fetchMeta();
|
||||
return () => { cancelled = true; };
|
||||
}, [apiCall]); // apiCall is stable — runs once on mount
|
||||
|
||||
// ── Fallback: derive from mapping data once it arrives ──
|
||||
useEffect(() => {
|
||||
if (!data?.mappings) return;
|
||||
if (needsChannelFallback && channels.length === 0) {
|
||||
console.info('[ObjectiveMappings] Deriving channels from mapping data');
|
||||
setChannels(deriveChannelsFromMappings(data));
|
||||
}
|
||||
if (needsObjectiveFallback && objectives.length === 0) {
|
||||
console.info('[ObjectiveMappings] Deriving objectives from mapping data');
|
||||
setObjectives(deriveObjectivesFromMappings(data));
|
||||
}
|
||||
}, [data, needsChannelFallback, needsObjectiveFallback, channels.length, objectives.length]);
|
||||
|
||||
// ── Mappings come from AdminContext `data` (the /api/admin/objectives response) ──
|
||||
const mappings = useMemo(() => {
|
||||
if (!data?.mappings) return [];
|
||||
return data.mappings;
|
||||
}, [data]);
|
||||
|
||||
// ── Lookup maps ──
|
||||
const channelMap = useMemo(() => {
|
||||
const map = {};
|
||||
channels.forEach(ch => {
|
||||
map[ch.code] = ch;
|
||||
});
|
||||
return map;
|
||||
}, [channels]);
|
||||
|
||||
const objectiveColorMap = useMemo(() => {
|
||||
const map = {};
|
||||
objectives.forEach(obj => {
|
||||
map[obj.name] = obj.color || '#6B7280';
|
||||
});
|
||||
return map;
|
||||
}, [objectives]);
|
||||
|
||||
// ── Coverage matrix: { [objective]: { [channelCode]: mapping | null } } ──
|
||||
const coverageMatrix = useMemo(() => {
|
||||
const activeObjectives = objectives.filter(o => o.isActive);
|
||||
const activeChannels = channels.filter(c => c.isActive);
|
||||
const matrix = {};
|
||||
|
||||
activeObjectives.forEach(obj => {
|
||||
matrix[obj.name] = {};
|
||||
activeChannels.forEach(ch => {
|
||||
const mapping = mappings.find(
|
||||
m => m.platformObjective === obj.name && m.channelType === ch.code && m.isActive
|
||||
);
|
||||
matrix[obj.name][ch.code] = mapping || null;
|
||||
});
|
||||
});
|
||||
|
||||
return matrix;
|
||||
}, [objectives, channels, mappings]);
|
||||
|
||||
// ── Coverage stats ──
|
||||
const coverageStats = useMemo(() => {
|
||||
const activeObjectives = objectives.filter(o => o.isActive);
|
||||
const activeChannels = channels.filter(c => c.isActive);
|
||||
const totalCells = activeObjectives.length * activeChannels.length;
|
||||
let filledCells = 0;
|
||||
|
||||
activeObjectives.forEach(obj => {
|
||||
activeChannels.forEach(ch => {
|
||||
if (coverageMatrix[obj.name]?.[ch.code]) filledCells++;
|
||||
});
|
||||
});
|
||||
|
||||
return {
|
||||
total: totalCells,
|
||||
filled: filledCells,
|
||||
gaps: totalCells - filledCells,
|
||||
pct: totalCells > 0 ? Math.round((filledCells / totalCells) * 100) : 0,
|
||||
};
|
||||
}, [coverageMatrix, objectives, channels]);
|
||||
|
||||
// ── CRUD callbacks ──
|
||||
const createMapping = useCallback(async (body) => {
|
||||
const result = await apiCall('/api/admin/objectives', 'POST', body);
|
||||
if (result?.ok) refresh();
|
||||
return result;
|
||||
}, [apiCall, refresh]);
|
||||
|
||||
const updateMapping = useCallback(async (mappingId, body) => {
|
||||
const result = await apiCall(`/api/admin/objectives/${mappingId}`, 'PUT', body);
|
||||
if (result?.ok) refresh();
|
||||
return result;
|
||||
}, [apiCall, refresh]);
|
||||
|
||||
const deleteMapping = useCallback(async (mappingId) => {
|
||||
const result = await apiCall(`/api/admin/objectives/${mappingId}`, 'DELETE');
|
||||
if (result?.ok) refresh();
|
||||
return result;
|
||||
}, [apiCall, refresh]);
|
||||
|
||||
// ── Provider objectives helper ──
|
||||
const getProviderObjectives = useCallback((channelCode) => {
|
||||
return PROVIDER_OBJECTIVES[channelCode] || [];
|
||||
}, []);
|
||||
|
||||
const value = useMemo(() => ({
|
||||
// Data
|
||||
channels,
|
||||
objectives,
|
||||
mappings,
|
||||
channelMap,
|
||||
objectiveColorMap,
|
||||
coverageMatrix,
|
||||
coverageStats,
|
||||
|
||||
// Loading
|
||||
loading: adminLoading || metaLoading,
|
||||
metaLoading,
|
||||
metaError,
|
||||
|
||||
// CRUD
|
||||
createMapping,
|
||||
updateMapping,
|
||||
deleteMapping,
|
||||
|
||||
// Helpers
|
||||
getProviderObjectives,
|
||||
refresh,
|
||||
}), [
|
||||
channels, objectives, mappings, channelMap, objectiveColorMap,
|
||||
coverageMatrix, coverageStats, adminLoading, metaLoading, metaError,
|
||||
createMapping, updateMapping, deleteMapping, getProviderObjectives, refresh,
|
||||
]);
|
||||
|
||||
return (
|
||||
<ObjectiveMappingsContext.Provider value={value}>
|
||||
{children}
|
||||
</ObjectiveMappingsContext.Provider>
|
||||
);
|
||||
}
|
||||
|
||||
// ── Fallback derivation helpers ──
|
||||
function deriveChannelsFromMappings(data) {
|
||||
if (!data?.mappings) return [];
|
||||
const codes = [...new Set(data.mappings.map(m => m.channelType))].sort();
|
||||
const defaults = {
|
||||
google_ads: { label: 'Google Ads', color: '#4285F4', icon: '🔵' },
|
||||
meta: { label: 'Meta', color: '#8B5CF6', icon: '🟣' },
|
||||
tiktok: { label: 'TikTok', color: '#FF004F', icon: '🔴' },
|
||||
};
|
||||
return codes.map((code, i) => ({
|
||||
channelId: i + 1,
|
||||
code,
|
||||
label: defaults[code]?.label || code,
|
||||
color: defaults[code]?.color || '#6B7280',
|
||||
icon: defaults[code]?.icon || '📢',
|
||||
sortOrder: i,
|
||||
isActive: true,
|
||||
}));
|
||||
}
|
||||
|
||||
function deriveObjectivesFromMappings(data) {
|
||||
if (!data?.mappings) return [];
|
||||
const names = [...new Set(data.mappings.map(m => m.platformObjective))].sort();
|
||||
const defaultColors = {
|
||||
awareness: '#3B82F6',
|
||||
traffic: '#F59E0B',
|
||||
leads: '#8B5CF6',
|
||||
conversions: '#10B981',
|
||||
sales: '#EF4444',
|
||||
};
|
||||
return names.map((name, i) => ({
|
||||
objectiveId: i + 1,
|
||||
name,
|
||||
color: defaultColors[name] || '#6B7280',
|
||||
sortOrder: i,
|
||||
isActive: true,
|
||||
}));
|
||||
}
|
||||
252
Client-Client/src/context/TemplatesContext.jsx
Normal file
252
Client-Client/src/context/TemplatesContext.jsx
Normal file
@@ -0,0 +1,252 @@
|
||||
import React, { createContext, useContext, useState, useCallback, useEffect, useMemo } from 'react';
|
||||
import { useAdmin } from './AdminContext';
|
||||
import { API_BASE_URL } from '../auth/authConfig';
|
||||
|
||||
// ─── Channel Configuration ──────────────────────────────────
|
||||
export const CHANNELS = ['google_ads', 'meta', 'tiktok'];
|
||||
|
||||
export const CHANNEL_META = {
|
||||
google_ads: { color: '#4285F4', label: 'Google Ads' },
|
||||
meta: { color: '#8B5CF6', label: 'Meta' },
|
||||
tiktok: { color: '#FF004F', label: 'TikTok' },
|
||||
};
|
||||
|
||||
// Default fallbacks when metadata tables haven't been seeded
|
||||
const FALLBACK_ICONS = {
|
||||
restaurant: '🍽️', retail: '🛍️', b2b_services: '💼',
|
||||
local_services: '🏠', health_wellness: '🏥', general: '📋',
|
||||
automotive: '🚗', education: '🎓', legal_services: '⚖️',
|
||||
fitness: '🏋️', real_estate: '🏢', pet_services: '🐾', travel: '✈️',
|
||||
};
|
||||
|
||||
const FALLBACK_OBJ_COLORS = {
|
||||
awareness: '#7dca7d', traffic: '#4fc3f7', leads: '#ffb74d',
|
||||
conversions: '#ce93d8', sales: '#ef5350',
|
||||
};
|
||||
|
||||
|
||||
// ─── Context ────────────────────────────────────────────────
|
||||
const TemplatesContext = createContext(null);
|
||||
|
||||
export function useTemplates() {
|
||||
const ctx = useContext(TemplatesContext);
|
||||
if (!ctx) throw new Error('useTemplates must be used within <TemplatesProvider>');
|
||||
return ctx;
|
||||
}
|
||||
|
||||
|
||||
// ─── Provider ───────────────────────────────────────────────
|
||||
export function TemplatesProvider({ children }) {
|
||||
const { data, sessionToken, apiCall, refresh } = useAdmin();
|
||||
const templates = data?.templates || [];
|
||||
|
||||
// ─── Metadata state ─────────────────────────────────────
|
||||
const [categories, setCategories] = useState([]);
|
||||
const [objectives, setObjectives] = useState([]);
|
||||
const [metaLoading, setMetaLoading] = useState(true);
|
||||
|
||||
// ─── Sidebar filter state ───────────────────────────────
|
||||
const [selectedCategory, setSelectedCategory] = useState(null);
|
||||
const [selectedObjective, setSelectedObjective] = useState(null);
|
||||
const [editMode, setEditMode] = useState(false);
|
||||
|
||||
// ─── Form state ─────────────────────────────────────────
|
||||
const [showForm, setShowForm] = useState(false);
|
||||
const [editingId, setEditingId] = useState(null);
|
||||
const [addingToGroup, setAddingToGroup] = useState(null);
|
||||
|
||||
// ─── New category state ─────────────────────────────────
|
||||
const [showNewCat, setShowNewCat] = useState(false);
|
||||
const [newCatName, setNewCatName] = useState('');
|
||||
const [newCatIcon, setNewCatIcon] = useState('');
|
||||
|
||||
// ─── Fetch category & objective metadata ────────────────
|
||||
const fetchMetadata = useCallback(async () => {
|
||||
if (!sessionToken) return;
|
||||
setMetaLoading(true);
|
||||
try {
|
||||
const [catRes, objRes] = await Promise.all([
|
||||
fetch(`${API_BASE_URL}/api/admin/template-config/categories`, {
|
||||
headers: { 'X-Session-Token': sessionToken }
|
||||
}).catch(() => null),
|
||||
fetch(`${API_BASE_URL}/api/admin/template-config/objectives`, {
|
||||
headers: { 'X-Session-Token': sessionToken }
|
||||
}).catch(() => null),
|
||||
]);
|
||||
|
||||
const catData = catRes ? await catRes.json().catch(() => null) : null;
|
||||
const objData = objRes ? await objRes.json().catch(() => null) : null;
|
||||
|
||||
if (catData?.ok && catData.categories) {
|
||||
setCategories([...catData.categories].sort((a, b) => a.name.localeCompare(b.name)));
|
||||
} else {
|
||||
const catNames = [...new Set(templates.map(t => t.businessCategory))].sort();
|
||||
setCategories(catNames.map((name, i) => ({
|
||||
categoryId: i + 1, name,
|
||||
icon: templates.find(t => t.businessCategory === name)?.categoryIcon || FALLBACK_ICONS[name] || '📋',
|
||||
isActive: true,
|
||||
templateCount: templates.filter(t => t.businessCategory === name).length,
|
||||
})));
|
||||
}
|
||||
|
||||
if (objData?.ok && objData.objectives) {
|
||||
setObjectives([...objData.objectives].sort((a, b) => a.name.localeCompare(b.name)));
|
||||
} else {
|
||||
const objNames = [...new Set(templates.map(t => t.objective))].sort();
|
||||
setObjectives(objNames.map((name, i) => ({
|
||||
objectiveId: i + 1, name,
|
||||
color: templates.find(t => t.objective === name)?.objectiveColor || FALLBACK_OBJ_COLORS[name] || '#999',
|
||||
isActive: true,
|
||||
templateCount: templates.filter(t => t.objective === name).length,
|
||||
})));
|
||||
}
|
||||
} catch (err) {
|
||||
console.warn('[Templates] Metadata fetch failed, using fallbacks:', err.message);
|
||||
const catNames = [...new Set(templates.map(t => t.businessCategory))].sort();
|
||||
setCategories(catNames.map((name, i) => ({
|
||||
categoryId: i + 1, name,
|
||||
icon: FALLBACK_ICONS[name] || '📋', isActive: true,
|
||||
templateCount: templates.filter(t => t.businessCategory === name).length,
|
||||
})));
|
||||
const objNames = [...new Set(templates.map(t => t.objective))].sort();
|
||||
setObjectives(objNames.map((name, i) => ({
|
||||
objectiveId: i + 1, name,
|
||||
color: FALLBACK_OBJ_COLORS[name] || '#999', isActive: true,
|
||||
templateCount: templates.filter(t => t.objective === name).length,
|
||||
})));
|
||||
} finally {
|
||||
setMetaLoading(false);
|
||||
}
|
||||
}, [sessionToken, templates]);
|
||||
|
||||
useEffect(() => { fetchMetadata(); }, [fetchMetadata]);
|
||||
|
||||
// Auto-select first category
|
||||
useEffect(() => {
|
||||
if (!selectedCategory && categories.length > 0) {
|
||||
setSelectedCategory(categories[0].name);
|
||||
}
|
||||
}, [categories, selectedCategory]);
|
||||
|
||||
// ─── Memoized lookups ───────────────────────────────────
|
||||
const catIconMap = useMemo(() => {
|
||||
const map = {};
|
||||
categories.forEach(c => { map[c.name] = c.icon; });
|
||||
return map;
|
||||
}, [categories]);
|
||||
|
||||
const objColorMap = useMemo(() => {
|
||||
const map = {};
|
||||
objectives.forEach(o => { map[o.name] = o.color; });
|
||||
return map;
|
||||
}, [objectives]);
|
||||
|
||||
const filtered = useMemo(() =>
|
||||
templates.filter(t =>
|
||||
(!selectedCategory || t.businessCategory === selectedCategory) &&
|
||||
(!selectedObjective || t.objective === selectedObjective)
|
||||
),
|
||||
[templates, selectedCategory, selectedObjective]
|
||||
);
|
||||
|
||||
const grouped = useMemo(() => {
|
||||
const g = {};
|
||||
filtered.forEach(t => {
|
||||
const key = `${t.businessCategory}|${t.objective}`;
|
||||
if (!g[key]) g[key] = [];
|
||||
g[key].push(t);
|
||||
});
|
||||
return g;
|
||||
}, [filtered]);
|
||||
|
||||
const catCounts = useMemo(() => {
|
||||
const counts = {};
|
||||
templates.forEach(t => {
|
||||
counts[t.businessCategory] = (counts[t.businessCategory] || 0) + 1;
|
||||
});
|
||||
return counts;
|
||||
}, [templates]);
|
||||
|
||||
// ─── CRUD operations ────────────────────────────────────
|
||||
const handleUpdateCategory = useCallback(async (categoryId, updates) => {
|
||||
const result = await apiCall(`/api/admin/template-config/categories/${categoryId}`, 'PUT', updates);
|
||||
if (result.ok) { fetchMetadata(); refresh(); }
|
||||
else alert(result.error || 'Update failed');
|
||||
}, [apiCall, fetchMetadata, refresh]);
|
||||
|
||||
const handleDeleteCategory = useCallback(async (categoryId, name) => {
|
||||
if (!confirm(`Delete category "${name}"? This cannot be undone.`)) return;
|
||||
const result = await apiCall(`/api/admin/template-config/categories/${categoryId}`, 'DELETE');
|
||||
if (result.ok) {
|
||||
if (selectedCategory === name) setSelectedCategory(null);
|
||||
fetchMetadata(); refresh();
|
||||
}
|
||||
else alert(result.error || 'Delete failed');
|
||||
}, [apiCall, fetchMetadata, refresh, selectedCategory]);
|
||||
|
||||
const handleCreateCategory = useCallback(async () => {
|
||||
if (!newCatName.trim()) return;
|
||||
const result = await apiCall('/api/admin/template-config/categories', 'POST', {
|
||||
name: newCatName.trim().toLowerCase().replace(/\s+/g, '_'),
|
||||
icon: newCatIcon || '📋',
|
||||
});
|
||||
if (result.ok) {
|
||||
setNewCatName(''); setNewCatIcon(''); setShowNewCat(false);
|
||||
fetchMetadata();
|
||||
}
|
||||
else alert(result.error || 'Create failed');
|
||||
}, [apiCall, fetchMetadata, newCatName, newCatIcon]);
|
||||
|
||||
const handleUpdateObjective = useCallback(async (objectiveId, updates) => {
|
||||
const result = await apiCall(`/api/admin/template-config/objectives/${objectiveId}`, 'PUT', updates);
|
||||
if (result.ok) fetchMetadata();
|
||||
else alert(result.error || 'Update failed');
|
||||
}, [apiCall, fetchMetadata]);
|
||||
|
||||
const handleDeleteTemplate = useCallback(async (templateId) => {
|
||||
if (!confirm('Delete this allocation template?')) return;
|
||||
const result = await apiCall(`/api/admin/templates/${templateId}`, 'DELETE');
|
||||
if (result.ok) refresh();
|
||||
else alert(result.error || 'Delete failed');
|
||||
}, [apiCall, refresh]);
|
||||
|
||||
// ─── Memoized context value ─────────────────────────────
|
||||
const value = useMemo(() => ({
|
||||
// Data
|
||||
templates, categories, objectives, metaLoading,
|
||||
catIconMap, objColorMap, catCounts,
|
||||
filtered, grouped,
|
||||
|
||||
// Sidebar state
|
||||
selectedCategory, setSelectedCategory,
|
||||
selectedObjective, setSelectedObjective,
|
||||
editMode, setEditMode,
|
||||
|
||||
// Form state
|
||||
showForm, setShowForm,
|
||||
editingId, setEditingId,
|
||||
addingToGroup, setAddingToGroup,
|
||||
|
||||
// New category
|
||||
showNewCat, setShowNewCat,
|
||||
newCatName, setNewCatName,
|
||||
newCatIcon, setNewCatIcon,
|
||||
|
||||
// Actions
|
||||
fetchMetadata,
|
||||
handleUpdateCategory, handleDeleteCategory, handleCreateCategory,
|
||||
handleUpdateObjective, handleDeleteTemplate,
|
||||
}), [
|
||||
templates, categories, objectives, metaLoading,
|
||||
catIconMap, objColorMap, catCounts,
|
||||
filtered, grouped,
|
||||
selectedCategory, selectedObjective, editMode,
|
||||
showForm, editingId, addingToGroup,
|
||||
showNewCat, newCatName, newCatIcon,
|
||||
fetchMetadata,
|
||||
handleUpdateCategory, handleDeleteCategory, handleCreateCategory,
|
||||
handleUpdateObjective, handleDeleteTemplate,
|
||||
]);
|
||||
|
||||
return <TemplatesContext.Provider value={value}>{children}</TemplatesContext.Provider>;
|
||||
}
|
||||
158
Client-Client/src/context/WizardConfigContext.jsx
Normal file
158
Client-Client/src/context/WizardConfigContext.jsx
Normal file
@@ -0,0 +1,158 @@
|
||||
import { createContext, useContext, useState, useEffect } from 'react';
|
||||
import { useAuth } from '../auth/AuthProvider';
|
||||
import { getWizardConfig } from '../services/wizardApi';
|
||||
import { getAvailableChannels } from '../services/initiativeApi';
|
||||
|
||||
// ── Context ──
|
||||
const WizardConfigContext = createContext(null);
|
||||
export const useWizardConfig = () => useContext(WizardConfigContext);
|
||||
|
||||
// ── Fallbacks (used if API call fails — wizard is never blocked) ──
|
||||
const fallbackObjectives = [
|
||||
{ name: 'awareness', objectiveId: 1 },
|
||||
{ name: 'traffic', objectiveId: 2 },
|
||||
{ name: 'leads', objectiveId: 3 },
|
||||
{ name: 'conversions', objectiveId: 4 },
|
||||
{ name: 'sales', objectiveId: 5 },
|
||||
];
|
||||
|
||||
const fallbackCategories = [
|
||||
{ name: 'restaurant', categoryId: 1, icon: '🍽️' },
|
||||
{ name: 'retail', categoryId: 2, icon: '🛍️' },
|
||||
{ name: 'b2b services', categoryId: 3, icon: '💼' },
|
||||
{ name: 'local services', categoryId: 4, icon: '🏠' },
|
||||
{ name: 'health & wellness', categoryId: 5, icon: '🏥' },
|
||||
{ name: 'general', categoryId: 6, icon: '📦' },
|
||||
];
|
||||
|
||||
// ── Display label helper ──
|
||||
// DB stores lowercase names like "b2b services" — format for display
|
||||
export function toDisplayLabel(name) {
|
||||
if (!name) return '';
|
||||
return name
|
||||
.split(' ')
|
||||
.map(w => {
|
||||
if (w === '&') return '&';
|
||||
if (w.toLowerCase() === 'b2b') return 'B2B';
|
||||
return w.charAt(0).toUpperCase() + w.slice(1);
|
||||
})
|
||||
.join(' ');
|
||||
}
|
||||
|
||||
// ── Client-side enrichment maps ──
|
||||
// DB stores core data (name, color); client adds icons + descriptions
|
||||
// New objectives added via admin still appear, just without enrichment.
|
||||
export const objectiveDescriptions = {
|
||||
awareness: 'Reach new audiences and build recognition',
|
||||
traffic: 'Drive more visitors to your site',
|
||||
leads: 'Get contact form submissions and signups',
|
||||
conversions: 'Drive specific actions on your site',
|
||||
sales: 'Drive purchases and revenue',
|
||||
};
|
||||
|
||||
export const objectiveIcons = {
|
||||
awareness: '📣',
|
||||
traffic: '🌐',
|
||||
leads: '📋',
|
||||
conversions: '🎯',
|
||||
sales: '💰',
|
||||
};
|
||||
|
||||
// Labels for display contexts (ReviewStep, etc.)
|
||||
export const objectiveLabels = Object.fromEntries(
|
||||
['awareness', 'traffic', 'leads', 'conversions', 'sales']
|
||||
.map(k => [k, toDisplayLabel(k)])
|
||||
);
|
||||
|
||||
export const categoryLabels = Object.fromEntries(
|
||||
fallbackCategories.map(c => [c.name, toDisplayLabel(c.name)])
|
||||
);
|
||||
|
||||
// ── Fallback channels (used if API call fails) ──
|
||||
const fallbackChannels = [
|
||||
{ channelType: 'google_ads', displayName: 'Google Ads', description: 'Search, Display, Shopping & Performance Max', minMonthlyBudget: 300, color: '#4285F4', isStub: false },
|
||||
{ channelType: 'meta', displayName: 'Meta Ads', description: 'Facebook, Instagram, Messenger & Threads', minMonthlyBudget: 250, color: '#0668E1', isStub: true },
|
||||
{ channelType: 'tiktok', displayName: 'TikTok Ads', description: 'In-feed video ads across TikTok', minMonthlyBudget: 200, color: '#000000', isStub: true },
|
||||
];
|
||||
|
||||
// ── Channel lookup helpers ──
|
||||
// Use these in AllocationStep, ReviewStep, etc. instead of hardcoded maps.
|
||||
|
||||
export function getChannelLabel(channelType, channels) {
|
||||
if (!channelType) return 'Unknown';
|
||||
const ch = channels.find(c => c.channelType === channelType);
|
||||
return ch?.displayName || toDisplayLabel(channelType.replace(/_/g, ' '));
|
||||
}
|
||||
|
||||
export function getChannelColor(channelType, channels) {
|
||||
if (!channelType) return '#4F46E5';
|
||||
const ch = channels.find(c => c.channelType === channelType);
|
||||
return ch?.color || '#4F46E5';
|
||||
}
|
||||
|
||||
// ── Provider ──
|
||||
export default function WizardConfigProvider({ children }) {
|
||||
const { sessionToken } = useAuth();
|
||||
const [categories, setCategories] = useState([]);
|
||||
const [objectives, setObjectives] = useState([]);
|
||||
const [channels, setChannels] = useState([]);
|
||||
const [allocationRules, setAllocationRules] = useState(null);
|
||||
const [loading, setLoading] = useState(true);
|
||||
|
||||
useEffect(() => {
|
||||
let cancelled = false;
|
||||
|
||||
async function loadConfig() {
|
||||
setLoading(true);
|
||||
// Fetch wizard config (categories + objectives) and channels in parallel
|
||||
const [configRes, channelsRes] = await Promise.all([
|
||||
getWizardConfig(sessionToken),
|
||||
getAvailableChannels(sessionToken),
|
||||
]);
|
||||
if (cancelled) return;
|
||||
|
||||
if (configRes.ok && configRes.data) {
|
||||
const d = configRes.data;
|
||||
setCategories(Array.isArray(d.categories) ? d.categories : fallbackCategories);
|
||||
setObjectives(Array.isArray(d.objectives) ? d.objectives : fallbackObjectives);
|
||||
} else {
|
||||
console.warn('Wizard config load failed, using fallbacks:', configRes.error);
|
||||
setCategories(fallbackCategories);
|
||||
setObjectives(fallbackObjectives);
|
||||
}
|
||||
|
||||
if (channelsRes.ok && channelsRes.data) {
|
||||
setChannels(channelsRes.data.channels || fallbackChannels);
|
||||
setAllocationRules(channelsRes.data.allocation || null);
|
||||
} else {
|
||||
console.warn('Channels load failed, using fallbacks:', channelsRes.error);
|
||||
setChannels(fallbackChannels);
|
||||
}
|
||||
|
||||
setLoading(false);
|
||||
}
|
||||
|
||||
loadConfig();
|
||||
return () => { cancelled = true; };
|
||||
}, [sessionToken]);
|
||||
|
||||
const value = {
|
||||
categories,
|
||||
objectives,
|
||||
channels,
|
||||
allocationRules,
|
||||
loading,
|
||||
// Helpers exposed so consumers don't need separate imports
|
||||
toDisplayLabel,
|
||||
objectiveDescriptions,
|
||||
objectiveIcons,
|
||||
getChannelLabel: (type) => getChannelLabel(type, channels),
|
||||
getChannelColor: (type) => getChannelColor(type, channels),
|
||||
};
|
||||
|
||||
return (
|
||||
<WizardConfigContext.Provider value={value}>
|
||||
{children}
|
||||
</WizardConfigContext.Provider>
|
||||
);
|
||||
}
|
||||
7
Client-Client/src/context/index.js
Normal file
7
Client-Client/src/context/index.js
Normal file
@@ -0,0 +1,7 @@
|
||||
export { AdminProvider, useAdmin, CATEGORY_TABS, TAB_ENDPOINTS, CATEGORY_LABELS } from './AdminContext';
|
||||
export { TemplatesProvider, useTemplates, CHANNELS, CHANNEL_META } from './TemplatesContext';
|
||||
export {
|
||||
ObjectiveMappingsProvider,
|
||||
useObjectiveMappings,
|
||||
PROVIDER_OBJECTIVES,
|
||||
} from './ObjectiveMappingsContext';
|
||||
@@ -1,6 +1,18 @@
|
||||
import React from 'react';
|
||||
import { createRoot } from 'react-dom/client';
|
||||
import { AuthProvider } from './auth/AuthProvider';
|
||||
import App from './app/App';
|
||||
import './styles/app.css';
|
||||
import './styles/wizard.css';
|
||||
import './styles/wizard-launch.css';
|
||||
import './styles/forecast.css';
|
||||
import './styles/intelligence.css';
|
||||
|
||||
createRoot(document.getElementById('root')).render(<App />);
|
||||
const root = createRoot(document.getElementById('root'));
|
||||
root.render(
|
||||
<React.StrictMode>
|
||||
<AuthProvider>
|
||||
<App />
|
||||
</AuthProvider>
|
||||
</React.StrictMode>
|
||||
);
|
||||
|
||||
@@ -64,7 +64,7 @@ export async function callService(provider, operation, params = {}, options = {}
|
||||
const body = {
|
||||
provider,
|
||||
operation,
|
||||
...params
|
||||
payload: params
|
||||
};
|
||||
|
||||
console.log('[API] Request URL:', url);
|
||||
|
||||
48
Client-Client/src/services/forecastApi.js
Normal file
48
Client-Client/src/services/forecastApi.js
Normal file
@@ -0,0 +1,48 @@
|
||||
import { GATEWAY_URL } from '../auth/authConfig';
|
||||
|
||||
// ── Helpers (same pattern as wizardApi.js) ──
|
||||
|
||||
function authHeaders(sessionToken) {
|
||||
const h = { 'Content-Type': 'application/json' };
|
||||
if (sessionToken) h['Authorization'] = `Bearer ${sessionToken}`;
|
||||
const tid = localStorage.getItem('adplatform_tenantId');
|
||||
if (tid) h['X-Tenant-Id'] = tid;
|
||||
return h;
|
||||
}
|
||||
|
||||
async function gw(method, path, body, sessionToken) {
|
||||
try {
|
||||
const opts = { method, headers: authHeaders(sessionToken) };
|
||||
if (body && method !== 'GET') opts.body = JSON.stringify(body);
|
||||
const res = await fetch(`${GATEWAY_URL}${path}`, opts);
|
||||
const data = await res.json().catch(() => null);
|
||||
return {
|
||||
ok: res.ok && data?.ok !== false,
|
||||
status: res.status,
|
||||
data,
|
||||
error: res.ok ? null : (data?.error || data?.message || `HTTP ${res.status}`)
|
||||
};
|
||||
} catch (e) {
|
||||
return { ok: false, status: 0, data: null, error: e.message };
|
||||
}
|
||||
}
|
||||
|
||||
// ── Channel Forecast ──
|
||||
|
||||
/**
|
||||
* Get channel performance estimates for the wizard budget step.
|
||||
* Calls Gateway POST /api/forecast/channel-estimate.
|
||||
*
|
||||
* @param {object} params
|
||||
* @param {string} params.objective - awareness, traffic, leads, sales
|
||||
* @param {string} params.businessCategory
|
||||
* @param {string[]} params.keywords - from URL analysis (Step 1)
|
||||
* @param {object} params.geoTargeting - { zipCodes, radiusMiles, geoTargetIds }
|
||||
* @param {object} params.audience - { ageMin, ageMax, genders, interests }
|
||||
* @param {number} params.monthlyBudget
|
||||
* @param {string[]} params.channels - selected channels from Step 3
|
||||
* @param {string} sessionToken
|
||||
*/
|
||||
export function getChannelForecast(params, sessionToken) {
|
||||
return gw('POST', '/api/forecast/channel-estimate', params, sessionToken);
|
||||
}
|
||||
168
Client-Client/src/services/initiativeApi.js
Normal file
168
Client-Client/src/services/initiativeApi.js
Normal file
@@ -0,0 +1,168 @@
|
||||
import { GATEWAY_URL } from '../auth/authConfig';
|
||||
|
||||
// ── Helpers ──
|
||||
|
||||
function authHeaders(sessionToken) {
|
||||
const h = { 'Content-Type': 'application/json' };
|
||||
if (sessionToken) h['Authorization'] = `Bearer ${sessionToken}`;
|
||||
const tid = localStorage.getItem('adplatform_tenantId');
|
||||
if (tid) h['X-Tenant-Id'] = tid;
|
||||
return h;
|
||||
}
|
||||
|
||||
async function gw(method, path, body, sessionToken) {
|
||||
try {
|
||||
const opts = { method, headers: authHeaders(sessionToken) };
|
||||
if (body && method !== 'GET') opts.body = JSON.stringify(body);
|
||||
const res = await fetch(`${GATEWAY_URL}${path}`, opts);
|
||||
const data = await res.json().catch(() => null);
|
||||
return {
|
||||
ok: res.ok && data?.ok !== false,
|
||||
status: res.status,
|
||||
data,
|
||||
error: res.ok ? null : (data?.error || data?.message || `HTTP ${res.status}`)
|
||||
};
|
||||
} catch (e) {
|
||||
return { ok: false, status: 0, data: null, error: e.message };
|
||||
}
|
||||
}
|
||||
|
||||
function qs(params) {
|
||||
const sp = new URLSearchParams();
|
||||
Object.entries(params).forEach(([k, v]) => {
|
||||
if (v != null) sp.set(k, String(v));
|
||||
});
|
||||
const s = sp.toString();
|
||||
return s ? '?' + s : '';
|
||||
}
|
||||
|
||||
// ── Initiative CRUD ──
|
||||
|
||||
export function createInitiative(data, sessionToken) {
|
||||
return gw('POST', '/api/initiative', data, sessionToken);
|
||||
}
|
||||
|
||||
export function getInitiative(initiativeId, sessionToken) {
|
||||
return gw('GET', `/api/initiative/${initiativeId}`, null, sessionToken);
|
||||
}
|
||||
|
||||
export function listInitiatives(sessionToken, status, page, pageSize) {
|
||||
return gw('GET', `/api/initiative${qs({ status, page, pageSize })}`, null, sessionToken);
|
||||
}
|
||||
|
||||
export function updateInitiative(initiativeId, data, sessionToken) {
|
||||
return gw('PUT', `/api/initiative/${initiativeId}`, data, sessionToken);
|
||||
}
|
||||
|
||||
export function updateInitiativeStatus(initiativeId, status, sessionToken) {
|
||||
return gw('PATCH', `/api/initiative/${initiativeId}/status`, { status }, sessionToken);
|
||||
}
|
||||
|
||||
export function deleteInitiative(initiativeId, sessionToken) {
|
||||
return gw('DELETE', `/api/initiative/${initiativeId}`, null, sessionToken);
|
||||
}
|
||||
|
||||
// ── Launch / Dispatch ──
|
||||
|
||||
/**
|
||||
* Stage an initiative — saves to DB with "staged" status and returns
|
||||
* server-calculated billing breakdown (ad spend + management fee + total).
|
||||
* Call this before launch to populate the confirmation modal.
|
||||
*/
|
||||
export function stageInitiative(data, sessionToken) {
|
||||
return gw('POST', '/api/initiative/stage', data, sessionToken);
|
||||
}
|
||||
|
||||
/**
|
||||
* Get billing breakdown for an already-staged initiative.
|
||||
* Re-displays the modal without re-staging.
|
||||
*/
|
||||
export function getBilling(initiativeId, sessionToken) {
|
||||
return gw('GET', `/api/initiative/${initiativeId}/billing`, null, sessionToken);
|
||||
}
|
||||
|
||||
/**
|
||||
* Launch an initiative — dispatches each channel campaign to its provider service.
|
||||
* Call after user confirms the billing shown by stageInitiative.
|
||||
* Returns per-channel dispatch results.
|
||||
*/
|
||||
export function launchInitiative(initiativeId, sessionToken) {
|
||||
return gw('POST', `/api/initiative/${initiativeId}/launch`, {}, sessionToken);
|
||||
}
|
||||
|
||||
// ── Channel Campaigns ──
|
||||
|
||||
export function listChannelCampaigns(initiativeId, sessionToken) {
|
||||
return gw('GET', `/api/initiative/${initiativeId}/channels`, null, sessionToken);
|
||||
}
|
||||
|
||||
export function getChannelCampaign(channelCampaignId, sessionToken) {
|
||||
return gw('GET', `/api/initiative/channel/${channelCampaignId}`, null, sessionToken);
|
||||
}
|
||||
|
||||
/**
|
||||
* Sync channel campaign status from provider.
|
||||
* providerStatus is the raw provider value (e.g. "ENABLED", "DELIVERY_OK").
|
||||
* channelType identifies which mapping table to use (e.g. "google_ads", "meta").
|
||||
* The Gateway auto-normalizes providerStatus → platform status.
|
||||
*/
|
||||
export function syncChannelStatus(channelCampaignId, { channelType, providerStatus, status, externalCampaignId } = {}, sessionToken) {
|
||||
return gw('PATCH', `/api/initiative/channel/${channelCampaignId}/sync`, {
|
||||
channelType,
|
||||
providerStatus,
|
||||
status,
|
||||
externalCampaignId,
|
||||
}, sessionToken);
|
||||
}
|
||||
|
||||
// ── Status Normalization ──
|
||||
|
||||
/**
|
||||
* Get provider → platform status mappings for diagnostics/admin.
|
||||
* Pass channelType to get a specific channel, or omit for all channels.
|
||||
*/
|
||||
export function getStatusMappings(sessionToken, channelType) {
|
||||
return gw('GET', `/api/initiative/channels/status-mappings${qs({ channelType })}`, null, sessionToken);
|
||||
}
|
||||
|
||||
// ── Budget Allocation ──
|
||||
|
||||
export function getAllocation(initiativeId, sessionToken) {
|
||||
return gw('GET', `/api/initiative/${initiativeId}/allocation`, null, sessionToken);
|
||||
}
|
||||
|
||||
export function updateAllocation(initiativeId, allocations, reason, sessionToken) {
|
||||
return gw('PUT', `/api/initiative/${initiativeId}/allocation`, { allocations, reason }, sessionToken);
|
||||
}
|
||||
|
||||
export function getRecommendation(initiativeId, sessionToken, businessCategory, objective) {
|
||||
return gw('GET', `/api/initiative/${initiativeId}/allocation/recommend${qs({ businessCategory, objective })}`, null, sessionToken);
|
||||
}
|
||||
|
||||
export function applyAllocation(initiativeId, data, sessionToken) {
|
||||
return gw('POST', `/api/initiative/${initiativeId}/allocation/apply`, data, sessionToken);
|
||||
}
|
||||
|
||||
export function getAllocationHistory(initiativeId, sessionToken, limit) {
|
||||
return gw('GET', `/api/initiative/${initiativeId}/allocation/history${qs({ limit })}`, null, sessionToken);
|
||||
}
|
||||
|
||||
// ── Reference Data ──
|
||||
|
||||
export function getAvailableChannels(sessionToken) {
|
||||
return gw('GET', '/api/initiative/channels/available', null, sessionToken);
|
||||
}
|
||||
|
||||
export function getTemplates(sessionToken, businessCategory, objective) {
|
||||
return gw('GET', `/api/initiative/templates${qs({ businessCategory, objective })}`, null, sessionToken);
|
||||
}
|
||||
|
||||
// ── Performance Metrics ──
|
||||
|
||||
export function getMetricsSummary(initiativeId, sessionToken, fromDate, toDate) {
|
||||
return gw('GET', `/api/initiative/${initiativeId}/metrics${qs({ fromDate, toDate })}`, null, sessionToken);
|
||||
}
|
||||
|
||||
export function getMetricsComparison(initiativeId, sessionToken, lookbackDays) {
|
||||
return gw('GET', `/api/initiative/${initiativeId}/metrics/compare${qs({ lookbackDays })}`, null, sessionToken);
|
||||
}
|
||||
69
Client-Client/src/services/intelligenceApi.js
Normal file
69
Client-Client/src/services/intelligenceApi.js
Normal file
@@ -0,0 +1,69 @@
|
||||
import { GATEWAY_URL } from '../auth/authConfig';
|
||||
|
||||
// ── Helpers ──
|
||||
|
||||
function authHeaders(sessionToken) {
|
||||
const h = { 'Content-Type': 'application/json' };
|
||||
if (sessionToken) h['Authorization'] = `Bearer ${sessionToken}`;
|
||||
const tid = localStorage.getItem('adplatform_tenantId');
|
||||
if (tid) h['X-Tenant-Id'] = tid;
|
||||
return h;
|
||||
}
|
||||
|
||||
async function gw(method, path, body, sessionToken) {
|
||||
try {
|
||||
const opts = { method, headers: authHeaders(sessionToken) };
|
||||
if (body && method !== 'GET') opts.body = JSON.stringify(body);
|
||||
const res = await fetch(`${GATEWAY_URL}${path}`, opts);
|
||||
const data = await res.json().catch(() => null);
|
||||
return {
|
||||
ok: res.ok && data?.ok !== false,
|
||||
status: res.status,
|
||||
data,
|
||||
error: res.ok ? null : (data?.error || data?.message || `HTTP ${res.status}`)
|
||||
};
|
||||
} catch (e) {
|
||||
return { ok: false, status: 0, data: null, error: e.message };
|
||||
}
|
||||
}
|
||||
|
||||
function qs(params) {
|
||||
const sp = new URLSearchParams();
|
||||
Object.entries(params).forEach(([k, v]) => {
|
||||
if (v != null) sp.set(k, String(v));
|
||||
});
|
||||
const s = sp.toString();
|
||||
return s ? '?' + s : '';
|
||||
}
|
||||
|
||||
// ── Campaign Intelligence ──
|
||||
|
||||
export function getCampaignHealth(sessionToken) {
|
||||
return gw('GET', '/api/intelligence/health', null, sessionToken);
|
||||
}
|
||||
|
||||
export function getBudgetPacing(initiativeId, sessionToken) {
|
||||
return gw('GET', `/api/intelligence/${initiativeId}/pacing`, null, sessionToken);
|
||||
}
|
||||
|
||||
export function getPostCampaignReport(initiativeId, sessionToken) {
|
||||
return gw('GET', `/api/intelligence/${initiativeId}/report`, null, sessionToken);
|
||||
}
|
||||
|
||||
// ── Recommendations ──
|
||||
|
||||
export function getRecommendations(sessionToken, status, limit) {
|
||||
return gw('GET', `/api/recommendations${qs({ status, limit })}`, null, sessionToken);
|
||||
}
|
||||
|
||||
export function getInitiativeRecommendations(initiativeId, sessionToken, status) {
|
||||
return gw('GET', `/api/recommendations/initiative/${initiativeId}${qs({ status })}`, null, sessionToken);
|
||||
}
|
||||
|
||||
export function dismissRecommendation(recommendationId, sessionToken) {
|
||||
return gw('POST', `/api/recommendations/${recommendationId}/dismiss`, {}, sessionToken);
|
||||
}
|
||||
|
||||
export function resolveRecommendation(recommendationId, sessionToken) {
|
||||
return gw('POST', `/api/recommendations/${recommendationId}/resolve`, {}, sessionToken);
|
||||
}
|
||||
128
Client-Client/src/services/wizardApi.js
Normal file
128
Client-Client/src/services/wizardApi.js
Normal file
@@ -0,0 +1,128 @@
|
||||
import { GATEWAY_URL } from '../auth/authConfig';
|
||||
import { callService } from './apiClient';
|
||||
|
||||
// ── Helpers ──
|
||||
|
||||
function authHeaders(sessionToken) {
|
||||
const h = { 'Content-Type': 'application/json' };
|
||||
if (sessionToken) h['Authorization'] = `Bearer ${sessionToken}`;
|
||||
const tid = localStorage.getItem('adplatform_tenantId');
|
||||
if (tid) h['X-Tenant-Id'] = tid;
|
||||
return h;
|
||||
}
|
||||
|
||||
async function gw(method, path, body, sessionToken) {
|
||||
try {
|
||||
const opts = { method, headers: authHeaders(sessionToken) };
|
||||
if (body && method !== 'GET') opts.body = JSON.stringify(body);
|
||||
const res = await fetch(`${GATEWAY_URL}${path}`, opts);
|
||||
const data = await res.json().catch(() => null);
|
||||
return {
|
||||
ok: res.ok && data?.ok !== false,
|
||||
status: res.status,
|
||||
data,
|
||||
error: res.ok ? null : (data?.error || data?.message || `HTTP ${res.status}`)
|
||||
};
|
||||
} catch (e) {
|
||||
return { ok: false, status: 0, data: null, error: e.message };
|
||||
}
|
||||
}
|
||||
|
||||
// ── Wizard Config (DB-driven categories + objectives) ──
|
||||
|
||||
export function getWizardConfig(sessionToken) {
|
||||
return gw('GET', '/api/wizard/config', null, sessionToken);
|
||||
}
|
||||
|
||||
// ── Wizard CRUD ──
|
||||
|
||||
export function createWizard(name, url, sessionToken) {
|
||||
return gw('POST', '/api/wizard', { name, url }, sessionToken);
|
||||
}
|
||||
|
||||
export function getWizard(wizardId, sessionToken) {
|
||||
return gw('GET', `/api/wizard/${wizardId}`, null, sessionToken);
|
||||
}
|
||||
|
||||
export function listWizards(sessionToken, status, limit) {
|
||||
const qs = new URLSearchParams();
|
||||
if (status) qs.set('status', status);
|
||||
if (limit) qs.set('limit', String(limit));
|
||||
const q = qs.toString();
|
||||
return gw('GET', `/api/wizard${q ? '?' + q : ''}`, null, sessionToken);
|
||||
}
|
||||
|
||||
export function deleteWizard(wizardId, sessionToken, force = false) {
|
||||
return gw('DELETE', `/api/wizard/${wizardId}${force ? '?force=true' : ''}`, null, sessionToken);
|
||||
}
|
||||
|
||||
// ── Step Operations ──
|
||||
|
||||
export function updateStep(wizardId, step, data, sessionToken, name) {
|
||||
return gw('PUT', `/api/wizard/${wizardId}/step/${step}`, { data, name }, sessionToken);
|
||||
}
|
||||
|
||||
export function setStep(wizardId, step, sessionToken) {
|
||||
return gw('PATCH', `/api/wizard/${wizardId}/step/${step}`, null, sessionToken);
|
||||
}
|
||||
|
||||
// ── Review & Submit ──
|
||||
|
||||
export function getSummary(wizardId, sessionToken) {
|
||||
return gw('GET', `/api/wizard/${wizardId}/summary`, null, sessionToken);
|
||||
}
|
||||
|
||||
export function submitWizard(wizardId, sessionToken, network = 'google') {
|
||||
return gw('POST', `/api/wizard/${wizardId}/submit`, { network }, sessionToken);
|
||||
}
|
||||
|
||||
export function updateStatus(wizardId, status, sessionToken) {
|
||||
return gw('PATCH', `/api/wizard/${wizardId}/status`, { status }, sessionToken);
|
||||
}
|
||||
|
||||
// ── Creative Service Calls (via execution gateway) ──
|
||||
|
||||
export function analyzeUrl(url, sessionToken) {
|
||||
return callService('creative', 'AnalyzeUrl', { url }, { sessionToken });
|
||||
}
|
||||
|
||||
export function generateAssets(analysis, sessionToken) {
|
||||
return callService('creative', 'GenerateAssets', { analysis }, { sessionToken });
|
||||
}
|
||||
|
||||
export function getImages(analysis, sessionToken) {
|
||||
return callService('creative', 'GetImages', { analysis }, { sessionToken });
|
||||
}
|
||||
|
||||
export function createDraft(url, sessionToken) {
|
||||
return callService('creative', 'CreateDraft', { url }, { sessionToken });
|
||||
}
|
||||
|
||||
// ── Allocation Recommendation (audience-adjusted) ──
|
||||
|
||||
export function getAudienceRecommendation(businessCategory, objective, ageSkew, marketScope, sessionToken) {
|
||||
const body = { businessCategory, objective };
|
||||
if (ageSkew) body.ageSkew = ageSkew;
|
||||
if (marketScope) body.marketScope = marketScope;
|
||||
return gw('POST', '/api/wizard/recommend', body, sessionToken);
|
||||
}
|
||||
|
||||
// ── Audience Service Calls ──
|
||||
|
||||
export function getAudienceSegments(sessionToken) {
|
||||
return callService('google', 'GetAudienceSegments', {}, { sessionToken });
|
||||
}
|
||||
|
||||
export function searchGeoTargets(query, sessionToken, countryCode = 'US', maxResults = 10) {
|
||||
return callService('google', 'SearchGeoTargets', { query, countryCode, maxResults }, { sessionToken });
|
||||
}
|
||||
|
||||
// ── Census Demographics ──
|
||||
|
||||
export function getDemographics(zcta, sessionToken) {
|
||||
return gw('GET', `/api/demographics/${zcta}`, null, sessionToken);
|
||||
}
|
||||
|
||||
export function searchDemographics(criteria, sessionToken) {
|
||||
return gw('POST', '/api/demographics/search', criteria, sessionToken);
|
||||
}
|
||||
@@ -47,7 +47,8 @@ body{font-family:var(--font-sans);background:var(--color-bg);color:var(--color-t
|
||||
.landing-logo-icon svg{width:22px;height:22px}
|
||||
.landing-logo-text{font-size:20px;font-weight:700;color:var(--color-text)}
|
||||
.landing-hero{flex:1;display:flex;flex-direction:column;align-items:center;justify-content:center;text-align:center;padding:60px 40px}
|
||||
.landing-hero h1{font-size:42px;font-weight:700;color:var(--color-text);margin-bottom:16px;letter-spacing:-0.5px}
|
||||
.landing-hero h1{font-size:42px;font-weight:700;color:var(--color-text);margin-bottom:8px;letter-spacing:-0.5px}
|
||||
.landing-hero .landing-subtitle{font-size:24px;font-weight:400;color:var(--color-text-secondary);margin-bottom:16px;letter-spacing:-0.3px}
|
||||
.landing-hero p{font-size:18px;color:var(--color-text-secondary);max-width:520px;margin-bottom:40px}
|
||||
.landing-features{display:flex;gap:24px;margin-top:60px;max-width:800px}
|
||||
.feature-card{flex:1;padding:24px;background:var(--color-bg-elevated);border-radius:var(--radius-lg);border:1px solid var(--color-border);box-shadow:var(--shadow-sm)}
|
||||
@@ -72,7 +73,7 @@ body{font-family:var(--font-sans);background:var(--color-bg);color:var(--color-t
|
||||
@keyframes spin{to{transform:rotate(360deg)}}
|
||||
|
||||
/* Platform Layout */
|
||||
.platform{display:flex;min-height:100vh}
|
||||
.platform{display:flex;height:100vh;overflow:hidden}
|
||||
.platform.sidebar-collapsed .main-area{margin-left:var(--sidebar-collapsed)}
|
||||
|
||||
/* Sidebar */
|
||||
@@ -100,7 +101,7 @@ body{font-family:var(--font-sans);background:var(--color-bg);color:var(--color-t
|
||||
.sidebar-brand strong{color:var(--color-text-secondary)}
|
||||
|
||||
/* Main Area */
|
||||
.main-area{flex:1;margin-left:var(--sidebar-width);min-height:100%;display:flex;flex-direction:column;transition:margin-left var(--transition-normal)}
|
||||
.main-area{flex:1;margin-left:var(--sidebar-width);min-height:0;display:flex;flex-direction:column;transition:margin-left var(--transition-normal)}
|
||||
|
||||
/* Top Bar */
|
||||
.top-bar{height:var(--topbar-height);padding:0 32px;display:flex;align-items:center;justify-content:space-between;background:var(--color-bg-elevated);border-bottom:1px solid var(--color-border);position:sticky;top:0;z-index:50}
|
||||
@@ -123,7 +124,7 @@ body{font-family:var(--font-sans);background:var(--color-bg);color:var(--color-t
|
||||
.user-dropdown-divider{height:1px;background:var(--color-border-light);margin:6px 0}
|
||||
|
||||
/* Content Area */
|
||||
.content-area{flex:1;padding:32px}
|
||||
.content-area{flex:1;padding:32px;display:flex;flex-direction:column;min-height:0;overflow:auto}
|
||||
.view-header{display:flex;align-items:center;justify-content:space-between;margin-bottom:28px}
|
||||
.view-header h1{font-size:28px;font-weight:700;letter-spacing:-0.3px}
|
||||
.view-subtitle{font-size:14px;color:var(--color-text-muted);margin-top:4px}
|
||||
@@ -163,7 +164,63 @@ body{font-family:var(--font-sans);background:var(--color-bg);color:var(--color-t
|
||||
.status-badge{display:inline-flex;align-items:center;padding:3px 10px;border-radius:20px;font-size:12px;font-weight:600;text-transform:capitalize}
|
||||
.status-badge.status-enabled,.status-badge.status-active{background:var(--color-success-light);color:var(--color-success)}
|
||||
.status-badge.status-paused{background:var(--color-warning-light);color:var(--color-warning)}
|
||||
.status-badge.status-removed,.status-badge.status-disabled{background:var(--color-danger-light);color:var(--color-danger)}
|
||||
.status-badge.status-removed,.status-badge.status-disabled,.status-badge.status-cancelled{background:var(--color-danger-light);color:var(--color-danger)}
|
||||
.status-badge.status-draft{background:var(--color-bg-subtle);color:var(--color-text-muted)}
|
||||
.status-badge.status-in-review,.status-badge.status-scheduled,.status-badge.status-staged{background:var(--color-primary-light);color:var(--color-primary)}
|
||||
.status-badge.status-completed{background:var(--color-bg-subtle);color:var(--color-text-secondary)}
|
||||
.status-badge.status-action-needed,.status-badge.status-error{background:var(--color-danger-light);color:var(--color-danger)}
|
||||
.status-badge.status-pending{background:var(--color-primary-light);color:var(--color-primary)}
|
||||
|
||||
/* Campaign Cards */
|
||||
.campaign-cards{display:grid;grid-template-columns:repeat(auto-fill,minmax(340px,1fr));gap:16px}
|
||||
.campaign-card{background:var(--color-bg-elevated);border:1px solid var(--color-border);border-radius:var(--radius-lg);padding:20px;cursor:pointer;transition:border-color 0.15s,box-shadow 0.15s}
|
||||
.campaign-card:hover{border-color:var(--color-primary);box-shadow:0 0 0 1px var(--color-primary)}
|
||||
.campaign-card-header{display:flex;justify-content:space-between;align-items:flex-start;margin-bottom:12px}
|
||||
.campaign-card-title{font-size:15px;font-weight:600;color:var(--color-text);line-height:1.3;flex:1;margin-right:12px}
|
||||
.campaign-card-body{display:flex;flex-direction:column;gap:10px}
|
||||
.campaign-card-meta{display:flex;flex-wrap:wrap;gap:12px;font-size:13px;color:var(--color-text-muted)}
|
||||
.campaign-card-meta span{display:inline-flex;align-items:center;gap:4px}
|
||||
.campaign-card-meta svg{opacity:0.6}
|
||||
.campaign-card-channels{display:flex;gap:6px;flex-wrap:wrap}
|
||||
.channel-pill{display:inline-flex;align-items:center;gap:4px;padding:2px 8px;border-radius:12px;font-size:11px;font-weight:600;background:var(--color-bg-subtle);color:var(--color-text-secondary);text-transform:capitalize}
|
||||
.campaign-card-actions{display:flex;gap:8px;margin-top:12px;padding-top:12px;border-top:1px solid var(--color-border-light)}
|
||||
.campaign-card-actions .btn{font-size:12px;padding:5px 12px}
|
||||
|
||||
/* Filter Tabs */
|
||||
.filter-tabs{display:flex;gap:2px;padding:3px;background:var(--color-bg-subtle);border-radius:var(--radius-md);width:fit-content;border:1px solid var(--color-border-light)}
|
||||
.filter-tab{padding:7px 14px;border-radius:calc(var(--radius-md) - 2px);font-size:13px;font-weight:500;color:var(--color-text-muted);background:none;border:none;cursor:pointer;transition:all 0.15s;white-space:nowrap}
|
||||
.filter-tab:hover{color:var(--color-text-secondary);background:rgba(0,0,0,0.03)}
|
||||
.filter-tab.active{background:var(--color-bg-elevated);color:var(--color-text);box-shadow:0 1px 3px rgba(0,0,0,0.08),0 1px 2px rgba(0,0,0,0.04);font-weight:600}
|
||||
.filter-tab .tab-count{display:inline-flex;align-items:center;justify-content:center;margin-left:6px;min-width:18px;height:18px;padding:0 5px;border-radius:9px;font-size:11px;font-weight:600;background:var(--color-bg-muted);color:var(--color-text-muted)}
|
||||
.filter-tab.active .tab-count{background:var(--color-primary);color:white}
|
||||
|
||||
/* Search Box */
|
||||
.search-box{position:relative;margin-left:auto}
|
||||
.search-icon{position:absolute;left:12px;top:50%;transform:translateY(-50%);color:var(--color-text-muted);pointer-events:none;display:flex}
|
||||
.search-input{width:240px;padding:8px 12px 8px 36px;background:var(--color-bg-elevated);border:1px solid var(--color-border);border-radius:var(--radius-md);font-size:13px;font-family:var(--font-sans);color:var(--color-text);transition:border-color var(--transition-fast),box-shadow var(--transition-fast),width var(--transition-normal)}
|
||||
.search-input:focus{outline:none;border-color:var(--color-primary);box-shadow:0 0 0 3px rgba(37,99,235,0.1);width:300px}
|
||||
.search-input::placeholder{color:var(--color-text-muted)}
|
||||
.search-clear{position:absolute;right:8px;top:50%;transform:translateY(-50%);background:none;border:none;cursor:pointer;color:var(--color-text-muted);padding:2px;display:flex;align-items:center;border-radius:var(--radius-sm)}
|
||||
.search-clear:hover{color:var(--color-text-secondary);background:var(--color-bg-subtle)}
|
||||
|
||||
/* Draft Section */
|
||||
.drafts-section{margin-bottom:24px}
|
||||
.drafts-section h3{font-size:14px;font-weight:600;color:var(--color-text-secondary);margin-bottom:12px}
|
||||
.draft-cards{display:flex;gap:12px;overflow-x:auto;padding-bottom:4px}
|
||||
.draft-card{display:flex;align-items:center;gap:12px;padding:12px 16px;background:var(--color-bg-elevated);border:1px dashed var(--color-warning);border-radius:var(--radius-md);cursor:pointer;white-space:nowrap;min-width:220px;transition:border-color 0.15s,background 0.15s}
|
||||
.draft-card:hover{background:var(--color-warning-light);border-color:var(--color-warning)}
|
||||
.draft-icon{display:flex;align-items:center;justify-content:center;width:32px;height:32px;border-radius:var(--radius-sm);background:var(--color-warning-light);flex-shrink:0}
|
||||
.draft-info{flex:1;min-width:0}
|
||||
.draft-name{font-size:13px;font-weight:600;color:var(--color-text);overflow:hidden;text-overflow:ellipsis}
|
||||
.draft-meta{font-size:11px;color:var(--color-text-muted);margin-top:2px}
|
||||
.draft-actions{display:flex;gap:4px;flex-shrink:0}
|
||||
.draft-actions button{background:none;border:none;padding:4px;cursor:pointer;color:var(--color-text-muted);border-radius:var(--radius-sm);transition:background 0.15s,color 0.15s}
|
||||
.draft-actions button:hover{background:var(--color-bg-subtle);color:var(--color-text-secondary)}
|
||||
.draft-actions button.danger:hover{background:var(--color-danger-light);color:var(--color-danger)}
|
||||
|
||||
/* Campaigns Toolbar */
|
||||
.campaigns-toolbar{display:flex;align-items:center;gap:16px;margin-bottom:20px;flex-wrap:wrap}
|
||||
.campaigns-toolbar .filter-tabs{margin-bottom:0}
|
||||
|
||||
/* Detail Panel */
|
||||
.detail-panel{position:fixed;right:0;top:0;bottom:0;width:420px;background:var(--color-bg-elevated);border-left:1px solid var(--color-border);box-shadow:var(--shadow-lg);z-index:150;display:flex;flex-direction:column;animation:slideIn 0.25s ease}
|
||||
@@ -177,9 +234,46 @@ body{font-family:var(--font-sans);background:var(--color-bg);color:var(--color-t
|
||||
.detail-item{display:flex;flex-direction:column;gap:4px}
|
||||
.detail-label{font-size:12px;color:var(--color-text-muted)}
|
||||
.detail-value{font-size:14px;font-weight:500;color:var(--color-text)}
|
||||
.detail-panel-overlay{position:fixed;inset:0;background:rgba(0,0,0,0.3);z-index:140;animation:fadeIn 0.2s ease}
|
||||
.detail-actions{display:flex;gap:8px;flex-wrap:wrap}
|
||||
.detail-actions .btn{gap:6px}
|
||||
.channel-allocation-list{display:flex;flex-direction:column;gap:12px}
|
||||
.channel-alloc-row{display:flex;align-items:center;gap:10px}
|
||||
.channel-alloc-info{display:flex;flex-direction:column;min-width:100px}
|
||||
.channel-alloc-name{font-size:13px;font-weight:600;color:var(--color-text)}
|
||||
.channel-alloc-budget{font-size:11px;color:var(--color-text-muted)}
|
||||
.channel-alloc-bar-track{flex:1;height:8px;background:var(--color-bg-subtle);border-radius:4px;overflow:hidden}
|
||||
.channel-alloc-bar{height:100%;background:var(--color-primary);border-radius:4px;transition:width 0.3s ease}
|
||||
.channel-alloc-pct{font-size:12px;font-weight:600;color:var(--color-text-muted);min-width:36px;text-align:right}
|
||||
.detail-timeline{display:flex;flex-direction:column;gap:12px;padding-left:8px;border-left:2px solid var(--color-border-light)}
|
||||
.timeline-item{display:flex;align-items:center;gap:10px;font-size:13px;color:var(--color-text-secondary);position:relative;margin-left:-9px}
|
||||
.timeline-dot{width:10px;height:10px;border-radius:50%;flex-shrink:0}
|
||||
.timeline-dot.created{background:var(--color-text-muted)}
|
||||
.timeline-dot.launched{background:var(--color-primary)}
|
||||
.timeline-dot.completed{background:var(--color-success)}
|
||||
|
||||
/* Modal */
|
||||
.modal-overlay{position:fixed;inset:0;background:rgba(0,0,0,0.4);display:flex;align-items:center;justify-content:center;z-index:200;animation:fadeIn 0.15s ease}
|
||||
.modal-content{background:var(--color-bg-elevated);border-radius:var(--radius-xl);box-shadow:0 20px 60px rgba(0,0,0,0.15);max-width:420px;width:90%;animation:scaleIn 0.2s ease}
|
||||
@keyframes scaleIn{from{opacity:0;transform:scale(0.95)}to{opacity:1;transform:scale(1)}}
|
||||
|
||||
/* Launch Modal */
|
||||
.launch-modal{padding:0;overflow:hidden}
|
||||
.launch-modal-header{padding:28px 28px 20px;text-align:center}
|
||||
.launch-modal-icon{display:inline-flex;align-items:center;justify-content:center;width:48px;height:48px;border-radius:50%;background:var(--color-primary-light);color:var(--color-primary);margin-bottom:12px}
|
||||
.launch-modal-icon svg{width:22px;height:22px}
|
||||
.launch-modal-header h3{font-size:18px;font-weight:700;color:var(--color-text);margin-bottom:4px}
|
||||
.launch-modal-header p{font-size:14px;color:var(--color-text-muted);line-height:1.4}
|
||||
.launch-modal-header p strong{color:var(--color-text)}
|
||||
|
||||
.launch-modal-billing{padding:0 28px 20px;display:flex;flex-direction:column;gap:10px}
|
||||
.billing-row{display:flex;justify-content:space-between;align-items:center;font-size:14px;color:var(--color-text-secondary);padding:6px 0}
|
||||
.billing-row span:last-child{font-weight:600;font-family:var(--font-mono);font-size:13px}
|
||||
.billing-total{border-top:1px solid var(--color-border);padding-top:12px;margin-top:4px;font-weight:600;color:var(--color-text);font-size:15px}
|
||||
.billing-total span:last-child{color:var(--color-primary);font-size:15px}
|
||||
|
||||
.launch-modal-actions{display:flex;justify-content:flex-end;gap:10px;padding:16px 28px;background:var(--color-bg-subtle);border-top:1px solid var(--color-border-light)}
|
||||
.btn-launch{gap:6px}
|
||||
@keyframes fadeIn{from{opacity:0}to{opacity:1}}
|
||||
.modal{width:520px;max-height:90vh;background:var(--color-bg-elevated);border-radius:var(--radius-xl);box-shadow:var(--shadow-lg);display:flex;flex-direction:column;animation:scaleIn 0.2s ease}
|
||||
@keyframes scaleIn{from{transform:scale(0.95);opacity:0}to{transform:scale(1);opacity:1}}
|
||||
@@ -211,6 +305,8 @@ body{font-family:var(--font-sans);background:var(--color-bg);color:var(--color-t
|
||||
.btn-danger{background:var(--color-danger);color:white;border-color:var(--color-danger)}
|
||||
.btn-danger:hover:not(:disabled){background:#b91c1c}
|
||||
.btn-sm{padding:6px 14px;font-size:13px}
|
||||
.btn-ghost{background:none;border:none;color:var(--color-text-muted);cursor:pointer;padding:6px 12px;border-radius:var(--radius-sm);transition:all 0.15s}
|
||||
.btn-ghost:hover{background:var(--color-bg-subtle);color:var(--color-text)}
|
||||
.btn-lg{padding:14px 28px;font-size:16px}
|
||||
.btn-icon{padding:8px}
|
||||
.btn-spinner{display:inline-block;width:14px;height:14px;border:2px solid rgba(255,255,255,0.3);border-top-color:white;border-radius:50%;animation:spin 0.6s linear infinite}
|
||||
@@ -261,6 +357,517 @@ body{font-family:var(--font-sans);background:var(--color-bg);color:var(--color-t
|
||||
.loading-placeholder{padding:16px 0}
|
||||
.loading-placeholder.padded{padding:20px}
|
||||
|
||||
/* Detail panel notices */
|
||||
.detail-notice{display:flex;gap:12px;padding:14px 16px;border-radius:var(--radius-md);margin-bottom:16px;font-size:13px;line-height:1.5}
|
||||
.detail-notice.notice-info{background:var(--color-primary-light);border:1px solid rgba(37,99,235,0.15)}
|
||||
.detail-notice .notice-icon{flex-shrink:0;margin-top:1px;color:var(--color-primary)}
|
||||
.detail-notice strong{display:block;font-weight:600;color:var(--color-text);margin-bottom:2px}
|
||||
.detail-notice p{color:var(--color-text-secondary);margin:0}
|
||||
|
||||
/* Small status badge (for channel rows) */
|
||||
.status-badge-sm{display:inline-flex;align-items:center;padding:1px 8px;border-radius:10px;font-size:11px;font-weight:600}
|
||||
.status-badge-sm.status-active,.status-badge-sm.status-enabled{background:var(--color-success-light);color:var(--color-success)}
|
||||
.status-badge-sm.status-in-review,.status-badge-sm.status-pending,.status-badge-sm.status-staged{background:var(--color-primary-light);color:var(--color-primary)}
|
||||
.status-badge-sm.status-paused{background:var(--color-warning-light);color:var(--color-warning)}
|
||||
.status-badge-sm.status-error,.status-badge-sm.status-cancelled{background:var(--color-danger-light);color:var(--color-danger)}
|
||||
|
||||
/* Danger outline button */
|
||||
.btn-danger-outline{color:var(--color-danger);border-color:var(--color-danger)}
|
||||
.btn-danger-outline:hover{background:var(--color-danger-light)}
|
||||
|
||||
/* Timeline dot: submitted */
|
||||
.timeline-dot.submitted{background:var(--color-primary)}
|
||||
|
||||
/* Responsive */
|
||||
@media(max-width:900px){.settings-layout{flex-direction:column}.settings-nav{width:100%;display:flex;gap:8px;overflow-x:auto}.landing-features{flex-direction:column}}
|
||||
@media(max-width:768px){.content-area{padding:20px}.view-header{flex-direction:column;gap:16px}.detail-panel{width:100%}.sidebar{transform:translateX(-100%)}.sidebar.open{transform:translateX(0)}.main-area{margin-left:0}}
|
||||
/* ============================================================
|
||||
Contextual Help System
|
||||
Append to: admin/src/styles/app.css
|
||||
client/src/styles/app.css (update --accent vars as needed)
|
||||
============================================================ */
|
||||
|
||||
/* ── Help Trigger (floating icon + label) ────────────────────*/
|
||||
|
||||
.help-trigger {
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
gap: 5px;
|
||||
background: none;
|
||||
border: 1px solid transparent;
|
||||
border-radius: 20px;
|
||||
padding: 3px 8px 3px 4px;
|
||||
cursor: pointer;
|
||||
color: var(--accent, #3b82f6);
|
||||
font-size: 12px;
|
||||
font-family: inherit;
|
||||
font-weight: 500;
|
||||
transition: background 0.15s, border-color 0.15s, color 0.15s;
|
||||
white-space: nowrap;
|
||||
line-height: 1;
|
||||
}
|
||||
|
||||
.help-trigger:hover {
|
||||
background: var(--accent-light, rgba(59,130,246,0.08));
|
||||
border-color: var(--accent, #3b82f6);
|
||||
color: var(--accent-hover, #2563eb);
|
||||
}
|
||||
|
||||
.help-trigger-icon {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
width: 20px;
|
||||
height: 20px;
|
||||
background: var(--accent-light, rgba(59,130,246,0.1));
|
||||
border-radius: 50%;
|
||||
flex-shrink: 0;
|
||||
color: var(--accent, #3b82f6);
|
||||
}
|
||||
|
||||
.help-trigger-label {
|
||||
color: var(--accent, #3b82f6);
|
||||
font-size: 12px;
|
||||
}
|
||||
|
||||
|
||||
/* ── Help Modal ───────────────────────────────────────────────*/
|
||||
|
||||
.help-overlay {
|
||||
position: fixed;
|
||||
inset: 0;
|
||||
background: rgba(0, 0, 0, 0.35);
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
z-index: 9999;
|
||||
padding: 20px;
|
||||
animation: helpFadeIn 0.15s ease;
|
||||
}
|
||||
|
||||
@keyframes helpFadeIn {
|
||||
from { opacity: 0; }
|
||||
to { opacity: 1; }
|
||||
}
|
||||
|
||||
.help-modal {
|
||||
background: var(--surface, #fff);
|
||||
border-radius: 12px;
|
||||
box-shadow: 0 20px 60px rgba(0,0,0,0.18), 0 4px 12px rgba(0,0,0,0.1);
|
||||
width: 100%;
|
||||
max-width: 520px;
|
||||
max-height: 80vh;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
overflow: hidden;
|
||||
animation: helpSlideUp 0.2s cubic-bezier(0.34,1.56,0.64,1);
|
||||
}
|
||||
|
||||
@keyframes helpSlideUp {
|
||||
from { transform: translateY(16px) scale(0.97); opacity: 0; }
|
||||
to { transform: translateY(0) scale(1); opacity: 1; }
|
||||
}
|
||||
|
||||
.help-modal-header {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: space-between;
|
||||
padding: 16px 20px 14px;
|
||||
border-bottom: 1px solid var(--border, rgba(0,0,0,0.09));
|
||||
flex-shrink: 0;
|
||||
}
|
||||
|
||||
.help-modal-title-row {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 10px;
|
||||
}
|
||||
|
||||
.help-modal-icon {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
width: 30px;
|
||||
height: 30px;
|
||||
background: var(--accent-light, rgba(59,130,246,0.1));
|
||||
border-radius: 8px;
|
||||
color: var(--accent, #3b82f6);
|
||||
flex-shrink: 0;
|
||||
}
|
||||
|
||||
.help-modal-title {
|
||||
font-size: 15px;
|
||||
font-weight: 600;
|
||||
color: var(--text, #1a1d23);
|
||||
margin: 0;
|
||||
}
|
||||
|
||||
.help-modal-close {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
width: 28px;
|
||||
height: 28px;
|
||||
background: none;
|
||||
border: none;
|
||||
border-radius: 6px;
|
||||
cursor: pointer;
|
||||
color: var(--text-dim, #9099a4);
|
||||
transition: background 0.12s, color 0.12s;
|
||||
flex-shrink: 0;
|
||||
}
|
||||
.help-modal-close:hover {
|
||||
background: var(--bg, #f4f5f7);
|
||||
color: var(--text, #1a1d23);
|
||||
}
|
||||
|
||||
.help-modal-body {
|
||||
flex: 1;
|
||||
overflow-y: auto;
|
||||
padding: 20px;
|
||||
}
|
||||
|
||||
.help-loading {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 10px;
|
||||
color: var(--text-dim, #9099a4);
|
||||
font-size: 14px;
|
||||
padding: 8px 0;
|
||||
}
|
||||
|
||||
.help-spinner {
|
||||
width: 18px;
|
||||
height: 18px;
|
||||
border: 2px solid var(--border, #e5e7eb);
|
||||
border-top-color: var(--accent, #3b82f6);
|
||||
border-radius: 50%;
|
||||
animation: spin 0.7s linear infinite;
|
||||
}
|
||||
|
||||
.help-error {
|
||||
color: var(--danger, #dc2626);
|
||||
font-size: 14px;
|
||||
background: var(--danger-bg, #fef2f2);
|
||||
padding: 10px 14px;
|
||||
border-radius: 8px;
|
||||
margin: 0;
|
||||
}
|
||||
|
||||
/* Rendered HTML help content */
|
||||
.help-content {
|
||||
font-size: 14px;
|
||||
line-height: 1.65;
|
||||
color: var(--text, #1a1d23);
|
||||
}
|
||||
.help-content p { margin: 0 0 12px; }
|
||||
.help-content p:last-child { margin-bottom: 0; }
|
||||
.help-content h4 { font-size: 13px; font-weight: 600; margin: 16px 0 6px; color: var(--text-secondary, #374151); text-transform: uppercase; letter-spacing: 0.3px; }
|
||||
.help-content ul, .help-content ol { margin: 0 0 12px 20px; }
|
||||
.help-content li { margin-bottom: 5px; }
|
||||
.help-content strong { font-weight: 600; }
|
||||
.help-content a { color: var(--accent, #3b82f6); text-decoration: none; }
|
||||
.help-content a:hover { text-decoration: underline; }
|
||||
|
||||
.help-modal-footer {
|
||||
padding: 10px 20px;
|
||||
border-top: 1px solid var(--border, rgba(0,0,0,0.06));
|
||||
flex-shrink: 0;
|
||||
background: var(--bg, #f4f5f7);
|
||||
}
|
||||
|
||||
.help-key-badge {
|
||||
font-family: var(--font-mono, monospace);
|
||||
font-size: 11px;
|
||||
color: var(--text-dim, #9099a4);
|
||||
}
|
||||
|
||||
|
||||
/* ── HelpPanel (Admin) ────────────────────────────────────────*/
|
||||
|
||||
.help-panel {
|
||||
padding: 24px;
|
||||
max-width: 1000px;
|
||||
position: relative;
|
||||
}
|
||||
|
||||
.help-panel-toast {
|
||||
position: fixed;
|
||||
bottom: 24px;
|
||||
right: 24px;
|
||||
background: var(--success, #16a34a);
|
||||
color: #fff;
|
||||
padding: 10px 18px;
|
||||
border-radius: 8px;
|
||||
font-size: 13px;
|
||||
font-weight: 500;
|
||||
box-shadow: 0 4px 12px rgba(0,0,0,0.15);
|
||||
z-index: 1000;
|
||||
animation: helpFadeIn 0.2s ease;
|
||||
}
|
||||
.help-panel-toast.help-panel-toast-error {
|
||||
background: var(--danger, #dc2626);
|
||||
}
|
||||
|
||||
.help-edit-card {
|
||||
background: var(--surface, #fff);
|
||||
border: 1px solid var(--accent, #3b82f6);
|
||||
border-radius: 10px;
|
||||
padding: 20px;
|
||||
margin-bottom: 24px;
|
||||
box-shadow: 0 0 0 4px var(--accent-light, rgba(59,130,246,0.08));
|
||||
}
|
||||
|
||||
.help-edit-header {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: space-between;
|
||||
margin-bottom: 18px;
|
||||
}
|
||||
.help-edit-header h3 {
|
||||
font-size: 14px;
|
||||
font-weight: 600;
|
||||
color: var(--text, #1a1d23);
|
||||
margin: 0;
|
||||
}
|
||||
|
||||
.help-form-row {
|
||||
margin-bottom: 14px;
|
||||
}
|
||||
.help-form-row-inline {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: space-between;
|
||||
}
|
||||
|
||||
.help-form-label {
|
||||
display: flex;
|
||||
align-items: baseline;
|
||||
gap: 8px;
|
||||
font-size: 12px;
|
||||
font-weight: 600;
|
||||
color: var(--text-secondary, #5f6672);
|
||||
text-transform: uppercase;
|
||||
letter-spacing: 0.3px;
|
||||
margin-bottom: 6px;
|
||||
}
|
||||
|
||||
.help-form-hint {
|
||||
font-weight: 400;
|
||||
font-size: 11px;
|
||||
color: var(--text-dim, #9099a4);
|
||||
text-transform: none;
|
||||
letter-spacing: 0;
|
||||
}
|
||||
|
||||
.help-key-suggestions {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
flex-wrap: wrap;
|
||||
gap: 6px;
|
||||
margin-top: 6px;
|
||||
}
|
||||
|
||||
.help-key-chip {
|
||||
background: var(--accent-light, rgba(59,130,246,0.08));
|
||||
border: 1px solid var(--accent, #3b82f6);
|
||||
color: var(--accent, #3b82f6);
|
||||
border-radius: 4px;
|
||||
padding: 2px 8px;
|
||||
font-size: 11px;
|
||||
font-family: var(--font-mono, monospace);
|
||||
cursor: pointer;
|
||||
transition: background 0.12s;
|
||||
}
|
||||
.help-key-chip:hover {
|
||||
background: var(--accent-light, rgba(59,130,246,0.16));
|
||||
}
|
||||
|
||||
.help-key-display {
|
||||
font-family: var(--font-mono, monospace);
|
||||
font-size: 13px;
|
||||
color: var(--text, #1a1d23);
|
||||
background: var(--bg, #f4f5f7);
|
||||
border: 1px solid var(--border, rgba(0,0,0,0.09));
|
||||
border-radius: 6px;
|
||||
padding: 7px 10px;
|
||||
}
|
||||
|
||||
.help-body-textarea {
|
||||
resize: vertical;
|
||||
min-height: 160px;
|
||||
}
|
||||
|
||||
/* Table extras */
|
||||
.help-table-wrap {
|
||||
background: var(--surface, #fff);
|
||||
border: 1px solid var(--border, rgba(0,0,0,0.09));
|
||||
border-radius: 10px;
|
||||
overflow: hidden;
|
||||
}
|
||||
|
||||
.help-row-inactive {
|
||||
opacity: 0.5;
|
||||
}
|
||||
|
||||
.help-key-code {
|
||||
font-family: var(--font-mono, monospace);
|
||||
font-size: 12px;
|
||||
background: var(--bg, #f4f5f7);
|
||||
padding: 2px 6px;
|
||||
border-radius: 4px;
|
||||
color: var(--text, #1a1d23);
|
||||
}
|
||||
|
||||
.toggle-label {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 6px;
|
||||
font-size: 13px;
|
||||
color: var(--text-secondary, #5f6672);
|
||||
cursor: pointer;
|
||||
user-select: none;
|
||||
}
|
||||
|
||||
/* ── Step intro header with HelpIcon aligned right ── */
|
||||
.step-intro-header {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: space-between;
|
||||
gap: 12px;
|
||||
margin-bottom: 4px;
|
||||
}
|
||||
.step-intro-header h2 {
|
||||
margin: 0;
|
||||
}
|
||||
|
||||
/* ── Client portal additions (new panels/layout) ── */
|
||||
|
||||
/* Layout */
|
||||
.dashboard-layout{display:flex;height:100vh;overflow:hidden}
|
||||
.dashboard-main{flex:1;margin-left:var(--sidebar-width);min-height:0;display:flex;flex-direction:column;transition:margin-left 0.25s ease;overflow:hidden}
|
||||
.sidebar-collapsed .dashboard-main{margin-left:72px}
|
||||
.sidebar-collapsed .sidebar{width:72px}
|
||||
.dashboard-header{padding:20px 32px 0;background:var(--color-bg-elevated);border-bottom:1px solid var(--color-border);flex-shrink:0}
|
||||
.dashboard-header-top{display:flex;align-items:center;justify-content:space-between;margin-bottom:16px}
|
||||
.dashboard-title{font-size:22px;font-weight:700;color:var(--color-text)}
|
||||
.dashboard-tabs{display:flex;gap:4px;padding-bottom:0}
|
||||
.dashboard-meta{display:flex;gap:16px;font-size:13px;color:var(--color-text-muted)}
|
||||
.dashboard-meta span{display:flex;gap:4px;align-items:center}
|
||||
.dashboard-content{flex:1;overflow-y:auto;padding:28px 32px}
|
||||
.dashboard-header-right{display:flex;align-items:center;gap:12px}
|
||||
|
||||
/* Tab buttons */
|
||||
.tab-btn{padding:8px 16px;border:none;background:none;font-size:14px;font-weight:500;color:var(--color-text-muted);cursor:pointer;border-bottom:2px solid transparent;transition:all 0.15s;white-space:nowrap}
|
||||
.tab-btn:hover{color:var(--color-text)}
|
||||
.tab-btn.active{color:var(--color-primary);border-bottom-color:var(--color-primary)}
|
||||
|
||||
/* Sidebar additions */
|
||||
.sidebar-logo{display:flex;align-items:center;gap:10px;padding:20px;border-bottom:1px solid var(--color-border-light)}
|
||||
.sidebar-logo-icon{font-size:20px;color:var(--color-primary)}
|
||||
.sidebar-logo-text{font-size:16px;font-weight:700;color:var(--color-text)}
|
||||
.sidebar-logo-badge{font-size:10px;font-weight:600;background:var(--color-primary-light);color:var(--color-primary);padding:2px 6px;border-radius:4px;text-transform:uppercase;letter-spacing:0.5px}
|
||||
.sidebar-item{display:flex;align-items:center;gap:12px;width:100%;padding:10px 12px;border:none;background:none;cursor:pointer;border-radius:var(--radius-md);font-size:14px;font-weight:500;color:var(--color-text-secondary);text-align:left;transition:all 0.15s;margin-bottom:2px}
|
||||
.sidebar-item:hover{background:var(--color-bg-subtle);color:var(--color-text)}
|
||||
.sidebar-item-active{background:var(--color-primary-light)!important;color:var(--color-primary)!important}
|
||||
.sidebar-item-disabled{opacity:0.4;cursor:not-allowed}
|
||||
.sidebar-item-icon{display:flex;align-items:center;justify-content:center;flex-shrink:0;width:22px;color:inherit}
|
||||
.sidebar-item-label{flex:1}
|
||||
.sidebar-soon{font-size:10px;font-weight:600;background:#f1f5f9;color:#94a3b8;padding:2px 6px;border-radius:10px}
|
||||
.sidebar-divider{height:1px;background:var(--color-border-light);margin:8px 12px}
|
||||
.sidebar-footer{padding:16px 12px;border-top:1px solid var(--color-border-light);margin-top:auto}
|
||||
.sidebar-user{display:flex;align-items:center;gap:10px;padding:8px 4px;margin-bottom:8px}
|
||||
.sidebar-user-avatar{width:32px;height:32px;border-radius:50%;background:var(--color-primary);color:#fff;display:flex;align-items:center;justify-content:center;font-size:13px;font-weight:700;flex-shrink:0}
|
||||
.sidebar-user-info{flex:1;min-width:0}
|
||||
.sidebar-user-name{display:block;font-size:13px;font-weight:500;color:var(--color-text);white-space:nowrap;overflow:hidden;text-overflow:ellipsis}
|
||||
.sidebar-user-signout{background:none;border:none;font-size:12px;color:var(--color-text-muted);cursor:pointer;padding:0}
|
||||
.sidebar-user-signout:hover{color:var(--color-danger)}
|
||||
.sidebar-user-collapsed{display:flex;justify-content:center;margin-bottom:8px}
|
||||
.sidebar-collapse-btn{display:flex;align-items:center;gap:8px;width:100%;padding:8px 12px;border:none;background:none;cursor:pointer;border-radius:var(--radius-md);font-size:13px;color:var(--color-text-muted);transition:all 0.15s}
|
||||
.sidebar-collapse-btn:hover{background:var(--color-bg-subtle);color:var(--color-text)}
|
||||
.sidebar-collapse-icon{display:flex;transition:transform 0.2s}
|
||||
.sidebar-collapse-icon-flip{transform:rotate(180deg)}
|
||||
.sidebar-badge{font-size:11px;font-weight:600;background:var(--color-primary);color:#fff;padding:2px 7px;border-radius:10px;min-width:18px;text-align:center}
|
||||
|
||||
/* Buttons */
|
||||
.btn-action{display:inline-flex;align-items:center;gap:6px;padding:8px 16px;background:var(--color-primary);color:#fff;border:none;border-radius:var(--radius-md);font-size:14px;font-weight:500;cursor:pointer;transition:background 0.15s;white-space:nowrap}
|
||||
.btn-action:hover{background:var(--color-primary-hover)}
|
||||
.btn-action:disabled{opacity:0.5;cursor:not-allowed}
|
||||
.btn-cancel{display:inline-flex;align-items:center;gap:6px;padding:8px 16px;background:#fff;color:var(--color-text-secondary);border:1px solid var(--color-border);border-radius:var(--radius-md);font-size:14px;font-weight:500;cursor:pointer;transition:all 0.15s}
|
||||
.btn-cancel:hover{background:var(--color-bg-subtle)}
|
||||
.btn-cancel:disabled{opacity:0.5;cursor:not-allowed}
|
||||
.btn-icon{display:inline-flex;align-items:center;justify-content:center;width:30px;height:30px;border:1px solid var(--color-border);background:#fff;border-radius:var(--radius-sm);font-size:14px;cursor:pointer;transition:all 0.15s;color:var(--color-text-secondary)}
|
||||
.btn-icon:hover{background:var(--color-bg-subtle)}
|
||||
.btn-icon:disabled{opacity:0.4;cursor:not-allowed}
|
||||
.btn-icon-danger:hover{background:var(--color-danger-light);border-color:#fca5a5;color:var(--color-danger)}
|
||||
.btn-signin{width:100%;padding:12px 20px;background:var(--color-primary);color:#fff;border:none;border-radius:var(--radius-md);font-size:15px;font-weight:600;cursor:pointer;transition:background 0.15s}
|
||||
.btn-signin:hover{background:var(--color-primary-dark)}
|
||||
.btn-signout{padding:6px 12px;border:1px solid var(--color-border);background:#fff;border-radius:var(--radius-md);font-size:13px;cursor:pointer;color:var(--color-text-secondary);transition:all 0.15s}
|
||||
.btn-signout:hover{background:var(--color-danger-light);border-color:var(--color-danger);color:var(--color-danger)}
|
||||
|
||||
/* Form elements */
|
||||
.form-group{margin-bottom:16px}
|
||||
.form-label{display:block;font-size:13px;font-weight:600;color:var(--color-text-secondary);margin-bottom:6px}
|
||||
.form-input{width:100%;padding:9px 12px;border:1px solid var(--color-border);border-radius:var(--radius-md);font-size:14px;font-family:inherit;color:var(--color-text);background:#fff;transition:border-color 0.15s;outline:none}
|
||||
.form-input:focus{border-color:var(--color-primary);box-shadow:0 0 0 3px rgba(37,99,235,0.1)}
|
||||
.form-select{width:100%;padding:9px 12px;border:1px solid var(--color-border);border-radius:var(--radius-md);font-size:14px;font-family:inherit;color:var(--color-text);background:#fff;cursor:pointer;outline:none}
|
||||
.form-select:focus{border-color:var(--color-primary)}
|
||||
.form-title{font-size:15px;font-weight:600;color:var(--color-text);margin-bottom:16px}
|
||||
.form-buttons{display:flex;gap:10px;margin-top:16px}
|
||||
.admin-form{background:#f8fafc;border:1px solid var(--color-border);border-radius:var(--radius-lg);padding:20px}
|
||||
|
||||
/* Data panel */
|
||||
.data-panel{background:transparent}
|
||||
.panel-header{display:flex;align-items:center;justify-content:space-between;margin-bottom:20px}
|
||||
.panel-title{font-size:18px;font-weight:700;color:var(--color-text);margin:0}
|
||||
.panel-subtitle{font-size:13px;color:var(--color-text-muted);margin:4px 0 0}
|
||||
|
||||
/* Messages */
|
||||
.loading-message{padding:40px;text-align:center;color:var(--color-text-muted);font-size:14px}
|
||||
.error-message{padding:12px 16px;background:var(--color-danger-light);border:1px solid #fca5a5;border-radius:var(--radius-md);color:var(--color-danger);font-size:14px}
|
||||
.loading-container{display:flex;flex-direction:column;align-items:center;justify-content:center;padding:60px;gap:16px;color:var(--color-text-muted)}
|
||||
|
||||
/* Spinner */
|
||||
.spinner{width:28px;height:28px;border:3px solid var(--color-border);border-top-color:var(--color-primary);border-radius:50%;animation:spin 0.8s linear infinite}
|
||||
@keyframes spin{to{transform:rotate(360deg)}}
|
||||
|
||||
/* Modals */
|
||||
.modal-overlay{position:fixed;inset:0;background:rgba(0,0,0,0.4);z-index:200;display:flex;align-items:center;justify-content:center;padding:20px}
|
||||
.modal-box{background:#fff;border-radius:var(--radius-xl);padding:28px;max-width:440px;width:100%;box-shadow:var(--shadow-lg)}
|
||||
.modal-title{font-size:17px;font-weight:700;color:var(--color-text);margin:0 0 10px}
|
||||
.modal-body{font-size:14px;color:var(--color-text-secondary);line-height:1.6;margin:0 0 20px}
|
||||
.modal-buttons{display:flex;gap:10px;justify-content:flex-end}
|
||||
|
||||
/* Placeholders */
|
||||
.placeholder-panel{display:flex;flex-direction:column;align-items:center;justify-content:center;padding:80px 40px;text-align:center;color:var(--color-text-muted)}
|
||||
.placeholder-icon{font-size:48px;margin-bottom:16px}
|
||||
.placeholder-panel h3{font-size:18px;font-weight:600;color:var(--color-text-secondary);margin-bottom:8px}
|
||||
.placeholder-panel p{font-size:14px}
|
||||
|
||||
/* Shell (pre-auth) */
|
||||
.shell{min-height:100vh;display:flex;flex-direction:column}
|
||||
.shell-header{padding:14px 24px;background:#fff;border-bottom:1px solid var(--color-border);display:flex;align-items:center;justify-content:space-between}
|
||||
.shell-logo{display:flex;align-items:center;gap:10px}
|
||||
.shell-logo .logo-icon{font-size:20px;color:var(--color-primary)}
|
||||
.shell-logo .logo-text{font-size:17px;font-weight:700;color:var(--color-text)}
|
||||
.shell-content{flex:1;display:flex;align-items:center;justify-content:center;padding:40px 20px}
|
||||
.shell-user{display:flex;align-items:center;gap:12px}
|
||||
.user-name{font-size:14px;color:var(--color-text-secondary)}
|
||||
|
||||
/* Sign-in */
|
||||
.signin-overlay{min-height:100vh;display:flex;align-items:center;justify-content:center;padding:40px}
|
||||
.signin-card{background:#fff;border-radius:var(--radius-xl);padding:40px;max-width:400px;width:100%;text-align:center;box-shadow:var(--shadow-lg)}
|
||||
.signin-icon{font-size:36px;color:var(--color-primary);margin-bottom:16px}
|
||||
.signin-card h1{font-size:24px;font-weight:700;color:var(--color-text);margin-bottom:8px}
|
||||
.signin-subtitle{font-size:15px;color:var(--color-text-muted);margin-bottom:28px}
|
||||
.btn-secondary{background:#fff;color:var(--color-primary);border:1px solid var(--color-primary)}
|
||||
|
||||
/* Sidebar collapsed width — component sets sidebar-collapsed on the aside itself */
|
||||
aside.sidebar-collapsed { width: 72px; }
|
||||
aside.sidebar-collapsed .sidebar-logo-text,
|
||||
aside.sidebar-collapsed .sidebar-item-label,
|
||||
aside.sidebar-collapsed .sidebar-soon,
|
||||
aside.sidebar-collapsed .sidebar-footer > .sidebar-user,
|
||||
aside.sidebar-collapsed .sidebar-collapse-btn > span:last-child { display: none; }
|
||||
aside.sidebar-collapsed .sidebar-item { justify-content: center; padding: 10px; }
|
||||
aside.sidebar-collapsed .sidebar-logo { justify-content: center; }
|
||||
|
||||
221
Client-Client/src/styles/forecast.css
Normal file
221
Client-Client/src/styles/forecast.css
Normal file
@@ -0,0 +1,221 @@
|
||||
/* ════════════════════════════════════════════════
|
||||
Forecast Comparison Card — AllocationStep
|
||||
Add to existing wizard stylesheet
|
||||
════════════════════════════════════════════════ */
|
||||
|
||||
.forecast-section {
|
||||
margin: 16px 0 24px;
|
||||
}
|
||||
|
||||
.forecast-loading {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 10px;
|
||||
padding: 20px;
|
||||
background: var(--surface-secondary, #f8f9fb);
|
||||
border-radius: 12px;
|
||||
color: var(--text-secondary, #6b7280);
|
||||
font-size: 0.9rem;
|
||||
}
|
||||
|
||||
.forecast-error {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 8px;
|
||||
padding: 12px 16px;
|
||||
background: #fef3c7;
|
||||
border-radius: 8px;
|
||||
color: #92400e;
|
||||
font-size: 0.85rem;
|
||||
}
|
||||
|
||||
/* ── Card container ── */
|
||||
|
||||
.forecast-card {
|
||||
background: var(--surface-secondary, #f8f9fb);
|
||||
border: 1px solid var(--border-light, #e5e7eb);
|
||||
border-radius: 12px;
|
||||
overflow: hidden;
|
||||
margin-bottom: 16px;
|
||||
}
|
||||
|
||||
.forecast-card-header {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: space-between;
|
||||
padding: 14px 18px;
|
||||
border-bottom: 1px solid var(--border-light, #e5e7eb);
|
||||
background: white;
|
||||
}
|
||||
|
||||
.forecast-title {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 8px;
|
||||
font-weight: 600;
|
||||
font-size: 0.95rem;
|
||||
color: var(--text-primary, #1a1a2e);
|
||||
}
|
||||
|
||||
.forecast-objective-tag {
|
||||
font-size: 0.78rem;
|
||||
padding: 3px 10px;
|
||||
border-radius: 20px;
|
||||
background: var(--primary-light, #eef2ff);
|
||||
color: var(--primary, #4F46E5);
|
||||
font-weight: 500;
|
||||
}
|
||||
|
||||
/* ── Channel comparison columns ── */
|
||||
|
||||
.forecast-channels {
|
||||
display: grid;
|
||||
grid-template-columns: repeat(auto-fit, minmax(220px, 1fr));
|
||||
gap: 1px;
|
||||
background: var(--border-light, #e5e7eb);
|
||||
}
|
||||
|
||||
.forecast-channel {
|
||||
background: white;
|
||||
padding: 16px 18px;
|
||||
}
|
||||
|
||||
.forecast-channel-template {
|
||||
background: #fafafa;
|
||||
}
|
||||
|
||||
.forecast-ch-header {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 6px;
|
||||
margin-bottom: 12px;
|
||||
flex-wrap: wrap;
|
||||
}
|
||||
|
||||
.forecast-ch-dot {
|
||||
width: 8px;
|
||||
height: 8px;
|
||||
border-radius: 50%;
|
||||
flex-shrink: 0;
|
||||
}
|
||||
|
||||
.forecast-ch-name {
|
||||
font-weight: 600;
|
||||
font-size: 0.9rem;
|
||||
color: var(--text-primary, #1a1a2e);
|
||||
}
|
||||
|
||||
.forecast-ch-budget {
|
||||
margin-left: auto;
|
||||
font-size: 0.85rem;
|
||||
font-weight: 500;
|
||||
color: var(--text-secondary, #6b7280);
|
||||
}
|
||||
|
||||
/* ── Metrics grid ── */
|
||||
|
||||
.forecast-ch-metrics {
|
||||
display: grid;
|
||||
grid-template-columns: repeat(2, 1fr);
|
||||
gap: 8px;
|
||||
margin-bottom: 10px;
|
||||
}
|
||||
|
||||
.forecast-metric {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
}
|
||||
|
||||
.forecast-metric-value {
|
||||
font-size: 1.15rem;
|
||||
font-weight: 700;
|
||||
color: var(--text-primary, #1a1a2e);
|
||||
line-height: 1.2;
|
||||
}
|
||||
|
||||
.forecast-metric-label {
|
||||
font-size: 0.75rem;
|
||||
color: var(--text-tertiary, #9ca3af);
|
||||
text-transform: uppercase;
|
||||
letter-spacing: 0.02em;
|
||||
}
|
||||
|
||||
/* ── Detail row (CPC, CPM, CPA) ── */
|
||||
|
||||
.forecast-ch-detail {
|
||||
display: flex;
|
||||
gap: 12px;
|
||||
flex-wrap: wrap;
|
||||
font-size: 0.8rem;
|
||||
color: var(--text-secondary, #6b7280);
|
||||
margin-bottom: 8px;
|
||||
}
|
||||
|
||||
/* ── Strength label ── */
|
||||
|
||||
.forecast-ch-strength {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 6px;
|
||||
font-size: 0.8rem;
|
||||
font-weight: 500;
|
||||
color: var(--text-secondary, #6b7280);
|
||||
padding-top: 8px;
|
||||
border-top: 1px solid var(--border-light, #e5e7eb);
|
||||
}
|
||||
|
||||
.forecast-strength-dot {
|
||||
width: 6px;
|
||||
height: 6px;
|
||||
border-radius: 50%;
|
||||
}
|
||||
|
||||
/* ── Confidence indicator ── */
|
||||
|
||||
.forecast-ch-confidence {
|
||||
font-size: 0.75rem;
|
||||
color: var(--text-tertiary, #9ca3af);
|
||||
margin-top: 4px;
|
||||
}
|
||||
|
||||
/* ── Template-only note (TikTok etc.) ── */
|
||||
|
||||
.forecast-ch-template-note {
|
||||
display: flex;
|
||||
align-items: flex-start;
|
||||
gap: 8px;
|
||||
font-size: 0.82rem;
|
||||
color: var(--text-tertiary, #9ca3af);
|
||||
line-height: 1.4;
|
||||
padding: 8px 0;
|
||||
}
|
||||
|
||||
.forecast-ch-template-note svg {
|
||||
flex-shrink: 0;
|
||||
margin-top: 2px;
|
||||
}
|
||||
|
||||
/* ── Recommendation highlights ── */
|
||||
|
||||
.forecast-highlights {
|
||||
display: flex;
|
||||
flex-wrap: wrap;
|
||||
gap: 8px;
|
||||
padding: 12px 18px;
|
||||
background: white;
|
||||
border-top: 1px solid var(--border-light, #e5e7eb);
|
||||
}
|
||||
|
||||
.forecast-highlight {
|
||||
font-size: 0.82rem;
|
||||
padding: 4px 12px;
|
||||
background: var(--surface-secondary, #f8f9fb);
|
||||
border-radius: 20px;
|
||||
color: var(--text-secondary, #6b7280);
|
||||
}
|
||||
|
||||
/* ── Reset button ── */
|
||||
|
||||
.forecast-reset-btn {
|
||||
margin: 8px 18px 12px;
|
||||
}
|
||||
389
Client-Client/src/styles/intelligence-panel.css
Normal file
389
Client-Client/src/styles/intelligence-panel.css
Normal file
@@ -0,0 +1,389 @@
|
||||
/* ============================================================
|
||||
Intelligence Panel Styles
|
||||
============================================================ */
|
||||
|
||||
.intel-panel {
|
||||
width: 100%;
|
||||
}
|
||||
|
||||
.intel-loading {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
align-items: center;
|
||||
gap: 12px;
|
||||
padding: 60px 20px;
|
||||
color: var(--text-dim);
|
||||
}
|
||||
|
||||
/* ─── Emulated Data Banner ───────────────────────────────── */
|
||||
|
||||
.intel-emulated-banner {
|
||||
padding: 10px 16px;
|
||||
background: #eff6ff;
|
||||
border: 1px solid #bfdbfe;
|
||||
border-radius: 8px;
|
||||
color: #1e40af;
|
||||
font-size: 13px;
|
||||
margin-bottom: 16px;
|
||||
}
|
||||
.intel-emulated-banner code {
|
||||
background: rgba(0,0,0,0.06);
|
||||
padding: 1px 5px;
|
||||
border-radius: 3px;
|
||||
font-size: 12px;
|
||||
}
|
||||
|
||||
|
||||
/* ─── KPI Grid ───────────────────────────────────────────── */
|
||||
|
||||
.intel-kpi-grid {
|
||||
display: grid;
|
||||
grid-template-columns: repeat(auto-fit, minmax(150px, 1fr));
|
||||
gap: 12px;
|
||||
margin: 16px 0 24px;
|
||||
}
|
||||
|
||||
.intel-kpi-card {
|
||||
background: var(--card-bg);
|
||||
border: 1px solid var(--border);
|
||||
border-radius: 10px;
|
||||
padding: 18px 16px;
|
||||
text-align: center;
|
||||
}
|
||||
|
||||
.intel-kpi-value {
|
||||
font-size: 26px;
|
||||
font-weight: 700;
|
||||
color: var(--accent);
|
||||
letter-spacing: -0.5px;
|
||||
margin-bottom: 4px;
|
||||
}
|
||||
|
||||
.intel-kpi-label {
|
||||
font-size: 11px;
|
||||
color: var(--text-dim);
|
||||
text-transform: uppercase;
|
||||
letter-spacing: 0.5px;
|
||||
font-weight: 550;
|
||||
}
|
||||
|
||||
|
||||
/* ─── Channel Breakdown ──────────────────────────────────── */
|
||||
|
||||
.intel-channel-breakdown {
|
||||
margin-bottom: 24px;
|
||||
}
|
||||
.intel-channel-breakdown h3 {
|
||||
font-size: 15px;
|
||||
font-weight: 600;
|
||||
margin-bottom: 12px;
|
||||
color: var(--text);
|
||||
}
|
||||
|
||||
.intel-channel-cards {
|
||||
display: grid;
|
||||
grid-template-columns: repeat(auto-fit, minmax(240px, 1fr));
|
||||
gap: 12px;
|
||||
}
|
||||
|
||||
.intel-channel-card {
|
||||
background: var(--card-bg);
|
||||
border: 1px solid var(--border);
|
||||
border-top: 3px solid;
|
||||
border-radius: 8px;
|
||||
padding: 16px;
|
||||
}
|
||||
|
||||
.intel-channel-header {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: space-between;
|
||||
margin-bottom: 12px;
|
||||
}
|
||||
|
||||
.intel-channel-badge {
|
||||
display: inline-block;
|
||||
padding: 3px 10px;
|
||||
border-radius: 12px;
|
||||
font-size: 12px;
|
||||
font-weight: 600;
|
||||
}
|
||||
|
||||
.intel-channel-count {
|
||||
font-size: 12px;
|
||||
color: var(--text-dim);
|
||||
}
|
||||
|
||||
.intel-channel-metrics {
|
||||
display: grid;
|
||||
grid-template-columns: 1fr 1fr;
|
||||
gap: 10px;
|
||||
}
|
||||
|
||||
.intel-channel-metrics > div {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
}
|
||||
|
||||
.metric-val {
|
||||
font-size: 16px;
|
||||
font-weight: 600;
|
||||
color: var(--text);
|
||||
}
|
||||
|
||||
.metric-lbl {
|
||||
font-size: 10px;
|
||||
color: var(--text-dim);
|
||||
text-transform: uppercase;
|
||||
letter-spacing: 0.3px;
|
||||
margin-top: 1px;
|
||||
}
|
||||
|
||||
|
||||
/* ─── Campaigns Performance Table ────────────────────────── */
|
||||
|
||||
.intel-campaigns-table {
|
||||
margin-top: 4px;
|
||||
}
|
||||
.intel-campaigns-table h3 {
|
||||
font-size: 15px;
|
||||
font-weight: 600;
|
||||
margin-bottom: 12px;
|
||||
color: var(--text);
|
||||
}
|
||||
|
||||
|
||||
/* ─── Pacing Bar ─────────────────────────────────────────── */
|
||||
|
||||
.intel-pacing {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 6px;
|
||||
min-width: 100px;
|
||||
}
|
||||
|
||||
.intel-pacing-label {
|
||||
font-size: 11px;
|
||||
color: var(--text-dim);
|
||||
white-space: nowrap;
|
||||
}
|
||||
|
||||
.intel-pacing-track {
|
||||
flex: 1;
|
||||
height: 6px;
|
||||
background: #e5e7eb;
|
||||
border-radius: 3px;
|
||||
overflow: hidden;
|
||||
min-width: 50px;
|
||||
}
|
||||
|
||||
.intel-pacing-fill {
|
||||
height: 100%;
|
||||
border-radius: 3px;
|
||||
transition: width 0.3s ease;
|
||||
}
|
||||
|
||||
.intel-pacing-pct {
|
||||
font-size: 11px;
|
||||
font-weight: 600;
|
||||
color: var(--text-secondary);
|
||||
min-width: 32px;
|
||||
text-align: right;
|
||||
}
|
||||
|
||||
|
||||
/* ─── Insights View ──────────────────────────────────────── */
|
||||
|
||||
.intel-insights-header {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: space-between;
|
||||
margin-bottom: 16px;
|
||||
}
|
||||
|
||||
.intel-insights-summary {
|
||||
display: flex;
|
||||
align-items: baseline;
|
||||
gap: 8px;
|
||||
font-size: 14px;
|
||||
color: var(--text-secondary);
|
||||
}
|
||||
|
||||
.intel-insights-count {
|
||||
font-size: 28px;
|
||||
font-weight: 700;
|
||||
color: var(--accent);
|
||||
letter-spacing: -0.5px;
|
||||
}
|
||||
|
||||
.intel-insights-list {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 10px;
|
||||
}
|
||||
|
||||
.intel-insight-card {
|
||||
background: var(--card-bg);
|
||||
border: 1px solid var(--border);
|
||||
border-left: 4px solid;
|
||||
border-radius: 8px;
|
||||
overflow: hidden;
|
||||
}
|
||||
|
||||
.intel-insight-top {
|
||||
padding: 14px 16px;
|
||||
}
|
||||
.intel-insight-top:hover {
|
||||
background: rgba(0,0,0,0.01);
|
||||
}
|
||||
|
||||
.intel-insight-meta {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 10px;
|
||||
margin-bottom: 6px;
|
||||
}
|
||||
|
||||
.intel-insight-severity {
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
gap: 4px;
|
||||
padding: 2px 10px;
|
||||
border-radius: 10px;
|
||||
font-size: 11px;
|
||||
font-weight: 600;
|
||||
}
|
||||
|
||||
.intel-insight-type {
|
||||
font-size: 11px;
|
||||
color: var(--text-dim);
|
||||
text-transform: uppercase;
|
||||
letter-spacing: 0.3px;
|
||||
}
|
||||
|
||||
.intel-insight-title {
|
||||
font-size: 14px;
|
||||
font-weight: 600;
|
||||
color: var(--text);
|
||||
margin: 0 0 4px;
|
||||
}
|
||||
|
||||
.intel-insight-context {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 6px;
|
||||
font-size: 12px;
|
||||
color: var(--text-dim);
|
||||
}
|
||||
|
||||
.intel-insight-expand {
|
||||
margin-left: auto;
|
||||
font-size: 12px;
|
||||
color: var(--text-dim);
|
||||
}
|
||||
|
||||
.intel-insight-body {
|
||||
padding: 0 16px 14px;
|
||||
border-top: 1px solid var(--border-light);
|
||||
padding-top: 12px;
|
||||
margin-top: 4px;
|
||||
}
|
||||
|
||||
.intel-insight-desc {
|
||||
font-size: 13px;
|
||||
color: var(--text-secondary);
|
||||
line-height: 1.6;
|
||||
margin: 0 0 10px;
|
||||
}
|
||||
|
||||
.intel-insight-rec {
|
||||
font-size: 13px;
|
||||
color: var(--text);
|
||||
background: #f0fdf4;
|
||||
border: 1px solid #bbf7d0;
|
||||
border-radius: 6px;
|
||||
padding: 10px 12px;
|
||||
line-height: 1.5;
|
||||
}
|
||||
.intel-insight-rec strong {
|
||||
color: #16a34a;
|
||||
}
|
||||
|
||||
|
||||
/* ─── Analysis View ──────────────────────────────────────── */
|
||||
|
||||
.intel-analysis-list {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 12px;
|
||||
}
|
||||
|
||||
.intel-analysis-card {
|
||||
background: var(--card-bg);
|
||||
border: 1px solid var(--border);
|
||||
border-radius: 10px;
|
||||
overflow: hidden;
|
||||
}
|
||||
|
||||
.intel-analysis-top {
|
||||
padding: 16px;
|
||||
}
|
||||
.intel-analysis-top:hover {
|
||||
background: rgba(0,0,0,0.01);
|
||||
}
|
||||
|
||||
.intel-analysis-header {
|
||||
display: flex;
|
||||
align-items: flex-start;
|
||||
justify-content: space-between;
|
||||
margin-bottom: 14px;
|
||||
}
|
||||
.intel-analysis-header h4 {
|
||||
font-size: 15px;
|
||||
font-weight: 600;
|
||||
color: var(--text);
|
||||
margin: 0 0 2px;
|
||||
}
|
||||
|
||||
.intel-analysis-expand {
|
||||
font-size: 14px;
|
||||
color: var(--text-dim);
|
||||
padding: 4px;
|
||||
}
|
||||
|
||||
.intel-analysis-kpis {
|
||||
display: grid;
|
||||
grid-template-columns: repeat(auto-fit, minmax(110px, 1fr));
|
||||
gap: 12px;
|
||||
margin-bottom: 12px;
|
||||
}
|
||||
|
||||
.intel-analysis-kpi {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
}
|
||||
|
||||
.intel-analysis-budget {
|
||||
margin-top: 4px;
|
||||
}
|
||||
|
||||
.intel-analysis-channels {
|
||||
padding: 0 16px 16px;
|
||||
border-top: 1px solid var(--border-light);
|
||||
padding-top: 14px;
|
||||
}
|
||||
.intel-analysis-channels h5 {
|
||||
font-size: 13px;
|
||||
font-weight: 600;
|
||||
color: var(--text);
|
||||
margin: 0 0 10px;
|
||||
}
|
||||
|
||||
|
||||
/* ─── Responsive ─────────────────────────────────────────── */
|
||||
|
||||
@media (max-width: 768px) {
|
||||
.intel-kpi-grid { grid-template-columns: repeat(2, 1fr); }
|
||||
.intel-channel-cards { grid-template-columns: 1fr; }
|
||||
.intel-analysis-kpis { grid-template-columns: repeat(3, 1fr); }
|
||||
}
|
||||
294
Client-Client/src/styles/intelligence.css
Normal file
294
Client-Client/src/styles/intelligence.css
Normal file
@@ -0,0 +1,294 @@
|
||||
/* ═══════════════════════════════════════════════════
|
||||
Campaign Intelligence Styles
|
||||
═══════════════════════════════════════════════════ */
|
||||
|
||||
/* Tabs */
|
||||
.intel-tabs {
|
||||
display: flex;
|
||||
gap: 4px;
|
||||
padding: 4px;
|
||||
background: var(--color-bg-subtle);
|
||||
border-radius: var(--radius-lg);
|
||||
margin-bottom: 24px;
|
||||
}
|
||||
.intel-tab {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 8px;
|
||||
padding: 10px 20px;
|
||||
background: none;
|
||||
border: none;
|
||||
border-radius: var(--radius-md);
|
||||
font-size: 14px;
|
||||
font-weight: 500;
|
||||
font-family: var(--font-sans);
|
||||
color: var(--color-text-secondary);
|
||||
cursor: pointer;
|
||||
transition: all var(--transition-fast);
|
||||
position: relative;
|
||||
}
|
||||
.intel-tab:hover { background: var(--color-bg-elevated); color: var(--color-text); }
|
||||
.intel-tab.active {
|
||||
background: var(--color-bg-elevated);
|
||||
color: var(--color-primary);
|
||||
box-shadow: var(--shadow-sm);
|
||||
font-weight: 600;
|
||||
}
|
||||
.tab-badge {
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
min-width: 20px;
|
||||
height: 20px;
|
||||
padding: 0 6px;
|
||||
background: var(--color-danger);
|
||||
color: white;
|
||||
border-radius: 10px;
|
||||
font-size: 11px;
|
||||
font-weight: 700;
|
||||
}
|
||||
|
||||
/* Alert Summary */
|
||||
.intel-alert-summary { display: flex; gap: 8px; align-items: center; }
|
||||
.intel-alert-badge {
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
padding: 4px 12px;
|
||||
border-radius: 16px;
|
||||
font-size: 12px;
|
||||
font-weight: 600;
|
||||
}
|
||||
.intel-alert-badge.sev-critical { background: var(--color-danger-light); color: var(--color-danger); }
|
||||
.intel-alert-badge.sev-warning { background: var(--color-warning-light); color: var(--color-warning); }
|
||||
.intel-alert-badge.sev-info { background: var(--color-primary-light); color: var(--color-primary); }
|
||||
|
||||
/* Section Header */
|
||||
.intel-section-header {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: space-between;
|
||||
margin-bottom: 16px;
|
||||
}
|
||||
.intel-section-header h3 { font-size: 16px; font-weight: 600; }
|
||||
|
||||
/* ─── Health Cards ─── */
|
||||
.health-grid { display: flex; flex-direction: column; gap: 16px; }
|
||||
.health-card {
|
||||
background: var(--color-bg-elevated);
|
||||
border: 1px solid var(--color-border);
|
||||
border-radius: var(--radius-lg);
|
||||
padding: 20px;
|
||||
cursor: pointer;
|
||||
transition: all var(--transition-fast);
|
||||
}
|
||||
.health-card:hover { box-shadow: var(--shadow-md); border-color: var(--color-text-muted); }
|
||||
.health-card.health-red { border-left: 4px solid var(--color-danger); }
|
||||
.health-card.health-yellow { border-left: 4px solid var(--color-warning); }
|
||||
.health-card.health-green { border-left: 4px solid var(--color-success); }
|
||||
|
||||
.health-card-header {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: space-between;
|
||||
margin-bottom: 8px;
|
||||
}
|
||||
.health-card-title { display: flex; align-items: center; gap: 8px; font-size: 16px; font-weight: 600; }
|
||||
.health-dot { font-size: 14px; }
|
||||
.health-status-label {
|
||||
font-size: 12px;
|
||||
font-weight: 600;
|
||||
padding: 2px 10px;
|
||||
border-radius: 12px;
|
||||
}
|
||||
.health-status-label.health-green { background: var(--color-success-light); color: var(--color-success); }
|
||||
.health-status-label.health-yellow { background: var(--color-warning-light); color: var(--color-warning); }
|
||||
.health-status-label.health-red { background: var(--color-danger-light); color: var(--color-danger); }
|
||||
|
||||
.health-card-meta {
|
||||
display: flex;
|
||||
gap: 8px;
|
||||
font-size: 13px;
|
||||
color: var(--color-text-muted);
|
||||
margin-bottom: 14px;
|
||||
}
|
||||
|
||||
.health-channels { display: flex; flex-direction: column; gap: 6px; }
|
||||
.health-channel-row {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: space-between;
|
||||
padding: 8px 12px;
|
||||
background: var(--color-bg-subtle);
|
||||
border-radius: var(--radius-md);
|
||||
font-size: 13px;
|
||||
}
|
||||
.health-channel-info { display: flex; align-items: center; gap: 8px; min-width: 140px; }
|
||||
.health-channel-dot { font-size: 10px; }
|
||||
.health-channel-name { font-weight: 500; }
|
||||
.health-channel-stats { display: flex; gap: 16px; color: var(--color-text-secondary); }
|
||||
.health-channel-alerts { display: flex; gap: 4px; }
|
||||
.alert-count {
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
min-width: 22px;
|
||||
height: 22px;
|
||||
padding: 0 6px;
|
||||
border-radius: 11px;
|
||||
font-size: 11px;
|
||||
font-weight: 700;
|
||||
}
|
||||
.alert-count.sev-critical { background: var(--color-danger-light); color: var(--color-danger); }
|
||||
.alert-count.sev-warning { background: var(--color-warning-light); color: var(--color-warning); }
|
||||
|
||||
/* ─── Recommendations ─── */
|
||||
.rec-filters { display: flex; gap: 6px; }
|
||||
.rec-list { display: flex; flex-direction: column; gap: 12px; }
|
||||
.rec-card {
|
||||
background: var(--color-bg-elevated);
|
||||
border: 1px solid var(--color-border);
|
||||
border-radius: var(--radius-lg);
|
||||
padding: 16px 20px;
|
||||
transition: all var(--transition-fast);
|
||||
}
|
||||
.rec-card.sev-critical { border-left: 4px solid var(--color-danger); }
|
||||
.rec-card.sev-warning { border-left: 4px solid var(--color-warning); }
|
||||
.rec-card.sev-info { border-left: 4px solid var(--color-primary); }
|
||||
|
||||
.rec-card-header {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 12px;
|
||||
margin-bottom: 10px;
|
||||
font-size: 12px;
|
||||
}
|
||||
.rec-severity {
|
||||
font-weight: 700;
|
||||
padding: 2px 8px;
|
||||
border-radius: 8px;
|
||||
}
|
||||
.rec-severity.sev-critical { background: var(--color-danger-light); color: var(--color-danger); }
|
||||
.rec-severity.sev-warning { background: var(--color-warning-light); color: var(--color-warning); }
|
||||
.rec-severity.sev-info { background: var(--color-primary-light); color: var(--color-primary); }
|
||||
|
||||
.rec-category {
|
||||
color: var(--color-text-muted);
|
||||
text-transform: capitalize;
|
||||
}
|
||||
.rec-channel { color: var(--color-text-secondary); font-weight: 500; }
|
||||
.rec-time { color: var(--color-text-muted); margin-left: auto; }
|
||||
|
||||
.rec-card-body { margin-bottom: 12px; }
|
||||
.rec-initiative { font-size: 13px; font-weight: 600; color: var(--color-text-secondary); margin-bottom: 4px; }
|
||||
.rec-message { font-size: 14px; color: var(--color-text); line-height: 1.5; }
|
||||
.rec-metric {
|
||||
margin-top: 8px;
|
||||
font-size: 12px;
|
||||
color: var(--color-text-muted);
|
||||
font-family: var(--font-mono);
|
||||
}
|
||||
.rec-metric strong { color: var(--color-text-secondary); }
|
||||
|
||||
.rec-card-actions {
|
||||
display: flex;
|
||||
gap: 8px;
|
||||
padding-top: 12px;
|
||||
border-top: 1px solid var(--color-border-light);
|
||||
}
|
||||
|
||||
/* ─── Budget Pacing ─── */
|
||||
.pacing-select-grid {
|
||||
display: grid;
|
||||
grid-template-columns: repeat(auto-fill, minmax(280px, 1fr));
|
||||
gap: 12px;
|
||||
}
|
||||
.pacing-select-card {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 4px;
|
||||
padding: 16px 20px;
|
||||
background: var(--color-bg-elevated);
|
||||
border: 1px solid var(--color-border);
|
||||
border-radius: var(--radius-lg);
|
||||
cursor: pointer;
|
||||
text-align: left;
|
||||
transition: all var(--transition-fast);
|
||||
font-family: var(--font-sans);
|
||||
}
|
||||
.pacing-select-card:hover { box-shadow: var(--shadow-md); border-color: var(--color-primary); }
|
||||
.pacing-select-name { font-size: 15px; font-weight: 600; color: var(--color-text); }
|
||||
.pacing-select-meta { font-size: 13px; color: var(--color-text-muted); }
|
||||
|
||||
.pacing-bar-container {
|
||||
background: var(--color-bg-elevated);
|
||||
border: 1px solid var(--color-border);
|
||||
border-radius: var(--radius-lg);
|
||||
padding: 20px;
|
||||
margin-top: 20px;
|
||||
}
|
||||
.pacing-bar-labels {
|
||||
display: flex;
|
||||
justify-content: space-between;
|
||||
font-size: 13px;
|
||||
color: var(--color-text-secondary);
|
||||
margin-bottom: 10px;
|
||||
}
|
||||
.pacing-bar {
|
||||
position: relative;
|
||||
height: 24px;
|
||||
background: var(--color-bg-subtle);
|
||||
border-radius: 12px;
|
||||
overflow: hidden;
|
||||
}
|
||||
.pacing-bar-expected {
|
||||
position: absolute;
|
||||
top: 0;
|
||||
left: 0;
|
||||
height: 100%;
|
||||
background: var(--color-bg-muted);
|
||||
border-radius: 12px;
|
||||
z-index: 1;
|
||||
}
|
||||
.pacing-bar-actual {
|
||||
position: absolute;
|
||||
top: 0;
|
||||
left: 0;
|
||||
height: 100%;
|
||||
border-radius: 12px;
|
||||
z-index: 2;
|
||||
transition: width 0.5s ease;
|
||||
}
|
||||
.pacing-bar-actual.on-track { background: var(--color-success); opacity: 0.8; }
|
||||
.pacing-bar-actual.overpacing { background: var(--color-danger); opacity: 0.8; }
|
||||
.pacing-bar-actual.underpacing { background: var(--color-warning); opacity: 0.8; }
|
||||
|
||||
.pacing-bar-legend {
|
||||
display: flex;
|
||||
gap: 20px;
|
||||
margin-top: 10px;
|
||||
font-size: 12px;
|
||||
color: var(--color-text-muted);
|
||||
}
|
||||
.legend-dot {
|
||||
display: inline-block;
|
||||
width: 10px;
|
||||
height: 10px;
|
||||
border-radius: 5px;
|
||||
margin-right: 6px;
|
||||
vertical-align: middle;
|
||||
}
|
||||
.legend-dot.expected { background: var(--color-bg-muted); }
|
||||
.legend-dot.actual { background: var(--color-success); }
|
||||
|
||||
/* Color helpers */
|
||||
.text-red { color: var(--color-danger) !important; }
|
||||
.text-orange { color: var(--color-warning) !important; }
|
||||
.text-green { color: var(--color-success) !important; }
|
||||
.text-blue { color: var(--color-primary) !important; }
|
||||
|
||||
/* Responsive */
|
||||
@media (max-width: 900px) {
|
||||
.health-channel-stats { display: none; }
|
||||
.rec-card-header { flex-wrap: wrap; }
|
||||
.pacing-select-grid { grid-template-columns: 1fr; }
|
||||
}
|
||||
465
Client-Client/src/styles/modifiers-panel.css
Normal file
465
Client-Client/src/styles/modifiers-panel.css
Normal file
@@ -0,0 +1,465 @@
|
||||
/* ── Modifiers Panel ────────────────────────────────────── */
|
||||
|
||||
.modifiers-panel {
|
||||
max-width: 900px;
|
||||
}
|
||||
|
||||
/* ── Toast ── */
|
||||
.mod-toast {
|
||||
position: fixed;
|
||||
top: 16px;
|
||||
right: 24px;
|
||||
background: #059669;
|
||||
color: #fff;
|
||||
padding: 8px 20px;
|
||||
border-radius: 8px;
|
||||
font-size: 13px;
|
||||
font-weight: 600;
|
||||
z-index: 1000;
|
||||
box-shadow: 0 4px 12px rgba(0,0,0,0.15);
|
||||
animation: mod-toast-in 0.25s ease-out;
|
||||
}
|
||||
@keyframes mod-toast-in {
|
||||
from { opacity: 0; transform: translateY(-8px); }
|
||||
to { opacity: 1; transform: translateY(0); }
|
||||
}
|
||||
|
||||
.mod-dismiss-btn {
|
||||
margin-left: 12px;
|
||||
cursor: pointer;
|
||||
background: none;
|
||||
border: none;
|
||||
font-weight: 700;
|
||||
font-size: 14px;
|
||||
color: inherit;
|
||||
}
|
||||
|
||||
/* ── Intro ── */
|
||||
.mod-intro {
|
||||
margin-bottom: 20px;
|
||||
display: flex;
|
||||
align-items: flex-start;
|
||||
justify-content: space-between;
|
||||
gap: 12px;
|
||||
}
|
||||
.mod-intro p {
|
||||
color: #64748b;
|
||||
margin: 0;
|
||||
font-size: 13px;
|
||||
line-height: 1.5;
|
||||
}
|
||||
.mod-dirty-badge {
|
||||
flex-shrink: 0;
|
||||
background: #fef3c7;
|
||||
color: #92400e;
|
||||
font-size: 11px;
|
||||
font-weight: 600;
|
||||
padding: 3px 10px;
|
||||
border-radius: 12px;
|
||||
white-space: nowrap;
|
||||
}
|
||||
|
||||
/* ── Factor Group Card ── */
|
||||
.mod-group {
|
||||
background: #fff;
|
||||
border: 1px solid var(--border, #e2e8f0);
|
||||
border-radius: 10px;
|
||||
margin-bottom: 16px;
|
||||
overflow: hidden;
|
||||
}
|
||||
.mod-group-header {
|
||||
display: flex;
|
||||
align-items: flex-start;
|
||||
gap: 12px;
|
||||
padding: 16px 20px 12px;
|
||||
border-bottom: 1px solid var(--border, #e2e8f0);
|
||||
background: #fafbfc;
|
||||
}
|
||||
.mod-group-icon {
|
||||
font-size: 20px;
|
||||
flex-shrink: 0;
|
||||
margin-top: 2px;
|
||||
}
|
||||
.mod-group-title {
|
||||
margin: 0;
|
||||
font-size: 15px;
|
||||
font-weight: 700;
|
||||
color: var(--text, #1e293b);
|
||||
}
|
||||
.mod-group-desc {
|
||||
margin: 2px 0 0;
|
||||
font-size: 12px;
|
||||
color: var(--text-dim, #64748b);
|
||||
}
|
||||
|
||||
/* ── Value Section (young, mature, local, etc.) ── */
|
||||
.mod-value-section {
|
||||
border-bottom: 1px solid #f1f5f9;
|
||||
}
|
||||
.mod-value-section:last-child {
|
||||
border-bottom: none;
|
||||
}
|
||||
.mod-value-header {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 8px;
|
||||
padding: 12px 20px 4px;
|
||||
}
|
||||
.mod-value-icon {
|
||||
font-size: 14px;
|
||||
}
|
||||
.mod-value-label {
|
||||
font-size: 13px;
|
||||
font-weight: 600;
|
||||
color: var(--text, #1e293b);
|
||||
flex: 1;
|
||||
}
|
||||
.mod-save-group-btn {
|
||||
background: var(--accent, #4F46E5);
|
||||
color: #fff;
|
||||
border: none;
|
||||
border-radius: 6px;
|
||||
padding: 4px 12px;
|
||||
font-size: 11px;
|
||||
font-weight: 600;
|
||||
cursor: pointer;
|
||||
transition: opacity 0.15s;
|
||||
}
|
||||
.mod-save-group-btn:hover { opacity: 0.85; }
|
||||
.mod-save-group-btn:disabled { opacity: 0.5; cursor: default; }
|
||||
|
||||
/* ── Modifier Row ── */
|
||||
.mod-slider-list {
|
||||
padding: 2px 20px 14px;
|
||||
}
|
||||
.mod-row {
|
||||
padding: 10px 0 6px;
|
||||
border-bottom: 1px solid #f8fafc;
|
||||
transition: opacity 0.2s;
|
||||
}
|
||||
.mod-row:last-child { border-bottom: none; }
|
||||
.mod-row-disabled { opacity: 0.4; }
|
||||
.mod-row-dirty {
|
||||
background: #fffbeb;
|
||||
margin: 0 -8px;
|
||||
padding-left: 8px;
|
||||
padding-right: 8px;
|
||||
border-radius: 6px;
|
||||
}
|
||||
.mod-row-top {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 10px;
|
||||
}
|
||||
.mod-ch-dot {
|
||||
width: 10px;
|
||||
height: 10px;
|
||||
border-radius: 50%;
|
||||
flex-shrink: 0;
|
||||
}
|
||||
.mod-ch-name {
|
||||
font-size: 13px;
|
||||
font-weight: 500;
|
||||
color: var(--text, #1e293b);
|
||||
min-width: 90px;
|
||||
}
|
||||
.mod-empty {
|
||||
padding: 12px 0;
|
||||
color: #94a3b8;
|
||||
font-size: 13px;
|
||||
font-style: italic;
|
||||
}
|
||||
|
||||
/* ── Slider ── */
|
||||
.mod-slider-wrap {
|
||||
flex: 1;
|
||||
min-width: 180px;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 6px;
|
||||
}
|
||||
.mod-slider-tick {
|
||||
font-size: 10px;
|
||||
color: #94a3b8;
|
||||
flex-shrink: 0;
|
||||
width: 24px;
|
||||
font-variant-numeric: tabular-nums;
|
||||
}
|
||||
.mod-slider-tick-neg { text-align: right; }
|
||||
.mod-slider-tick-pos { text-align: left; }
|
||||
|
||||
.mod-slider {
|
||||
-webkit-appearance: none;
|
||||
appearance: none;
|
||||
width: 100%;
|
||||
height: 6px;
|
||||
border-radius: 3px;
|
||||
background: linear-gradient(to right, #fecaca 0%, #f1f5f9 50%, #bbf7d0 100%);
|
||||
outline: none;
|
||||
cursor: pointer;
|
||||
}
|
||||
.mod-slider::-webkit-slider-thumb {
|
||||
-webkit-appearance: none;
|
||||
appearance: none;
|
||||
width: 18px;
|
||||
height: 18px;
|
||||
border-radius: 50%;
|
||||
background: var(--slider-color, #94a3b8);
|
||||
border: 2px solid #fff;
|
||||
box-shadow: 0 1px 4px rgba(0,0,0,0.2);
|
||||
cursor: grab;
|
||||
transition: transform 0.1s;
|
||||
}
|
||||
.mod-slider::-webkit-slider-thumb:hover {
|
||||
transform: scale(1.15);
|
||||
}
|
||||
.mod-slider::-moz-range-thumb {
|
||||
width: 18px;
|
||||
height: 18px;
|
||||
border-radius: 50%;
|
||||
background: var(--slider-color, #94a3b8);
|
||||
border: 2px solid #fff;
|
||||
box-shadow: 0 1px 4px rgba(0,0,0,0.2);
|
||||
cursor: grab;
|
||||
}
|
||||
|
||||
/* ── Percentage Badge ── */
|
||||
.mod-pct-badge {
|
||||
font-size: 13px;
|
||||
font-weight: 700;
|
||||
min-width: 48px;
|
||||
text-align: center;
|
||||
padding: 2px 6px;
|
||||
border-radius: 4px;
|
||||
font-variant-numeric: tabular-nums;
|
||||
color: #64748b;
|
||||
background: #f1f5f9;
|
||||
}
|
||||
.mod-pct-badge.pos { color: #059669; background: #ecfdf5; }
|
||||
.mod-pct-badge.neg { color: #dc2626; background: #fef2f2; }
|
||||
|
||||
/* ── Toggle Switch ── */
|
||||
.mod-toggle {
|
||||
position: relative;
|
||||
cursor: pointer;
|
||||
flex-shrink: 0;
|
||||
}
|
||||
.mod-toggle input {
|
||||
position: absolute;
|
||||
opacity: 0;
|
||||
width: 0;
|
||||
height: 0;
|
||||
}
|
||||
.mod-toggle-track {
|
||||
display: block;
|
||||
width: 34px;
|
||||
height: 18px;
|
||||
border-radius: 9px;
|
||||
background: #cbd5e1;
|
||||
transition: background 0.2s;
|
||||
position: relative;
|
||||
}
|
||||
.mod-toggle-track::after {
|
||||
content: '';
|
||||
position: absolute;
|
||||
top: 2px;
|
||||
left: 2px;
|
||||
width: 14px;
|
||||
height: 14px;
|
||||
border-radius: 50%;
|
||||
background: #fff;
|
||||
transition: transform 0.2s;
|
||||
box-shadow: 0 1px 2px rgba(0,0,0,0.1);
|
||||
}
|
||||
.mod-toggle input:checked + .mod-toggle-track {
|
||||
background: #4F46E5;
|
||||
}
|
||||
.mod-toggle input:checked + .mod-toggle-track::after {
|
||||
transform: translateX(16px);
|
||||
}
|
||||
|
||||
/* ── Save Button (per row) ── */
|
||||
.mod-save-btn {
|
||||
background: #4F46E5;
|
||||
color: #fff;
|
||||
border: none;
|
||||
border-radius: 6px;
|
||||
width: 28px;
|
||||
height: 28px;
|
||||
font-size: 14px;
|
||||
font-weight: 700;
|
||||
cursor: pointer;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
flex-shrink: 0;
|
||||
transition: opacity 0.15s;
|
||||
}
|
||||
.mod-save-btn:hover { opacity: 0.85; }
|
||||
.mod-save-btn:disabled { opacity: 0.5; cursor: default; }
|
||||
|
||||
/* ── Rationale Row ── */
|
||||
.mod-row-bottom {
|
||||
margin-top: 4px;
|
||||
padding-left: 20px;
|
||||
}
|
||||
.mod-rationale {
|
||||
width: 100%;
|
||||
border: 1px solid transparent;
|
||||
border-radius: 4px;
|
||||
padding: 4px 8px;
|
||||
font-size: 11px;
|
||||
color: #64748b;
|
||||
background: transparent;
|
||||
transition: border-color 0.15s, background 0.15s;
|
||||
box-sizing: border-box;
|
||||
}
|
||||
.mod-rationale:hover {
|
||||
background: #f8fafc;
|
||||
}
|
||||
.mod-rationale:focus {
|
||||
outline: none;
|
||||
border-color: #4F46E5;
|
||||
background: #fff;
|
||||
color: #1e293b;
|
||||
}
|
||||
.mod-rationale::placeholder {
|
||||
color: #94a3b8;
|
||||
}
|
||||
|
||||
/* ── Preview Section ── */
|
||||
.mod-preview-controls {
|
||||
display: flex;
|
||||
flex-wrap: wrap;
|
||||
gap: 12px;
|
||||
padding: 14px 20px;
|
||||
align-items: flex-end;
|
||||
}
|
||||
.mod-preview-field {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 4px;
|
||||
}
|
||||
.mod-preview-field label {
|
||||
font-size: 11px;
|
||||
font-weight: 600;
|
||||
color: #64748b;
|
||||
text-transform: uppercase;
|
||||
letter-spacing: 0.3px;
|
||||
}
|
||||
.mod-preview-field input,
|
||||
.mod-preview-field select {
|
||||
border: 1px solid var(--border, #e2e8f0);
|
||||
border-radius: 6px;
|
||||
padding: 6px 10px;
|
||||
font-size: 13px;
|
||||
min-width: 120px;
|
||||
color: var(--text, #1e293b);
|
||||
background: #fff;
|
||||
}
|
||||
.mod-preview-field input:focus,
|
||||
.mod-preview-field select:focus {
|
||||
outline: none;
|
||||
border-color: #4F46E5;
|
||||
}
|
||||
.mod-preview-btn {
|
||||
background: var(--text, #1e293b);
|
||||
color: #fff;
|
||||
border: none;
|
||||
border-radius: 6px;
|
||||
padding: 7px 18px;
|
||||
font-size: 13px;
|
||||
font-weight: 600;
|
||||
cursor: pointer;
|
||||
transition: opacity 0.15s;
|
||||
}
|
||||
.mod-preview-btn:hover { opacity: 0.85; }
|
||||
.mod-preview-btn:disabled { opacity: 0.5; cursor: default; }
|
||||
|
||||
/* ── Preview Result ── */
|
||||
.mod-preview-result {
|
||||
padding: 14px 20px;
|
||||
border-top: 1px solid #f1f5f9;
|
||||
}
|
||||
.mod-preview-channels {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 2px;
|
||||
}
|
||||
.mod-preview-row {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 10px;
|
||||
padding: 6px 0;
|
||||
font-size: 13px;
|
||||
}
|
||||
.mod-preview-header-row {
|
||||
padding-bottom: 4px;
|
||||
border-bottom: 1px solid #f1f5f9;
|
||||
margin-bottom: 2px;
|
||||
}
|
||||
.mod-preview-ch-dot {
|
||||
width: 10px;
|
||||
height: 10px;
|
||||
border-radius: 50%;
|
||||
flex-shrink: 0;
|
||||
}
|
||||
.mod-preview-ch-dot-spacer {
|
||||
width: 10px;
|
||||
flex-shrink: 0;
|
||||
}
|
||||
.mod-preview-ch-name {
|
||||
font-weight: 500;
|
||||
min-width: 90px;
|
||||
flex: 1;
|
||||
}
|
||||
.mod-preview-ch-val {
|
||||
min-width: 40px;
|
||||
text-align: right;
|
||||
font-variant-numeric: tabular-nums;
|
||||
}
|
||||
.mod-preview-ch-arrow {
|
||||
color: #94a3b8;
|
||||
font-size: 12px;
|
||||
width: 16px;
|
||||
text-align: center;
|
||||
}
|
||||
.mod-preview-ch-diff {
|
||||
font-size: 11px;
|
||||
font-weight: 600;
|
||||
padding: 1px 8px;
|
||||
border-radius: 4px;
|
||||
min-width: 32px;
|
||||
text-align: center;
|
||||
font-variant-numeric: tabular-nums;
|
||||
}
|
||||
.mod-preview-ch-diff.pos {
|
||||
color: #059669;
|
||||
background: #ecfdf5;
|
||||
}
|
||||
.mod-preview-ch-diff.neg {
|
||||
color: #dc2626;
|
||||
background: #fef2f2;
|
||||
}
|
||||
.mod-preview-ch-diff-spacer {
|
||||
min-width: 32px;
|
||||
}
|
||||
|
||||
/* ── Responsive ── */
|
||||
@media (max-width: 768px) {
|
||||
.mod-row-top {
|
||||
flex-wrap: wrap;
|
||||
}
|
||||
.mod-slider-wrap {
|
||||
min-width: 100%;
|
||||
order: 10;
|
||||
margin-top: 4px;
|
||||
}
|
||||
.mod-preview-controls {
|
||||
flex-direction: column;
|
||||
}
|
||||
.mod-preview-field input,
|
||||
.mod-preview-field select {
|
||||
min-width: 100%;
|
||||
}
|
||||
}
|
||||
632
Client-Client/src/styles/templates-panel.css
Normal file
632
Client-Client/src/styles/templates-panel.css
Normal file
@@ -0,0 +1,632 @@
|
||||
/* ============================================================
|
||||
Templates Panel — Sidebar + Grouped Card Layout
|
||||
Append to app.css or import separately
|
||||
============================================================ */
|
||||
|
||||
/* ─── Layout ─────────────────────────────────────────────── */
|
||||
.templates-layout {
|
||||
display: flex;
|
||||
gap: 0;
|
||||
min-height: calc(100vh - var(--header-height) - 100px);
|
||||
}
|
||||
|
||||
.templates-sidebar {
|
||||
width: 240px;
|
||||
min-width: 240px;
|
||||
background: var(--surface);
|
||||
border-right: 1px solid var(--border);
|
||||
border-radius: 8px 0 0 8px;
|
||||
padding: 0;
|
||||
overflow-y: auto;
|
||||
max-height: calc(100vh - var(--header-height) - 100px);
|
||||
}
|
||||
|
||||
.templates-content {
|
||||
flex: 1;
|
||||
min-width: 0;
|
||||
padding: 20px 24px;
|
||||
}
|
||||
|
||||
/* ─── Sidebar Sections ───────────────────────────────────── */
|
||||
.sidebar-section {
|
||||
padding: 12px 0;
|
||||
border-bottom: 1px solid var(--border-light);
|
||||
}
|
||||
|
||||
.sidebar-section:last-child {
|
||||
border-bottom: none;
|
||||
}
|
||||
|
||||
.sidebar-section-header {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: space-between;
|
||||
padding: 0 14px 6px;
|
||||
}
|
||||
|
||||
.sidebar-section-title {
|
||||
font-size: 10px;
|
||||
font-weight: 700;
|
||||
text-transform: uppercase;
|
||||
letter-spacing: 0.08em;
|
||||
color: var(--text-dim);
|
||||
}
|
||||
|
||||
/* ─── Sidebar Items ──────────────────────────────────────── */
|
||||
.sidebar-list {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 1px;
|
||||
}
|
||||
|
||||
.sidebar-list-scrollable {
|
||||
max-height: 280px;
|
||||
overflow-y: auto;
|
||||
scrollbar-width: thin;
|
||||
scrollbar-color: var(--border) transparent;
|
||||
}
|
||||
|
||||
.sidebar-list-scrollable::-webkit-scrollbar {
|
||||
width: 4px;
|
||||
}
|
||||
|
||||
.sidebar-list-scrollable::-webkit-scrollbar-thumb {
|
||||
background: var(--border);
|
||||
border-radius: 4px;
|
||||
}
|
||||
|
||||
.sidebar-list-scrollable::-webkit-scrollbar-track {
|
||||
background: transparent;
|
||||
}
|
||||
|
||||
/* Add button pinned below the scrollable list */
|
||||
.sidebar-section > .sidebar-add-btn,
|
||||
.sidebar-section > .sidebar-new-item {
|
||||
margin: 4px 6px 0;
|
||||
}
|
||||
|
||||
.sidebar-item-wrapper {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
position: relative;
|
||||
}
|
||||
|
||||
.sidebar-item-wrapper .btn-icon-xs {
|
||||
position: absolute;
|
||||
right: 6px;
|
||||
top: 50%;
|
||||
transform: translateY(-50%);
|
||||
opacity: 0;
|
||||
transition: opacity 0.15s;
|
||||
}
|
||||
|
||||
.sidebar-item-wrapper:hover .btn-icon-xs {
|
||||
opacity: 1;
|
||||
}
|
||||
|
||||
.sidebar-item {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 8px;
|
||||
width: 100%;
|
||||
padding: 7px 14px;
|
||||
border: none;
|
||||
background: none;
|
||||
cursor: pointer;
|
||||
font-size: 13px;
|
||||
color: var(--text);
|
||||
text-align: left;
|
||||
font-family: inherit;
|
||||
transition: background 0.12s;
|
||||
border-radius: 0;
|
||||
}
|
||||
|
||||
.sidebar-item:hover {
|
||||
background: var(--sidebar-hover);
|
||||
}
|
||||
|
||||
.sidebar-item.active {
|
||||
background: var(--sidebar-active);
|
||||
color: var(--accent);
|
||||
font-weight: 600;
|
||||
}
|
||||
|
||||
.sidebar-item-static {
|
||||
cursor: default;
|
||||
}
|
||||
|
||||
.sidebar-item-static:hover {
|
||||
background: none;
|
||||
}
|
||||
|
||||
.sidebar-item-icon {
|
||||
font-size: 16px;
|
||||
width: 22px;
|
||||
text-align: center;
|
||||
flex-shrink: 0;
|
||||
}
|
||||
|
||||
.sidebar-item-label {
|
||||
flex: 1;
|
||||
white-space: nowrap;
|
||||
overflow: hidden;
|
||||
text-overflow: ellipsis;
|
||||
}
|
||||
|
||||
.sidebar-item-count {
|
||||
font-size: 11px;
|
||||
color: var(--text-dim);
|
||||
background: var(--bg);
|
||||
padding: 1px 7px;
|
||||
border-radius: 10px;
|
||||
font-weight: 500;
|
||||
flex-shrink: 0;
|
||||
}
|
||||
|
||||
.sidebar-color-dot {
|
||||
width: 10px;
|
||||
height: 10px;
|
||||
border-radius: 50%;
|
||||
flex-shrink: 0;
|
||||
margin-left: 6px;
|
||||
}
|
||||
|
||||
.sidebar-add-btn {
|
||||
color: var(--accent);
|
||||
font-style: italic;
|
||||
}
|
||||
|
||||
.sidebar-new-item {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 4px;
|
||||
padding: 4px 14px;
|
||||
}
|
||||
|
||||
.sidebar-new-item input {
|
||||
flex: 1;
|
||||
font-size: 12px;
|
||||
padding: 4px 8px;
|
||||
border: 1px solid var(--border);
|
||||
border-radius: 4px;
|
||||
font-family: inherit;
|
||||
background: var(--bg);
|
||||
}
|
||||
|
||||
/* ─── Icon & Color Pickers ───────────────────────────────── */
|
||||
.icon-picker-btn {
|
||||
font-size: 16px;
|
||||
background: var(--bg);
|
||||
border: 1px solid var(--border);
|
||||
border-radius: 5px;
|
||||
cursor: pointer;
|
||||
width: 28px;
|
||||
height: 28px;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
padding: 0;
|
||||
transition: border-color 0.15s;
|
||||
}
|
||||
|
||||
.icon-picker-btn:hover {
|
||||
border-color: var(--accent);
|
||||
}
|
||||
|
||||
.icon-picker-popover {
|
||||
position: absolute;
|
||||
left: 0;
|
||||
z-index: 999;
|
||||
border-radius: 10px;
|
||||
box-shadow: 0 8px 24px rgba(0,0,0,0.15), 0 1px 4px rgba(0,0,0,0.08);
|
||||
overflow: hidden;
|
||||
animation: iconPickerIn 0.12s ease-out;
|
||||
}
|
||||
|
||||
.icon-picker-popover em-emoji-picker {
|
||||
--em-rgb-background: var(--surface-rgb, 255, 255, 255);
|
||||
--em-rgb-input: 241, 245, 249;
|
||||
border: none !important;
|
||||
}
|
||||
|
||||
@keyframes iconPickerIn {
|
||||
from { opacity: 0; transform: translateY(-3px) scale(0.98); }
|
||||
to { opacity: 1; transform: translateY(0) scale(1); }
|
||||
}
|
||||
|
||||
.color-picker-btn {
|
||||
width: 14px;
|
||||
height: 14px;
|
||||
border-radius: 50%;
|
||||
border: 2px solid var(--border);
|
||||
cursor: pointer;
|
||||
padding: 0;
|
||||
transition: border-color 0.15s;
|
||||
margin-left: 6px;
|
||||
flex-shrink: 0;
|
||||
}
|
||||
|
||||
.color-picker-btn:hover {
|
||||
border-color: var(--accent);
|
||||
}
|
||||
|
||||
.color-picker-grid {
|
||||
position: absolute;
|
||||
left: 0;
|
||||
top: 24px;
|
||||
background: var(--surface);
|
||||
border: 1px solid var(--border);
|
||||
border-radius: 8px;
|
||||
padding: 6px;
|
||||
display: grid;
|
||||
grid-template-columns: repeat(5, 1fr);
|
||||
gap: 4px;
|
||||
z-index: 99;
|
||||
box-shadow: 0 8px 24px rgba(0,0,0,0.12);
|
||||
}
|
||||
|
||||
.color-picker-option {
|
||||
width: 22px;
|
||||
height: 22px;
|
||||
border-radius: 50%;
|
||||
border: 2px solid transparent;
|
||||
cursor: pointer;
|
||||
padding: 0;
|
||||
transition: border-color 0.15s, transform 0.1s;
|
||||
}
|
||||
|
||||
.color-picker-option:hover {
|
||||
transform: scale(1.15);
|
||||
}
|
||||
|
||||
.color-picker-option.active {
|
||||
border-color: var(--text);
|
||||
}
|
||||
|
||||
/* ─── Small Button Variants ──────────────────────────────── */
|
||||
.btn-icon-sm {
|
||||
width: 24px;
|
||||
height: 24px;
|
||||
border: 1px solid var(--border);
|
||||
background: var(--bg);
|
||||
border-radius: 4px;
|
||||
cursor: pointer;
|
||||
font-size: 13px;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
color: var(--text-secondary);
|
||||
transition: all 0.12s;
|
||||
padding: 0;
|
||||
font-family: inherit;
|
||||
}
|
||||
|
||||
.btn-icon-sm:hover {
|
||||
background: var(--accent-light);
|
||||
color: var(--accent);
|
||||
border-color: var(--accent);
|
||||
}
|
||||
|
||||
.btn-icon-xs {
|
||||
width: 20px;
|
||||
height: 20px;
|
||||
border: none;
|
||||
background: none;
|
||||
border-radius: 4px;
|
||||
cursor: pointer;
|
||||
font-size: 12px;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
color: var(--text-dim);
|
||||
transition: all 0.12s;
|
||||
padding: 0;
|
||||
font-family: inherit;
|
||||
}
|
||||
|
||||
.btn-icon-xs:hover {
|
||||
background: var(--danger-bg);
|
||||
color: var(--danger);
|
||||
}
|
||||
|
||||
.btn-icon-xs:disabled {
|
||||
opacity: 0.3;
|
||||
cursor: not-allowed;
|
||||
}
|
||||
|
||||
.btn-danger-icon:hover {
|
||||
background: var(--danger-bg);
|
||||
color: var(--danger);
|
||||
}
|
||||
|
||||
/* ─── Template Groups ────────────────────────────────────── */
|
||||
.template-group {
|
||||
background: var(--surface);
|
||||
border: 1px solid var(--border);
|
||||
border-radius: 8px;
|
||||
margin-bottom: 16px;
|
||||
overflow: hidden;
|
||||
}
|
||||
|
||||
.template-group-header {
|
||||
padding: 16px 20px;
|
||||
background: var(--bg);
|
||||
border-bottom: 1px solid var(--border-light);
|
||||
}
|
||||
|
||||
.template-group-info {
|
||||
display: flex;
|
||||
align-items: flex-start;
|
||||
gap: 20px;
|
||||
}
|
||||
|
||||
.template-group-meta {
|
||||
flex: 1;
|
||||
min-width: 0;
|
||||
}
|
||||
|
||||
.template-group-title {
|
||||
font-size: 15px;
|
||||
font-weight: 600;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 8px;
|
||||
margin-bottom: 4px;
|
||||
}
|
||||
|
||||
.cat-icon {
|
||||
font-size: 18px;
|
||||
}
|
||||
|
||||
.objective-tag,
|
||||
.header-objective-tag {
|
||||
display: inline-block;
|
||||
font-size: 11px;
|
||||
font-weight: 600;
|
||||
color: #fff;
|
||||
padding: 2px 10px;
|
||||
border-radius: 10px;
|
||||
margin-left: 6px;
|
||||
}
|
||||
|
||||
.template-group-stats {
|
||||
font-size: 12px;
|
||||
color: var(--text-secondary);
|
||||
margin-bottom: 10px;
|
||||
}
|
||||
|
||||
.text-success { color: var(--success); font-weight: 600; }
|
||||
.text-danger { color: var(--danger); font-weight: 600; }
|
||||
.text-dim { color: var(--text-dim); }
|
||||
|
||||
/* ─── Channel Bars (mini horizontal bars) ────────────────── */
|
||||
.template-group-channel-bars {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 4px;
|
||||
}
|
||||
|
||||
.channel-bar {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 8px;
|
||||
font-size: 12px;
|
||||
}
|
||||
|
||||
.channel-bar-color {
|
||||
width: 8px;
|
||||
height: 8px;
|
||||
border-radius: 50%;
|
||||
flex-shrink: 0;
|
||||
}
|
||||
|
||||
.channel-bar-label {
|
||||
width: 80px;
|
||||
flex-shrink: 0;
|
||||
color: var(--text-secondary);
|
||||
}
|
||||
|
||||
.channel-bar-pct {
|
||||
width: 36px;
|
||||
text-align: right;
|
||||
font-weight: 600;
|
||||
flex-shrink: 0;
|
||||
}
|
||||
|
||||
.channel-bar-track {
|
||||
flex: 1;
|
||||
height: 6px;
|
||||
background: var(--border-light);
|
||||
border-radius: 3px;
|
||||
overflow: hidden;
|
||||
max-width: 200px;
|
||||
}
|
||||
|
||||
.channel-bar-fill {
|
||||
height: 100%;
|
||||
border-radius: 3px;
|
||||
transition: width 0.3s ease;
|
||||
}
|
||||
|
||||
/* ─── Template Group Table ───────────────────────────────── */
|
||||
.template-group .data-table {
|
||||
margin: 0;
|
||||
border: none;
|
||||
border-radius: 0;
|
||||
}
|
||||
|
||||
.template-group .data-table thead th {
|
||||
background: var(--surface);
|
||||
}
|
||||
|
||||
/* ─── Channel Legend Bar (top of content) ───────────────── */
|
||||
.channel-legend-bar {
|
||||
display: flex;
|
||||
gap: 8px;
|
||||
flex-wrap: wrap;
|
||||
margin-bottom: 14px;
|
||||
}
|
||||
|
||||
.channel-chip {
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
gap: 6px;
|
||||
padding: 4px 12px;
|
||||
font-size: 12px;
|
||||
font-weight: 500;
|
||||
color: var(--text-secondary);
|
||||
background: var(--surface);
|
||||
border: 1px solid var(--border);
|
||||
border-radius: 20px;
|
||||
border-left-width: 3px;
|
||||
}
|
||||
|
||||
.channel-chip-dot {
|
||||
width: 8px;
|
||||
height: 8px;
|
||||
border-radius: 50%;
|
||||
flex-shrink: 0;
|
||||
}
|
||||
|
||||
/* ─── Add Channel Button (per group) ────────────────────── */
|
||||
.btn-add-channel {
|
||||
display: block;
|
||||
margin: 8px 0 4px;
|
||||
padding: 6px 12px;
|
||||
font-size: 12px;
|
||||
color: var(--accent);
|
||||
background: var(--surface);
|
||||
border: 1px dashed var(--border);
|
||||
border-radius: 6px;
|
||||
cursor: pointer;
|
||||
transition: all 0.15s;
|
||||
}
|
||||
|
||||
.btn-add-channel:hover {
|
||||
background: var(--accent-light, #f0f7ff);
|
||||
border-color: var(--accent);
|
||||
}
|
||||
|
||||
/* ─── Empty State ────────────────────────────────────────── */
|
||||
.empty-state {
|
||||
text-align: center;
|
||||
padding: 60px 20px;
|
||||
color: var(--text-secondary);
|
||||
}
|
||||
|
||||
.empty-state-icon {
|
||||
font-size: 48px;
|
||||
margin-bottom: 12px;
|
||||
}
|
||||
|
||||
/* ─── Responsive ─────────────────────────────────────────── */
|
||||
@media (max-width: 1024px) {
|
||||
.templates-sidebar {
|
||||
width: 200px;
|
||||
min-width: 200px;
|
||||
}
|
||||
}
|
||||
|
||||
@media (max-width: 768px) {
|
||||
.templates-layout {
|
||||
flex-direction: column;
|
||||
}
|
||||
|
||||
.templates-sidebar {
|
||||
width: 100%;
|
||||
min-width: unset;
|
||||
max-height: 260px;
|
||||
border-right: none;
|
||||
border-bottom: 1px solid var(--border);
|
||||
border-radius: 8px 8px 0 0;
|
||||
}
|
||||
|
||||
.template-group-info {
|
||||
flex-direction: column;
|
||||
align-items: center;
|
||||
}
|
||||
}
|
||||
|
||||
/* ── Forecast Validation ── */
|
||||
.btn-validate {
|
||||
background: none;
|
||||
border: 1px solid var(--border, #d1d5db);
|
||||
color: var(--text-primary, #374151);
|
||||
padding: 4px 12px;
|
||||
border-radius: 6px;
|
||||
font-size: 12px;
|
||||
cursor: pointer;
|
||||
transition: all 0.15s;
|
||||
}
|
||||
.btn-validate:hover:not(:disabled) {
|
||||
background: var(--bg-hover, #f3f4f6);
|
||||
border-color: #8B5CF6;
|
||||
color: #8B5CF6;
|
||||
}
|
||||
.btn-validate:disabled {
|
||||
opacity: 0.6;
|
||||
cursor: wait;
|
||||
}
|
||||
|
||||
.validation-results {
|
||||
background: #fafafa;
|
||||
border: 1px solid var(--border, #e5e7eb);
|
||||
border-radius: 8px;
|
||||
padding: 12px 16px;
|
||||
margin: 8px 0 12px;
|
||||
font-size: 13px;
|
||||
}
|
||||
.validation-results.validation-error {
|
||||
border-color: #fca5a5;
|
||||
background: #fef2f2;
|
||||
}
|
||||
.validation-header {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: space-between;
|
||||
margin-bottom: 10px;
|
||||
font-weight: 600;
|
||||
font-size: 13px;
|
||||
}
|
||||
.validation-comparison {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 6px;
|
||||
}
|
||||
.validation-row {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 8px;
|
||||
padding: 4px 0;
|
||||
}
|
||||
.validation-ch {
|
||||
min-width: 80px;
|
||||
font-size: 12px;
|
||||
}
|
||||
.validation-template {
|
||||
min-width: 36px;
|
||||
text-align: right;
|
||||
font-weight: 500;
|
||||
color: var(--text-dim, #6b7280);
|
||||
}
|
||||
.validation-arrow {
|
||||
color: var(--text-dim, #9ca3af);
|
||||
font-size: 11px;
|
||||
}
|
||||
.validation-api {
|
||||
min-width: 36px;
|
||||
text-align: right;
|
||||
font-weight: 600;
|
||||
}
|
||||
.validation-api.validation-divergent {
|
||||
color: #d97706;
|
||||
}
|
||||
.validation-diff {
|
||||
font-size: 11px;
|
||||
font-weight: 600;
|
||||
min-width: 40px;
|
||||
}
|
||||
.validation-error-msg {
|
||||
color: #dc2626;
|
||||
font-size: 13px;
|
||||
}
|
||||
330
Client-Client/src/styles/wizard-launch.css
Normal file
330
Client-Client/src/styles/wizard-launch.css
Normal file
@@ -0,0 +1,330 @@
|
||||
/* ================================================================
|
||||
LAUNCH PROGRESS & RESULTS — append to wizard.css
|
||||
================================================================ */
|
||||
|
||||
/* Progress spinner during create/launch */
|
||||
.submit-progress {
|
||||
text-align: center;
|
||||
padding: 48px 24px;
|
||||
}
|
||||
.submit-progress h2 {
|
||||
margin: 16px 0 8px;
|
||||
font-size: 1.25rem;
|
||||
}
|
||||
.submit-progress p {
|
||||
color: #6b7280;
|
||||
margin-bottom: 32px;
|
||||
}
|
||||
|
||||
.progress-spinner {
|
||||
width: 48px;
|
||||
height: 48px;
|
||||
border: 3px solid #e5e7eb;
|
||||
border-top-color: #3b82f6;
|
||||
border-radius: 50%;
|
||||
margin: 0 auto;
|
||||
animation: spin 0.8s linear infinite;
|
||||
}
|
||||
@keyframes spin {
|
||||
to { transform: rotate(360deg); }
|
||||
}
|
||||
|
||||
/* Two-step progress indicator */
|
||||
.progress-steps {
|
||||
display: flex;
|
||||
justify-content: center;
|
||||
gap: 32px;
|
||||
margin-top: 24px;
|
||||
}
|
||||
.progress-step {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 8px;
|
||||
font-size: 0.875rem;
|
||||
color: #9ca3af;
|
||||
}
|
||||
.progress-step.step-active {
|
||||
color: #3b82f6;
|
||||
font-weight: 500;
|
||||
}
|
||||
.progress-step.step-done {
|
||||
color: #10b981;
|
||||
}
|
||||
.step-indicator {
|
||||
width: 24px;
|
||||
height: 24px;
|
||||
border-radius: 50%;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
font-size: 0.75rem;
|
||||
font-weight: 600;
|
||||
background: #f3f4f6;
|
||||
}
|
||||
.step-active .step-indicator {
|
||||
background: #dbeafe;
|
||||
color: #3b82f6;
|
||||
}
|
||||
.step-done .step-indicator {
|
||||
background: #d1fae5;
|
||||
color: #10b981;
|
||||
}
|
||||
.mini-spinner {
|
||||
display: inline-block;
|
||||
width: 14px;
|
||||
height: 14px;
|
||||
border: 2px solid #93c5fd;
|
||||
border-top-color: #3b82f6;
|
||||
border-radius: 50%;
|
||||
animation: spin 0.6s linear infinite;
|
||||
}
|
||||
|
||||
/* Per-channel launch results */
|
||||
.launch-results {
|
||||
text-align: left;
|
||||
margin: 24px auto;
|
||||
max-width: 480px;
|
||||
}
|
||||
.launch-results h3 {
|
||||
font-size: 0.875rem;
|
||||
text-transform: uppercase;
|
||||
letter-spacing: 0.05em;
|
||||
color: #6b7280;
|
||||
margin-bottom: 12px;
|
||||
}
|
||||
.launch-channel-result {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 10px;
|
||||
padding: 12px 16px;
|
||||
border-radius: 8px;
|
||||
margin-bottom: 8px;
|
||||
background: #f9fafb;
|
||||
border: 1px solid #e5e7eb;
|
||||
flex-wrap: wrap;
|
||||
}
|
||||
.launch-channel-result.launch-status-submitted {
|
||||
border-color: #d1fae5;
|
||||
background: #f0fdf4;
|
||||
}
|
||||
.launch-channel-result.launch-status-pending_review {
|
||||
border-color: #fef3c7;
|
||||
background: #fffbeb;
|
||||
}
|
||||
.launch-channel-result.launch-status-error {
|
||||
border-color: #fecaca;
|
||||
background: #fef2f2;
|
||||
}
|
||||
.launch-channel-dot {
|
||||
width: 10px;
|
||||
height: 10px;
|
||||
border-radius: 50%;
|
||||
flex-shrink: 0;
|
||||
}
|
||||
.launch-channel-name {
|
||||
font-weight: 500;
|
||||
min-width: 100px;
|
||||
}
|
||||
.launch-status-badge {
|
||||
font-size: 0.8rem;
|
||||
font-weight: 500;
|
||||
padding: 2px 8px;
|
||||
border-radius: 4px;
|
||||
margin-left: auto;
|
||||
}
|
||||
.launch-status-badge.status-submitted {
|
||||
color: #059669;
|
||||
background: #d1fae5;
|
||||
}
|
||||
.launch-status-badge.status-pending_review {
|
||||
color: #d97706;
|
||||
background: #fef3c7;
|
||||
}
|
||||
.launch-status-badge.status-error {
|
||||
color: #dc2626;
|
||||
background: #fecaca;
|
||||
}
|
||||
.launch-channel-message {
|
||||
width: 100%;
|
||||
font-size: 0.8rem;
|
||||
color: #6b7280;
|
||||
padding-left: 20px;
|
||||
}
|
||||
.launch-external-id {
|
||||
font-size: 0.75rem;
|
||||
font-family: monospace;
|
||||
color: #9ca3af;
|
||||
padding-left: 20px;
|
||||
}
|
||||
|
||||
/* Error state */
|
||||
.submit-error {
|
||||
text-align: center;
|
||||
padding: 48px 24px;
|
||||
}
|
||||
.submit-error h2 {
|
||||
margin: 16px 0 8px;
|
||||
color: #dc2626;
|
||||
}
|
||||
.submit-error p {
|
||||
color: #6b7280;
|
||||
margin-bottom: 24px;
|
||||
}
|
||||
.error-actions {
|
||||
display: flex;
|
||||
justify-content: center;
|
||||
gap: 12px;
|
||||
}
|
||||
|
||||
/* Status badges in results */
|
||||
.status-badge.status-warning {
|
||||
background: #fef3c7;
|
||||
color: #d97706;
|
||||
}
|
||||
|
||||
/* ================================================================
|
||||
CONFIRM LAUNCH MODAL
|
||||
================================================================ */
|
||||
|
||||
.confirm-overlay {
|
||||
position: fixed;
|
||||
inset: 0;
|
||||
background: rgba(0, 0, 0, 0.5);
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
z-index: 1000;
|
||||
padding: 24px;
|
||||
backdrop-filter: blur(2px);
|
||||
}
|
||||
.confirm-modal {
|
||||
background: #fff;
|
||||
border-radius: 12px;
|
||||
max-width: 520px;
|
||||
width: 100%;
|
||||
padding: 28px 32px;
|
||||
box-shadow: 0 20px 60px rgba(0, 0, 0, 0.2);
|
||||
animation: modalIn 0.2s ease-out;
|
||||
}
|
||||
@keyframes modalIn {
|
||||
from { opacity: 0; transform: translateY(12px) scale(0.98); }
|
||||
to { opacity: 1; transform: translateY(0) scale(1); }
|
||||
}
|
||||
|
||||
.confirm-header {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 12px;
|
||||
margin-bottom: 12px;
|
||||
}
|
||||
.confirm-icon-wrap {
|
||||
width: 44px;
|
||||
height: 44px;
|
||||
border-radius: 50%;
|
||||
background: #fffbeb;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
flex-shrink: 0;
|
||||
}
|
||||
.confirm-header h2 {
|
||||
font-size: 1.15rem;
|
||||
margin: 0;
|
||||
}
|
||||
.confirm-subtitle {
|
||||
font-size: 0.9rem;
|
||||
color: #4b5563;
|
||||
margin: 0 0 20px;
|
||||
line-height: 1.5;
|
||||
}
|
||||
|
||||
/* Billing summary table */
|
||||
.confirm-billing {
|
||||
border: 1px solid #e5e7eb;
|
||||
border-radius: 8px;
|
||||
overflow: hidden;
|
||||
margin-bottom: 16px;
|
||||
}
|
||||
.confirm-billing-row {
|
||||
display: flex;
|
||||
justify-content: space-between;
|
||||
align-items: center;
|
||||
padding: 10px 16px;
|
||||
font-size: 0.875rem;
|
||||
border-bottom: 1px solid #f3f4f6;
|
||||
}
|
||||
.confirm-billing-row:last-child {
|
||||
border-bottom: none;
|
||||
}
|
||||
.confirm-billing-sub {
|
||||
background: #f9fafb;
|
||||
padding-left: 28px;
|
||||
font-size: 0.825rem;
|
||||
color: #6b7280;
|
||||
}
|
||||
.confirm-billing-total {
|
||||
background: #f0f9ff;
|
||||
font-weight: 600;
|
||||
border-top: 2px solid #e5e7eb;
|
||||
}
|
||||
.confirm-amount {
|
||||
font-weight: 600;
|
||||
font-variant-numeric: tabular-nums;
|
||||
}
|
||||
.confirm-ch-dot {
|
||||
display: inline-block;
|
||||
width: 8px;
|
||||
height: 8px;
|
||||
border-radius: 50%;
|
||||
margin-right: 6px;
|
||||
vertical-align: middle;
|
||||
}
|
||||
|
||||
/* Note / fine print */
|
||||
.confirm-note {
|
||||
font-size: 0.78rem;
|
||||
color: #9ca3af;
|
||||
line-height: 1.5;
|
||||
margin-bottom: 16px;
|
||||
}
|
||||
|
||||
/* Terms checkbox */
|
||||
.confirm-terms {
|
||||
display: flex;
|
||||
align-items: flex-start;
|
||||
gap: 10px;
|
||||
padding: 14px 16px;
|
||||
background: #f9fafb;
|
||||
border: 1px solid #e5e7eb;
|
||||
border-radius: 8px;
|
||||
margin-bottom: 20px;
|
||||
cursor: pointer;
|
||||
font-size: 0.84rem;
|
||||
line-height: 1.5;
|
||||
color: #374151;
|
||||
}
|
||||
.confirm-terms input[type="checkbox"] {
|
||||
margin-top: 3px;
|
||||
width: 16px;
|
||||
height: 16px;
|
||||
flex-shrink: 0;
|
||||
accent-color: #3b82f6;
|
||||
cursor: pointer;
|
||||
}
|
||||
|
||||
/* Actions */
|
||||
.confirm-actions {
|
||||
display: flex;
|
||||
justify-content: flex-end;
|
||||
gap: 12px;
|
||||
}
|
||||
.confirm-actions .btn-primary:disabled {
|
||||
opacity: 0.4;
|
||||
cursor: not-allowed;
|
||||
}
|
||||
|
||||
/* Pricing source tag (visible when client has negotiated rate) */
|
||||
.confirm-pricing-source {
|
||||
color: #059669;
|
||||
font-weight: 500;
|
||||
}
|
||||
2181
Client-Client/src/styles/wizard.css
Normal file
2181
Client-Client/src/styles/wizard.css
Normal file
File diff suppressed because it is too large
Load Diff
@@ -2,26 +2,37 @@ const path = require('path');
|
||||
const HtmlWebpackPlugin = require('html-webpack-plugin');
|
||||
|
||||
module.exports = {
|
||||
entry: './src/index.js',
|
||||
output: {
|
||||
path: path.resolve(__dirname, 'dist'),
|
||||
filename: 'bundle.js',
|
||||
publicPath: '/',
|
||||
clean: true
|
||||
},
|
||||
module: {
|
||||
rules: [
|
||||
{ test: /\.(js|jsx)$/, exclude: /node_modules/, use: 'babel-loader' },
|
||||
{ test: /\.css$/, use: ['style-loader', 'css-loader'] }
|
||||
]
|
||||
},
|
||||
resolve: { extensions: ['.js', '.jsx'] },
|
||||
plugins: [
|
||||
new HtmlWebpackPlugin({ template: './public/index.html', favicon: false })
|
||||
],
|
||||
devServer: {
|
||||
port: 8080,
|
||||
historyApiFallback: true,
|
||||
hot: true
|
||||
}
|
||||
entry: './src/index.js',
|
||||
output: {
|
||||
path: path.resolve(__dirname, 'dist'),
|
||||
filename: 'bundle.js',
|
||||
},
|
||||
module: {
|
||||
rules: [
|
||||
{
|
||||
test: /\.(js|jsx)$/,
|
||||
exclude: /node_modules/,
|
||||
use: 'babel-loader',
|
||||
},
|
||||
{
|
||||
test: /\.css$/,
|
||||
use: ['style-loader', 'css-loader'],
|
||||
},
|
||||
],
|
||||
},
|
||||
resolve: {
|
||||
extensions: ['.js', '.jsx'],
|
||||
},
|
||||
plugins: [
|
||||
new HtmlWebpackPlugin({
|
||||
template: './public/index.html',
|
||||
inject: 'body',
|
||||
}),
|
||||
],
|
||||
devServer: {
|
||||
static: './public',
|
||||
port: 3000,
|
||||
hot: true,
|
||||
historyApiFallback: true,
|
||||
},
|
||||
};
|
||||
|
||||
Reference in New Issue
Block a user