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

This commit is contained in:
Grae Jones
2026-03-21 17:54:42 -07:00
parent 3647b304a3
commit fdb3e117a9
203 changed files with 35733 additions and 18189 deletions

View File

@@ -0,0 +1,35 @@
name: Client Admin
on:
push:
branches: [master]
paths:
- 'Client-Admin/**'
- '.gitea/workflows/client-admin.yml'
jobs:
build-deploy:
runs-on: host
env:
NGINX_HOST: 192.168.77.227
NGINX_PATH: /var/www/positivespend/websites/admin
SSH_KEY: ~/.ssh/gitea_runner
steps:
- name: Checkout code
uses: actions/checkout@v3
- name: Install dependencies
working-directory: Client-Admin
run: npm ci
- name: Build
working-directory: Client-Admin
run: npm run build
- name: Deploy to Nginx server
run: |
rsync -az --delete \
-e "ssh -i ${{ env.SSH_KEY }} -o StrictHostKeyChecking=no" \
Client-Admin/dist/ \
root@${{ env.NGINX_HOST }}:${{ env.NGINX_PATH }}/

View File

@@ -0,0 +1,35 @@
name: Client Client
on:
push:
branches: [master]
paths:
- 'Client-Client/**'
- '.gitea/workflows/client-client.yml'
jobs:
build-deploy:
runs-on: host
env:
NGINX_HOST: 192.168.77.227
NGINX_PATH: /var/www/positivespend/websites/client
SSH_KEY: ~/.ssh/gitea_runner
steps:
- name: Checkout code
uses: actions/checkout@v3
- name: Install dependencies
working-directory: Client-Client
run: npm ci
- name: Build
working-directory: Client-Client
run: npm run build
- name: Deploy to Nginx server
run: |
rsync -az --delete \
-e "ssh -i ${{ env.SSH_KEY }} -o StrictHostKeyChecking=no" \
Client-Client/dist/ \
root@${{ env.NGINX_HOST }}:${{ env.NGINX_PATH }}/

View File

@@ -0,0 +1,35 @@
name: Client Home
on:
push:
branches: [master]
paths:
- 'Client-Home/**'
- '.gitea/workflows/client-home.yml'
jobs:
build-deploy:
runs-on: host
env:
NGINX_HOST: 192.168.77.227
NGINX_PATH: /var/www/positivespend/websites/home
SSH_KEY: ~/.ssh/gitea_runner
steps:
- name: Checkout code
uses: actions/checkout@v3
- name: Install dependencies
working-directory: Client-Home
run: npm ci
- name: Build
working-directory: Client-Home
run: npm run build
- name: Deploy to Nginx server
run: |
rsync -az --delete \
-e "ssh -i ${{ env.SSH_KEY }} -o StrictHostKeyChecking=no" \
Client-Home/dist/ \
root@${{ env.NGINX_HOST }}:${{ env.NGINX_PATH }}/

View File

@@ -0,0 +1,35 @@
name: Client Registration
on:
push:
branches: [master]
paths:
- 'Client-Registration/**'
- '.gitea/workflows/client-registration.yml'
jobs:
build-deploy:
runs-on: host
env:
NGINX_HOST: 192.168.77.227
NGINX_PATH: /var/www/positivespend/websites/regist
SSH_KEY: ~/.ssh/gitea_runner
steps:
- name: Checkout code
uses: actions/checkout@v3
- name: Install dependencies
working-directory: Client-Registration
run: npm ci
- name: Build
working-directory: Client-Registration
run: npm run build
- name: Deploy to Nginx server
run: |
rsync -az --delete \
-e "ssh -i ${{ env.SSH_KEY }} -o StrictHostKeyChecking=no" \
Client-Registration/dist/ \
root@${{ env.NGINX_HOST }}:${{ env.NGINX_PATH }}/

View File

@@ -0,0 +1,35 @@
name: Client Tech
on:
push:
branches: [master]
paths:
- 'Client-Tech/**'
- '.gitea/workflows/client-tech.yml'
jobs:
build-deploy:
runs-on: host
env:
NGINX_HOST: 192.168.77.227
NGINX_PATH: /var/www/positivespend/websites/tech
SSH_KEY: ~/.ssh/gitea_runner
steps:
- name: Checkout code
uses: actions/checkout@v3
- name: Install dependencies
working-directory: Client-Tech
run: npm ci
- name: Build
working-directory: Client-Tech
run: npm run build
- name: Deploy to Nginx server
run: |
rsync -az --delete \
-e "ssh -i ${{ env.SSH_KEY }} -o StrictHostKeyChecking=no" \
Client-Tech/dist/ \
root@${{ env.NGINX_HOST }}:${{ env.NGINX_PATH }}/

View File

@@ -1,6 +1,6 @@
{
"presets": [
"@babel/preset-env",
["@babel/preset-react", { "runtime": "automatic" }]
]
"presets": [
"@babel/preset-env",
["@babel/preset-react", { "runtime": "automatic" }]
]
}

File diff suppressed because one or more lines are too long

View File

@@ -1 +1 @@
<!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 href="https://fonts.googleapis.com/css2?family=Inter:wght@400;500;600;700&display=swap" rel="stylesheet"></head><body><div id="root"></div><script defer="defer" src="bundle.js"></script></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>

File diff suppressed because it is too large Load Diff

View File

@@ -9,6 +9,8 @@
"dependencies": {
"@azure/msal-browser": "^3.6.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"
},

View File

@@ -5,7 +5,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 href="https://fonts.googleapis.com/css2?family=Inter:wght@400;500;600;700&display=swap" rel="stylesheet">
<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>

View File

@@ -1,43 +1,39 @@
import React, { useState } from 'react';
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 RegistrationForm from '../components/RegistrationForm';
import Dashboard from '../components/Dashboard';
export default function App() {
const { isLoading, isSignedIn, isRegistered, needsRegistration } = useAuth();
const { authState } = useAuth();
if (isLoading) {
return (
<Shell>
<div className="loading-container">
<div className="spinner"></div>
<p>Loading...</p>
</div>
</Shell>
);
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>
);
}
if (!isSignedIn) {
return (
<Shell>
<SignInOverlay />
</Shell>
);
}
if (needsRegistration && !isRegistered) {
return (
<Shell>
<RegistrationForm />
</Shell>
);
}
return (
<Shell>
<Dashboard />
</Shell>
);
}

View File

@@ -1,219 +1,157 @@
import React, { createContext, useContext, useState, useEffect, useCallback } from 'react';
import { PublicClientApplication, InteractionStatus } from '@azure/msal-browser';
import { MsalProvider, useMsal, useIsAuthenticated } from '@azure/msal-react';
import { msalConfig, loginRequest, API_BASE_URL, GATEWAY_API_URL } from './authConfig';
import { createContext, useContext, useState, useEffect, useCallback } from 'react';
import { PublicClientApplication } from '@azure/msal-browser';
import { MsalProvider, useMsal } from '@azure/msal-react';
import { msalConfig, loginRequest, MGMT_SCOPE } from './authConfig';
// ─── MSAL instance (singleton) ───
const msalInstance = new PublicClientApplication(msalConfig);
const msalReady = msalInstance.initialize();
// ─── Context ─────────────────────────────────────────────────────────────────
const AuthContext = createContext(null);
export const useAuth = () => useContext(AuthContext);
export function useAuth() {
return useContext(AuthContext);
// ─── Decode roles from a JWT access token ────────────────────────────────────
function extractRole(accessToken) {
try {
const payload = JSON.parse(atob(accessToken.split('.')[1]));
const roles = payload.roles || [];
if (roles.includes('Staff.Admin')) return 'Admin';
if (roles.includes('Staff.Tech')) return 'Tech';
return null;
} catch {
return null;
}
}
function AuthProviderInner({ children }) {
const { instance, accounts, inProgress } = useMsal();
const isAuthenticated = useIsAuthenticated();
// ─── Inner provider (needs useMsal) ──────────────────────────────────────────
function AuthInner({ children }) {
const { instance } = useMsal();
const [authState, setAuthState] = useState({
isLoading: true,
isSignedIn: false,
isRegistered: false,
needsRegistration: false,
user: null,
session: null,
clients: [],
error: null,
const [authState, setAuthState] = useState('authenticating');
const [sessionUser, setSessionUser] = useState(null);
const [userRole, setUserRole] = useState(null); // 'Admin' | 'Tech' | null
const buildUser = (account, role = null) => ({
userId: account.localAccountId,
email: account.username,
displayName: account.name || account.username,
role,
});
// Eagerly acquire access token to extract role from claims
async function resolveRole(account) {
try {
const result = await instance.acquireTokenSilent({ scopes: [MGMT_SCOPE], account });
return extractRole(result.accessToken);
} catch {
return null;
}
}
// ─── Init: handle redirect result or restore active account ─────────────
useEffect(() => {
let cancelled = false;
async function init() {
await msalReady;
try {
const response = await instance.handleRedirectPromise();
if (response?.account && !cancelled) {
instance.setActiveAccount(response.account);
const role = await resolveRole(response.account);
if (cancelled) return;
setUserRole(role);
setSessionUser(buildUser(response.account, role));
setAuthState('active');
return;
}
} catch (err) {
console.warn('[Auth] Redirect error:', err.message);
}
if (cancelled) return;
const account = instance.getActiveAccount() || instance.getAllAccounts()[0];
if (account) {
instance.setActiveAccount(account);
const role = await resolveRole(account);
if (cancelled) return;
setUserRole(role);
setSessionUser(buildUser(account, role));
setAuthState('active');
} else {
setAuthState('unauthenticated');
}
}
init();
return () => { cancelled = true; };
}, [instance]);
// ─── Acquire a fresh access token for Management API calls ──────────────
const getAccessToken = useCallback(async () => {
const account = instance.getActiveAccount() || instance.getAllAccounts()[0];
if (!account) return null;
try {
const result = await instance.acquireTokenSilent({ scopes: [MGMT_SCOPE], account });
return result?.accessToken ?? null;
} catch (err) {
console.warn('[Auth] Silent token failed, redirecting to login:', err.message);
instance.loginRedirect({ scopes: [MGMT_SCOPE] });
return null;
}
}, [instance]);
// ─── Actions ─────────────────────────────────────────────────────────────
const signIn = useCallback(() => {
setAuthState('authenticating');
instance.loginRedirect(loginRequest).catch(err => {
console.error('[Auth] Login redirect error:', err);
setAuthState('unauthenticated');
});
}, [instance]);
const getIdToken = useCallback(async () => {
if (accounts.length === 0) return null;
const signOut = useCallback(() => {
setSessionUser(null);
setUserRole(null);
setAuthState('unauthenticated');
instance.logoutRedirect({ postLogoutRedirectUri: window.location.origin });
}, [instance]);
try {
const response = await instance.acquireTokenSilent({
...loginRequest,
account: accounts[0],
});
return response.idToken;
} catch (error) {
console.error('Token acquisition failed:', error);
return null;
}
}, [instance, accounts]);
const retrySignIn = useCallback(() => {
setAuthState('unauthenticated');
instance.logoutRedirect({ postLogoutRedirectUri: window.location.origin }).catch(() => {
setAuthState('unauthenticated');
});
}, [instance]);
const checkRegistration = useCallback(async () => {
const token = await getIdToken();
if (!token) return null;
const value = {
authState,
sessionUser,
userRole, // 'Admin' | 'Tech' | null
isAdmin: userRole === 'Admin',
isTech: userRole === 'Tech',
isStaff: userRole !== null,
isAuthenticated: authState === 'active',
getAccessToken,
getIdToken: getAccessToken, // alias — AdminContext still uses this name
signIn,
signOut,
retrySignIn,
};
try {
const response = await fetch(`${API_BASE_URL}/api/onboarding/status`, {
headers: { 'Authorization': `Bearer ${token}` }
});
return await response.json();
} catch (error) {
console.error('Registration check failed:', error);
return null;
}
}, [getIdToken]);
const register = useCallback(async (clientName) => {
const token = await getIdToken();
if (!token) throw new Error('Not authenticated');
const response = await fetch(`${API_BASE_URL}/api/onboarding/register`, {
method: 'POST',
headers: {
'Authorization': `Bearer ${token}`,
'Content-Type': 'application/json',
},
body: JSON.stringify({ clientName }),
});
const data = await response.json();
if (!data.ok) throw new Error(data.error || 'Registration failed');
return data;
}, [getIdToken]);
const createSession = useCallback(async () => {
const token = await getIdToken();
if (!token) throw new Error('Not authenticated');
const response = await fetch(`${GATEWAY_API_URL}/api/auth/session`, {
method: 'POST',
headers: {
'Authorization': `Bearer ${token}`,
'Content-Type': 'application/json',
},
body: JSON.stringify({}),
});
const data = await response.json();
if (!data.ok) throw new Error(data.error || 'Session creation failed');
return data.data || data;
}, [getIdToken]);
const signIn = useCallback(async () => {
try {
await instance.loginPopup(loginRequest);
} catch (error) {
console.error('Login failed:', error);
setAuthState(prev => ({ ...prev, error: error.message }));
}
}, [instance]);
const signOut = useCallback(async () => {
await instance.logoutPopup();
setAuthState({
isLoading: false,
isSignedIn: false,
isRegistered: false,
needsRegistration: false,
user: null,
session: null,
clients: [],
error: null,
});
}, [instance]);
useEffect(() => {
if (inProgress !== InteractionStatus.None) return;
const initAuth = async () => {
if (!isAuthenticated || accounts.length === 0) {
setAuthState(prev => ({ ...prev, isLoading: false, isSignedIn: false }));
return;
}
const account = accounts[0];
setAuthState(prev => ({
...prev,
isSignedIn: true,
user: { name: account.name, email: account.username },
}));
const status = await checkRegistration();
if (status?.ok) {
if (status.isRegistered) {
try {
const session = await createSession();
setAuthState(prev => ({
...prev,
isLoading: false,
isRegistered: true,
needsRegistration: false,
session,
clients: status.clients || [],
}));
} catch (error) {
setAuthState(prev => ({
...prev,
isLoading: false,
isRegistered: true,
error: error.message,
}));
}
} else {
setAuthState(prev => ({
...prev,
isLoading: false,
isRegistered: false,
needsRegistration: true,
}));
}
} else {
setAuthState(prev => ({
...prev,
isLoading: false,
error: 'Failed to check registration status',
}));
}
};
initAuth();
}, [isAuthenticated, accounts, inProgress, checkRegistration, createSession]);
const completeRegistration = useCallback(async (clientName) => {
setAuthState(prev => ({ ...prev, isLoading: true, error: null }));
try {
await register(clientName);
const session = await createSession();
setAuthState(prev => ({
...prev,
isLoading: false,
isRegistered: true,
needsRegistration: false,
session,
}));
return true;
} catch (error) {
setAuthState(prev => ({
...prev,
isLoading: false,
error: error.message,
}));
return false;
}
}, [register, createSession]);
const value = {
...authState,
signIn,
signOut,
getIdToken,
completeRegistration,
};
return <AuthContext.Provider value={value}>{children}</AuthContext.Provider>;
return <AuthContext.Provider value={value}>{children}</AuthContext.Provider>;
}
export function AuthProvider({ children }) {
return (
<MsalProvider instance={msalInstance}>
<AuthProviderInner>{children}</AuthProviderInner>
</MsalProvider>
);
// ─── Outer wrapper ────────────────────────────────────────────────────────────
export default function AuthProvider({ children }) {
return (
<MsalProvider instance={msalInstance}>
<AuthInner>{children}</AuthInner>
</MsalProvider>
);
}
export { AuthProvider };

View File

@@ -1,25 +1,54 @@
/**
* MSAL Configuration for Entra External ID
*/
// ─── Entra org tenant — staff only ───────────────────────────────────────────
const TENANT_ID = '0be4c23a-6941-4bdb-b397-a4faf88de4b3';
// MediaPoint-Admin app registration clientId (Admin SPA — NOT the Graph API app)
// b0f29246... is the Graph API server app — replace this with the correct value
// from Entra portal → App Registrations → MediaPoint-Admin → Application (client) ID
const CLIENT_ID = 'b0f29246-91e7-4615-96db-5de9b6f8da2e'; // TODO: replace with MediaPoint-Admin clientId
const AUTHORITY = `https://login.microsoftonline.com/${TENANT_ID}`;
// ─── Management API resource ──────────────────────────────────────────────────
// This is the audience for access tokens sent to Management API.
// Scope requests this audience so tokens carry the `roles` claim.
export const MGMT_APP_ID = '4e4d69c3-558a-4a27-a689-17bd397175e5'; // MediaPoint-Management appId
export const MGMT_SCOPE = `api://${MGMT_APP_ID}/access_as_user`;
// ─── URLs ─────────────────────────────────────────────────────────────────────
export const MANAGEMENT_URL = 'https://adpmgmt.usimdev.com';
export const REGISTRATION_URL = 'https://adpregist.usimdev.com';
// ─── MSAL configuration ───────────────────────────────────────────────────────
export const msalConfig = {
auth: {
clientId: '154c9111-14a0-4c0f-8132-7bc68254a74e',
authority: 'https://login.microsoftonline.com/891f98f1-ed34-42a1-9b6c-28b0554d92c2',
redirectUri: window.location.origin,
postLogoutRedirectUri: window.location.origin,
},
cache: {
cacheLocation: 'sessionStorage',
storeAuthStateInCookie: false,
auth: {
clientId: CLIENT_ID,
authority: AUTHORITY,
redirectUri: window.location.origin,
postLogoutRedirectUri: window.location.origin,
knownAuthorities: ['login.microsoftonline.com'],
navigateToLoginRequestUrl: true,
},
cache: {
cacheLocation: 'localStorage',
storeAuthStateInCookie: false,
},
system: {
loggerOptions: {
loggerCallback: (level, message, containsPii) => {
if (containsPii) return;
if (level === 0) console.error(message);
else if (level === 1) console.warn(message);
},
logLevel: 1, // warn + error only
},
},
};
// Login request — include Management API scope so access tokens carry `roles`
export const loginRequest = {
scopes: ['openid', 'profile', 'email'],
scopes: ['openid', 'profile', 'email', MGMT_SCOPE],
prompt: 'select_account',
};
// Management API base URL
export const API_BASE_URL = 'https://usim-adp-management.lemonbeach-1e8e273b.westus.azurecontainerapps.io';
// Gateway API base URL
export const GATEWAY_API_URL = 'https://adsapi.usimdev.com';
// ─── Aliases ──────────────────────────────────────────────────────────────────
export const MGMT_BASE = MANAGEMENT_URL;

View File

@@ -1,119 +1,170 @@
import React, { useState, useEffect, useCallback } from 'react';
import { useAuth } from '../auth/AuthProvider';
import { API_BASE_URL } from '../auth/authConfig';
import ClientsPanel from './admin/ClientsPanel';
import UsersPanel from './admin/UsersPanel';
import React, { useRef } from 'react';
import { useAdmin, CATEGORY_LABELS, TAB_ENDPOINTS } from '../context/AdminContext';
import { TemplatesProvider } from '../context/TemplatesContext';
import { ObjectiveMappingsProvider } from '../context/ObjectiveMappingsContext';
import Sidebar from './Sidebar';
import ClientUsersPanel from './admin/ClientUsersPanel';
import SessionsPanel from './admin/SessionsPanel';
import TemplatesPanel from './admin/TemplatesPanel';
import ObjectiveMappingPanel from './admin/ObjectiveMappingPanel';
import CampaignsPanel from './admin/CampaignsPanel';
import IntelligencePanel from './admin/IntelligencePanel';
import ModifiersPanel from './admin/ModifiersPanel';
import ClientManagementPanel from './admin/ClientManagementPanel';
import ClientActivityPanel from './admin/ClientActivityPanel';
import ClientDocumentsPanel from './admin/ClientDocumentsPanel';
import DocumentsPanel from './admin/DocumentsPanel';
export default function Dashboard() {
const { session } = useAuth();
const [activeTab, setActiveTab] = useState('overview');
const [data, setData] = useState(null);
const [loading, setLoading] = useState(false);
const [error, setError] = useState(null);
const [refreshKey, setRefreshKey] = useState(0);
const fetchData = useCallback(async (endpoint) => {
if (!session?.sessionToken) return null;
setLoading(true);
setError(null);
try {
const response = await fetch(`${API_BASE_URL}${endpoint}`, {
headers: { 'X-Session-Token': session.sessionToken }
});
const result = await response.json();
if (!result.ok) throw new Error(result.error || 'Request failed');
return result;
} catch (err) {
setError(err.message);
return null;
} finally {
setLoading(false);
}
}, [session]);
const refresh = () => setRefreshKey(k => k + 1);
useEffect(() => {
const loadData = async () => {
let result = null;
switch (activeTab) {
case 'overview':
result = await fetchData('/api/monitoring/health');
break;
case 'clients':
result = await fetchData('/api/admin/clients');
break;
case 'users':
result = await fetchData('/api/admin/users');
break;
case 'sessions':
result = await fetchData('/api/admin/sessions');
break;
}
setData(result);
};
loadData();
}, [activeTab, fetchData, refreshKey]);
const tabs = [
{ id: 'overview', label: 'Overview' },
{ id: 'clients', label: 'Clients' },
{ id: 'users', label: 'Users' },
{ id: 'sessions', label: 'Sessions' },
];
const {
user, userRole, isAuthenticated,
activeCategory, activeTab, tabs, collapsed,
setActiveCategory, setActiveTab, setCollapsed,
data, loading, error,
} = useAdmin();
const tabBarRef = useRef(null);
return (
<div className="dashboard">
<div className="dashboard-header">
<h1>Management Dashboard</h1>
<div className="dashboard-info">
<span className="info-item"><strong>Client:</strong> {session?.clientName}</span>
<span className="info-item"><strong>Role:</strong> {session?.role}</span>
<div className="dashboard-layout">
{/* Sidebar */}
<Sidebar
activeCategory={activeCategory}
onSelectCategory={setActiveCategory}
collapsed={collapsed}
onToggleCollapse={() => setCollapsed(c => !c)}
/>
{/* Main area */}
<div className="dashboard-main">
{/* Header with category title + tabs */}
<header className="dashboard-header">
<div className="dashboard-header-top">
<h1 className="dashboard-title">
{CATEGORY_LABELS[activeCategory] || activeCategory}
</h1>
<div className="dashboard-header-right">
<span className="dashboard-meta">
{user?.displayName && <span>{user.displayName}</span>}
{userRole && <span><strong>Role:</strong> {userRole}</span>}
</span>
</div>
</div>
{/* Horizontal tabs (within the current category) */}
{tabs.length > 1 && (
<div className="dashboard-tabs" ref={tabBarRef}>
{tabs.map(tab => (
<button
key={tab.id}
className={`tab-btn ${activeTab === tab.id ? 'active' : ''}`}
onClick={() => setActiveTab(tab.id)}
>
{tab.label}
</button>
))}
</div>
)}
</header>
{/* Content area */}
<div className="dashboard-content">
{/* Loading state — full spinner only on initial load */}
{loading && !data && (
<div className="loading-container">
<div className="spinner"></div>
<p>Loading...</p>
</div>
)}
{/* Refreshing indicator — subtle, panels stay mounted */}
{loading && data && (
<div style={{ padding: '4px 12px', fontSize: '12px', color: '#888', textAlign: 'right' }}>
Refreshing
</div>
)}
{/* Error state */}
{error && !loading && (
<div className="error-message" style={{ margin: '0 0 16px 0' }}>
<strong>Error:</strong> {error}
</div>
)}
{/* Data-driven panels — stay mounted during refresh */}
{(data || (error && activeTab === 'templates') || activeTab === 'objectives'
|| activeTab === 'modifiers'
|| activeTab === 'performance' || activeTab === 'insights' || activeTab === 'analysis'
|| activeTab === 'pending' || activeTab === 'allClients'
|| activeTab === 'clientActivity'
|| activeTab === 'clientDocuments'
|| activeTab === 'documents') && (
<div className="data-panel" style={{ opacity: loading ? 0.6 : 1, transition: 'opacity 0.2s' }}>
{activeTab === 'overview' && data && <OverviewPanel data={data} />}
{activeTab === 'sessions' && data && <SessionsPanel />}
{activeTab === 'clientUsers' && data && <ClientUsersPanel />}
{activeTab === 'templates' && (
<TemplatesProvider>
<TemplatesPanel />
</TemplatesProvider>
)}
{activeTab === 'objectives' && (
<ObjectiveMappingsProvider>
<ObjectiveMappingPanel />
</ObjectiveMappingsProvider>
)}
{activeTab === 'modifiers' && <ModifiersPanel />}
{activeTab === 'campaigns' && data && <CampaignsPanel />}
{(activeTab === 'performance' || activeTab === 'insights' || activeTab === 'analysis') && (
<IntelligencePanel activeTab={activeTab} />
)}
{(activeTab === 'pending' || activeTab === 'allClients') && (
<ClientManagementPanel activeTab={activeTab} />
)}
{activeTab === 'clientActivity' && <ClientActivityPanel />}
{activeTab === 'clientDocuments' && <ClientDocumentsPanel />}
{activeTab === 'documents' && <DocumentsPanel />}
</div>
)}
{/* Placeholder for tabs without API endpoints */}
{!loading && !error && !data && !TAB_ENDPOINTS[activeTab]
&& activeTab !== 'pending' && activeTab !== 'allClients'
&& activeTab !== 'clientActivity'
&& activeTab !== 'clientDocuments'
&& activeTab !== 'performance' && activeTab !== 'insights' && activeTab !== 'analysis'
&& activeTab !== 'documents' && (
<div className="placeholder-panel">
<div className="placeholder-icon">🚧</div>
<h3>{activeTab.charAt(0).toUpperCase() + activeTab.slice(1)}</h3>
<p>This section is under development.</p>
</div>
)}
{/* Fallback */}
{!loading && !error && !data && activeTab !== 'templates'
&& activeTab !== 'modifiers'
&& activeTab !== 'pending' && activeTab !== 'allClients'
&& activeTab !== 'clientActivity'
&& activeTab !== 'clientDocuments'
&& activeTab !== 'performance' && activeTab !== 'insights' && activeTab !== 'analysis'
&& activeTab !== 'documents'
&& TAB_ENDPOINTS[activeTab] && (
<div style={{ padding: '40px', textAlign: 'center', color: '#888' }}>
<p>No data loaded. Check the browser console for logs.</p>
</div>
)}
</div>
</div>
<nav className="dashboard-tabs">
{tabs.map(tab => (
<button
key={tab.id}
className={`tab-btn ${activeTab === tab.id ? 'active' : ''}`}
onClick={() => setActiveTab(tab.id)}
>
{tab.label}
</button>
))}
</nav>
<div className="dashboard-content">
{loading && (
<div className="loading-container">
<div className="spinner"></div>
<p>Loading...</p>
</div>
)}
{error && <div className="error-message">{error}</div>}
{!loading && !error && data && (
<div className="data-panel">
{activeTab === 'overview' && <OverviewPanel data={data} />}
{activeTab === 'clients' && <ClientsPanel data={data} sessionToken={session?.sessionToken} onRefresh={refresh} />}
{activeTab === 'users' && <UsersPanel data={data} sessionToken={session?.sessionToken} onRefresh={refresh} />}
{activeTab === 'sessions' && <SessionsPanel data={data} sessionToken={session?.sessionToken} onRefresh={refresh} />}
</div>
)}
</div>
</div>
);
}
// ─── Overview Panel (lightweight, stays local) ───────────────
function OverviewPanel({ data }) {
return (
<div className="overview-panel">
<h2>System Overview</h2>
<div className="stats-grid">
<StatCard label="Active Clients" value={data.activeClients} />
<StatCard label="Active Users" value={data.activeUsers} />
@@ -128,7 +179,7 @@ function OverviewPanel({ data }) {
function StatCard({ label, value }) {
return (
<div className="stat-card">
<div className="stat-value">{value ?? '-'}</div>
<div className="stat-value">{value ?? ''}</div>
<div className="stat-label">{label}</div>
</div>
);

View 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="admin.campaigns.pacing" label="Budget Pacing" />
//
// helpKey format: {app}.{section}.{element}
// admin.campaigns.status admin.clients.approval
// client.wizard.budget client.wizard.audience
// ─────────────────────────────────────────────────────────────
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 || error ? 'Help' : label)}
</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>
)}
</>
);
}

View File

@@ -2,7 +2,7 @@ import React from 'react';
import { useAuth } from '../auth/AuthProvider';
export default function Shell({ children }) {
const { isSignedIn, user, signOut } = useAuth();
const { isAuthenticated: isSignedIn, sessionUser: user, signOut } = useAuth();
return (
<div className="shell">

View File

@@ -0,0 +1,170 @@
import React from 'react';
import { useAuth } from '../auth/AuthProvider';
// ─── SVG Icon components ──────────────────────────────────────────────────────
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>
),
clients: (
<svg width="20" height="20" viewBox="0 0 20 20" fill="none" stroke="currentColor" strokeWidth="1.5" strokeLinecap="round" strokeLinejoin="round">
<circle cx="7" cy="6" r="3" />
<circle cx="14" cy="7" r="2.5" />
<path d="M1.5 17c0-3 2.5-5 5.5-5s5.5 2 5.5 5" />
<path d="M12.5 12c1.5 0 4 1 4 3.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" />
<path d="M16 3v14" />
</svg>
),
config: (
<svg width="20" height="20" viewBox="0 0 20 20" fill="none" stroke="currentColor" strokeWidth="1.5" strokeLinecap="round" strokeLinejoin="round">
<path d="M3 5h14M3 10h14M3 15h14" />
<circle cx="7" cy="5" r="1.5" fill="currentColor" />
<circle cx="13" cy="10" r="1.5" fill="currentColor" />
<circle cx="9" cy="15" r="1.5" fill="currentColor" />
</svg>
),
intelligence: (
<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 8h16" />
<path d="M6 12h3" />
</svg>
),
settings: (
<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="2.5" />
<path d="M10 2v2.5M10 15.5V18M18 10h-2.5M4.5 10H2M15.66 4.34l-1.77 1.77M6.11 13.89l-1.77 1.77M15.66 15.66l-1.77-1.77M6.11 6.11L4.34 4.34" />
</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="M11.5 2H5a1.5 1.5 0 00-1.5 1.5v13A1.5 1.5 0 005 18h10a1.5 1.5 0 001.5-1.5V7z" />
<path d="M11.5 2v5H17" />
<line x1="6.5" y1="11" x2="13.5" y2="11" />
<line x1="6.5" y1="14" x2="13.5" y2="14" />
</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>
),
};
// ─── Navigation structure ─────────────────────────────────────────────────────
// Each item maps to a category in the sidebar. Tabs within each category
// are defined in AdminContext.jsx (CATEGORY_TABS).
export const NAV_ITEMS = [
{ id: 'dashboard', label: 'Dashboard', icon: 'dashboard' },
{ id: 'clients', label: 'Clients', icon: 'clients', badge: null },
{ id: 'campaigns', label: 'Campaigns', icon: 'campaigns' },
{ id: 'intelligence', label: 'Intelligence', icon: 'intelligence' },
{ id: 'config', label: 'Configuration', icon: 'config' },
{ id: 'documents', label: 'Documents', icon: 'documents' },
{ divider: true },
{ id: 'billing', label: 'Billing', icon: 'billing', disabled: true },
{ id: 'settings', label: 'Settings', icon: 'settings', disabled: true },
];
export default function Sidebar({ activeCategory, onSelectCategory, collapsed, onToggleCollapse }) {
const { user, signOut } = useAuth();
return (
<aside className={`sidebar ${collapsed ? 'sidebar-collapsed' : ''}`}>
{/* Logo */}
<div className="sidebar-logo">
<span className="sidebar-logo-icon"></span>
{!collapsed && (
<>
<span className="sidebar-logo-text">AdPlatform</span>
<span className="sidebar-logo-badge">Mgmt</span>
</>
)}
</div>
{/* Nav items */}
<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>
{item.badge != null && (
<span className="sidebar-badge">{item.badge}</span>
)}
{isDisabled && (
<span className="sidebar-soon">Soon</span>
)}
</>
)}
</button>
);
})}
</nav>
{/* Footer: user + collapse */}
<div className="sidebar-footer">
{!collapsed && user && (
<div className="sidebar-user">
<div className="sidebar-user-avatar">
{(user.name || user.email || '?')[0].toUpperCase()}
</div>
<div className="sidebar-user-info">
<span className="sidebar-user-name">{user.name || user.email}</span>
<button onClick={signOut} className="sidebar-user-signout">Sign Out</button>
</div>
</div>
)}
{collapsed && user && (
<div className="sidebar-user-collapsed" title={user.name || user.email}>
<div className="sidebar-user-avatar">
{(user.name || user.email || '?')[0].toUpperCase()}
</div>
</div>
)}
<button
className="sidebar-collapse-btn"
onClick={onToggleCollapse}
title={collapsed ? 'Expand' : 'Collapse'}
>
<span className={`sidebar-collapse-icon ${collapsed ? '' : 'sidebar-collapse-icon-flip'}`}>
{Icons.chevron}
</span>
{!collapsed && <span>Collapse</span>}
</button>
</div>
</aside>
);
}

View File

@@ -2,20 +2,54 @@ import React from 'react';
import { useAuth } from '../auth/AuthProvider';
export default function SignInOverlay() {
const { signIn, error } = useAuth();
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 Management</h1>
<p className="signin-subtitle">Sign in to manage your advertising platform</p>
{error && <div className="error-message">{error}</div>}
<button onClick={signIn} className="btn-signin">Sign in with Microsoft</button>
<p className="signin-help">Use your organization account to sign in</p>
<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>
);

View File

@@ -0,0 +1,389 @@
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/list', 'POST', {});
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);
const body = {};
if (statusFilter) body.status = statusFilter;
if (clientFilter) body.clientId = clientFilter;
if (dateFrom) body.dateFrom = dateFrom;
if (dateTo) body.dateTo = dateTo;
const result = await apiCall('/api/admin/campaigns/list', 'POST', body);
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;
}

View File

@@ -0,0 +1,341 @@
import React, { useState, useEffect, useCallback } from 'react';
import { useAdmin } from '../../context/AdminContext';
// ─── Helpers ──────────────────────────────────────────────────────────────────
const fmtDateTime = (iso) => {
if (!iso) return '—';
return new Date(iso).toLocaleString('en-US', {
month: 'short', day: 'numeric', hour: 'numeric', minute: '2-digit', second: '2-digit',
});
};
const fmtDuration = (ms) => {
if (ms == null) return '—';
if (ms < 1000) return `${ms}ms`;
return `${(ms / 1000).toFixed(1)}s`;
};
function StatusBadge({ code }) {
if (!code) return <span style={{ color: '#94a3b8' }}></span>;
let bg, color;
if (code < 300) { bg = '#dcfce7'; color = '#166534'; }
else if (code < 400) { bg = '#dbeafe'; color = '#1e40af'; }
else if (code < 500) { bg = '#fef9c3'; color = '#854d0e'; }
else { bg = '#fee2e2'; color = '#991b1b'; }
return (
<span style={{
background: bg, color, padding: '2px 7px', borderRadius: 10,
fontSize: 11, fontWeight: 600,
}}>
{code}
</span>
);
}
function MethodBadge({ method }) {
if (!method) return null;
const colors = {
GET: { bg: '#eff6ff', color: '#1d4ed8' },
POST: { bg: '#f0fdf4', color: '#166534' },
PUT: { bg: '#fffbeb', color: '#92400e' },
PATCH: { bg: '#fdf4ff', color: '#6b21a8' },
DELETE: { bg: '#fff1f2', color: '#9f1239' },
};
const s = colors[method] || { bg: '#f1f5f9', color: '#475569' };
return (
<span style={{
background: s.bg, color: s.color, padding: '2px 7px', borderRadius: 10,
fontSize: 11, fontWeight: 700, letterSpacing: 0.4,
}}>
{method}
</span>
);
}
// ─── Component ────────────────────────────────────────────────────────────────
export default function ClientActivityPanel() {
const { apiCall } = useAdmin();
// Client picker state
const [clients, setClients] = useState([]);
const [clientsLoading, setClientsLoading] = useState(true);
const [selectedClientId, setSelectedClientId] = useState('');
const [selectedClientName, setSelectedClientName] = useState('');
// Activity list state
const [items, setItems] = useState([]);
const [totalCount, setTotalCount] = useState(0);
const [page, setPage] = useState(1);
const pageSize = 50;
const [loading, setLoading] = useState(false);
const [error, setError] = useState(null);
// Filters
const [pathFilter, setPathFilter] = useState('');
const [statusFilter, setStatusFilter] = useState('');
const [dateFrom, setDateFrom] = useState('');
const [dateTo, setDateTo] = useState('');
// ── Load client list for picker ──────────────────────────────────────────
useEffect(() => {
(async () => {
setClientsLoading(true);
try {
const result = await apiCall('/api/admin/clients/list', 'POST', { page: 1, pageSize: 200 });
if (result?.ok && Array.isArray(result.clients)) {
const active = result.clients.filter(c => c.cltStatus === 'Active' || c.status === 'Active');
setClients(active.length ? active : result.clients);
}
} catch { /* ignore */ }
finally { setClientsLoading(false); }
})();
}, [apiCall]);
// ── Load activity for selected client ────────────────────────────────────
const loadActivity = useCallback(async (clientId, pg = 1) => {
if (!clientId) return;
setLoading(true);
setError(null);
try {
const body = {
clientId,
page: pg,
pageSize,
pathContains: pathFilter || undefined,
statusCode: statusFilter ? parseInt(statusFilter) : undefined,
dateFrom: dateFrom || undefined,
dateTo: dateTo || undefined,
};
const result = await apiCall('/api/admin/client-activity/list', 'POST', body);
if (result?.ok) {
setItems(Array.isArray(result.items) ? result.items : []);
setTotalCount(result.totalCount ?? 0);
setPage(pg);
} else {
setError(result?.error || 'Failed to load activity');
}
} catch (err) {
setError(err.message);
} finally {
setLoading(false);
}
}, [apiCall, pathFilter, statusFilter, dateFrom, dateTo]);
const handleClientChange = (e) => {
const cid = e.target.value;
const name = clients.find(c => (c.cltId || c.clientId) === cid)?.cltName || cid;
setSelectedClientId(cid);
setSelectedClientName(name);
setPage(1);
setItems([]);
setTotalCount(0);
if (cid) loadActivity(cid, 1);
};
const handleSearch = () => {
if (selectedClientId) loadActivity(selectedClientId, 1);
};
const totalPages = Math.ceil(totalCount / pageSize);
// ── Render ────────────────────────────────────────────────────────────────
return (
<div className="data-panel" style={{ maxWidth: 1100 }}>
{/* Header */}
<div className="panel-header">
<div>
<h3 className="panel-title">Client Activity</h3>
<p className="panel-subtitle">
Gateway request log from <code>tbAccessLog</code>
</p>
</div>
</div>
{/* Client picker + filters */}
<div style={{ display: 'flex', flexWrap: 'wrap', gap: 10, marginBottom: 20, alignItems: 'flex-end' }}>
<div style={{ flex: '0 0 220px' }}>
<label className="form-label" style={{ marginBottom: 4 }}>Client</label>
<select
className="form-select"
value={selectedClientId}
onChange={handleClientChange}
disabled={clientsLoading}
>
<option value="">
{clientsLoading ? 'Loading…' : '— select a client —'}
</option>
{clients.map(c => {
const id = c.cltId || c.clientId;
const name = c.cltName || c.name;
return <option key={id} value={id}>{name}</option>;
})}
</select>
</div>
<div style={{ flex: '1 1 160px', minWidth: 140 }}>
<label className="form-label" style={{ marginBottom: 4 }}>Path contains</label>
<input
className="form-input"
type="text"
placeholder="/api/initiative"
value={pathFilter}
onChange={e => setPathFilter(e.target.value)}
onKeyDown={e => e.key === 'Enter' && handleSearch()}
/>
</div>
<div style={{ flex: '0 0 100px' }}>
<label className="form-label" style={{ marginBottom: 4 }}>Status code</label>
<select
className="form-select"
value={statusFilter}
onChange={e => setStatusFilter(e.target.value)}
>
<option value="">All</option>
<option value="200">200</option>
<option value="400">400</option>
<option value="401">401</option>
<option value="403">403</option>
<option value="404">404</option>
<option value="500">500</option>
</select>
</div>
<div style={{ flex: '0 0 160px' }}>
<label className="form-label" style={{ marginBottom: 4 }}>From</label>
<input
className="form-input"
type="date"
value={dateFrom}
onChange={e => setDateFrom(e.target.value)}
/>
</div>
<div style={{ flex: '0 0 160px' }}>
<label className="form-label" style={{ marginBottom: 4 }}>To</label>
<input
className="form-input"
type="date"
value={dateTo}
onChange={e => setDateTo(e.target.value)}
/>
</div>
<button
className="btn-action"
onClick={handleSearch}
disabled={!selectedClientId || loading}
style={{ alignSelf: 'flex-end' }}
>
{loading ? 'Loading…' : 'Search'}
</button>
</div>
{/* Empty prompt */}
{!selectedClientId && (
<div className="empty-state">
Select a client to view their activity log.
</div>
)}
{/* Error */}
{error && <div className="error-message">{error}</div>}
{/* Summary bar */}
{selectedClientId && !loading && items.length > 0 && (
<div style={{
display: 'flex', gap: 20, padding: '8px 12px', background: '#f8fafc',
borderRadius: 6, marginBottom: 12, fontSize: 13, color: '#475569',
border: '1px solid #e2e8f0',
}}>
<span><strong>{selectedClientName}</strong></span>
<span>{totalCount.toLocaleString()} request{totalCount !== 1 ? 's' : ''}</span>
{totalPages > 1 && <span>Page {page} of {totalPages}</span>}
</div>
)}
{/* Activity table */}
{selectedClientId && !loading && items.length > 0 && (
<>
<table className="data-table" style={{ fontSize: 12 }}>
<thead>
<tr>
<th>Time</th>
<th>Method</th>
<th>Path</th>
<th>Status</th>
<th>Duration</th>
<th>User</th>
<th>IP</th>
</tr>
</thead>
<tbody>
{items.map(item => (
<tr key={item.id}
style={item.statusCode >= 500 ? { background: '#fff1f2' }
: item.statusCode >= 400 ? { background: '#fffbeb' }
: undefined}
>
<td style={{ whiteSpace: 'nowrap', color: '#64748b' }}>
{fmtDateTime(item.timestamp)}
</td>
<td><MethodBadge method={item.method} /></td>
<td style={{ fontFamily: 'monospace', fontSize: 11, maxWidth: 320, overflow: 'hidden', textOverflow: 'ellipsis', whiteSpace: 'nowrap' }}>
<span title={item.path + (item.queryString || '')}>
{item.path}
{item.queryString && (
<span style={{ color: '#94a3b8' }}>{item.queryString}</span>
)}
</span>
</td>
<td><StatusBadge code={item.statusCode} /></td>
<td style={{ color: '#64748b', whiteSpace: 'nowrap' }}>
{fmtDuration(item.durationMs)}
</td>
<td style={{ color: '#64748b', fontSize: 11 }}>
{item.userEmail || item.userDisplayName || '—'}
</td>
<td style={{ color: '#94a3b8', fontSize: 11, fontFamily: 'monospace' }}>
{item.ipAddress || '—'}
</td>
</tr>
))}
</tbody>
</table>
{/* Pagination */}
{totalPages > 1 && (
<div style={{ display: 'flex', gap: 8, marginTop: 16, justifyContent: 'center' }}>
<button
className="btn-cancel"
disabled={page <= 1}
onClick={() => loadActivity(selectedClientId, page - 1)}
>
Prev
</button>
<span style={{ padding: '6px 12px', fontSize: 13, color: '#475569' }}>
{page} / {totalPages}
</span>
<button
className="btn-cancel"
disabled={page >= totalPages}
onClick={() => loadActivity(selectedClientId, page + 1)}
>
Next
</button>
</div>
)}
</>
)}
{/* Empty result */}
{selectedClientId && !loading && !error && items.length === 0 && (
<div className="empty-state">
No activity found for this client with the current filters.
</div>
)}
</div>
);
}

View File

@@ -0,0 +1,396 @@
import React, { useState, useEffect, useCallback, useRef } from 'react';
import { useAdmin } from '../../context/AdminContext';
// ─── Constants ────────────────────────────────────────────────────────────────
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' },
};
// ─── Helpers ──────────────────────────────────────────────────────────────────
const formatBytes = (bytes) => {
if (!bytes) return '—';
if (bytes < 1024) return `${bytes} B`;
if (bytes < 1048576) return `${(bytes / 1024).toFixed(1)} KB`;
return `${(bytes / 1048576).toFixed(1)} MB`;
};
const formatDate = (iso) => {
if (!iso) return '—';
return new Date(iso).toLocaleString('en-US', {
month: 'short', day: 'numeric', year: 'numeric',
hour: 'numeric', minute: '2-digit',
});
};
function FileIcon({ mimeType }) {
if (!mimeType) return <span>📄</span>;
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('presentation') || mimeType.includes('powerpoint')) return <span>📙</span>;
if (mimeType.includes('image')) return <span>🖼</span>;
if (mimeType.includes('zip') || mimeType.includes('compressed')) return <span>📦</span>;
return <span>📄</span>;
}
function CategoryBadge({ category }) {
const style = CATEGORY_COLORS[category] || { bg: '#f1f5f9', text: '#475569' };
return (
<span style={{
background: style.bg, color: style.text,
padding: '2px 8px', borderRadius: 12,
fontSize: 11, fontWeight: 600, whiteSpace: 'nowrap',
}}>
{category || 'Uncategorized'}
</span>
);
}
// ─── Component ────────────────────────────────────────────────────────────────
export default function ClientDocumentsPanel() {
const { apiCall } = useAdmin();
// Client picker
const [clients, setClients] = useState([]);
const [clientsLoading, setClientsLoading] = useState(true);
const [selectedClientId, setSelectedClientId] = useState('');
const [selectedClientName, setSelectedClientName] = useState('');
// Document list
const [docs, setDocs] = useState([]);
const [loading, setLoading] = useState(false);
const [error, setError] = useState(null);
const [downloading, setDownloading] = useState(null);
const [deleteId, setDeleteId] = useState(null);
const [deleting, setDeleting] = useState(false);
// Upload form
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 fileInputRef = useRef();
// ── Load client list ──────────────────────────────────────────────────────
useEffect(() => {
(async () => {
setClientsLoading(true);
try {
const result = await apiCall('/api/admin/clients/list', 'POST', { page: 1, pageSize: 200 });
if (result?.ok && Array.isArray(result.clients)) {
setClients(result.clients);
}
} catch { /* ignore */ }
finally { setClientsLoading(false); }
})();
}, [apiCall]);
// ── Load documents for selected client ────────────────────────────────────
const loadDocs = useCallback(async (clientId) => {
if (!clientId) return;
setLoading(true);
setError(null);
try {
const result = await apiCall('/api/admin/client-documents/list', 'POST', { clientId });
if (result?.ok && Array.isArray(result.documents)) {
setDocs(result.documents);
} else {
setError(result?.error || result?.message || 'Failed to load documents');
}
} catch (err) {
setError(err.message);
} finally {
setLoading(false);
}
}, [apiCall]);
const handleClientChange = (e) => {
const cid = e.target.value;
const name = clients.find(c => (c.cltId || c.clientId) === cid)?.cltName || cid;
setSelectedClientId(cid);
setSelectedClientName(name);
setDocs([]);
setError(null);
setShowUpload(false);
if (cid) loadDocs(cid);
};
// ── Upload ────────────────────────────────────────────────────────────────
const handleUpload = async () => {
if (!uploadFile) { setUploadError('Please select a file'); return; }
if (!selectedClientId) { setUploadError('No client selected'); return; }
setUploading(true);
setUploadError(null);
try {
const formData = new FormData();
formData.append('file', uploadFile);
formData.append('clientId', selectedClientId);
formData.append('category', uploadCat);
formData.append('description', uploadDesc);
const result = await apiCall('/api/admin/client-documents/upload', {
method: 'POST',
body: formData,
});
if (!result?.ok) throw new Error(result?.error || result?.message || 'Upload failed');
setShowUpload(false);
setUploadFile(null);
setUploadDesc('');
setUploadCat('Business');
await loadDocs(selectedClientId);
} catch (err) {
setUploadError(err.message);
} finally {
setUploading(false);
}
};
// ── Download ──────────────────────────────────────────────────────────────
const handleDownload = async (doc) => {
setDownloading(doc.docId);
try {
const result = await apiCall(`/api/admin/client-documents/${doc.docId}/download`, {
rawResponse: true,
});
if (!result) throw new Error('No response');
const blob = await result.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);
}
};
// ── Delete ────────────────────────────────────────────────────────────────
const handleDelete = async () => {
if (!deleteId) return;
setDeleting(true);
try {
const result = await apiCall(`/api/admin/client-documents/${deleteId}`, { method: 'DELETE' });
if (!result?.ok) throw new Error(result?.error || result?.message || 'Delete failed');
setDeleteId(null);
await loadDocs(selectedClientId);
} catch (err) {
alert(`Delete failed: ${err.message}`);
} finally {
setDeleting(false);
}
};
// ── Render ────────────────────────────────────────────────────────────────
return (
<div className="data-panel" style={{ maxWidth: 1000 }}>
{/* Header */}
<div className="panel-header">
<div>
<h3 className="panel-title">Client Documents</h3>
<p className="panel-subtitle">Documents shared with or uploaded by clients</p>
</div>
{selectedClientId && (
<button
className="btn-action"
onClick={() => { setShowUpload(true); setUploadError(null); }}
>
Upload for {selectedClientName}
</button>
)}
</div>
{/* Client picker */}
<div style={{ marginBottom: 20 }}>
<label className="form-label" style={{ marginBottom: 6 }}>Client</label>
<select
className="form-select"
value={selectedClientId}
onChange={handleClientChange}
disabled={clientsLoading}
style={{ maxWidth: 320 }}
>
<option value="">
{clientsLoading ? 'Loading…' : '— select a client —'}
</option>
{clients.map(c => {
const id = c.cltId || c.clientId;
const name = c.cltName || c.name;
return <option key={id} value={id}>{name}</option>;
})}
</select>
</div>
{/* Upload form */}
{showUpload && (
<div className="admin-form" style={{ marginBottom: 20 }}>
<div className="form-title">Upload Document for {selectedClientName}</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',
transition: 'all 0.2s',
}}
onClick={() => fileInputRef.current?.click()}
>
{uploadFile ? (
<div style={{ color: 'var(--accent)', fontWeight: 600 }}>
📎 {uploadFile.name}
<span style={{ color: '#64748b', fontWeight: 400, marginLeft: 8 }}>
({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>
)}
{/* Empty prompt */}
{!selectedClientId && (
<div className="empty-state">Select a client to view their documents.</div>
)}
{error && <div className="error-message">{error}</div>}
{loading && <div className="loading-message">Loading documents</div>}
{/* Document list */}
{selectedClientId && !loading && !error && (
<>
{docs.length === 0 ? (
<div className="empty-state">
No documents for {selectedClientName} yet. Click Upload to add the first one.
</div>
) : (
<table className="data-table">
<thead>
<tr>
<th style={{ width: 36 }}></th>
<th>File</th>
<th>Category</th>
<th>Size</th>
<th>Uploaded By</th>
<th>Date</th>
<th style={{ width: 100 }}>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 }}>{doc.docUploadedBy || '—'}</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>
)}
</>
)}
{/* Delete confirm */}
{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. This cannot be undone.</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>
);
}

View File

@@ -0,0 +1,762 @@
import React, { useState, useEffect, useCallback, memo } from 'react';
import { useAdmin } from '../../context/AdminContext';
// ─── 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 } = useAdmin();
const [applicants, setApplicants] = useState([]);
const [loading, setLoading] = useState(true);
const [error, setError] = useState(null);
const [approving, setApproving] = useState(null);
const [approvalResult, setApprovalResult] = useState(null);
const [rejecting, setRejecting] = useState(null);
const [rejectReason, setRejectReason] = useState('');
const fetchPending = useCallback(async () => {
setLoading(true);
setError(null);
try {
const data = await apiCall('/api/registration/pending');
setApplicants(data?.applicants || []);
if (!data?.ok && data?.error && !data.error.includes('404') && !data.error.includes('502'))
setError(data.error);
} catch (err) {
console.info('[Clients] Registration endpoint not available:', err.message);
setApplicants([]);
} finally {
setLoading(false);
}
}, [apiCall]);
useEffect(() => { fetchPending(); }, [fetchPending]);
const handleApprove = async (applicant) => {
setApproving(applicant.registrationId);
setApprovalResult(null);
// Only registrationId needed — server fetches all data including OID from Registration
const result = await apiCall('/api/admin/clients', 'POST', {
registrationId: applicant.registrationId,
});
if (result.ok) {
setApprovalResult({ name: result.name, providerResults: result.providerResults || [] });
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>
)}
{approvalResult && (
<div style={{
background: '#f0fdf4', border: '1px solid #86efac', borderRadius: 8,
padding: '12px 16px', marginBottom: 16
}}>
<div style={{ fontWeight: 600, color: '#166534', marginBottom: 6 }}>
{approvalResult.name} approved
</div>
<div style={{ display: 'flex', gap: 12, flexWrap: 'wrap' }}>
{approvalResult.providerResults.map(p => {
const icon = p.status === 'Succeeded' ? '✓'
: p.status === 'Skipped' ? '⏳' : '⚠';
const color = p.status === 'Succeeded' ? '#166534'
: p.status === 'Skipped' ? '#92400e' : '#991b1b';
return (
<span key={p.provider} style={{ fontSize: 12, color }}>
{icon} {p.provider.charAt(0).toUpperCase() + p.provider.slice(1)}
{p.externalAccountId ? ` (${p.externalAccountId})` : ''}
{p.error ? `${p.error}` : ''}
</span>
);
})}
</div>
<button style={{
marginTop: 8, fontSize: 11, background: 'none', border: 'none',
color: '#166534', cursor: 'pointer', textDecoration: 'underline', padding: 0
}} onClick={() => setApprovalResult(null)}>Dismiss</button>
</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 body = statusFilter && statusFilter !== 'all' ? { status: statusFilter } : {};
const result = await apiCall('/api/admin/clients/list', 'POST', body);
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>
);
}

View File

@@ -0,0 +1,159 @@
import React, { useState, useEffect } from 'react';
import { useAdmin } from '../../context/AdminContext';
// ─── Client Users Panel ───────────────────────────────────────────────────────
// Manages tbClientUser / tbClientUserRole — client planet only.
// No admin-plane concepts here.
// ─────────────────────────────────────────────────────────────────────────────
export default function ClientUsersPanel() {
const { data, apiCall, refresh } = useAdmin();
const [showForm, setShowForm] = useState(false);
const users = data.users || [];
const deactivate = async (userId) => {
if (!confirm('Deactivate this client user?')) return;
const result = await apiCall(`/api/admin/client-users/${userId}`, 'DELETE');
if (result.ok) refresh();
else alert(result.error || 'Failed to deactivate user');
};
return (
<div className="table-panel">
<div className="panel-header">
<div>
<h2>Client Users ({data.totalCount ?? users.length})</h2>
<span style={{ fontSize: 13, color: 'var(--text-dim)' }}>
Users on the client platform
</span>
</div>
<button className="btn-action" onClick={() => setShowForm(!showForm)}>
{showForm ? 'Cancel' : '+ Add User'}
</button>
</div>
{showForm && (
<CreateClientUserForm
onSuccess={() => { setShowForm(false); refresh(); }}
onCancel={() => setShowForm(false)}
apiCall={apiCall}
/>
)}
<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(u => (
<tr key={u.userId}>
<td>{u.email}</td>
<td>{u.displayName || '—'}</td>
<td><StatusBadge status={u.status} /></td>
<td>{u.clients?.map(c => `${c.clientName} (${c.role})`).join(', ') || '—'}</td>
<td>{u.createdAt ? new Date(u.createdAt).toLocaleDateString() : '—'}</td>
<td>
<button className="btn-small btn-danger" onClick={() => deactivate(u.userId)}>
Deactivate
</button>
</td>
</tr>
))}
{users.length === 0 && (
<tr><td colSpan="6" className="empty-row">No client users found</td></tr>
)}
</tbody>
</table>
</div>
);
}
function CreateClientUserForm({ onSuccess, onCancel, apiCall }) {
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(() => {
apiCall('/api/admin/clients/list', 'POST', { pageSize: 100 }).then(r => {
if (r.ok) setClients(r.clients || []);
setLoadingClients(false);
});
}, [apiCall]);
const handleSubmit = async () => {
if (!email.trim()) return;
setLoading(true);
setError(null);
const result = await apiCall('/api/admin/client-users', 'POST', {
email: email.trim(),
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 Client User</h3>
{error && <div className="error-message">{error}</div>}
<div className="form-group">
<label>Email *</label>
<input type="email" value={email} onChange={e => setEmail(e.target.value)}
placeholder="user@example.com" disabled={loading} autoFocus />
</div>
<div className="form-group">
<label>Display Name</label>
<input type="text" value={displayName} onChange={e => setDisplayName(e.target.value)}
placeholder="Jane 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 (assign 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 className="btn-cancel" onClick={onCancel} disabled={loading}>Cancel</button>
<button className="btn-primary" onClick={handleSubmit} disabled={loading || !email.trim()}>
{loading ? 'Creating…' : 'Create User'}
</button>
</div>
</div>
);
}
function StatusBadge({ status }) {
const style = status === 'Active'
? { background: '#dcfce7', color: '#166534', border: '1px solid #86efac' }
: { background: '#f3f4f6', color: '#6b7280', border: '1px solid #d1d5db' };
return <span className="status-badge" style={style}>{status}</span>;
}

View File

@@ -1,147 +0,0 @@
import React, { useState } from 'react';
import { API_BASE_URL } from '../../auth/authConfig';
export default function ClientsPanel({ data, sessionToken, onRefresh }) {
const [showForm, setShowForm] = useState(false);
const clients = data.clients || [];
return (
<div className="table-panel">
<div className="panel-header">
<h2>Clients ({data.totalCount})</h2>
<button className="btn-action" onClick={() => setShowForm(!showForm)}>
{showForm ? 'Cancel' : '+ Add Client'}
</button>
</div>
{showForm && (
<CreateClientForm
sessionToken={sessionToken}
onSuccess={() => { setShowForm(false); onRefresh(); }}
onCancel={() => setShowForm(false)}
/>
)}
<table className="data-table">
<thead>
<tr>
<th>Name</th>
<th>Status</th>
<th>Users</th>
<th>Created</th>
<th>Actions</th>
</tr>
</thead>
<tbody>
{clients.map(client => (
<tr key={client.clientId}>
<td>{client.clientName}</td>
<td><StatusBadge status={client.status} /></td>
<td>{client.userCount}</td>
<td>{new Date(client.createdAt).toLocaleDateString()}</td>
<td>
<button
className="btn-small btn-danger"
onClick={() => deleteClient(client.clientId, sessionToken, onRefresh)}
>
Deactivate
</button>
</td>
</tr>
))}
{clients.length === 0 && (
<tr><td colSpan="5" className="empty-row">No clients found</td></tr>
)}
</tbody>
</table>
</div>
);
}
function CreateClientForm({ sessionToken, onSuccess, onCancel }) {
const [clientName, setClientName] = useState('');
const [loading, setLoading] = useState(false);
const [error, setError] = useState(null);
const handleSubmit = async (e) => {
e.preventDefault();
setLoading(true);
setError(null);
try {
const response = await fetch(`${API_BASE_URL}/api/admin/clients`, {
method: 'POST',
headers: {
'Content-Type': 'application/json',
'X-Session-Token': sessionToken,
},
body: JSON.stringify({ clientName }),
});
const data = await response.json();
if (data.ok) {
onSuccess();
} else {
setError(data.error || 'Failed to create client');
}
} catch (err) {
setError('Network error: ' + err.message);
} finally {
setLoading(false);
}
};
return (
<div className="admin-form">
<h3>Create Client</h3>
{error && <div className="error-message">{error}</div>}
<form onSubmit={handleSubmit}>
<div className="form-group">
<label>Client Name</label>
<input
type="text"
value={clientName}
onChange={(e) => setClientName(e.target.value)}
placeholder="e.g., Acme Corporation"
disabled={loading}
required
autoFocus
/>
</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 || !clientName.trim()}>
{loading ? 'Creating...' : 'Create Client'}
</button>
</div>
</form>
</div>
);
}
async function deleteClient(clientId, sessionToken, onRefresh) {
if (!confirm('Are you sure you want to deactivate this client?')) return;
try {
const response = await fetch(`${API_BASE_URL}/api/admin/clients/${clientId}`, {
method: 'DELETE',
headers: { 'X-Session-Token': sessionToken },
});
const data = await response.json();
if (data.ok) {
onRefresh();
} else {
alert(data.error || 'Failed to deactivate client');
}
} catch (err) {
alert('Network error: ' + err.message);
}
}
function StatusBadge({ status }) {
const statusClass = {
'Active': 'status-active',
'Inactive': 'status-inactive',
}[status] || 'status-default';
return <span className={`status-badge ${statusClass}`}>{status}</span>;
}

View File

@@ -0,0 +1,425 @@
import React, { useState, useEffect, useRef, useCallback } from 'react';
import { useAdmin } from '../../context/AdminContext';
// ─── Constants ────────────────────────────────────────────────────────────────
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' },
};
// ─── Helpers ──────────────────────────────────────────────────────────────────
function formatBytes(bytes) {
if (!bytes) return '—';
if (bytes < 1024) return `${bytes} B`;
if (bytes < 1048576) return `${(bytes / 1024).toFixed(1)} KB`;
return `${(bytes / 1048576).toFixed(1)} MB`;
}
function formatDate(iso) {
if (!iso) return '—';
return new Date(iso).toLocaleString('en-US', {
month: 'short', day: 'numeric', year: 'numeric',
hour: 'numeric', minute: '2-digit',
});
}
function FileIcon({ mimeType }) {
if (!mimeType) return <span>📄</span>;
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('presentation') || mimeType.includes('powerpoint')) return <span>📙</span>;
if (mimeType.includes('image')) return <span>🖼</span>;
if (mimeType.includes('zip') || mimeType.includes('compressed')) return <span>📦</span>;
return <span>📄</span>;
}
function CategoryBadge({ category }) {
const style = CATEGORY_COLORS[category] || { bg: '#f1f5f9', text: '#475569' };
return (
<span style={{
background: style.bg, color: style.text,
padding: '2px 8px', borderRadius: 12,
fontSize: 11, fontWeight: 600, letterSpacing: 0.3,
whiteSpace: 'nowrap',
}}>
{category || 'Uncategorized'}
</span>
);
}
// ─── Component ────────────────────────────────────────────────────────────────
export default function DocumentsPanel() {
const { apiCall } = useAdmin();
const [docs, setDocs] = useState([]);
const [loading, setLoading] = useState(true);
const [error, setError] = useState(null);
const [uploading, setUploading] = useState(false);
const [uploadError, setUploadError] = useState(null);
const [deleteId, setDeleteId] = useState(null);
const [deleting, setDeleting] = useState(false);
const [downloading, setDownloading] = useState(null);
const [filterCat, setFilterCat] = useState('');
const [search, setSearch] = useState('');
// Upload form
const [showUpload, setShowUpload] = useState(false);
const [uploadFile, setUploadFile] = useState(null);
const [uploadCat, setUploadCat] = useState('Technical');
const [uploadDesc, setUploadDesc] = useState('');
const fileInputRef = useRef();
// ─── Load ─────────────────────────────────────────────────────────────────
const loadDocs = useCallback(async () => {
setLoading(true);
setError(null);
try {
const result = await apiCall('/api/documents/list', 'POST', {});
if (result?.ok && Array.isArray(result.documents)) {
setDocs(result.documents);
} else {
setError(result?.error || 'Failed to load documents');
}
} catch (err) {
setError(err.message);
} finally {
setLoading(false);
}
}, [apiCall]);
useEffect(() => { loadDocs(); }, [loadDocs]);
// ─── Upload ───────────────────────────────────────────────────────────────
const handleUpload = async () => {
if (!uploadFile) { setUploadError('Please select a file'); return; }
setUploading(true);
setUploadError(null);
try {
const formData = new FormData();
formData.append('file', uploadFile);
formData.append('category', uploadCat);
formData.append('description', uploadDesc);
// apiCall doesn't support FormData directly — use raw fetch with the
// same auth header that apiCall would use
const result = await apiCall('/api/documents', {
method: 'POST',
body: formData,
// Don't set Content-Type — browser sets multipart boundary
skipJson: true,
});
if (!result?.ok) throw new Error(result?.error || 'Upload failed');
setShowUpload(false);
setUploadFile(null);
setUploadDesc('');
setUploadCat('Technical');
await loadDocs();
} catch (err) {
setUploadError(err.message);
} finally {
setUploading(false);
}
};
// ─── Download ─────────────────────────────────────────────────────────────
const handleDownload = async (doc) => {
setDownloading(doc.docId);
try {
const result = await apiCall(`/api/documents/${doc.docId}/download`, {
rawResponse: true,
});
if (!result) throw new Error('No response');
const blob = await result.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);
}
};
// ─── Delete ───────────────────────────────────────────────────────────────
const handleDelete = async () => {
if (!deleteId) return;
setDeleting(true);
try {
const result = await apiCall(`/api/documents/${deleteId}`, { method: 'DELETE' });
if (!result?.ok) throw new Error(result?.error || 'Delete failed');
setDeleteId(null);
await loadDocs();
} catch (err) {
alert(`Delete failed: ${err.message}`);
} finally {
setDeleting(false);
}
};
// ─── Filter ───────────────────────────────────────────────────────────────
const filtered = docs.filter(d => {
const matchCat = !filterCat || d.docCategory === filterCat;
const matchSrch = !search ||
d.docFileName.toLowerCase().includes(search.toLowerCase()) ||
(d.docDescription || '').toLowerCase().includes(search.toLowerCase()) ||
(d.docUploadedBy || '').toLowerCase().includes(search.toLowerCase());
return matchCat && matchSrch;
});
const categories = [...new Set(docs.map(d => d.docCategory).filter(Boolean))];
// ─── Render ───────────────────────────────────────────────────────────────
return (
<div className="data-panel" style={{ maxWidth: 1000 }}>
{/* Header */}
<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 Document
</button>
</div>
{/* Upload form */}
{showUpload && (
<div className="admin-form" style={{ marginBottom: 20 }}>
<div className="form-title">Upload New Document</div>
{/* File picker */}
<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',
transition: 'all 0.2s',
}}
onClick={() => fileInputRef.current?.click()}
>
{uploadFile ? (
<div style={{ color: 'var(--accent)', fontWeight: 600 }}>
📎 {uploadFile.name}
<span style={{ color: '#64748b', fontWeight: 400, marginLeft: 8 }}>
({formatBytes(uploadFile.size)})
</span>
</div>
) : (
<div style={{ color: '#94a3b8' }}>
Click to choose a file, or drag and drop
</div>
)}
<input
ref={fileInputRef}
type="file"
style={{ display: 'none' }}
onChange={e => setUploadFile(e.target.files?.[0] || null)}
/>
</div>
</div>
{/* Category */}
<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>
{/* Description */}
<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>
)}
{/* Filters */}
{!loading && !error && docs.length > 0 && (
<div className="filter-bar" style={{ marginBottom: 16, display: 'flex', gap: 10 }}>
<input
className="filter-input"
type="text"
placeholder="Search by name, description, uploader…"
value={search}
onChange={e => setSearch(e.target.value)}
style={{ flex: 1 }}
/>
<select
className="filter-select"
value={filterCat}
onChange={e => setFilterCat(e.target.value)}
style={{ minWidth: 140 }}
>
<option value="">All Categories</option>
{categories.map(c => <option key={c} value={c}>{c}</option>)}
</select>
{(search || filterCat) && (
<button
className="btn-cancel"
onClick={() => { setSearch(''); setFilterCat(''); }}
>
Clear
</button>
)}
</div>
)}
{/* States */}
{loading && <div className="loading-message">Loading documents</div>}
{error && <div className="error-message">{error}</div>}
{/* Document list */}
{!loading && !error && (
<>
{filtered.length === 0 ? (
<div className="empty-state">
{docs.length === 0
? 'No documents uploaded yet. Click Upload Document to add the first one.'
: 'No documents match your current filter.'}
</div>
) : (
<table className="data-table">
<thead>
<tr>
<th style={{ width: 36 }}></th>
<th>File</th>
<th>Category</th>
<th>Size</th>
<th>Uploaded By</th>
<th>Date</th>
<th style={{ width: 100 }}>Actions</th>
</tr>
</thead>
<tbody>
{filtered.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 }}>
{doc.docUploadedBy || '—'}
</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>
)}
</>
)}
{/* Delete confirm dialog */}
{deleteId && (
<div className="modal-overlay">
<div className="modal-box">
<h4 className="modal-title">Delete Document?</h4>
<p className="modal-body">
This will permanently delete the document. This action cannot be undone.
</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>
);
}

View 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/list', 'POST', { 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">
&lt;p&gt; &lt;ul&gt; &lt;li&gt; &lt;strong&gt; &lt;h4&gt; 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>
);
}

View File

@@ -0,0 +1,702 @@
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/list', 'POST', {});
if (r.ok && r.clients) setClients(r.clients);
})();
}, [apiCall]);
const loadData = useCallback(async () => {
setLoading(true);
setError(null);
setEmulated(false);
try {
const reportBody = {};
if (dateFrom) reportBody.dateFrom = dateFrom;
if (dateTo) reportBody.dateTo = dateTo;
if (clientFilter) reportBody.clientId = clientFilter;
const [summaryRes, campaignsRes] = await Promise.all([
apiCall('/api/admin/reporting/summary', 'POST', reportBody),
apiCall('/api/admin/reporting/campaigns', 'POST', reportBody),
]);
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 insightsBody = {};
if (severityFilter) insightsBody.severity = severityFilter;
const res = await apiCall('/api/admin/reporting/insights', 'POST', insightsBody);
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', 'POST', {});
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 },
],
},
],
};

View 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 (1834)', 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>
);
});

View 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>
);
}

View File

@@ -1,14 +1,32 @@
import React from 'react';
import { API_BASE_URL } from '../../auth/authConfig';
import { useAdmin } from '../../context/AdminContext';
export default function SessionsPanel({ data, sessionToken, onRefresh }) {
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', 'POST', { daysOld: 30 });
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(sessionToken, onRefresh)}>
<button className="btn-action" onClick={cleanupSessions}>
Cleanup Expired
</button>
</div>
@@ -38,7 +56,7 @@ export default function SessionsPanel({ data, sessionToken, onRefresh }) {
<td>
<button
className="btn-small btn-danger"
onClick={() => revokeSession(session.sessionId, sessionToken, onRefresh)}
onClick={() => revokeSession(session.sessionId)}
>
Revoke
</button>
@@ -53,42 +71,3 @@ export default function SessionsPanel({ data, sessionToken, onRefresh }) {
</div>
);
}
async function revokeSession(sessionId, sessionToken, onRefresh) {
if (!confirm('Are you sure you want to revoke this session?')) return;
try {
const response = await fetch(`${API_BASE_URL}/api/admin/sessions/${sessionId}/revoke`, {
method: 'POST',
headers: { 'X-Session-Token': sessionToken },
});
const data = await response.json();
if (data.ok) {
onRefresh();
} else {
alert(data.error || 'Failed to revoke session');
}
} catch (err) {
alert('Network error: ' + err.message);
}
}
async function cleanupSessions(sessionToken, onRefresh) {
if (!confirm('This will delete all expired sessions older than 30 days. Continue?')) return;
try {
const response = await fetch(`${API_BASE_URL}/api/admin/sessions/cleanup?daysOld=30`, {
method: 'POST',
headers: { 'X-Session-Token': sessionToken },
});
const data = await response.json();
if (data.ok) {
alert(`Cleaned up ${data.rowsDeleted} expired sessions`);
onRefresh();
} else {
alert(data.error || 'Failed to cleanup sessions');
}
} catch (err) {
alert('Network error: ' + err.message);
}
}

View File

@@ -0,0 +1,658 @@
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 { MANAGEMENT_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 { apiCall } = 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 data = await apiCall('/api/forecast/channel-estimate', 'POST', {
objective,
businessCategory: category,
keywords: [category, objective],
monthlyBudget: 1500,
channels: currentTemplates.map(t => t.channelType),
});
if (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>
);
}

View File

@@ -0,0 +1,263 @@
import React, { createContext, useContext, useState, useCallback, useEffect, useRef, useMemo } from 'react';
import { useAuth } from '../auth/AuthProvider';
import { MANAGEMENT_URL } from '../auth/authConfig';
// ─── Tab / Category Configuration ─────────────────────────────────────────────
export const CATEGORY_TABS = {
dashboard: [
{ id: 'overview', label: 'Overview' },
{ id: 'sessions', label: 'Sessions' },
],
clients: [
{ id: 'pending', label: 'Pending' },
{ id: 'allClients', label: 'All Clients' },
{ id: 'clientUsers', label: 'Client Users' },
{ id: 'clientActivity', label: 'Activity' },
{ id: 'clientDocuments', label: 'Documents' },
],
campaigns: [{ id: 'campaigns', label: 'All Campaigns' }],
intelligence: [
{ id: 'performance', label: 'Performance' },
{ id: 'insights', label: 'Insights' },
{ id: 'analysis', label: 'Analysis' },
],
config: [
{ id: 'objectives', label: 'Objective Mapping' },
{ id: 'templates', label: 'Allocation Templates' },
{ id: 'modifiers', label: 'Audience Modifiers' },
],
documents: [{ id: 'documents', label: 'Documents' }],
};
export const TAB_ENDPOINTS = {
overview: '/api/monitoring/health', // GET
sessions: '/api/admin/sessions/list', // POST {}
clientUsers: '/api/admin/client-users/list', // POST {}
templates: '/api/admin/templates/list', // POST {}
objectives: '/api/admin/objectives/list', // POST {}
campaigns: '/api/admin/campaigns/list', // POST {}
help: '/api/admin/help/list', // POST {}
// documents — self-managing panel, no auto-fetch
};
export const CATEGORY_LABELS = {
dashboard: 'Dashboard',
clients: 'Clients',
campaigns: 'Campaigns',
intelligence: 'Campaign Intelligence',
config: 'Configuration',
documents: 'Documents',
};
// ─── Cache config ─────────────────────────────────────────────────────────────
const CACHE_TTL_MS = 2 * 60 * 1000; // 2 minutes
// ─── Context ──────────────────────────────────────────────────────────────────
const AdminContext = createContext(null);
export function useAdmin() {
const ctx = useContext(AdminContext);
if (!ctx) throw new Error('useAdmin must be used within <AdminProvider>');
return ctx;
}
// ─── Provider ─────────────────────────────────────────────────────────────────
export function AdminProvider({ children }) {
const {
sessionUser: user,
signOut,
getIdToken,
isAuthenticated: isSignedIn,
userRole,
isAdmin,
isTech,
isStaff,
} = useAuth();
// Navigation
const [activeCategory, setActiveCategory] = useState('dashboard');
const [activeTab, setActiveTab] = useState('overview');
const [collapsed, setCollapsed] = useState(false);
// Active tab fetch state
const [data, setData] = useState(null);
const [loading, setLoading] = useState(false);
const [error, setError] = useState(null);
const [refreshKey, setRefreshKey] = useState(0);
// ─── Response cache ───────────────────────────────────────────────────────
// Stored in a ref so cache reads/writes never trigger re-renders.
// Shape: Map<url, { data: any, timestamp: number }>
const cacheRef = useRef(new Map());
const isCacheValid = (entry) =>
entry != null && (Date.now() - entry.timestamp) < CACHE_TTL_MS;
const invalidateCache = useCallback((pathPrefix = null) => {
if (!pathPrefix) {
cacheRef.current.clear();
} else {
for (const key of cacheRef.current.keys()) {
if (key.includes(pathPrefix)) cacheRef.current.delete(key);
}
}
}, []);
// ─── Core API call — JWT Bearer, with GET caching ─────────────────────────
// Two calling conventions:
// apiCall(url, 'POST', body) — standard
// apiCall(url, { method, body, rawResponse }) — options-object for special cases
// rawResponse: true → returns raw fetch Response (for blob downloads)
// body: FormData → multipart upload; Content-Type not set (browser sets boundary)
const apiCall = useCallback(async (urlOrPath, methodOrOpts = 'GET', body = null) => {
const url = urlOrPath.startsWith('http') ? urlOrPath : `${MANAGEMENT_URL}${urlOrPath}`;
// Resolve calling convention
let method, reqBody, isFormData, rawResponse;
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 : (typeof b === 'string' ? b : (b ? JSON.stringify(b) : null));
} else {
method = methodOrOpts;
rawResponse = false;
isFormData = false;
reqBody = body ? JSON.stringify(body) : null;
}
// Cache hit for plain GETs only
if (method === 'GET' && !rawResponse) {
const cached = cacheRef.current.get(url);
if (isCacheValid(cached)) {
console.debug('[Admin] Cache hit:', urlOrPath);
return cached.data;
}
}
const token = await getIdToken();
if (!token) {
console.warn('[Admin] apiCall: no token');
return rawResponse ? null : { ok: false, error: 'Not authenticated' };
}
const headers = { 'Authorization': `Bearer ${token}` };
if (!isFormData) headers['Content-Type'] = 'application/json';
const fetchOpts = { method, headers };
if (reqBody) fetchOpts.body = reqBody;
try {
const res = await fetch(url, fetchOpts);
if (!res.ok) {
if (rawResponse) return null;
const text = await res.text();
if (res.status !== 404) {
console.error(`[Admin] HTTP ${res.status}:`, text.substring(0, 200));
}
return { ok: false, error: `HTTP ${res.status}: ${text.substring(0, 100)}` };
}
if (rawResponse) return res;
const result = await res.json();
if (method === 'GET') {
cacheRef.current.set(url, { data: result, timestamp: Date.now() });
} else if (result?.ok) {
const basePath = urlOrPath.replace(/\/[^/]+$/, '');
const baseUrl = `${MANAGEMENT_URL}${basePath}`;
if (cacheRef.current.has(baseUrl)) {
cacheRef.current.set(baseUrl, { data: result, timestamp: Date.now() });
}
}
return result;
} catch (err) {
console.error('[Admin] Network error:', err.message);
return rawResponse ? null : { ok: false, error: 'Network error: ' + err.message };
}
}, [getIdToken, invalidateCache]);
// ─── Navigation ───────────────────────────────────────────────────────────
const handleSelectCategory = useCallback((categoryId) => {
setActiveCategory(categoryId);
const tabs = CATEGORY_TABS[categoryId];
if (tabs?.length > 0) setActiveTab(tabs[0].id);
setData(null);
setError(null);
}, []);
const handleSelectTab = useCallback((tabId) => {
setActiveTab(tabId);
setData(null);
setError(null);
}, []);
// ─── Tab data fetch ───────────────────────────────────────────────────────
// /api/monitoring/health is a true GET.
// All list endpoints use POST with an empty body.
const fetchTabData = useCallback(async (endpoint) => {
if (!isSignedIn) return null;
setLoading(true);
setError(null);
try {
const isGet = endpoint === '/api/monitoring/health';
const result = isGet
? await apiCall(endpoint)
: await apiCall(endpoint, 'POST', {});
if (!result?.ok) throw new Error(result?.error || 'Request failed');
return result;
} catch (err) {
console.error('[Admin] Tab fetch error:', err);
setError(err.message);
return null;
} finally {
setLoading(false);
}
}, [isSignedIn, apiCall]);
// Force-refresh: bust the cache for the current tab endpoint, then re-fetch
const refresh = useCallback(() => {
const endpoint = TAB_ENDPOINTS[activeTab];
if (endpoint) invalidateCache(`${MANAGEMENT_URL}${endpoint}`);
setRefreshKey(k => k + 1);
}, [activeTab, invalidateCache]);
useEffect(() => {
const endpoint = TAB_ENDPOINTS[activeTab];
if (!endpoint) {
setData(null);
setLoading(false);
return;
}
fetchTabData(endpoint).then(setData);
}, [activeTab, fetchTabData, refreshKey]);
// ─── Context value ────────────────────────────────────────────────────────
const value = useMemo(() => ({
user, signOut,
userRole, isAdmin, isTech, isStaff,
activeCategory, activeTab, collapsed,
setActiveCategory: handleSelectCategory,
setActiveTab: handleSelectTab,
setCollapsed,
tabs: CATEGORY_TABS[activeCategory] || [],
data, loading, error, refresh,
apiCall,
invalidateCache,
}), [
user, signOut,
userRole, isAdmin, isTech, isStaff,
activeCategory, activeTab, collapsed,
handleSelectCategory, handleSelectTab,
data, loading, error, refresh,
apiCall, invalidateCache,
]);
return <AdminContext.Provider value={value}>{children}</AdminContext.Provider>;
}

View 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,
}));
}

View File

@@ -0,0 +1,244 @@
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, 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 () => {
setMetaLoading(true);
try {
const [catData, objData] = await Promise.all([
apiCall('/api/admin/template-config/categories').catch(() => null),
apiCall('/api/admin/template-config/objectives').catch(() => 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);
}
}, [apiCall, 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>;
}

View 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';

View File

@@ -3,6 +3,8 @@ import { createRoot } from 'react-dom/client';
import { AuthProvider } from './auth/AuthProvider';
import App from './app/App';
import './styles/app.css';
import './styles/templates-panel.css';
import './styles/modifiers-panel.css';
const root = createRoot(document.getElementById('root'));
root.render(

File diff suppressed because it is too large Load Diff

View 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); }
}

View 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%;
}
}

View 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;
}

72
Client-Client/README.md Normal file
View 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

File diff suppressed because one or more lines are too long

View File

@@ -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>

View File

@@ -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"
}
}

View File

@@ -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>

View File

@@ -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>
);
}
}

View File

@@ -260,3 +260,7 @@ export default function AuthProvider({ children }) {
</MsalProvider>
);
}
// ─── Named export alias (index.js uses { AuthProvider }) ───
export { AuthProvider };

View File

@@ -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;

View File

@@ -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>
);
}

View 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>
);
}

View 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>
)}
</>
);
}

View File

@@ -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>
);
}

View 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>
);
}

View File

@@ -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>
);
}

View 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>
);
}

View File

@@ -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>
);
}

View 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>
);
}

View 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;
}

View 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>
);
}

View 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">
&lt;p&gt; &lt;ul&gt; &lt;li&gt; &lt;strong&gt; &lt;h4&gt; 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>
);
}

View 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 },
],
},
],
};

View 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 (1834)', 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>
);
});

View 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>
);
}

View 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>
);
}

View 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>
);
}

View 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>
);
}

View File

@@ -1,10 +1,18 @@
import React, { useState, useEffect } from 'react';
import { API_BASE_URL } from '../../auth/authConfig';
import { useAdmin } from '../../context/AdminContext';
export default function UsersPanel({ data, sessionToken, onRefresh }) {
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">
@@ -16,8 +24,7 @@ export default function UsersPanel({ data, sessionToken, onRefresh }) {
{showForm && (
<CreateUserForm
sessionToken={sessionToken}
onSuccess={() => { setShowForm(false); onRefresh(); }}
onSuccess={() => { setShowForm(false); refresh(); }}
onCancel={() => setShowForm(false)}
/>
)}
@@ -44,7 +51,7 @@ export default function UsersPanel({ data, sessionToken, onRefresh }) {
<td>
<button
className="btn-small btn-danger"
onClick={() => deleteUser(user.userId, sessionToken, onRefresh)}
onClick={() => deleteUser(user.userId)}
>
Deactivate
</button>
@@ -60,7 +67,8 @@ export default function UsersPanel({ data, sessionToken, onRefresh }) {
);
}
function CreateUserForm({ sessionToken, onSuccess, onCancel }) {
function CreateUserForm({ onSuccess, onCancel }) {
const { apiCall } = useAdmin();
const [email, setEmail] = useState('');
const [displayName, setDisplayName] = useState('');
const [clientId, setClientId] = useState('');
@@ -72,52 +80,30 @@ function CreateUserForm({ sessionToken, onSuccess, onCancel }) {
useEffect(() => {
const loadClients = async () => {
try {
const response = await fetch(`${API_BASE_URL}/api/admin/clients?pageSize=100`, {
headers: { 'X-Session-Token': sessionToken },
});
const data = await response.json();
if (data.ok) setClients(data.clients || []);
} catch (err) {
console.error('Failed to load clients:', err);
} finally {
setLoadingClients(false);
}
const result = await apiCall('/api/admin/clients?pageSize=100');
if (result.ok) setClients(result.clients || []);
setLoadingClients(false);
};
loadClients();
}, [sessionToken]);
}, [apiCall]);
const handleSubmit = async (e) => {
e.preventDefault();
setLoading(true);
setError(null);
try {
const response = await fetch(`${API_BASE_URL}/api/admin/users`, {
method: 'POST',
headers: {
'Content-Type': 'application/json',
'X-Session-Token': sessionToken,
},
body: JSON.stringify({
email,
displayName: displayName || null,
clientId: clientId || null,
role
}),
});
const data = await response.json();
if (data.ok) {
onSuccess();
} else {
setError(data.error || 'Failed to create user');
}
} catch (err) {
setError('Network error: ' + err.message);
} finally {
setLoading(false);
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 (
@@ -181,25 +167,6 @@ function CreateUserForm({ sessionToken, onSuccess, onCancel }) {
);
}
async function deleteUser(userId, sessionToken, onRefresh) {
if (!confirm('Are you sure you want to deactivate this user?')) return;
try {
const response = await fetch(`${API_BASE_URL}/api/admin/users/${userId}`, {
method: 'DELETE',
headers: { 'X-Session-Token': sessionToken },
});
const data = await response.json();
if (data.ok) {
onRefresh();
} else {
alert(data.error || 'Failed to deactivate user');
}
} catch (err) {
alert('Network error: ' + err.message);
}
}
function StatusBadge({ status }) {
const statusClass = {
'Active': 'status-active',

View 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} />;
}

View 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 2448 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>
);
}

View 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>
);
}

View 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>
);
}

View 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>
);
}

View 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')}
/>
);
}

View 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>
);
}

View File

@@ -0,0 +1,6 @@
import React from 'react';
import IntelligenceView from './IntelligenceView';
export default function PerformancePanel() {
return <IntelligenceView />;
}

View 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>
);
}

View File

@@ -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>
);
}

View File

@@ -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>
);
}

View File

@@ -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>
);
}

View File

@@ -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>
);
}

View File

@@ -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>
);
}

View File

@@ -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>
);
}

View 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>
);
}

View 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: '1824' },
{ id: 'AGE_25_34', label: '2534' },
{ id: 'AGE_35_44', label: '3544' },
{ id: 'AGE_45_54', label: '4554' },
{ id: 'AGE_55_64', label: '5564' },
{ 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: '1120%' },
{ id: 'TOP_21_30', label: '2130%' },
{ id: 'TOP_31_40', label: '3140%' },
{ id: 'TOP_41_50', label: '4150%' },
{ 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;

View 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 12 channels', color: '#3B82F6', channels: 2 };
if (monthly < 3000) return { label: 'Accelerate', desc: 'Supports 23 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;

View File

@@ -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;

View 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;

View 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 : '';
}

View 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: '1824', AGE_25_34: '2534', AGE_35_44: '3544',
AGE_45_54: '4554', AGE_55_64: '5564', 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 &amp; Launch
</button>
</div>
</div>
</div>
);
}

View 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>;
}

View 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,
}));
}

View 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>;
}

View 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>
);
}

View 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';

View File

@@ -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>
);

View File

@@ -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);

View 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);
}

View 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);
}

View 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);
}

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