First build
Some checks failed
Client Admin / build-deploy (push) Failing after 8s
Client Client / build-deploy (push) Failing after 3s
Client Registration / build-deploy (push) Failing after 20s
Client Tech / build-deploy (push) Failing after 1s
Client Home / build-deploy (push) Successful in 14s
Some checks failed
Client Admin / build-deploy (push) Failing after 8s
Client Client / build-deploy (push) Failing after 3s
Client Registration / build-deploy (push) Failing after 20s
Client Tech / build-deploy (push) Failing after 1s
Client Home / build-deploy (push) Successful in 14s
This commit is contained in:
2
Client-Tech/dist/bundle.js
vendored
Normal file
2
Client-Tech/dist/bundle.js
vendored
Normal file
File diff suppressed because one or more lines are too long
37
Client-Tech/dist/bundle.js.LICENSE.txt
vendored
Normal file
37
Client-Tech/dist/bundle.js.LICENSE.txt
vendored
Normal file
@@ -0,0 +1,37 @@
|
||||
/*! @azure/msal-browser v3.30.0 2025-08-05 */
|
||||
|
||||
/*! @azure/msal-common v14.16.1 2025-08-05 */
|
||||
|
||||
/*! @azure/msal-react v2.2.0 2024-11-05 */
|
||||
|
||||
/*! regenerator-runtime -- Copyright (c) 2014-present, Facebook, Inc. -- license (MIT): https://github.com/babel/babel/blob/main/packages/babel-helpers/LICENSE */
|
||||
|
||||
/**
|
||||
* @license React
|
||||
* react-dom.production.min.js
|
||||
*
|
||||
* Copyright (c) Facebook, Inc. and its affiliates.
|
||||
*
|
||||
* This source code is licensed under the MIT license found in the
|
||||
* LICENSE file in the root directory of this source tree.
|
||||
*/
|
||||
|
||||
/**
|
||||
* @license React
|
||||
* react.production.min.js
|
||||
*
|
||||
* Copyright (c) Facebook, Inc. and its affiliates.
|
||||
*
|
||||
* This source code is licensed under the MIT license found in the
|
||||
* LICENSE file in the root directory of this source tree.
|
||||
*/
|
||||
|
||||
/**
|
||||
* @license React
|
||||
* scheduler.production.min.js
|
||||
*
|
||||
* Copyright (c) Facebook, Inc. and its affiliates.
|
||||
*
|
||||
* This source code is licensed under the MIT license found in the
|
||||
* LICENSE file in the root directory of this source tree.
|
||||
*/
|
||||
26
Client-Tech/package.json
Normal file
26
Client-Tech/package.json
Normal file
@@ -0,0 +1,26 @@
|
||||
{
|
||||
"name": "react-api-tech",
|
||||
"version": "0.1.0",
|
||||
"private": true,
|
||||
"scripts": {
|
||||
"start": "webpack serve --mode development",
|
||||
"build": "webpack --mode production"
|
||||
},
|
||||
"dependencies": {
|
||||
"@azure/msal-browser": "^3.27.0",
|
||||
"@azure/msal-react": "^2.1.1",
|
||||
"react": "^18.2.0",
|
||||
"react-dom": "^18.2.0"
|
||||
},
|
||||
"devDependencies": {
|
||||
"@babel/core": "^7.24.5",
|
||||
"@babel/preset-env": "^7.24.5",
|
||||
"@babel/preset-react": "^7.24.5",
|
||||
"babel-loader": "^9.1.3",
|
||||
"css-loader": "^7.1.2",
|
||||
"style-loader": "^4.0.0",
|
||||
"webpack": "^5.91.0",
|
||||
"webpack-cli": "^5.1.4",
|
||||
"webpack-dev-server": "^5.2.3"
|
||||
}
|
||||
}
|
||||
2
Client-Tech/public/bundle.js
Normal file
2
Client-Tech/public/bundle.js
Normal file
File diff suppressed because one or more lines are too long
37
Client-Tech/public/bundle.js.LICENSE.txt
Normal file
37
Client-Tech/public/bundle.js.LICENSE.txt
Normal file
@@ -0,0 +1,37 @@
|
||||
/*! @azure/msal-browser v3.30.0 2025-08-05 */
|
||||
|
||||
/*! @azure/msal-common v14.16.1 2025-08-05 */
|
||||
|
||||
/*! @azure/msal-react v2.2.0 2024-11-05 */
|
||||
|
||||
/*! regenerator-runtime -- Copyright (c) 2014-present, Facebook, Inc. -- license (MIT): https://github.com/babel/babel/blob/main/packages/babel-helpers/LICENSE */
|
||||
|
||||
/**
|
||||
* @license React
|
||||
* react-dom.production.min.js
|
||||
*
|
||||
* Copyright (c) Facebook, Inc. and its affiliates.
|
||||
*
|
||||
* This source code is licensed under the MIT license found in the
|
||||
* LICENSE file in the root directory of this source tree.
|
||||
*/
|
||||
|
||||
/**
|
||||
* @license React
|
||||
* react.production.min.js
|
||||
*
|
||||
* Copyright (c) Facebook, Inc. and its affiliates.
|
||||
*
|
||||
* This source code is licensed under the MIT license found in the
|
||||
* LICENSE file in the root directory of this source tree.
|
||||
*/
|
||||
|
||||
/**
|
||||
* @license React
|
||||
* scheduler.production.min.js
|
||||
*
|
||||
* Copyright (c) Facebook, Inc. and its affiliates.
|
||||
*
|
||||
* This source code is licensed under the MIT license found in the
|
||||
* LICENSE file in the root directory of this source tree.
|
||||
*/
|
||||
38
Client-Tech/src/app/App.js
Normal file
38
Client-Tech/src/app/App.js
Normal file
@@ -0,0 +1,38 @@
|
||||
import React from 'react';
|
||||
import { AuthProvider, useAuth } from '../auth/AuthProvider';
|
||||
import SignInOverlay from '../components/SignInOverlay';
|
||||
import Shell from '../components/Shell';
|
||||
|
||||
function AppContent() {
|
||||
const { isAuthenticated, isLoading } = useAuth();
|
||||
|
||||
// Show loading state while checking auth
|
||||
if (isLoading) {
|
||||
return (
|
||||
<div className="app-loading">
|
||||
<div className="loading-spinner" />
|
||||
<p>Loading...</p>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="app-container">
|
||||
{/* Dashboard is always rendered but blurred when not authenticated */}
|
||||
<div className={`dashboard ${!isAuthenticated ? 'dashboard-blurred' : ''}`}>
|
||||
<Shell />
|
||||
</div>
|
||||
|
||||
{/* Sign-in overlay shown when not authenticated */}
|
||||
{!isAuthenticated && <SignInOverlay />}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
export default function App() {
|
||||
return (
|
||||
<AuthProvider>
|
||||
<AppContent />
|
||||
</AuthProvider>
|
||||
);
|
||||
}
|
||||
159
Client-Tech/src/auth/AuthProvider.jsx
Normal file
159
Client-Tech/src/auth/AuthProvider.jsx
Normal file
@@ -0,0 +1,159 @@
|
||||
// src/auth/AuthProvider.jsx
|
||||
//
|
||||
// Staff-plane auth — NO Gateway session exchange.
|
||||
// MSAL acquires a JWT from the org tenant; that JWT is sent directly to
|
||||
// Management API as a Bearer token on every request. No session token exists.
|
||||
//
|
||||
import React, { createContext, useContext, useState, useEffect, useCallback, useRef } from 'react';
|
||||
import { PublicClientApplication, InteractionStatus } from '@azure/msal-browser';
|
||||
import { MsalProvider, useMsal, useIsAuthenticated } from '@azure/msal-react';
|
||||
import { msalConfig, loginRequest } from './authConfig';
|
||||
|
||||
const msalInstance = new PublicClientApplication(msalConfig);
|
||||
|
||||
msalInstance.initialize().then(() => {
|
||||
msalInstance.handleRedirectPromise().catch(console.error);
|
||||
});
|
||||
|
||||
const AuthContext = createContext(null);
|
||||
|
||||
export function useAuth() {
|
||||
const context = useContext(AuthContext);
|
||||
if (!context) throw new Error('useAuth must be used within an AuthProvider');
|
||||
return context;
|
||||
}
|
||||
|
||||
function AuthProviderInner({ children }) {
|
||||
const { instance, accounts, inProgress } = useMsal();
|
||||
const isAuthenticated = useIsAuthenticated();
|
||||
|
||||
const [accessToken, setAccessToken] = useState(null);
|
||||
const [sessionUser, setSessionUser] = useState(null);
|
||||
const [isLoading, setIsLoading] = useState(true);
|
||||
const [error, setError] = useState(null);
|
||||
const initialized = useRef(false);
|
||||
|
||||
const account = accounts[0] || null;
|
||||
|
||||
// Acquire MSAL token silently and store it — no Gateway exchange.
|
||||
const initializeSession = useCallback(async () => {
|
||||
if (!account) { setIsLoading(false); return; }
|
||||
try {
|
||||
setIsLoading(true);
|
||||
setError(null);
|
||||
|
||||
const tokenResponse = await instance.acquireTokenSilent({
|
||||
...loginRequest,
|
||||
account,
|
||||
});
|
||||
|
||||
const jwt = tokenResponse.accessToken;
|
||||
setAccessToken(jwt);
|
||||
setSessionUser({
|
||||
email: account.username,
|
||||
name: account.name,
|
||||
});
|
||||
sessionStorage.setItem('adp_access_token', jwt);
|
||||
} catch (err) {
|
||||
console.error('[Auth] Token acquisition error:', err);
|
||||
setError('Please sign in again');
|
||||
} finally {
|
||||
setIsLoading(false);
|
||||
}
|
||||
}, [account, instance]);
|
||||
|
||||
// On mount: restore from sessionStorage or acquire fresh.
|
||||
useEffect(() => {
|
||||
if (initialized.current) return;
|
||||
if (inProgress !== InteractionStatus.None) return;
|
||||
initialized.current = true;
|
||||
|
||||
const stored = sessionStorage.getItem('adp_access_token');
|
||||
if (stored) {
|
||||
// Optimistically restore — MSAL will silently refresh when it expires.
|
||||
setAccessToken(stored);
|
||||
setSessionUser({ email: account?.username, name: account?.name });
|
||||
setIsLoading(false);
|
||||
} else if (isAuthenticated) {
|
||||
initializeSession();
|
||||
} else {
|
||||
// No account and no stored token — redirect straight to Microsoft login.
|
||||
// Skips the app's own login screen entirely.
|
||||
instance.loginRedirect(loginRequest).catch(err => {
|
||||
console.error('[Auth] Auto-redirect failed:', err);
|
||||
setIsLoading(false);
|
||||
});
|
||||
}
|
||||
}, [isAuthenticated, inProgress, initializeSession, account, instance]);
|
||||
|
||||
const signIn = useCallback(async () => {
|
||||
try {
|
||||
setError(null);
|
||||
await instance.loginRedirect(loginRequest);
|
||||
} catch (err) {
|
||||
console.error('[Auth] Sign in error:', err);
|
||||
setError(err.message);
|
||||
}
|
||||
}, [instance]);
|
||||
|
||||
const signOut = useCallback(async () => {
|
||||
try {
|
||||
sessionStorage.removeItem('adp_access_token');
|
||||
setAccessToken(null);
|
||||
setSessionUser(null);
|
||||
await instance.logoutRedirect();
|
||||
} catch (err) {
|
||||
console.error('[Auth] Sign out error:', err);
|
||||
}
|
||||
}, [instance]);
|
||||
|
||||
// Acquire a fresh token (MSAL handles caching/refresh automatically).
|
||||
const refreshSession = useCallback(async () => {
|
||||
await initializeSession();
|
||||
}, [initializeSession]);
|
||||
|
||||
// Both helpers return the same MSAL JWT — Management API only needs Bearer.
|
||||
const getAccessToken = useCallback(async () => {
|
||||
if (!account) return null;
|
||||
try {
|
||||
const resp = await instance.acquireTokenSilent({ ...loginRequest, account });
|
||||
return resp.accessToken;
|
||||
} catch {
|
||||
return accessToken;
|
||||
}
|
||||
}, [account, instance, accessToken]);
|
||||
|
||||
// Alias kept so components that call getIdToken() continue to work.
|
||||
const getIdToken = getAccessToken;
|
||||
|
||||
const value = {
|
||||
isAuthenticated: !!accessToken,
|
||||
isLoading: isLoading || inProgress !== InteractionStatus.None,
|
||||
error,
|
||||
account,
|
||||
sessionUser,
|
||||
sessionToken: accessToken, // alias — components reading sessionToken get the JWT
|
||||
signIn,
|
||||
signOut,
|
||||
refreshSession,
|
||||
getIdToken,
|
||||
getAccessToken,
|
||||
clearError: () => setError(null),
|
||||
};
|
||||
|
||||
return (
|
||||
<AuthContext.Provider value={value}>
|
||||
{children}
|
||||
</AuthContext.Provider>
|
||||
);
|
||||
}
|
||||
|
||||
export function AuthProvider({ children }) {
|
||||
return (
|
||||
<MsalProvider instance={msalInstance}>
|
||||
<AuthProviderInner>
|
||||
{children}
|
||||
</AuthProviderInner>
|
||||
</MsalProvider>
|
||||
);
|
||||
}
|
||||
68
Client-Tech/src/auth/authConfig.js
Normal file
68
Client-Tech/src/auth/authConfig.js
Normal file
@@ -0,0 +1,68 @@
|
||||
/**
|
||||
* authConfig.js — Tech Client (Staff Plane)
|
||||
*
|
||||
* ┌─────────────────────────────────────────────────────────────────────────┐
|
||||
* │ PRODUCTION MIGRATION — only these values change at handoff: │
|
||||
* │ │
|
||||
* │ STAFF_AUTHORITY → 'https://login.microsoftonline.com/{ORG_TENANT}' │
|
||||
* │ STAFF_TENANT_ID → new company org tenant ID │
|
||||
* │ STAFF_CLIENT_ID → staff app registration in org tenant │
|
||||
* │ │
|
||||
* │ No other code changes required anywhere. │
|
||||
* └─────────────────────────────────────────────────────────────────────────┘
|
||||
*
|
||||
* DEV NOTE: Staff currently authenticate against the CIAM tenant (same as
|
||||
* clients) because no org tenant exists yet. The login screen looks identical
|
||||
* to the client login — this is cosmetic only. API isolation is enforced by
|
||||
* audience: staff tokens are rejected by Gateway, client tokens by Management.
|
||||
*/
|
||||
|
||||
// ── Staff Identity Config ─────────────────────────────────────────────────────
|
||||
|
||||
const STAFF_TENANT_ID = '0be4c23a-6941-4bdb-b397-a4faf88de4b3';
|
||||
const STAFF_CLIENT_ID = '846a3677-9135-4ba6-b7f5-933dcce126be';
|
||||
|
||||
// PROD: swap to → 'https://login.microsoftonline.com/' + STAFF_TENANT_ID
|
||||
const STAFF_AUTHORITY = 'https://login.microsoftonline.com/' + STAFF_TENANT_ID;
|
||||
|
||||
// ── MSAL Config ───────────────────────────────────────────────────────────────
|
||||
|
||||
export const msalConfig = {
|
||||
auth: {
|
||||
clientId: STAFF_CLIENT_ID,
|
||||
authority: STAFF_AUTHORITY,
|
||||
redirectUri: window.location.origin,
|
||||
postLogoutRedirectUri: window.location.origin,
|
||||
navigateToLoginRequestUrl: true,
|
||||
},
|
||||
cache: {
|
||||
cacheLocation: 'sessionStorage',
|
||||
storeAuthStateInCookie: false,
|
||||
},
|
||||
system: {
|
||||
loggerOptions: {
|
||||
loggerCallback: (level, message, containsPii) => {
|
||||
if (containsPii) return;
|
||||
switch (level) {
|
||||
case 0: console.error(message); break;
|
||||
case 1: console.warn(message); break;
|
||||
case 2: console.info(message); break;
|
||||
case 3: console.debug(message); break;
|
||||
}
|
||||
},
|
||||
logLevel: 3,
|
||||
},
|
||||
},
|
||||
};
|
||||
|
||||
export const loginRequest = {
|
||||
scopes: ["api://4e4d69c3-558a-4a27-a689-17bd397175e5/access_as_user"]
|
||||
};
|
||||
|
||||
// ── API Endpoints ─────────────────────────────────────────────────────────────
|
||||
|
||||
export const API_BASE = 'https://adpapi.usimdev.com'; // Gateway API
|
||||
export const MGMT_BASE = 'https://adpmgmt.usimdev.com'; // Management API
|
||||
|
||||
// Legacy — kept for backward compatibility with apiClient.js
|
||||
export const SESSION_ENDPOINT = `${API_BASE}/api/auth/session`;
|
||||
383
Client-Tech/src/components/ActivityPanel.jsx
Normal file
383
Client-Tech/src/components/ActivityPanel.jsx
Normal file
@@ -0,0 +1,383 @@
|
||||
import React, { useState, useEffect, useCallback } from 'react';
|
||||
import { useAuth } from '../auth/AuthProvider';
|
||||
import { MGMT_BASE } from '../auth/authConfig';
|
||||
|
||||
const PAGE_SIZE = 20;
|
||||
|
||||
// ─── Date presets ──────────────────────────────────────────────────────────────
|
||||
function getPreset(key) {
|
||||
const now = new Date();
|
||||
const today = new Date(Date.UTC(now.getUTCFullYear(), now.getUTCMonth(), now.getUTCDate()));
|
||||
switch (key) {
|
||||
case 'today':
|
||||
return { dateFrom: today.toISOString(), dateTo: null };
|
||||
case 'yesterday': {
|
||||
const d = new Date(today); d.setUTCDate(d.getUTCDate() - 1);
|
||||
const e = new Date(today); e.setUTCMilliseconds(-1);
|
||||
return { dateFrom: d.toISOString(), dateTo: e.toISOString() };
|
||||
}
|
||||
case '7d': {
|
||||
const d = new Date(today); d.setUTCDate(d.getUTCDate() - 7);
|
||||
return { dateFrom: d.toISOString(), dateTo: null };
|
||||
}
|
||||
case '30d': {
|
||||
const d = new Date(today); d.setUTCDate(d.getUTCDate() - 30);
|
||||
return { dateFrom: d.toISOString(), dateTo: null };
|
||||
}
|
||||
default: return { dateFrom: null, dateTo: null };
|
||||
}
|
||||
}
|
||||
|
||||
function isoToLocal(iso) {
|
||||
if (!iso) return '';
|
||||
const d = new Date(iso);
|
||||
const p = n => String(n).padStart(2, '0');
|
||||
return `${d.getFullYear()}-${p(d.getMonth()+1)}-${p(d.getDate())}T${p(d.getHours())}:${p(d.getMinutes())}`;
|
||||
}
|
||||
|
||||
const PRESETS = [
|
||||
{ key: 'all', label: 'All time' },
|
||||
{ key: 'today', label: 'Today' },
|
||||
{ key: 'yesterday', label: 'Yesterday' },
|
||||
{ key: '7d', label: 'Last 7 days' },
|
||||
{ key: '30d', label: 'Last 30 days' },
|
||||
{ key: 'custom', label: 'Custom...' },
|
||||
];
|
||||
|
||||
// ─── Path breadcrumb ──────────────────────────────────────────────────────────
|
||||
function parsePath(path) {
|
||||
const parts = (path || '').replace(/^\/api\//, '').split('/').filter(Boolean);
|
||||
const isId = s => /^[0-9a-f-]{8,}$/i.test(s) || /^\d+$/.test(s) || s.length > 24;
|
||||
return parts.map(p => ({ value: p, isId: isId(p) }));
|
||||
}
|
||||
|
||||
function PathCell({ path }) {
|
||||
const segs = parsePath(path);
|
||||
return (
|
||||
<td title={path} style={{ padding: '8px 14px', maxWidth: 420 }}>
|
||||
<span style={{ fontFamily: 'monospace', fontSize: 12, display: 'flex', flexWrap: 'wrap', gap: 2, alignItems: 'center' }}>
|
||||
{segs.map((seg, i) => (
|
||||
<React.Fragment key={i}>
|
||||
{i > 0 && <span style={{ color: '#cbd5e1', margin: '0 1px' }}>›</span>}
|
||||
<span style={{
|
||||
color: seg.isId ? '#7c3aed' : '#1e293b',
|
||||
fontWeight: seg.isId ? 400 : 500,
|
||||
fontStyle: seg.isId ? 'italic' : 'normal',
|
||||
}}>
|
||||
{seg.value}
|
||||
</span>
|
||||
</React.Fragment>
|
||||
))}
|
||||
</span>
|
||||
</td>
|
||||
);
|
||||
}
|
||||
|
||||
// ─── Status badge ─────────────────────────────────────────────────────────────
|
||||
function StatusBadge({ code }) {
|
||||
const ok = code >= 200 && code < 300;
|
||||
const warn = code >= 400 && code < 500;
|
||||
const bg = ok ? '#dcfce7' : warn ? '#fef9c3' : '#fee2e2';
|
||||
const color = ok ? '#15803d' : warn ? '#854d0e' : '#b91c1c';
|
||||
return (
|
||||
<span style={{ background: bg, color, padding: '2px 8px', borderRadius: 4, fontSize: 11, fontWeight: 700 }}>
|
||||
{code}
|
||||
</span>
|
||||
);
|
||||
}
|
||||
|
||||
function fmt(iso) {
|
||||
if (!iso) return '—';
|
||||
return new Date(iso).toLocaleString(undefined, {
|
||||
month: 'short', day: 'numeric',
|
||||
hour: 'numeric', minute: '2-digit', second: '2-digit',
|
||||
});
|
||||
}
|
||||
|
||||
function rel(iso) {
|
||||
if (!iso) return '';
|
||||
const s = Math.floor((Date.now() - new Date(iso).getTime()) / 1000);
|
||||
if (s < 60) return s + 's ago';
|
||||
if (s < 3600) return Math.floor(s / 60) + 'm ago';
|
||||
if (s < 86400) return Math.floor(s / 3600) + 'h ago';
|
||||
if (s < 604800) return Math.floor(s / 86400) + 'd ago';
|
||||
return new Date(iso).toLocaleDateString();
|
||||
}
|
||||
|
||||
// ─── Component ────────────────────────────────────────────────────────────────
|
||||
export default function ActivityPanel() {
|
||||
const { getAccessToken } = useAuth();
|
||||
|
||||
const [items, setItems] = useState([]);
|
||||
const [total, setTotal] = useState(0);
|
||||
const [loading, setLoading] = useState(false);
|
||||
const [error, setError] = useState(null);
|
||||
const [page, setPage] = useState(1);
|
||||
|
||||
const [staff, setStaff] = useState([]);
|
||||
const [staffLoading, setStaffLoading] = useState(true);
|
||||
const [filterOid, setFilterOid] = useState('');
|
||||
|
||||
const [preset, setPreset] = useState('all');
|
||||
const [dateFrom, setDateFrom] = useState(null);
|
||||
const [dateTo, setDateTo] = useState(null);
|
||||
const [customFrom, setCustomFrom] = useState('');
|
||||
const [customTo, setCustomTo] = useState('');
|
||||
|
||||
useEffect(() => {
|
||||
async function loadStaff() {
|
||||
try {
|
||||
const token = await getAccessToken();
|
||||
const res = await fetch(MGMT_BASE + '/api/monitoring/staff', {
|
||||
headers: { Authorization: 'Bearer ' + token },
|
||||
});
|
||||
const data = await res.json();
|
||||
if (data.ok) setStaff(data.staff || []);
|
||||
} catch (e) {
|
||||
console.warn('[Activity] Staff list failed:', e.message);
|
||||
} finally {
|
||||
setStaffLoading(false);
|
||||
}
|
||||
}
|
||||
loadStaff();
|
||||
}, [getAccessToken]);
|
||||
|
||||
const load = useCallback(async (pg, oid, from, to) => {
|
||||
setLoading(true);
|
||||
setError(null);
|
||||
try {
|
||||
const token = await getAccessToken();
|
||||
const body = { page: pg, pageSize: PAGE_SIZE };
|
||||
if (oid) body.oid = oid;
|
||||
if (from) body.dateFrom = from;
|
||||
if (to) body.dateTo = to;
|
||||
|
||||
const res = await fetch(MGMT_BASE + '/api/monitoring/activity', {
|
||||
method: 'POST',
|
||||
headers: { Authorization: 'Bearer ' + token, 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify(body),
|
||||
});
|
||||
const data = await res.json();
|
||||
if (!data.ok && !data.items) throw new Error(data.error || 'HTTP ' + res.status);
|
||||
setItems(data.items || []);
|
||||
setTotal(data.total || 0);
|
||||
setPage(pg);
|
||||
} catch (e) {
|
||||
setError(e.message);
|
||||
} finally {
|
||||
setLoading(false);
|
||||
}
|
||||
}, [getAccessToken]);
|
||||
|
||||
useEffect(() => { load(1, '', null, null); }, []);
|
||||
|
||||
const applyPreset = (key) => {
|
||||
setPreset(key);
|
||||
if (key === 'custom') return;
|
||||
const { dateFrom: f, dateTo: t } = getPreset(key);
|
||||
setDateFrom(f);
|
||||
setDateTo(t);
|
||||
setCustomFrom(f ? isoToLocal(f) : '');
|
||||
setCustomTo(t ? isoToLocal(t) : '');
|
||||
load(1, filterOid, f, t);
|
||||
};
|
||||
|
||||
const applyCustom = () => {
|
||||
const f = customFrom ? new Date(customFrom).toISOString() : null;
|
||||
const t = customTo ? new Date(customTo).toISOString() : null;
|
||||
setDateFrom(f);
|
||||
setDateTo(t);
|
||||
load(1, filterOid, f, t);
|
||||
};
|
||||
|
||||
const handleStaffChange = (oid) => {
|
||||
setFilterOid(oid);
|
||||
load(1, oid, dateFrom, dateTo);
|
||||
};
|
||||
|
||||
const totalPages = Math.max(1, Math.ceil(total / PAGE_SIZE));
|
||||
const selectedName = staff.find(s => s.oid === filterOid) && staff.find(s => s.oid === filterOid).displayName;
|
||||
const presetLabel = (PRESETS.find(p => p.key === preset) || {}).label || '';
|
||||
|
||||
const pill = (active) => ({
|
||||
padding: '6px 12px', borderRadius: 20, fontSize: 12, cursor: 'pointer',
|
||||
border: active ? '1.5px solid #3b82f6' : '1px solid #e2e8f0',
|
||||
background: active ? '#eff6ff' : '#f8fafc',
|
||||
color: active ? '#1d4ed8' : '#475569',
|
||||
fontWeight: active ? 600 : 400,
|
||||
});
|
||||
|
||||
const pageBtn = (disabled) => ({
|
||||
padding: '6px 14px', borderRadius: 5, border: '1px solid #e2e8f0',
|
||||
background: disabled ? '#f8fafc' : '#fff',
|
||||
cursor: disabled ? 'default' : 'pointer',
|
||||
fontSize: 13, color: disabled ? '#cbd5e1' : '#374151',
|
||||
});
|
||||
|
||||
return (
|
||||
<div style={{ padding: 24, maxWidth: 1100 }}>
|
||||
|
||||
<div style={{ display: 'flex', alignItems: 'center', justifyContent: 'space-between', marginBottom: 20 }}>
|
||||
<div>
|
||||
<h2 style={{ margin: 0, fontSize: 18, fontWeight: 700, color: '#0f172a' }}>Admin Activity Log</h2>
|
||||
<p style={{ margin: '4px 0 0', fontSize: 13, color: '#64748b' }}>
|
||||
All mutating operations performed by staff members
|
||||
</p>
|
||||
</div>
|
||||
<button
|
||||
onClick={() => load(page, filterOid, dateFrom, dateTo)}
|
||||
disabled={loading}
|
||||
style={{ padding: '7px 16px', borderRadius: 6, border: '1px solid #e2e8f0', background: '#f8fafc', cursor: 'pointer', fontSize: 13, color: '#374151' }}
|
||||
>
|
||||
{loading ? 'Loading...' : '↻ Refresh'}
|
||||
</button>
|
||||
</div>
|
||||
|
||||
{/* Filter bar */}
|
||||
<div style={{ display: 'flex', gap: 10, marginBottom: 8, flexWrap: 'wrap', alignItems: 'center' }}>
|
||||
<div style={{ position: 'relative', minWidth: 220 }}>
|
||||
<select
|
||||
value={filterOid}
|
||||
onChange={e => handleStaffChange(e.target.value)}
|
||||
disabled={staffLoading}
|
||||
style={{
|
||||
width: '100%', padding: '7px 28px 7px 12px', borderRadius: 6,
|
||||
border: '1px solid #e2e8f0', fontSize: 13, background: '#fff',
|
||||
color: filterOid ? '#1e293b' : '#94a3b8', cursor: 'pointer', appearance: 'none',
|
||||
}}
|
||||
>
|
||||
<option value="">{staffLoading ? 'Loading...' : 'All staff (' + staff.length + ')'}</option>
|
||||
{staff.map(s => (
|
||||
<option key={s.oid} value={s.oid}>
|
||||
{s.displayName || s.email}{s.displayName && s.email ? ' — ' + s.email : ''}
|
||||
</option>
|
||||
))}
|
||||
</select>
|
||||
<span style={{ position: 'absolute', right: 9, top: '50%', transform: 'translateY(-50%)', pointerEvents: 'none', color: '#94a3b8', fontSize: 11 }}>▾</span>
|
||||
</div>
|
||||
|
||||
<span style={{ color: '#e2e8f0' }}>|</span>
|
||||
|
||||
{PRESETS.map(p => (
|
||||
<button key={p.key} onClick={() => applyPreset(p.key)} style={pill(preset === p.key)}>
|
||||
{p.label}
|
||||
</button>
|
||||
))}
|
||||
|
||||
{filterOid && (
|
||||
<button onClick={() => handleStaffChange('')} style={{ ...pill(false), borderRadius: 6 }}>
|
||||
× Clear
|
||||
</button>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* Custom range inputs */}
|
||||
{preset === 'custom' && (
|
||||
<div style={{
|
||||
display: 'flex', gap: 8, alignItems: 'center', marginBottom: 12,
|
||||
padding: '10px 14px', borderRadius: 8, background: '#f8fafc', border: '1px solid #e2e8f0',
|
||||
}}>
|
||||
<label style={{ fontSize: 12, color: '#64748b', whiteSpace: 'nowrap' }}>From</label>
|
||||
<input type="datetime-local" value={customFrom} onChange={e => setCustomFrom(e.target.value)}
|
||||
style={{ padding: '5px 10px', borderRadius: 6, border: '1px solid #e2e8f0', fontSize: 13 }} />
|
||||
<label style={{ fontSize: 12, color: '#64748b', whiteSpace: 'nowrap' }}>To</label>
|
||||
<input type="datetime-local" value={customTo} onChange={e => setCustomTo(e.target.value)}
|
||||
style={{ padding: '5px 10px', borderRadius: 6, border: '1px solid #e2e8f0', fontSize: 13 }} />
|
||||
<button onClick={applyCustom}
|
||||
style={{ padding: '6px 16px', borderRadius: 6, border: 'none', background: '#3b82f6', color: '#fff', cursor: 'pointer', fontSize: 13, fontWeight: 600 }}>
|
||||
Apply
|
||||
</button>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{error && (
|
||||
<div style={{ padding: 12, borderRadius: 6, background: '#fee2e2', color: '#b91c1c', marginBottom: 12, fontSize: 13 }}>
|
||||
{error}
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Table */}
|
||||
<div style={{ border: '1px solid #e2e8f0', borderRadius: 8, overflow: 'hidden', background: '#fff' }}>
|
||||
<table style={{ width: '100%', borderCollapse: 'collapse', fontSize: 13 }}>
|
||||
<thead>
|
||||
<tr style={{ background: '#f8fafc', borderBottom: '1px solid #e2e8f0' }}>
|
||||
{['Timestamp', 'Who', 'Path', 'Status'].map(h => (
|
||||
<th key={h} style={{ padding: '10px 14px', textAlign: 'left', fontWeight: 600, color: '#475569', fontSize: 12 }}>
|
||||
{h}
|
||||
</th>
|
||||
))}
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
{loading && items.length === 0 ? (
|
||||
<tr><td colSpan={4} style={{ padding: 32, textAlign: 'center', color: '#94a3b8' }}>Loading...</td></tr>
|
||||
) : items.length === 0 ? (
|
||||
<tr><td colSpan={4} style={{ padding: 32, textAlign: 'center', color: '#94a3b8' }}>No activity in this range.</td></tr>
|
||||
) : items.map((row, i) => (
|
||||
<React.Fragment key={row.activityId}>
|
||||
<tr style={{
|
||||
borderBottom: row.filter ? 'none' : '1px solid #f1f5f9',
|
||||
background: i % 2 === 0 ? '#fff' : '#fafafa',
|
||||
opacity: loading ? 0.6 : 1, transition: 'opacity 0.15s',
|
||||
}}>
|
||||
<td title={rel(row.activityAt)} style={{ padding: '8px 14px', whiteSpace: 'nowrap' }}>
|
||||
<span style={{ color: '#475569', fontSize: 12, fontFamily: 'monospace' }}>
|
||||
{fmt(row.activityAt)}
|
||||
</span>
|
||||
</td>
|
||||
<td title={row.email || row.oid || ''} style={{ padding: '8px 14px', whiteSpace: 'nowrap' }}>
|
||||
<span style={{ fontWeight: 500, color: '#1e293b' }}>
|
||||
{row.displayName || row.email || row.oid || '—'}
|
||||
</span>
|
||||
</td>
|
||||
<PathCell path={row.path} />
|
||||
<td style={{ padding: '8px 14px' }}>
|
||||
<StatusBadge code={row.statusCode} />
|
||||
</td>
|
||||
</tr>
|
||||
{row.filter && (
|
||||
<tr style={{
|
||||
borderBottom: '1px solid #f1f5f9',
|
||||
background: i % 2 === 0 ? '#fff' : '#fafafa',
|
||||
opacity: loading ? 0.6 : 1,
|
||||
}}>
|
||||
<td colSpan={4} style={{ padding: '0 14px 7px', textAlign: 'center' }}>
|
||||
<span style={{
|
||||
fontFamily: 'monospace', fontSize: 11,
|
||||
color: '#94a3b8', letterSpacing: '0.01em',
|
||||
}}>
|
||||
{row.filter}
|
||||
</span>
|
||||
</td>
|
||||
</tr>
|
||||
)}
|
||||
</React.Fragment>
|
||||
))}
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
|
||||
{/* Pagination — always visible */}
|
||||
<div style={{ display: 'flex', alignItems: 'center', justifyContent: 'space-between', marginTop: 12 }}>
|
||||
<span style={{ fontSize: 12, color: '#64748b' }}>
|
||||
{total.toLocaleString()} record{total !== 1 ? 's' : ''}
|
||||
{selectedName ? ' · ' + selectedName : ''}
|
||||
{preset !== 'all' ? ' · ' + presetLabel.toLowerCase() : ''}
|
||||
</span>
|
||||
<div style={{ display: 'flex', gap: 8, alignItems: 'center' }}>
|
||||
<button disabled={page <= 1 || loading} onClick={() => load(page - 1, filterOid, dateFrom, dateTo)}
|
||||
style={pageBtn(page <= 1 || loading)}>
|
||||
← Prev
|
||||
</button>
|
||||
<span style={{ fontSize: 12, color: '#94a3b8', minWidth: 70, textAlign: 'center' }}>
|
||||
{page} / {totalPages}
|
||||
</span>
|
||||
<button disabled={page >= totalPages || loading} onClick={() => load(page + 1, filterOid, dateFrom, dateTo)}
|
||||
style={pageBtn(page >= totalPages || loading)}>
|
||||
Next →
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
165
Client-Tech/src/components/ApiTestingPanel.jsx
Normal file
165
Client-Tech/src/components/ApiTestingPanel.jsx
Normal file
@@ -0,0 +1,165 @@
|
||||
import React, { useState } from 'react';
|
||||
import { providers } from '../services/providerCatalog';
|
||||
import ServiceForm from './ServiceForm';
|
||||
import GoogleTokenTool from './GoogleTokenTool';
|
||||
|
||||
const PROVIDER_ICONS = {
|
||||
gateway: '🔀',
|
||||
google: '🔍',
|
||||
meta: '📘',
|
||||
tiktok: '🎵',
|
||||
creative: '🎨',
|
||||
intelligence: '🧠',
|
||||
registration: '📝',
|
||||
management: '⚙️',
|
||||
};
|
||||
|
||||
const PROVIDER_DESC = {
|
||||
gateway: 'Direct Gateway calls — wizard, intelligence, ping',
|
||||
google: 'Google Ads API via internal GoogleApi container',
|
||||
meta: 'Meta Ads API via internal MetaApi container',
|
||||
tiktok: 'TikTok Ads API via internal TikTokApi container',
|
||||
creative: 'Creative pipeline — URL analysis, copy, images',
|
||||
intelligence: 'Spend distribution forecast engine',
|
||||
registration: 'Registration API — prospects and onboarding',
|
||||
management: 'Management API — help content, documents',
|
||||
};
|
||||
|
||||
export default function ApiTestingPanel() {
|
||||
const [selectedProvider, setSelectedProvider] = useState(null);
|
||||
const [showTokenTool, setShowTokenTool] = useState(false);
|
||||
|
||||
if (showTokenTool) {
|
||||
return (
|
||||
<div className="content-panel">
|
||||
<button
|
||||
className="token-btn"
|
||||
onClick={() => setShowTokenTool(false)}
|
||||
style={{ marginBottom: 16, display: 'flex', alignItems: 'center', gap: 6 }}
|
||||
>
|
||||
← Back to API Testing
|
||||
</button>
|
||||
<GoogleTokenTool />
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
if (selectedProvider) {
|
||||
return (
|
||||
<div>
|
||||
<div style={{ padding: '0 0 12px' }}>
|
||||
<button
|
||||
className="token-btn"
|
||||
onClick={() => setSelectedProvider(null)}
|
||||
style={{ display: 'flex', alignItems: 'center', gap: 6 }}
|
||||
>
|
||||
← Back to API Testing
|
||||
</button>
|
||||
</div>
|
||||
<ServiceForm initialProvider={selectedProvider} />
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="content-panel" style={{ maxWidth: 860 }}>
|
||||
<div style={{ marginBottom: 20 }}>
|
||||
<h3 style={{ margin: 0, fontSize: 16, color: '#1e293b' }}>API Testing</h3>
|
||||
<p style={{ margin: '4px 0 0', fontSize: 13, color: '#64748b' }}>
|
||||
Select a provider to test its endpoints
|
||||
</p>
|
||||
</div>
|
||||
|
||||
{/* Provider grid */}
|
||||
<div style={{
|
||||
display: 'grid',
|
||||
gridTemplateColumns: 'repeat(auto-fill, minmax(240px, 1fr))',
|
||||
gap: 12,
|
||||
marginBottom: 24
|
||||
}}>
|
||||
{providers.filter(p => p.id !== 'management' || true).map(p => (
|
||||
<button
|
||||
key={p.id}
|
||||
onClick={() => setSelectedProvider(p.id)}
|
||||
style={{
|
||||
background: '#fff',
|
||||
border: '1px solid #e2e8f0',
|
||||
borderRadius: 8,
|
||||
padding: '16px 18px',
|
||||
textAlign: 'left',
|
||||
cursor: 'pointer',
|
||||
transition: 'all 0.15s',
|
||||
display: 'flex',
|
||||
alignItems: 'flex-start',
|
||||
gap: 12,
|
||||
}}
|
||||
onMouseEnter={e => {
|
||||
e.currentTarget.style.borderColor = '#0066cc';
|
||||
e.currentTarget.style.boxShadow = '0 0 0 3px rgba(0,102,204,0.1)';
|
||||
}}
|
||||
onMouseLeave={e => {
|
||||
e.currentTarget.style.borderColor = '#e2e8f0';
|
||||
e.currentTarget.style.boxShadow = 'none';
|
||||
}}
|
||||
>
|
||||
<span style={{ fontSize: 22, flexShrink: 0, marginTop: 1 }}>
|
||||
{PROVIDER_ICONS[p.id] || '📡'}
|
||||
</span>
|
||||
<div>
|
||||
<div style={{ fontWeight: 600, fontSize: 14, color: '#1e293b', marginBottom: 3 }}>
|
||||
{p.label}
|
||||
</div>
|
||||
<div style={{ fontSize: 12, color: '#64748b', lineHeight: 1.4 }}>
|
||||
{PROVIDER_DESC[p.id] || 'Test API endpoints'}
|
||||
</div>
|
||||
</div>
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
|
||||
{/* Google OAuth tool */}
|
||||
<div style={{
|
||||
borderTop: '1px solid #e2e8f0',
|
||||
paddingTop: 20
|
||||
}}>
|
||||
<div style={{ fontSize: 11, fontWeight: 700, letterSpacing: 1, color: '#94a3b8', marginBottom: 12 }}>
|
||||
TOOLS
|
||||
</div>
|
||||
<button
|
||||
onClick={() => setShowTokenTool(true)}
|
||||
style={{
|
||||
background: '#fff',
|
||||
border: '1px solid #e2e8f0',
|
||||
borderRadius: 8,
|
||||
padding: '14px 18px',
|
||||
textAlign: 'left',
|
||||
cursor: 'pointer',
|
||||
display: 'flex',
|
||||
alignItems: 'center',
|
||||
gap: 12,
|
||||
width: '100%',
|
||||
maxWidth: 300,
|
||||
}}
|
||||
onMouseEnter={e => {
|
||||
e.currentTarget.style.borderColor = '#0066cc';
|
||||
e.currentTarget.style.boxShadow = '0 0 0 3px rgba(0,102,204,0.1)';
|
||||
}}
|
||||
onMouseLeave={e => {
|
||||
e.currentTarget.style.borderColor = '#e2e8f0';
|
||||
e.currentTarget.style.boxShadow = 'none';
|
||||
}}
|
||||
>
|
||||
<span style={{ fontSize: 20 }}>🔑</span>
|
||||
<div>
|
||||
<div style={{ fontWeight: 600, fontSize: 14, color: '#1e293b', marginBottom: 2 }}>
|
||||
Google OAuth Token
|
||||
</div>
|
||||
<div style={{ fontSize: 12, color: '#64748b' }}>
|
||||
Generate and inspect OAuth tokens
|
||||
</div>
|
||||
</div>
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
192
Client-Tech/src/components/AudiencePreview.jsx
Normal file
192
Client-Tech/src/components/AudiencePreview.jsx
Normal file
@@ -0,0 +1,192 @@
|
||||
import React, { useState } from 'react';
|
||||
|
||||
/**
|
||||
* Renders a visual preview of Audience segments data including
|
||||
* affinity, in-market, life events, and detailed demographics.
|
||||
* Also handles geo target search results.
|
||||
*/
|
||||
export default function AudiencePreview({ data }) {
|
||||
const [expandedSections, setExpandedSections] = useState({
|
||||
affinity: true,
|
||||
inMarket: true,
|
||||
lifeEvents: true,
|
||||
detailedDemographics: false
|
||||
});
|
||||
|
||||
if (!data) return null;
|
||||
|
||||
// Handle nested response structure
|
||||
const segments = data?.result?.data || data?.data || data;
|
||||
|
||||
// Check if this is a geo search result
|
||||
const geoResults = segments?.results || [];
|
||||
const geoQuery = segments?.query;
|
||||
|
||||
if (geoQuery !== undefined || geoResults.length > 0) {
|
||||
return (
|
||||
<div className="audience-preview">
|
||||
<h4 className="preview-title">
|
||||
🌍 Location Search Results
|
||||
<span className="source-badge">Query: "{geoQuery}"</span>
|
||||
</h4>
|
||||
|
||||
<div className="geo-results">
|
||||
{geoResults.length === 0 ? (
|
||||
<p className="no-results">No locations found</p>
|
||||
) : (
|
||||
<table className="geo-table">
|
||||
<thead>
|
||||
<tr>
|
||||
<th>ID</th>
|
||||
<th>Name</th>
|
||||
<th>Type</th>
|
||||
<th>Full Name</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
{geoResults.map((geo, idx) => (
|
||||
<tr key={geo.id || idx}>
|
||||
<td className="geo-id">{geo.id}</td>
|
||||
<td className="geo-name">{geo.name}</td>
|
||||
<td>
|
||||
<span className={`type-badge type-${(geo.targetType || '').toLowerCase()}`}>
|
||||
{geo.targetType}
|
||||
</span>
|
||||
</td>
|
||||
<td className="geo-canonical">{geo.canonicalName}</td>
|
||||
</tr>
|
||||
))}
|
||||
</tbody>
|
||||
</table>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
// Audience segments view
|
||||
const affinity = segments?.affinity || [];
|
||||
const inMarket = segments?.inMarket || [];
|
||||
const lifeEvents = segments?.lifeEvents || [];
|
||||
const detailedDemographics = segments?.detailedDemographics || [];
|
||||
const totalCount = segments?.totalCount || 0;
|
||||
const retrievedAt = segments?.retrievedAt;
|
||||
|
||||
if (totalCount === 0 && affinity.length === 0) {
|
||||
return null;
|
||||
}
|
||||
|
||||
const toggleSection = (section) => {
|
||||
setExpandedSections(prev => ({
|
||||
...prev,
|
||||
[section]: !prev[section]
|
||||
}));
|
||||
};
|
||||
|
||||
const renderSegmentList = (segmentList, sectionKey, icon) => {
|
||||
const isExpanded = expandedSections[sectionKey];
|
||||
const displayCount = isExpanded ? segmentList.length : Math.min(5, segmentList.length);
|
||||
|
||||
return (
|
||||
<div className="segment-list">
|
||||
{segmentList.slice(0, displayCount).map((seg, idx) => (
|
||||
<div key={seg.id || idx} className="segment-item">
|
||||
<span className="segment-icon">{icon}</span>
|
||||
<span className="segment-name">{seg.name}</span>
|
||||
<span className="segment-id">ID: {seg.id}</span>
|
||||
</div>
|
||||
))}
|
||||
{segmentList.length > 5 && (
|
||||
<button
|
||||
className="toggle-btn"
|
||||
onClick={() => toggleSection(sectionKey)}
|
||||
>
|
||||
{isExpanded ? '▲ Show less' : `▼ Show all ${segmentList.length}`}
|
||||
</button>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
return (
|
||||
<div className="audience-preview">
|
||||
<h4 className="preview-title">
|
||||
👥 Audience Segments
|
||||
<span className="source-badge">Total: {totalCount}</span>
|
||||
</h4>
|
||||
|
||||
{retrievedAt && (
|
||||
<div className="retrieved-at">
|
||||
Retrieved: {new Date(retrievedAt).toLocaleString()}
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Affinity Audiences */}
|
||||
{affinity.length > 0 && (
|
||||
<div className="segment-section">
|
||||
<h5 onClick={() => toggleSection('affinity')} className="section-header clickable">
|
||||
<span>💫 Affinity ({affinity.length})</span>
|
||||
<span className="expand-icon">{expandedSections.affinity ? '▼' : '▶'}</span>
|
||||
</h5>
|
||||
<p className="section-desc">Reach users based on lifestyle, interests, and habits</p>
|
||||
{expandedSections.affinity && renderSegmentList(affinity, 'affinity', '💫')}
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* In-Market Audiences */}
|
||||
{inMarket.length > 0 && (
|
||||
<div className="segment-section">
|
||||
<h5 onClick={() => toggleSection('inMarket')} className="section-header clickable">
|
||||
<span>🛒 In-Market ({inMarket.length})</span>
|
||||
<span className="expand-icon">{expandedSections.inMarket ? '▼' : '▶'}</span>
|
||||
</h5>
|
||||
<p className="section-desc">Reach users actively researching or planning to purchase</p>
|
||||
{expandedSections.inMarket && renderSegmentList(inMarket, 'inMarket', '🛒')}
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Life Events */}
|
||||
{lifeEvents.length > 0 && (
|
||||
<div className="segment-section">
|
||||
<h5 onClick={() => toggleSection('lifeEvents')} className="section-header clickable">
|
||||
<span>🎯 Life Events ({lifeEvents.length})</span>
|
||||
<span className="expand-icon">{expandedSections.lifeEvents ? '▼' : '▶'}</span>
|
||||
</h5>
|
||||
<p className="section-desc">Reach users during important life milestones</p>
|
||||
{expandedSections.lifeEvents && renderSegmentList(lifeEvents, 'lifeEvents', '🎯')}
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Detailed Demographics */}
|
||||
{detailedDemographics.length > 0 && (
|
||||
<div className="segment-section">
|
||||
<h5 onClick={() => toggleSection('detailedDemographics')} className="section-header clickable">
|
||||
<span>📊 Detailed Demographics ({detailedDemographics.length})</span>
|
||||
<span className="expand-icon">{expandedSections.detailedDemographics ? '▼' : '▶'}</span>
|
||||
</h5>
|
||||
<p className="section-desc">Parental status, education, homeownership, employment</p>
|
||||
{expandedSections.detailedDemographics && renderSegmentList(detailedDemographics, 'detailedDemographics', '📊')}
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Quick Reference */}
|
||||
<div className="quick-reference">
|
||||
<h5>📋 Static Demographics (no API needed)</h5>
|
||||
<div className="static-options">
|
||||
<div className="option-group">
|
||||
<strong>Age:</strong> 18-24, 25-34, 35-44, 45-54, 55-64, 65+
|
||||
</div>
|
||||
<div className="option-group">
|
||||
<strong>Gender:</strong> Male, Female, Unknown
|
||||
</div>
|
||||
<div className="option-group">
|
||||
<strong>Income:</strong> Top 10%, 11-20%, 21-30%, 31-40%, 41-50%, Lower 50%
|
||||
</div>
|
||||
<div className="option-group">
|
||||
<strong>Parental:</strong> Parent, Not a parent, Unknown
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
116
Client-Tech/src/components/CreativePreview.jsx
Normal file
116
Client-Tech/src/components/CreativePreview.jsx
Normal file
@@ -0,0 +1,116 @@
|
||||
import React from 'react';
|
||||
|
||||
/**
|
||||
* Renders a visual preview of Creative draft data including images,
|
||||
* headlines, and descriptions.
|
||||
*/
|
||||
export default function CreativePreview({ data }) {
|
||||
if (!data) return null;
|
||||
|
||||
// Handle nested response structure: result.data or just data
|
||||
const draft = data?.result?.data || data?.data || data;
|
||||
|
||||
const images = draft?.images || [];
|
||||
const headlines = draft?.headlines || [];
|
||||
const descriptions = draft?.descriptions || [];
|
||||
const analysis = draft?.analysis;
|
||||
const source = draft?.source;
|
||||
const imageSource = draft?.imageSource;
|
||||
|
||||
// Only show preview if we have images or assets
|
||||
if (images.length === 0 && headlines.length === 0) {
|
||||
return null;
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="creative-preview">
|
||||
<h4 className="preview-title">
|
||||
Creative Preview
|
||||
{source && <span className="source-badge">Copy: {source}</span>}
|
||||
{imageSource && <span className="source-badge">Images: {imageSource}</span>}
|
||||
</h4>
|
||||
|
||||
{/* Analysis Summary */}
|
||||
{analysis && (
|
||||
<div className="analysis-summary">
|
||||
<div className="analysis-title">{analysis.title || 'Untitled'}</div>
|
||||
{analysis.inferredCategory && (
|
||||
<span className="category-badge">{analysis.inferredCategory}</span>
|
||||
)}
|
||||
{analysis.metaDescription && (
|
||||
<p className="analysis-desc">{analysis.metaDescription}</p>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Images Grid */}
|
||||
{images.length > 0 && (
|
||||
<div className="images-section">
|
||||
<h5>Images ({images.length})</h5>
|
||||
<div className="images-grid">
|
||||
{images.map((img, idx) => (
|
||||
<div key={img.imageId || idx} className="image-card">
|
||||
<div className="image-wrapper">
|
||||
<img
|
||||
src={img.url}
|
||||
alt={img.altText || `${img.orientation} image`}
|
||||
loading="lazy"
|
||||
onError={(e) => {
|
||||
e.target.style.display = 'none';
|
||||
e.target.nextSibling.style.display = 'flex';
|
||||
}}
|
||||
/>
|
||||
<div className="image-error" style={{ display: 'none' }}>
|
||||
Failed to load
|
||||
</div>
|
||||
</div>
|
||||
<div className="image-meta">
|
||||
<span className="orientation-badge">{img.orientation}</span>
|
||||
<span className="dimensions">{img.width}×{img.height}</span>
|
||||
</div>
|
||||
{img.attribution && (
|
||||
<div className="attribution">{img.attribution}</div>
|
||||
)}
|
||||
{img.blobStored && (
|
||||
<div className="blob-indicator" title="Stored in blob storage">
|
||||
☁️ Stored
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Headlines */}
|
||||
{headlines.length > 0 && (
|
||||
<div className="assets-section">
|
||||
<h5>Headlines ({headlines.length})</h5>
|
||||
<div className="assets-list">
|
||||
{headlines.map((h, idx) => (
|
||||
<div key={idx} className="asset-item headline">
|
||||
<span className="asset-text">{h.text}</span>
|
||||
<span className="char-count">{h.charCount}/30</span>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Descriptions */}
|
||||
{descriptions.length > 0 && (
|
||||
<div className="assets-section">
|
||||
<h5>Descriptions ({descriptions.length})</h5>
|
||||
<div className="assets-list">
|
||||
{descriptions.map((d, idx) => (
|
||||
<div key={idx} className="asset-item description">
|
||||
<span className="asset-text">{d.text}</span>
|
||||
<span className="char-count">{d.charCount}/90</span>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
450
Client-Tech/src/components/DocumentsPanel.jsx
Normal file
450
Client-Tech/src/components/DocumentsPanel.jsx
Normal file
@@ -0,0 +1,450 @@
|
||||
import React, { useState, useEffect, useRef } from 'react';
|
||||
import { useAuth } from '../auth/AuthProvider';
|
||||
import { MGMT_BASE } from '../auth/authConfig';
|
||||
|
||||
const CATEGORIES = ['Operations', 'Technical'];
|
||||
|
||||
const CATEGORY_COLORS = {
|
||||
Operations: { bg: '#f3e8ff', text: '#6b21a8' },
|
||||
Technical: { bg: '#dcfce7', text: '#166534' },
|
||||
};
|
||||
|
||||
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 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
|
||||
}}>
|
||||
{category || 'Uncategorized'}
|
||||
</span>
|
||||
);
|
||||
}
|
||||
|
||||
function FileIcon({ mimeType }) {
|
||||
if (!mimeType) return '📄';
|
||||
if (mimeType.includes('pdf')) return '📕';
|
||||
if (mimeType.includes('word') || mimeType.includes('document')) return '📘';
|
||||
if (mimeType.includes('sheet') || mimeType.includes('excel')) return '📗';
|
||||
if (mimeType.includes('presentation') || mimeType.includes('powerpoint')) return '📙';
|
||||
if (mimeType.includes('image')) return '🖼';
|
||||
if (mimeType.includes('text')) return '📄';
|
||||
if (mimeType.includes('zip') || mimeType.includes('compressed')) return '📦';
|
||||
return '📄';
|
||||
}
|
||||
|
||||
export default function DocumentsPanel() {
|
||||
const { getIdToken, sessionUser } = useAuth();
|
||||
|
||||
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); // confirm dialog
|
||||
const [deleting, setDeleting] = useState(false);
|
||||
const [downloading, setDownloading] = useState(null); // docId being downloaded
|
||||
const [filterCat, setFilterCat] = useState('');
|
||||
const [search, setSearch] = useState('');
|
||||
|
||||
// Upload form state
|
||||
const [showUpload, setShowUpload] = useState(false);
|
||||
const [uploadFile, setUploadFile] = useState(null);
|
||||
const [uploadCat, setUploadCat] = useState('Technical');
|
||||
const [uploadDesc, setUploadDesc] = useState('');
|
||||
const fileInputRef = useRef();
|
||||
|
||||
const authHeader = async () => {
|
||||
const token = await getIdToken();
|
||||
return token ? { Authorization: `Bearer ${token}` } : {};
|
||||
};
|
||||
|
||||
// ─── Load documents ─────────────────────────────────────────────────────────
|
||||
|
||||
const loadDocs = async () => {
|
||||
setLoading(true);
|
||||
setError(null);
|
||||
try {
|
||||
const resp = await fetch(`${MGMT_BASE}/api/documents/list`, {
|
||||
method: 'POST',
|
||||
headers: { ...await authHeader(), 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify({ categories: CATEGORIES })
|
||||
});
|
||||
if (!resp.ok) throw new Error(`HTTP ${resp.status}`);
|
||||
const data = await resp.json();
|
||||
setDocs(data.documents || []);
|
||||
} catch (err) {
|
||||
setError(err.message);
|
||||
} finally {
|
||||
setLoading(false);
|
||||
}
|
||||
};
|
||||
|
||||
useEffect(() => { loadDocs(); }, []);
|
||||
|
||||
// ─── Upload ──────────────────────────────────────────────────────────────────
|
||||
|
||||
const handleUpload = async () => {
|
||||
if (!uploadFile) return;
|
||||
setUploading(true);
|
||||
setUploadError(null);
|
||||
try {
|
||||
const form = new FormData();
|
||||
form.append('file', uploadFile);
|
||||
form.append('category', uploadCat);
|
||||
form.append('description', uploadDesc);
|
||||
form.append('uploadedBy', sessionUser?.name || sessionUser?.email || 'Unknown');
|
||||
|
||||
const resp = await fetch(`${MGMT_BASE}/api/documents`, {
|
||||
method: 'POST',
|
||||
headers: await authHeader(), // no Content-Type — browser sets multipart boundary
|
||||
body: form
|
||||
});
|
||||
if (!resp.ok) {
|
||||
const txt = await resp.text();
|
||||
throw new Error(`HTTP ${resp.status}: ${txt}`);
|
||||
}
|
||||
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 resp = await fetch(`${MGMT_BASE}/api/documents/${doc.docId}/download`, {
|
||||
headers: await authHeader()
|
||||
});
|
||||
if (!resp.ok) throw new Error(`HTTP ${resp.status}`);
|
||||
const blob = await resp.blob();
|
||||
const url = URL.createObjectURL(blob);
|
||||
const a = document.createElement('a');
|
||||
a.href = url;
|
||||
a.download = doc.docFileName;
|
||||
document.body.appendChild(a);
|
||||
a.click();
|
||||
document.body.removeChild(a);
|
||||
URL.revokeObjectURL(url);
|
||||
} catch (err) {
|
||||
alert(`Download failed: ${err.message}`);
|
||||
} finally {
|
||||
setDownloading(null);
|
||||
}
|
||||
};
|
||||
|
||||
// ─── Delete ──────────────────────────────────────────────────────────────────
|
||||
|
||||
const handleDelete = async () => {
|
||||
if (!deleteId) return;
|
||||
setDeleting(true);
|
||||
try {
|
||||
const resp = await fetch(`${MGMT_BASE}/api/documents/${deleteId}`, {
|
||||
method: 'DELETE',
|
||||
headers: await authHeader()
|
||||
});
|
||||
if (!resp.ok) throw new Error(`HTTP ${resp.status}`);
|
||||
setDeleteId(null);
|
||||
await loadDocs();
|
||||
} catch (err) {
|
||||
alert(`Delete failed: ${err.message}`);
|
||||
} finally {
|
||||
setDeleting(false);
|
||||
}
|
||||
};
|
||||
|
||||
// ─── Filtered list ────────────────────────────────────────────────────────────
|
||||
|
||||
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;
|
||||
});
|
||||
|
||||
// ─── Render ───────────────────────────────────────────────────────────────────
|
||||
|
||||
return (
|
||||
<div className="content-panel" style={{ maxWidth: 1000 }}>
|
||||
|
||||
{/* Header */}
|
||||
<div style={{ display: 'flex', alignItems: 'center', justifyContent: 'space-between', marginBottom: 20 }}>
|
||||
<div>
|
||||
<h3 style={{ margin: 0, fontSize: 16, color: '#1e293b' }}>Project Documents</h3>
|
||||
<p style={{ margin: '4px 0 0', fontSize: 13, color: '#64748b' }}>
|
||||
{docs.length} document{docs.length !== 1 ? 's' : ''} stored
|
||||
</p>
|
||||
</div>
|
||||
<button
|
||||
className="token-btn token-btn-primary"
|
||||
onClick={() => { setShowUpload(true); setUploadError(null); }}
|
||||
style={{ display: 'flex', alignItems: 'center', gap: 6 }}
|
||||
>
|
||||
↑ Upload Document
|
||||
</button>
|
||||
</div>
|
||||
|
||||
{/* Upload form */}
|
||||
{showUpload && (
|
||||
<div style={{
|
||||
background: '#f8fafc', border: '1px solid #e2e8f0', borderRadius: 8,
|
||||
padding: 20, marginBottom: 20
|
||||
}}>
|
||||
<div style={{ fontWeight: 600, marginBottom: 14, color: '#1e293b' }}>Upload New Document</div>
|
||||
|
||||
{/* File picker */}
|
||||
<div style={{ marginBottom: 12 }}>
|
||||
<label style={labelStyle}>File</label>
|
||||
<div
|
||||
style={{
|
||||
border: `2px dashed ${uploadFile ? '#0066cc' : '#cbd5e1'}`,
|
||||
borderRadius: 6, padding: '16px 20px', textAlign: 'center',
|
||||
cursor: 'pointer', background: uploadFile ? '#eff6ff' : '#fff',
|
||||
transition: 'all 0.2s'
|
||||
}}
|
||||
onClick={() => fileInputRef.current?.click()}
|
||||
>
|
||||
{uploadFile ? (
|
||||
<span style={{ color: '#0066cc', fontWeight: 500 }}>
|
||||
{FileIcon(uploadFile.type)} {uploadFile.name} ({formatBytes(uploadFile.size)})
|
||||
</span>
|
||||
) : (
|
||||
<span style={{ color: '#94a3b8' }}>Click to select a file</span>
|
||||
)}
|
||||
<input
|
||||
ref={fileInputRef}
|
||||
type="file"
|
||||
style={{ display: 'none' }}
|
||||
onChange={e => setUploadFile(e.target.files[0] || null)}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Category + Description row */}
|
||||
<div style={{ display: 'grid', gridTemplateColumns: '180px 1fr', gap: 12, marginBottom: 12 }}>
|
||||
<div>
|
||||
<label style={labelStyle}>Category</label>
|
||||
<select
|
||||
value={uploadCat}
|
||||
onChange={e => setUploadCat(e.target.value)}
|
||||
style={inputStyle}
|
||||
>
|
||||
{CATEGORIES.map(c => <option key={c}>{c}</option>)}
|
||||
</select>
|
||||
</div>
|
||||
<div>
|
||||
<label style={labelStyle}>Description (optional)</label>
|
||||
<input
|
||||
type="text"
|
||||
value={uploadDesc}
|
||||
onChange={e => setUploadDesc(e.target.value)}
|
||||
placeholder="Brief description of this document"
|
||||
style={inputStyle}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{uploadError && (
|
||||
<div style={{ color: '#dc2626', fontSize: 13, marginBottom: 10 }}>
|
||||
⚠ {uploadError}
|
||||
</div>
|
||||
)}
|
||||
|
||||
<div style={{ display: 'flex', gap: 8 }}>
|
||||
<button
|
||||
className="token-btn token-btn-primary"
|
||||
onClick={handleUpload}
|
||||
disabled={!uploadFile || uploading}
|
||||
>
|
||||
{uploading ? '⟳ Uploading…' : '↑ Upload'}
|
||||
</button>
|
||||
<button
|
||||
className="token-btn"
|
||||
onClick={() => { setShowUpload(false); setUploadFile(null); setUploadError(null); }}
|
||||
disabled={uploading}
|
||||
>
|
||||
Cancel
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Filters */}
|
||||
<div style={{ display: 'flex', gap: 10, marginBottom: 16, alignItems: 'center' }}>
|
||||
<input
|
||||
type="text"
|
||||
placeholder="Search documents…"
|
||||
value={search}
|
||||
onChange={e => setSearch(e.target.value)}
|
||||
style={{ ...inputStyle, flex: 1, maxWidth: 280 }}
|
||||
/>
|
||||
<select
|
||||
value={filterCat}
|
||||
onChange={e => setFilterCat(e.target.value)}
|
||||
style={{ ...inputStyle, width: 150 }}
|
||||
>
|
||||
<option value="">All Categories</option>
|
||||
{CATEGORIES.map(c => <option key={c}>{c}</option>)}
|
||||
</select>
|
||||
<button className="token-btn" onClick={loadDocs} title="Refresh">⟳</button>
|
||||
</div>
|
||||
|
||||
{/* Content */}
|
||||
{loading ? (
|
||||
<div style={{ textAlign: 'center', padding: 40, color: '#94a3b8' }}>Loading documents…</div>
|
||||
) : error ? (
|
||||
<div style={{ textAlign: 'center', padding: 40 }}>
|
||||
<div style={{ color: '#dc2626', marginBottom: 8 }}>⚠ {error}</div>
|
||||
<button className="token-btn" onClick={loadDocs}>Retry</button>
|
||||
</div>
|
||||
) : filtered.length === 0 ? (
|
||||
<div style={{
|
||||
textAlign: 'center', padding: 48,
|
||||
border: '1px dashed #e2e8f0', borderRadius: 8, color: '#94a3b8'
|
||||
}}>
|
||||
{docs.length === 0
|
||||
? <>No documents yet. Click <strong>Upload Document</strong> to add the first one.</>
|
||||
: 'No documents match the current filter.'}
|
||||
</div>
|
||||
) : (
|
||||
<div style={{ display: 'flex', flexDirection: 'column', gap: 8 }}>
|
||||
{filtered.map(doc => (
|
||||
<div
|
||||
key={doc.docId}
|
||||
style={{
|
||||
background: '#fff', border: '1px solid #e2e8f0', borderRadius: 8,
|
||||
padding: '14px 16px', display: 'flex', alignItems: 'center', gap: 14
|
||||
}}
|
||||
>
|
||||
{/* Icon */}
|
||||
<span style={{ fontSize: 24, flexShrink: 0 }}>
|
||||
{FileIcon(doc.docMimeType)}
|
||||
</span>
|
||||
|
||||
{/* Main info */}
|
||||
<div style={{ flex: 1, minWidth: 0 }}>
|
||||
<div style={{ display: 'flex', alignItems: 'center', gap: 8, marginBottom: 4 }}>
|
||||
<span style={{
|
||||
fontWeight: 600, fontSize: 14, color: '#1e293b',
|
||||
overflow: 'hidden', textOverflow: 'ellipsis', whiteSpace: 'nowrap'
|
||||
}}>
|
||||
{doc.docFileName}
|
||||
</span>
|
||||
<CategoryBadge category={doc.docCategory} />
|
||||
</div>
|
||||
{doc.docDescription && (
|
||||
<div style={{ fontSize: 13, color: '#64748b', marginBottom: 4 }}>
|
||||
{doc.docDescription}
|
||||
</div>
|
||||
)}
|
||||
<div style={{ fontSize: 12, color: '#94a3b8', display: 'flex', gap: 16 }}>
|
||||
<span>{formatBytes(doc.docFileSize)}</span>
|
||||
<span>Uploaded by {doc.docUploadedBy || '—'}</span>
|
||||
<span>{formatDate(doc.docUploadedAt)}</span>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Actions */}
|
||||
<div style={{ display: 'flex', gap: 8, flexShrink: 0 }}>
|
||||
<button
|
||||
className="token-btn token-btn-primary"
|
||||
onClick={() => handleDownload(doc)}
|
||||
disabled={downloading === doc.docId}
|
||||
style={{ fontSize: 12, padding: '5px 12px' }}
|
||||
>
|
||||
{downloading === doc.docId ? '⟳' : '↓ Download'}
|
||||
</button>
|
||||
<button
|
||||
className="token-btn"
|
||||
onClick={() => setDeleteId(doc.docId)}
|
||||
style={{ fontSize: 12, padding: '5px 12px', color: '#dc2626', borderColor: '#fecaca' }}
|
||||
>
|
||||
🗑 Delete
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Delete confirmation dialog */}
|
||||
{deleteId && (
|
||||
<div style={{
|
||||
position: 'fixed', inset: 0, background: 'rgba(0,0,0,0.5)',
|
||||
display: 'flex', alignItems: 'center', justifyContent: 'center', zIndex: 1000
|
||||
}}>
|
||||
<div style={{
|
||||
background: '#fff', borderRadius: 10, padding: 28,
|
||||
maxWidth: 380, width: '90%', boxShadow: '0 20px 60px rgba(0,0,0,0.3)'
|
||||
}}>
|
||||
<div style={{ fontWeight: 700, fontSize: 16, marginBottom: 8 }}>Delete Document?</div>
|
||||
<div style={{ color: '#64748b', fontSize: 14, marginBottom: 20 }}>
|
||||
{(() => {
|
||||
const doc = docs.find(d => d.docId === deleteId);
|
||||
return doc
|
||||
? <>Are you sure you want to delete <strong>{doc.docFileName}</strong>? This cannot be undone.</>
|
||||
: 'Are you sure you want to delete this document? This cannot be undone.';
|
||||
})()}
|
||||
</div>
|
||||
<div style={{ display: 'flex', gap: 8, justifyContent: 'flex-end' }}>
|
||||
<button
|
||||
className="token-btn"
|
||||
onClick={() => setDeleteId(null)}
|
||||
disabled={deleting}
|
||||
>
|
||||
Cancel
|
||||
</button>
|
||||
<button
|
||||
className="token-btn"
|
||||
onClick={handleDelete}
|
||||
disabled={deleting}
|
||||
style={{ background: '#dc2626', color: '#fff', borderColor: '#dc2626' }}
|
||||
>
|
||||
{deleting ? 'Deleting…' : 'Delete'}
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
// Shared input/label styles
|
||||
const labelStyle = {
|
||||
display: 'block', fontSize: 12, fontWeight: 600,
|
||||
color: '#475569', marginBottom: 5, letterSpacing: 0.3
|
||||
};
|
||||
|
||||
const inputStyle = {
|
||||
width: '100%', padding: '7px 10px',
|
||||
border: '1px solid #cbd5e1', borderRadius: 6,
|
||||
fontSize: 13, color: '#1e293b', background: '#fff',
|
||||
outline: 'none', boxSizing: 'border-box'
|
||||
};
|
||||
220
Client-Tech/src/components/GoogleTokenTool.jsx
Normal file
220
Client-Tech/src/components/GoogleTokenTool.jsx
Normal file
@@ -0,0 +1,220 @@
|
||||
import React, { useState, useEffect } from 'react';
|
||||
|
||||
/**
|
||||
* Google OAuth Refresh Token Tool
|
||||
*
|
||||
* Handles the full flow:
|
||||
* 1. Opens Google OAuth consent in a new tab
|
||||
* 2. User copies the authorization code
|
||||
* 3. Exchanges code for refresh token (direct to Google, no backend needed)
|
||||
* 4. Shows the az CLI command to update the container
|
||||
*
|
||||
* Also supports auto-capture: if the TestAPI URL is registered as a redirect URI,
|
||||
* the code will appear in the URL params and be auto-populated.
|
||||
*/
|
||||
|
||||
// Default credentials (from your Google Cloud Console - Desktop app type)
|
||||
const DEFAULTS = {
|
||||
clientId: '330518338348-a1qto1jug5tmpc6565059apsggsfg12i.apps.googleusercontent.com',
|
||||
clientSecret: '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',
|
||||
scope: 'https://www.googleapis.com/auth/adwords',
|
||||
containerApp: 'usim-adp-googleapi',
|
||||
resourceGroup: 'RG-GraeJones'
|
||||
};
|
||||
|
||||
export default function GoogleTokenTool() {
|
||||
const [clientId, setClientId] = useState(DEFAULTS.clientId);
|
||||
const [clientSecret, setClientSecret] = useState(DEFAULTS.clientSecret);
|
||||
const [authCode, setAuthCode] = useState('');
|
||||
const [result, setResult] = useState(null);
|
||||
const [error, setError] = useState(null);
|
||||
const [isBusy, setIsBusy] = useState(false);
|
||||
const [copied, setCopied] = useState(null);
|
||||
const [useRedirect, setUseRedirect] = useState(false);
|
||||
|
||||
const redirectUri = useRedirect
|
||||
? window.location.origin
|
||||
: 'urn:ietf:wg:oauth:2.0:oob';
|
||||
|
||||
// Auto-capture: check URL for ?code= on mount (if redirect mode is used)
|
||||
useEffect(() => {
|
||||
const params = new URLSearchParams(window.location.search);
|
||||
const code = params.get('code');
|
||||
if (code) {
|
||||
setAuthCode(code);
|
||||
setUseRedirect(true);
|
||||
// Clean the URL
|
||||
window.history.replaceState({}, '', window.location.pathname);
|
||||
}
|
||||
}, []);
|
||||
|
||||
const authUrl = `https://accounts.google.com/o/oauth2/v2/auth?` +
|
||||
`client_id=${encodeURIComponent(clientId)}` +
|
||||
`&redirect_uri=${encodeURIComponent(redirectUri)}` +
|
||||
`&response_type=code` +
|
||||
`&scope=${encodeURIComponent(DEFAULTS.scope)}` +
|
||||
`&access_type=offline` +
|
||||
`&prompt=consent`;
|
||||
|
||||
const openAuth = () => {
|
||||
window.open(authUrl, '_blank', 'width=600,height=700');
|
||||
};
|
||||
|
||||
const exchangeCode = async () => {
|
||||
if (!authCode.trim()) return;
|
||||
|
||||
setIsBusy(true);
|
||||
setError(null);
|
||||
setResult(null);
|
||||
|
||||
try {
|
||||
const resp = await fetch('https://oauth2.googleapis.com/token', {
|
||||
method: 'POST',
|
||||
headers: { 'Content-Type': 'application/x-www-form-urlencoded' },
|
||||
body: new URLSearchParams({
|
||||
code: authCode.trim(),
|
||||
client_id: clientId,
|
||||
client_secret: clientSecret,
|
||||
redirect_uri: redirectUri,
|
||||
grant_type: 'authorization_code'
|
||||
})
|
||||
});
|
||||
|
||||
const data = await resp.json();
|
||||
|
||||
if (data.error) {
|
||||
setError(`${data.error}: ${data.error_description || 'Unknown error'}`);
|
||||
} else {
|
||||
setResult({
|
||||
refreshToken: data.refresh_token,
|
||||
accessToken: data.access_token,
|
||||
expiresIn: data.expires_in,
|
||||
scope: data.scope,
|
||||
tokenType: data.token_type
|
||||
});
|
||||
}
|
||||
} catch (e) {
|
||||
setError(`Network error: ${e.message}`);
|
||||
} finally {
|
||||
setIsBusy(false);
|
||||
}
|
||||
};
|
||||
|
||||
const azCommand = result?.refreshToken
|
||||
? `az containerapp update --name ${DEFAULTS.containerApp} --resource-group ${DEFAULTS.resourceGroup} --set-env-vars GoogleAds__OAuth__RefreshToken=${result.refreshToken}`
|
||||
: '';
|
||||
|
||||
const copyToClipboard = (text, label) => {
|
||||
navigator.clipboard.writeText(text).then(() => {
|
||||
setCopied(label);
|
||||
setTimeout(() => setCopied(null), 2000);
|
||||
});
|
||||
};
|
||||
|
||||
return (
|
||||
<div className="token-tool">
|
||||
<div className="token-tool-header">
|
||||
<h3>Google OAuth Token Tool</h3>
|
||||
<span className="token-tool-badge">Utility</span>
|
||||
</div>
|
||||
|
||||
{/* Step 1: Credentials (pre-filled, collapsible) */}
|
||||
<details className="token-step">
|
||||
<summary>OAuth Credentials (pre-filled)</summary>
|
||||
<div className="token-field">
|
||||
<label>Client ID</label>
|
||||
<input type="text" value={clientId} onChange={e => setClientId(e.target.value)} />
|
||||
</div>
|
||||
<div className="token-field">
|
||||
<label>Client Secret</label>
|
||||
<input type="text" value={clientSecret} onChange={e => setClientSecret(e.target.value)} />
|
||||
</div>
|
||||
<div className="token-field token-field-inline">
|
||||
<label>
|
||||
<input
|
||||
type="checkbox"
|
||||
checked={useRedirect}
|
||||
onChange={e => setUseRedirect(e.target.checked)}
|
||||
/>
|
||||
{' '}Use redirect mode (requires {window.location.origin} in Google Cloud authorized redirect URIs)
|
||||
</label>
|
||||
</div>
|
||||
</details>
|
||||
|
||||
{/* Step 2: Authorize */}
|
||||
<div className="token-step-open">
|
||||
<div className="step-label">Step 1 — Authorize with Google</div>
|
||||
<p className="step-hint">
|
||||
Opens Google sign-in. Sign in with <strong>addplatform.mcc@gmail.com</strong> and grant access.
|
||||
{!useRedirect && ' Copy the authorization code shown after approval.'}
|
||||
</p>
|
||||
<button className="token-btn token-btn-primary" onClick={openAuth}>
|
||||
Open Google Authorization
|
||||
</button>
|
||||
</div>
|
||||
|
||||
{/* Step 3: Exchange code */}
|
||||
<div className="token-step-open">
|
||||
<div className="step-label">Step 2 — Exchange Code for Token</div>
|
||||
<div className="token-field">
|
||||
<label>Authorization Code</label>
|
||||
<input
|
||||
type="text"
|
||||
value={authCode}
|
||||
onChange={e => setAuthCode(e.target.value)}
|
||||
placeholder="Paste the authorization code here"
|
||||
className={authCode ? 'has-value' : ''}
|
||||
/>
|
||||
</div>
|
||||
<button
|
||||
className="token-btn token-btn-primary"
|
||||
onClick={exchangeCode}
|
||||
disabled={!authCode.trim() || isBusy}
|
||||
>
|
||||
{isBusy ? 'Exchanging...' : 'Exchange for Refresh Token'}
|
||||
</button>
|
||||
</div>
|
||||
|
||||
{/* Error */}
|
||||
{error && (
|
||||
<div className="token-error">
|
||||
<strong>Error:</strong> {error}
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Result */}
|
||||
{result && (
|
||||
<div className="token-result">
|
||||
<div className="step-label">Refresh Token</div>
|
||||
<div className="token-value-row">
|
||||
<code className="token-value">{result.refreshToken}</code>
|
||||
<button
|
||||
className="token-btn token-btn-small"
|
||||
onClick={() => copyToClipboard(result.refreshToken, 'token')}
|
||||
>
|
||||
{copied === 'token' ? '✓ Copied' : 'Copy'}
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<div className="step-label" style={{ marginTop: 16 }}>
|
||||
Step 3 — Update Container (run in PowerShell)
|
||||
</div>
|
||||
<div className="token-value-row">
|
||||
<code className="token-value token-cmd">{azCommand}</code>
|
||||
<button
|
||||
className="token-btn token-btn-small"
|
||||
onClick={() => copyToClipboard(azCommand, 'cmd')}
|
||||
>
|
||||
{copied === 'cmd' ? '✓ Copied' : 'Copy'}
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<details className="token-details" style={{ marginTop: 12 }}>
|
||||
<summary>Full token response</summary>
|
||||
<pre>{JSON.stringify(result, null, 2)}</pre>
|
||||
</details>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
362
Client-Tech/src/components/HealthDashboard.jsx
Normal file
362
Client-Tech/src/components/HealthDashboard.jsx
Normal file
@@ -0,0 +1,362 @@
|
||||
import React, { useState, useCallback } from 'react';
|
||||
import { useAuth } from '../auth/AuthProvider';
|
||||
import { API_BASE } from '../auth/authConfig';
|
||||
|
||||
const HELP_BASE = 'https://adpmgmt.usimdev.com/api/help';
|
||||
|
||||
// ─── Service definitions ──────────────────────────────────────────────────────
|
||||
|
||||
const SERVICE_META = {
|
||||
gateway: {
|
||||
label: 'Gateway API', sub: 'adsapi.usimdev.com', tier: 'public', color: '#0066cc',
|
||||
mode: 'direct', url: `${API_BASE}/api/test/ping`,
|
||||
tech: '.NET 8 · Azure Container Apps · SQL Server',
|
||||
},
|
||||
management: {
|
||||
label: 'Management API', sub: 'adpmgmt.usimdev.com', tier: 'public', color: '#0ea5e9',
|
||||
mode: 'direct', url: 'https://adpmgmt.usimdev.com/health',
|
||||
tech: '.NET 8 · Azure Container Apps · SQL Server',
|
||||
},
|
||||
tech: {
|
||||
label: 'Tech (TestAPI)', sub: 'adptestapi.usimdev.com', tier: 'public', color: '#64748b',
|
||||
mode: 'none',
|
||||
tech: 'React · MSAL · Azure Container Apps',
|
||||
},
|
||||
registration: {
|
||||
label: 'Registration', sub: 'adpregapi.usimdev.com', tier: 'registration', color: '#f97316',
|
||||
mode: 'direct', url: 'https://adpregapi.usimdev.com/api/health',
|
||||
tech: 'Azure Functions · .NET 8',
|
||||
},
|
||||
creative: {
|
||||
label: 'Creative', sub: 'OpenAI / DALL·E', tier: 'internal', color: '#8b5cf6',
|
||||
mode: 'provider', provider: 'creative',
|
||||
tech: 'OpenAI · DALL-E 3 · Unsplash · Azure Blob',
|
||||
},
|
||||
google: {
|
||||
label: 'Google Ads', sub: 'GoogleApi', tier: 'internal', color: '#4285f4',
|
||||
mode: 'provider', provider: 'google',
|
||||
tech: 'Google Ads .NET Client Library · Key Vault',
|
||||
},
|
||||
meta: {
|
||||
label: 'Meta Ads', sub: 'MetaApi', tier: 'internal', color: '#1877f2',
|
||||
mode: 'provider', provider: 'meta',
|
||||
tech: 'Meta Ads API · Business Manager',
|
||||
},
|
||||
tiktok: {
|
||||
label: 'TikTok Ads', sub: 'TikTokApi', tier: 'internal', color: '#2d2d2d',
|
||||
mode: 'provider', provider: 'tiktok',
|
||||
tech: 'TikTok Ads API · Business Center',
|
||||
},
|
||||
intelligence: {
|
||||
label: 'Intelligence', sub: 'IntelligenceApi', tier: 'internal', color: '#10b981',
|
||||
mode: 'provider', provider: 'intelligence',
|
||||
tech: '.NET 8 · Azure Container Apps · Internal Only',
|
||||
},
|
||||
};
|
||||
|
||||
const TIER_PUBLIC = ['management', 'tech', 'gateway'];
|
||||
const TIER_REG = ['registration'];
|
||||
const TIER_INTERNAL = ['creative', 'google', 'meta', 'tiktok', 'intelligence'];
|
||||
const CHECKABLE = [...TIER_PUBLIC, ...TIER_REG, ...TIER_INTERNAL].filter(
|
||||
id => SERVICE_META[id].mode !== 'none'
|
||||
);
|
||||
|
||||
// ─── Status helpers ───────────────────────────────────────────────────────────
|
||||
|
||||
const statusColor = (s) => ({ healthy: '#22c55e', error: '#f59e0b', unreachable: '#ef4444' }[s] || '#cbd5e1');
|
||||
const statusLabel = (s) => ({ healthy: 'Healthy', error: 'Error', unreachable: 'Unreachable' }[s] || 'Not checked');
|
||||
|
||||
// ─── Service Node ─────────────────────────────────────────────────────────────
|
||||
|
||||
function ServiceNode({ id, result, onClick }) {
|
||||
const svc = SERVICE_META[id];
|
||||
const sc = statusColor(result?.status);
|
||||
|
||||
return (
|
||||
<button
|
||||
className="svc-node"
|
||||
onClick={() => onClick(id)}
|
||||
style={{ borderColor: result ? sc : undefined }}
|
||||
>
|
||||
{result && (
|
||||
<span className="svc-node-dot" style={{ background: sc, boxShadow: `0 0 5px ${sc}` }} />
|
||||
)}
|
||||
<span className="svc-node-name" style={{ color: svc.color }}>{svc.label}</span>
|
||||
<span className="svc-node-sub">{svc.sub}</span>
|
||||
{result?.ms != null && <span className="svc-node-ms">{result.ms}ms</span>}
|
||||
</button>
|
||||
);
|
||||
}
|
||||
|
||||
// ─── Service Popup ────────────────────────────────────────────────────────────
|
||||
|
||||
function ServicePopup({ id, result, onClose }) {
|
||||
const svc = SERVICE_META[id];
|
||||
const sc = statusColor(result?.status);
|
||||
|
||||
const [helpTitle, setHelpTitle] = React.useState(null);
|
||||
const [helpBody, setHelpBody] = React.useState(null);
|
||||
const [helpLoading, setHelpLoading] = React.useState(true);
|
||||
|
||||
React.useEffect(() => {
|
||||
setHelpLoading(true);
|
||||
fetch(`${HELP_BASE}/service.${id}`)
|
||||
.then(r => r.json())
|
||||
.then(data => {
|
||||
setHelpTitle(data.title || svc.label);
|
||||
setHelpBody(data.body || '<p>No information available for this topic yet.</p>');
|
||||
})
|
||||
.catch(() => {
|
||||
setHelpBody('<p>No information available for this topic yet.</p>');
|
||||
})
|
||||
.finally(() => setHelpLoading(false));
|
||||
}, [id]);
|
||||
|
||||
const handleBackdrop = (e) => { if (e.target === e.currentTarget) onClose(); };
|
||||
|
||||
return (
|
||||
<div className="svc-popup-backdrop" onClick={handleBackdrop}>
|
||||
<div className="svc-popup">
|
||||
<div className="svc-popup-bar" style={{ background: svc.color }} />
|
||||
|
||||
<div className="svc-popup-head">
|
||||
<div>
|
||||
<div className="svc-popup-title" style={{ color: svc.color }}>{svc.label}</div>
|
||||
<div className="svc-popup-sub">{svc.sub}</div>
|
||||
</div>
|
||||
<button className="svc-popup-close" onClick={onClose}>✕</button>
|
||||
</div>
|
||||
|
||||
{/* Status row */}
|
||||
<div className="svc-popup-status-row">
|
||||
<div className="svc-popup-stat">
|
||||
<div className="svc-popup-stat-label">STATUS</div>
|
||||
<div className="svc-popup-stat-value" style={{ color: result ? sc : '#94a3b8' }}>
|
||||
{result && <span className="svc-popup-dot" style={{ background: sc, boxShadow: `0 0 4px ${sc}` }} />}
|
||||
{statusLabel(result?.status)}
|
||||
</div>
|
||||
</div>
|
||||
{result?.ms != null && (
|
||||
<div className="svc-popup-stat">
|
||||
<div className="svc-popup-stat-label">RESPONSE</div>
|
||||
<div className="svc-popup-stat-value">{result.ms}ms</div>
|
||||
</div>
|
||||
)}
|
||||
<div className="svc-popup-stat">
|
||||
<div className="svc-popup-stat-label">TIER</div>
|
||||
<div className="svc-popup-stat-value" style={{ color: '#64748b' }}>
|
||||
{ svc.tier === 'public' ? 'Public' : svc.tier === 'registration' ? 'Registration' : 'Internal' }
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Health endpoint */}
|
||||
{svc.mode !== 'none' && (
|
||||
<div className="svc-popup-section">
|
||||
<div className="svc-popup-section-label">HEALTH ENDPOINT</div>
|
||||
<code className="svc-popup-code">
|
||||
{svc.mode === 'direct'
|
||||
? svc.url
|
||||
: `POST ${API_BASE}/api/execution/request → provider: "${svc.provider}" → Ping`}
|
||||
</code>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* About — from help DB */}
|
||||
<div className="svc-popup-section">
|
||||
<div className="svc-popup-section-label">ABOUT</div>
|
||||
{helpLoading
|
||||
? <p className="svc-popup-loading">Loading…</p>
|
||||
: <div
|
||||
className="svc-popup-about"
|
||||
dangerouslySetInnerHTML={{ __html: helpBody }}
|
||||
/>
|
||||
}
|
||||
</div>
|
||||
|
||||
{/* Stack */}
|
||||
<div className="svc-popup-section">
|
||||
<div className="svc-popup-section-label">STACK</div>
|
||||
<div className="svc-popup-tech">{svc.tech}</div>
|
||||
</div>
|
||||
|
||||
{/* Error detail */}
|
||||
{result?.message && (
|
||||
<div className="svc-popup-section">
|
||||
<div className="svc-popup-section-label">DETAIL</div>
|
||||
<code className="svc-popup-code svc-popup-error">{result.message}</code>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
// ─── Architecture Diagram ─────────────────────────────────────────────────────
|
||||
|
||||
function ArchDiagram({ results, onNodeClick }) {
|
||||
return (
|
||||
<div className="arch2-wrap">
|
||||
<div className="arch2-title">Platform Architecture</div>
|
||||
|
||||
<div className="arch2-top">
|
||||
{/* Public tier */}
|
||||
<div className="arch2-tier" style={{ flex: 1 }}>
|
||||
<div className="arch2-tier-label">Public Access</div>
|
||||
<div className="arch2-tier-nodes">
|
||||
{TIER_PUBLIC.map(id => (
|
||||
<ServiceNode key={id} id={id} result={results[id]} onClick={onNodeClick} />
|
||||
))}
|
||||
</div>
|
||||
<div className="arch2-db-connector">
|
||||
<div className="arch2-vline" />
|
||||
<div className="arch2-db">🗄 SQL DB</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="arch2-pillar-sep" />
|
||||
|
||||
{/* Registration tier */}
|
||||
<div className="arch2-tier arch2-tier-reg">
|
||||
<div className="arch2-tier-label">Registration</div>
|
||||
<div className="arch2-tier-nodes">
|
||||
{TIER_REG.map(id => (
|
||||
<ServiceNode key={id} id={id} result={results[id]} onClick={onNodeClick} />
|
||||
))}
|
||||
</div>
|
||||
<div className="arch2-db-connector">
|
||||
<div className="arch2-vline" />
|
||||
<div className="arch2-db">🗄 Reg DB</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Arrow */}
|
||||
<div className="arch2-arrow-zone">
|
||||
<div className="arch2-arrow-line">
|
||||
<span className="arch2-arrow-label">internal routing via Gateway</span>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Internal tier */}
|
||||
<div className="arch2-tier arch2-tier-internal">
|
||||
<div className="arch2-tier-label">Internal Only — Azure Container Apps</div>
|
||||
<div className="arch2-tier-nodes arch2-tier-nodes-center">
|
||||
{TIER_INTERNAL.map(id => (
|
||||
<ServiceNode key={id} id={id} result={results[id]} onClick={onNodeClick} />
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Legend */}
|
||||
<div className="arch2-legend">
|
||||
{[['#22c55e','Healthy'],['#f59e0b','Error'],['#ef4444','Unreachable'],['#cbd5e1','Not checked']].map(([c,l]) => (
|
||||
<span key={l} className="arch2-legend-item">
|
||||
<span className="arch2-legend-dot" style={{ background: c }} />{l}
|
||||
</span>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
// ─── Main Dashboard ───────────────────────────────────────────────────────────
|
||||
|
||||
export default function HealthDashboard() {
|
||||
const { getIdToken } = useAuth();
|
||||
const [results, setResults] = useState({});
|
||||
const [checking, setChecking] = useState(false);
|
||||
const [lastCheck, setLastCheck] = useState(null);
|
||||
const [popup, setPopup] = useState(null);
|
||||
|
||||
const checkAll = useCallback(async () => {
|
||||
setChecking(true);
|
||||
const next = {};
|
||||
const token = await getIdToken();
|
||||
|
||||
await Promise.all(
|
||||
CHECKABLE.map(async (id) => {
|
||||
const svc = SERVICE_META[id];
|
||||
const start = performance.now();
|
||||
try {
|
||||
let resp;
|
||||
if (svc.mode === 'direct') {
|
||||
resp = await fetch(svc.url, { signal: AbortSignal.timeout(8000) });
|
||||
} else {
|
||||
resp = await fetch(`${API_BASE}/api/execution/request`, {
|
||||
method: 'POST',
|
||||
headers: {
|
||||
'Content-Type': 'application/json',
|
||||
...(token ? { Authorization: `Bearer ${token}` } : {})
|
||||
},
|
||||
body: JSON.stringify({ provider: svc.provider, operation: 'Ping', payload: {} }),
|
||||
signal: AbortSignal.timeout(8000)
|
||||
});
|
||||
}
|
||||
const ms = Math.round(performance.now() - start);
|
||||
if (resp.ok) {
|
||||
let data = null;
|
||||
try { data = await resp.json(); } catch {}
|
||||
next[id] = { status: 'healthy', ms, data, httpStatus: resp.status };
|
||||
} else {
|
||||
let text = '';
|
||||
try { text = await resp.text(); } catch {}
|
||||
next[id] = { status: 'error', ms, httpStatus: resp.status, message: `HTTP ${resp.status}: ${text.substring(0, 200)}` };
|
||||
}
|
||||
} catch (err) {
|
||||
const ms = Math.round(performance.now() - start);
|
||||
next[id] = { status: 'unreachable', ms, message: err.name === 'TimeoutError' ? 'Timed out (8s)' : err.message };
|
||||
}
|
||||
})
|
||||
);
|
||||
|
||||
setResults(next);
|
||||
setLastCheck(new Date());
|
||||
setChecking(false);
|
||||
}, [getIdToken]);
|
||||
|
||||
const healthyCount = Object.values(results).filter(r => r.status === 'healthy').length;
|
||||
const totalChecked = Object.keys(results).length;
|
||||
|
||||
return (
|
||||
<div className="health-dashboard">
|
||||
<div className="health-header">
|
||||
<div className="health-title-row">
|
||||
<h3 className="health-title">⚡ Service Health</h3>
|
||||
{totalChecked > 0 && (
|
||||
<span className={`health-summary ${healthyCount === totalChecked ? 'all-healthy' : 'has-issues'}`}>
|
||||
{healthyCount}/{totalChecked} services up
|
||||
</span>
|
||||
)}
|
||||
</div>
|
||||
<div className="health-actions">
|
||||
{lastCheck && (
|
||||
<span className="health-timestamp">Last check: {lastCheck.toLocaleTimeString()}</span>
|
||||
)}
|
||||
<button
|
||||
className="token-btn token-btn-primary"
|
||||
onClick={checkAll}
|
||||
disabled={checking}
|
||||
style={{ minWidth: 120 }}
|
||||
>
|
||||
{checking ? '⟳ Checking…' : '▶ Check All'}
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{totalChecked === 0 && (
|
||||
<p className="arch2-hint">Click any node for details · Run Check All to test live status</p>
|
||||
)}
|
||||
|
||||
<ArchDiagram results={results} onNodeClick={setPopup} />
|
||||
|
||||
{popup && (
|
||||
<ServicePopup
|
||||
id={popup}
|
||||
result={results[popup]}
|
||||
onClose={() => setPopup(null)}
|
||||
/>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
563
Client-Tech/src/components/HelpContentPanel.jsx
Normal file
563
Client-Tech/src/components/HelpContentPanel.jsx
Normal file
@@ -0,0 +1,563 @@
|
||||
import React, { useState, useEffect, useCallback, useRef, useMemo } from 'react';
|
||||
import { useAuth } from '../auth/AuthProvider';
|
||||
import { MGMT_BASE } from '../auth/authConfig';
|
||||
|
||||
// ── Icon helpers ──────────────────────────────────────────────────────────────
|
||||
const Icon = ({ d, size = 14 }) => (
|
||||
<svg width={size} height={size} viewBox="0 0 24 24" fill="none"
|
||||
stroke="currentColor" strokeWidth={2} strokeLinecap="round" strokeLinejoin="round">
|
||||
<path d={d} />
|
||||
</svg>
|
||||
);
|
||||
const IcPlus = () => <Icon d="M12 5v14M5 12h14" />;
|
||||
const IcEdit = () => <Icon d="M11 4H4a2 2 0 0 0-2 2v14a2 2 0 0 0 2 2h14a2 2 0 0 0 2-2v-7M18.5 2.5a2.121 2.121 0 0 1 3 3L12 15l-4 1 1-4 9.5-9.5z" />;
|
||||
const IcTrash = () => <Icon d="M3 6h18M19 6l-1 14a2 2 0 0 1-2 2H8a2 2 0 0 1-2-2L5 6M10 11v6M14 11v6M9 6V4a1 1 0 0 1 1-1h4a1 1 0 0 1 1 1v2" />;
|
||||
const IcRefresh = () => <Icon d="M23 4v6h-6M1 20v-6h6M3.51 9a9 9 0 0 1 14.85-3.36L23 10M1 14l4.64 4.36A9 9 0 0 0 20.49 15" />;
|
||||
const IcX = () => <Icon d="M18 6L6 18M6 6l12 12" size={12} />;
|
||||
|
||||
// ── Module colour map ─────────────────────────────────────────────────────────
|
||||
const MODULE_COLORS = {
|
||||
admin: { bg: '#eff6ff', border: '#bfdbfe', text: '#1d4ed8' },
|
||||
client: { bg: '#f0fdf4', border: '#bbf7d0', text: '#15803d' },
|
||||
service: { bg: '#fdf4ff', border: '#e9d5ff', text: '#7e22ce' },
|
||||
};
|
||||
const defaultModuleColor = { bg: '#f8fafc', border: '#e2e8f0', text: '#475569' };
|
||||
const moduleColor = (mod) => MODULE_COLORS[mod] || defaultModuleColor;
|
||||
|
||||
// ── Styled help-key chip ──────────────────────────────────────────────────────
|
||||
function HelpKey({ helpKey }) {
|
||||
const parts = helpKey.split('.');
|
||||
const mod = parts[0] || '';
|
||||
const col = moduleColor(mod);
|
||||
return (
|
||||
<span style={{ display: 'inline-flex', alignItems: 'center', fontFamily: 'monospace', fontSize: 12 }}>
|
||||
<span style={{
|
||||
padding: '2px 7px', borderRadius: '4px 0 0 4px',
|
||||
background: col.bg, border: `1px solid ${col.border}`,
|
||||
color: col.text, fontWeight: 700, fontSize: 11,
|
||||
borderRight: 'none',
|
||||
}}>{mod}</span>
|
||||
{parts.slice(1).map((seg, i) => {
|
||||
const isLast = i === parts.slice(1).length - 1;
|
||||
return (
|
||||
<span key={i} style={{
|
||||
padding: '2px 0',
|
||||
borderTop: '1px solid #e2e8f0', borderBottom: '1px solid #e2e8f0',
|
||||
borderRight: isLast ? '1px solid #e2e8f0' : 'none',
|
||||
borderRadius: isLast ? '0 4px 4px 0' : 0,
|
||||
background: '#fff', color: isLast ? '#0f172a' : '#64748b',
|
||||
fontWeight: isLast ? 600 : 400,
|
||||
}}>
|
||||
<span style={{ color: '#cbd5e1', padding: '0 3px 0 4px' }}>.</span>{seg}
|
||||
</span>
|
||||
);
|
||||
})}
|
||||
</span>
|
||||
);
|
||||
}
|
||||
|
||||
// ── Status dot ────────────────────────────────────────────────────────────────
|
||||
function StatusDot({ active }) {
|
||||
return (
|
||||
<span style={{ display: 'inline-flex', alignItems: 'center', gap: 5 }}>
|
||||
<span style={{
|
||||
width: 6, height: 6, borderRadius: '50%', flexShrink: 0,
|
||||
background: active ? '#22c55e' : '#d1d5db',
|
||||
}} />
|
||||
<span style={{ fontSize: 12, color: active ? '#15803d' : '#9ca3af', fontWeight: 500 }}>
|
||||
{active ? 'Active' : 'Inactive'}
|
||||
</span>
|
||||
</span>
|
||||
);
|
||||
}
|
||||
|
||||
// ── Icon button ───────────────────────────────────────────────────────────────
|
||||
function IconBtn({ onClick, disabled, title, danger, children }) {
|
||||
const [hover, setHover] = useState(false);
|
||||
return (
|
||||
<button onClick={onClick} disabled={disabled} title={title}
|
||||
onMouseEnter={() => setHover(true)} onMouseLeave={() => setHover(false)}
|
||||
style={{
|
||||
display: 'inline-flex', alignItems: 'center', gap: 4,
|
||||
padding: '5px 10px', borderRadius: 5, border: '1px solid',
|
||||
fontSize: 12, fontWeight: 500, cursor: disabled ? 'not-allowed' : 'pointer',
|
||||
fontFamily: 'inherit', transition: 'all 0.12s', opacity: disabled ? 0.45 : 1,
|
||||
background: danger ? (hover ? '#fef2f2' : '#fff') : (hover ? '#eff6ff' : '#fff'),
|
||||
borderColor: danger ? (hover ? '#fca5a5' : '#e2e8f0') : (hover ? '#bfdbfe' : '#e2e8f0'),
|
||||
color: danger ? (hover ? '#dc2626' : '#94a3b8') : (hover ? '#2563eb' : '#64748b'),
|
||||
}}>
|
||||
{children}
|
||||
</button>
|
||||
);
|
||||
}
|
||||
|
||||
export default function HelpPanel() {
|
||||
const { getAccessToken } = useAuth();
|
||||
|
||||
const apiCall = useCallback(async (path, method = 'GET', body = null) => {
|
||||
const token = await getAccessToken();
|
||||
if (!token) return { ok: false, error: 'Not authenticated' };
|
||||
const opts = { method, headers: { 'Authorization': `Bearer ${token}`, 'Content-Type': 'application/json' } };
|
||||
if (body) opts.body = JSON.stringify(body);
|
||||
try {
|
||||
const res = await fetch(`${MGMT_BASE}${path}`, opts);
|
||||
const text = await res.text();
|
||||
try { return JSON.parse(text); } catch { return { ok: false, error: text }; }
|
||||
} catch (e) { return { ok: false, error: e.message }; }
|
||||
}, [getAccessToken]);
|
||||
|
||||
const [items, setItems] = useState([]);
|
||||
const [loading, setLoading] = useState(true);
|
||||
const [error, setError] = useState(null);
|
||||
const [toast, setToast] = useState(null);
|
||||
const [showInactive, setShowInactive] = useState(false);
|
||||
const [filterModule, setFilterModule] = useState('');
|
||||
const [filterSection, setFilterSection] = useState('');
|
||||
const [editKey, setEditKey] = useState(null);
|
||||
const [form, setForm] = useState({ helpKey: '', title: '', body: '', isActive: true });
|
||||
const [saving, setSaving] = useState(false);
|
||||
const [deleting, setDeleting] = useState(null);
|
||||
const [confirmDelete, setConfirmDelete] = useState(null);
|
||||
const bodyRef = useRef(null);
|
||||
|
||||
const showToast = useCallback((msg, type = 'success') => {
|
||||
setToast({ msg, type });
|
||||
setTimeout(() => setToast(null), 3000);
|
||||
}, []);
|
||||
|
||||
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]);
|
||||
|
||||
const { modules, sections } = useMemo(() => {
|
||||
const mods = new Set(), secs = new Set();
|
||||
items.forEach(({ helpKey }) => {
|
||||
const p = helpKey.split('.');
|
||||
if (p[0]) mods.add(p[0]);
|
||||
if (p[1]) secs.add(p[1]);
|
||||
});
|
||||
return { modules: [...mods].sort(), sections: [...secs].sort() };
|
||||
}, [items]);
|
||||
|
||||
const handleModuleChange = (val) => { setFilterModule(val); setFilterSection(''); };
|
||||
|
||||
const availableSections = useMemo(() => {
|
||||
if (!filterModule) return sections;
|
||||
const secs = new Set();
|
||||
items.forEach(({ helpKey }) => {
|
||||
const p = helpKey.split('.');
|
||||
if (p[0] === filterModule && p[1]) secs.add(p[1]);
|
||||
});
|
||||
return [...secs].sort();
|
||||
}, [items, filterModule, sections]);
|
||||
|
||||
const visibleItems = useMemo(() => items.filter(({ helpKey }) => {
|
||||
const p = helpKey.split('.');
|
||||
if (filterModule && p[0] !== filterModule) return false;
|
||||
if (filterSection && p[1] !== filterSection) return false;
|
||||
return true;
|
||||
}), [items, filterModule, filterSection]);
|
||||
|
||||
const hasFilter = filterModule || filterSection;
|
||||
|
||||
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 }); };
|
||||
|
||||
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', 'error'); }
|
||||
finally { setSaving(false); }
|
||||
};
|
||||
|
||||
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', 'error'); }
|
||||
finally { setDeleting(null); }
|
||||
};
|
||||
|
||||
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));
|
||||
|
||||
const activeCount = items.filter(i => i.isActive).length;
|
||||
const inactiveCount = items.length - activeCount;
|
||||
|
||||
const fmtDate = (d) => {
|
||||
if (!d) return '—';
|
||||
return new Date(d).toLocaleDateString('en-US', { month: 'short', day: 'numeric', year: '2-digit' });
|
||||
};
|
||||
|
||||
// ── Shared styles ─────────────────────────────────────────────────────────
|
||||
const inputStyle = {
|
||||
width: '100%', padding: '7px 11px', border: '1px solid #e2e8f0',
|
||||
borderRadius: 6, fontSize: 13, fontFamily: 'inherit',
|
||||
outline: 'none', boxSizing: 'border-box', color: '#1e293b',
|
||||
};
|
||||
const labelStyle = {
|
||||
display: 'block', fontSize: 11, fontWeight: 700, color: '#64748b',
|
||||
textTransform: 'uppercase', letterSpacing: '0.05em', marginBottom: 5,
|
||||
};
|
||||
|
||||
return (
|
||||
<div style={{ maxWidth: 960 }}>
|
||||
|
||||
{/* ── Toast ─────────────────────────────────────────────────── */}
|
||||
{toast && (
|
||||
<div style={{
|
||||
position: 'fixed', bottom: 24, right: 24, zIndex: 1000,
|
||||
background: toast.type === 'error' ? '#dc2626' : '#16a34a',
|
||||
color: '#fff', padding: '10px 18px', borderRadius: 8,
|
||||
fontSize: 13, fontWeight: 500, boxShadow: '0 4px 16px rgba(0,0,0,0.18)',
|
||||
}}>
|
||||
{toast.msg}
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* ── Header ────────────────────────────────────────────────── */}
|
||||
<div style={{ display: 'flex', alignItems: 'flex-start', justifyContent: 'space-between', marginBottom: 20 }}>
|
||||
<div>
|
||||
<h3 style={{ margin: 0, fontSize: 16, fontWeight: 600, color: '#1e293b' }}>Help Content</h3>
|
||||
<div style={{ display: 'flex', alignItems: 'center', gap: 10, marginTop: 4 }}>
|
||||
<span style={{ fontSize: 13, color: '#64748b' }}>
|
||||
{hasFilter ? `${visibleItems.length} of ${items.length} entries` : `${items.length} entries`}
|
||||
</span>
|
||||
{!loading && items.length > 0 && (<>
|
||||
<span style={{ color: '#e2e8f0' }}>·</span>
|
||||
<span style={{ fontSize: 12, color: '#22c55e', fontWeight: 500 }}>{activeCount} active</span>
|
||||
{inactiveCount > 0 && (<>
|
||||
<span style={{ color: '#e2e8f0' }}>·</span>
|
||||
<span style={{ fontSize: 12, color: '#94a3b8' }}>{inactiveCount} inactive</span>
|
||||
</>)}
|
||||
</>)}
|
||||
</div>
|
||||
</div>
|
||||
<div style={{ display: 'flex', alignItems: 'center', gap: 10 }}>
|
||||
<label style={{
|
||||
display: 'flex', alignItems: 'center', gap: 6,
|
||||
fontSize: 13, color: '#64748b', cursor: 'pointer', userSelect: 'none',
|
||||
}}>
|
||||
<input type="checkbox" checked={showInactive}
|
||||
onChange={e => setShowInactive(e.target.checked)} />
|
||||
Show inactive
|
||||
</label>
|
||||
<button onClick={loadItems} disabled={loading} title="Refresh"
|
||||
style={{
|
||||
display: 'inline-flex', alignItems: 'center', justifyContent: 'center',
|
||||
width: 32, height: 32, borderRadius: 6, border: '1px solid #e2e8f0',
|
||||
background: '#fff', cursor: 'pointer', color: '#64748b', flexShrink: 0,
|
||||
}}>
|
||||
<IcRefresh />
|
||||
</button>
|
||||
<button onClick={startNew}
|
||||
style={{
|
||||
display: 'inline-flex', alignItems: 'center', gap: 6,
|
||||
padding: '7px 14px', borderRadius: 6, border: 'none',
|
||||
background: '#2563eb', color: '#fff',
|
||||
fontSize: 13, fontWeight: 500, cursor: 'pointer', fontFamily: 'inherit',
|
||||
}}>
|
||||
<IcPlus /> New Entry
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* ── Filter pills ──────────────────────────────────────────── */}
|
||||
{!loading && modules.length > 0 && (
|
||||
<div style={{ display: 'flex', alignItems: 'center', gap: 8, marginBottom: 16, flexWrap: 'wrap' }}>
|
||||
{/* Module label + pills */}
|
||||
<span style={{ fontSize: 11, fontWeight: 700, color: '#94a3b8', textTransform: 'uppercase', letterSpacing: '0.05em' }}>Module</span>
|
||||
{['', ...modules].map(m => {
|
||||
const active = filterModule === m;
|
||||
const col = m ? moduleColor(m) : defaultModuleColor;
|
||||
return (
|
||||
<button key={m || '__all'} onClick={() => handleModuleChange(m)}
|
||||
style={{
|
||||
padding: '4px 12px', borderRadius: 20, border: '1px solid',
|
||||
fontSize: 12, fontWeight: active ? 600 : 400,
|
||||
cursor: 'pointer', fontFamily: 'inherit', transition: 'all 0.12s',
|
||||
background: active ? col.bg : '#fff',
|
||||
borderColor: active ? col.border : '#e2e8f0',
|
||||
color: active ? col.text : '#64748b',
|
||||
}}>
|
||||
{m || 'All'}
|
||||
</button>
|
||||
);
|
||||
})}
|
||||
|
||||
{/* Section pills */}
|
||||
{filterModule && availableSections.length > 0 && (<>
|
||||
<span style={{ width: 1, height: 16, background: '#e2e8f0', flexShrink: 0 }} />
|
||||
<span style={{ fontSize: 11, fontWeight: 700, color: '#94a3b8', textTransform: 'uppercase', letterSpacing: '0.05em' }}>Section</span>
|
||||
{availableSections.map(s => {
|
||||
const active = filterSection === s;
|
||||
return (
|
||||
<button key={s} onClick={() => setFilterSection(active ? '' : s)}
|
||||
style={{
|
||||
padding: '3px 10px', borderRadius: 20, border: '1px solid',
|
||||
fontSize: 11, fontWeight: active ? 600 : 400, fontFamily: 'monospace',
|
||||
cursor: 'pointer', transition: 'all 0.12s',
|
||||
background: active ? '#0f172a' : '#fff',
|
||||
borderColor: active ? '#0f172a' : '#e2e8f0',
|
||||
color: active ? '#fff' : '#64748b',
|
||||
}}>
|
||||
{s}
|
||||
</button>
|
||||
);
|
||||
})}
|
||||
</>)}
|
||||
|
||||
{hasFilter && (
|
||||
<button onClick={() => { setFilterModule(''); setFilterSection(''); }}
|
||||
style={{
|
||||
display: 'inline-flex', alignItems: 'center', gap: 4,
|
||||
padding: '3px 9px', borderRadius: 20,
|
||||
border: '1px solid #e2e8f0', background: '#f8fafc',
|
||||
fontSize: 11, color: '#64748b', cursor: 'pointer', fontFamily: 'inherit',
|
||||
}}>
|
||||
<IcX /> Clear
|
||||
</button>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* ── Edit / new form ───────────────────────────────────────── */}
|
||||
{editKey && (
|
||||
<div style={{
|
||||
background: '#fff', border: '1px solid #bfdbfe', borderRadius: 10,
|
||||
padding: 20, marginBottom: 20, boxShadow: '0 0 0 3px rgba(59,130,246,0.06)',
|
||||
}}>
|
||||
<div style={{ display: 'flex', alignItems: 'center', justifyContent: 'space-between', marginBottom: 18 }}>
|
||||
<div style={{ fontWeight: 600, fontSize: 14, color: '#1e293b' }}>
|
||||
{editKey === 'new' ? '✦ New Help Entry' : `Editing: ${editKey}`}
|
||||
</div>
|
||||
<button onClick={cancelEdit}
|
||||
style={{
|
||||
padding: '5px 12px', borderRadius: 6, border: '1px solid #e2e8f0',
|
||||
background: '#fff', fontSize: 12, color: '#64748b',
|
||||
cursor: 'pointer', fontFamily: 'inherit',
|
||||
}}>
|
||||
Cancel
|
||||
</button>
|
||||
</div>
|
||||
|
||||
{/* Help key */}
|
||||
<div style={{ marginBottom: 14 }}>
|
||||
<label style={labelStyle}>
|
||||
Help Key <span style={{ color: '#94a3b8', fontWeight: 400, textTransform: 'none', letterSpacing: 0 }}>— lowercase, dot-separated</span>
|
||||
</label>
|
||||
{editKey === 'new' ? (
|
||||
<div>
|
||||
<input style={inputStyle} placeholder="e.g. client.wizard.budget"
|
||||
value={form.helpKey}
|
||||
onChange={e => setForm(f => ({ ...f, helpKey: e.target.value.toLowerCase() }))} />
|
||||
{unusedSuggestions.length > 0 && (
|
||||
<div style={{ display: 'flex', alignItems: 'center', flexWrap: 'wrap', gap: 5, marginTop: 7 }}>
|
||||
<span style={{ fontSize: 11, color: '#94a3b8' }}>Suggested:</span>
|
||||
{unusedSuggestions.slice(0, 5).map(k => (
|
||||
<button key={k} onClick={() => setForm(f => ({ ...f, helpKey: k }))}
|
||||
style={{
|
||||
padding: '2px 8px', borderRadius: 4, border: '1px solid #bfdbfe',
|
||||
background: '#eff6ff', color: '#2563eb', fontSize: 11,
|
||||
fontFamily: 'monospace', cursor: 'pointer',
|
||||
}}>
|
||||
{k}
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
) : (
|
||||
<div style={{
|
||||
padding: '7px 11px', background: '#f8fafc', border: '1px solid #e2e8f0',
|
||||
borderRadius: 6, fontFamily: 'monospace', fontSize: 13, color: '#475569',
|
||||
}}>
|
||||
{form.helpKey}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* Title + Active toggle */}
|
||||
<div style={{ display: 'grid', gridTemplateColumns: '1fr auto', gap: 14, alignItems: 'end', marginBottom: 14 }}>
|
||||
<div>
|
||||
<label style={labelStyle}>Title</label>
|
||||
<input style={inputStyle} placeholder="Modal heading shown to users"
|
||||
value={form.title} onChange={e => setForm(f => ({ ...f, title: e.target.value }))} />
|
||||
</div>
|
||||
<label style={{
|
||||
display: 'flex', alignItems: 'center', gap: 6, paddingBottom: 8,
|
||||
fontSize: 13, color: '#475569', cursor: 'pointer', userSelect: 'none', whiteSpace: 'nowrap',
|
||||
}}>
|
||||
<input type="checkbox" checked={form.isActive}
|
||||
onChange={e => setForm(f => ({ ...f, isActive: e.target.checked }))} />
|
||||
Active
|
||||
</label>
|
||||
</div>
|
||||
|
||||
{/* Body */}
|
||||
<div style={{ marginBottom: 16 }}>
|
||||
<label style={labelStyle}>
|
||||
Body
|
||||
<span style={{ color: '#94a3b8', fontWeight: 400, textTransform: 'none', letterSpacing: 0, marginLeft: 6 }}>
|
||||
— <p> <ul> <li> <strong> <h4> render in popup
|
||||
</span>
|
||||
</label>
|
||||
<textarea ref={bodyRef} style={{
|
||||
...inputStyle, resize: 'vertical', minHeight: 160,
|
||||
fontFamily: "'SF Mono','Menlo','Monaco','Consolas',monospace",
|
||||
fontSize: 12, lineHeight: 1.6,
|
||||
}}
|
||||
placeholder="<p>Your help text here…</p>" value={form.body}
|
||||
onChange={e => setForm(f => ({ ...f, body: e.target.value }))} rows={8} />
|
||||
</div>
|
||||
|
||||
<div style={{ display: 'flex', justifyContent: 'flex-end' }}>
|
||||
<button onClick={handleSave} disabled={saving}
|
||||
style={{
|
||||
padding: '7px 20px', borderRadius: 6, border: 'none',
|
||||
background: saving ? '#93c5fd' : '#2563eb', color: '#fff',
|
||||
fontSize: 13, fontWeight: 500, cursor: saving ? 'not-allowed' : 'pointer',
|
||||
fontFamily: 'inherit',
|
||||
}}>
|
||||
{saving ? 'Saving…' : 'Save Entry'}
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* ── Loading / error / empty ───────────────────────────────── */}
|
||||
{loading && (
|
||||
<div style={{ padding: '40px 0', textAlign: 'center', color: '#94a3b8', fontSize: 13 }}>
|
||||
Loading help content…
|
||||
</div>
|
||||
)}
|
||||
{error && !loading && (
|
||||
<div style={{
|
||||
padding: '12px 16px', background: '#fef2f2', border: '1px solid #fecaca',
|
||||
borderRadius: 8, color: '#dc2626', fontSize: 13, marginBottom: 16,
|
||||
}}>
|
||||
{error}
|
||||
</div>
|
||||
)}
|
||||
{!loading && !error && items.length === 0 && (
|
||||
<div style={{ textAlign: 'center', padding: '60px 20px', color: '#94a3b8' }}>
|
||||
<div style={{ fontSize: 32, marginBottom: 12 }}>📝</div>
|
||||
<div style={{ fontWeight: 600, color: '#475569', marginBottom: 6 }}>No help entries yet</div>
|
||||
<div style={{ fontSize: 13 }}>Click <strong>New Entry</strong> to create the first one.</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* ── Table ─────────────────────────────────────────────────── */}
|
||||
{!loading && items.length > 0 && (
|
||||
<div style={{ background: '#fff', border: '1px solid #e5e7eb', borderRadius: 10, overflow: 'hidden' }}>
|
||||
{visibleItems.length === 0 ? (
|
||||
<div style={{ padding: '32px 20px', textAlign: 'center', color: '#94a3b8', fontSize: 13 }}>
|
||||
No entries match the current filter.
|
||||
</div>
|
||||
) : (
|
||||
<table style={{ width: '100%', borderCollapse: 'collapse' }}>
|
||||
<thead>
|
||||
<tr style={{ background: '#f8fafc', borderBottom: '1px solid #e5e7eb' }}>
|
||||
{[
|
||||
{ label: 'Help Key', width: '38%' },
|
||||
{ label: 'Title', width: null },
|
||||
{ label: 'Status', width: 90 },
|
||||
{ label: 'Updated', width: 80 },
|
||||
{ label: '', width: 106 },
|
||||
].map(({ label, width }) => (
|
||||
<th key={label} style={{
|
||||
padding: '10px 16px', textAlign: 'left',
|
||||
fontSize: 11, fontWeight: 700, color: '#94a3b8',
|
||||
letterSpacing: '0.05em', textTransform: 'uppercase',
|
||||
width: width || undefined,
|
||||
}}>
|
||||
{label}
|
||||
</th>
|
||||
))}
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
{visibleItems.map((item, idx) => (
|
||||
<tr key={item.helpKey} style={{
|
||||
borderBottom: idx < visibleItems.length - 1 ? '1px solid #f1f5f9' : 'none',
|
||||
opacity: !item.isActive ? 0.5 : 1,
|
||||
background: editKey === item.helpKey ? '#f8fbff' : 'transparent',
|
||||
}}>
|
||||
<td style={{ padding: '11px 16px' }}>
|
||||
<HelpKey helpKey={item.helpKey} />
|
||||
</td>
|
||||
<td style={{ padding: '11px 16px', fontSize: 13, color: '#374151' }}>
|
||||
{item.title}
|
||||
</td>
|
||||
<td style={{ padding: '11px 16px' }}>
|
||||
<StatusDot active={item.isActive} />
|
||||
</td>
|
||||
<td style={{ padding: '11px 16px', fontSize: 12, color: '#94a3b8', whiteSpace: 'nowrap' }}>
|
||||
{fmtDate(item.updatedAt)}
|
||||
</td>
|
||||
<td style={{ padding: '8px 12px' }}>
|
||||
{confirmDelete === item.helpKey ? (
|
||||
<div style={{ display: 'flex', gap: 4 }}>
|
||||
<button onClick={() => handleDelete(item.helpKey)}
|
||||
disabled={deleting === item.helpKey}
|
||||
style={{
|
||||
padding: '4px 10px', borderRadius: 5, border: '1px solid #fca5a5',
|
||||
background: '#fef2f2', color: '#dc2626',
|
||||
fontSize: 11, fontWeight: 600, cursor: 'pointer', fontFamily: 'inherit',
|
||||
}}>
|
||||
{deleting === item.helpKey ? '…' : 'Confirm'}
|
||||
</button>
|
||||
<button onClick={() => setConfirmDelete(null)}
|
||||
style={{
|
||||
padding: '4px 8px', borderRadius: 5, border: '1px solid #e2e8f0',
|
||||
background: '#fff', color: '#94a3b8',
|
||||
fontSize: 12, cursor: 'pointer',
|
||||
}}>
|
||||
✕
|
||||
</button>
|
||||
</div>
|
||||
) : (
|
||||
<div style={{ display: 'flex', gap: 5 }}>
|
||||
<IconBtn onClick={() => startEdit(item)} disabled={editKey === item.helpKey} title="Edit">
|
||||
<IcEdit /> Edit
|
||||
</IconBtn>
|
||||
<IconBtn onClick={() => setConfirmDelete(item.helpKey)} danger title="Delete">
|
||||
<IcTrash />
|
||||
</IconBtn>
|
||||
</div>
|
||||
)}
|
||||
</td>
|
||||
</tr>
|
||||
))}
|
||||
</tbody>
|
||||
</table>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
9
Client-Tech/src/components/ResponsePanel.jsx
Normal file
9
Client-Tech/src/components/ResponsePanel.jsx
Normal file
@@ -0,0 +1,9 @@
|
||||
import React from 'react';
|
||||
|
||||
export default function ResponsePanel() {
|
||||
return (
|
||||
<pre id="response-panel">
|
||||
Response will appear here
|
||||
</pre>
|
||||
);
|
||||
}
|
||||
169
Client-Tech/src/components/ServiceForm.js
Normal file
169
Client-Tech/src/components/ServiceForm.js
Normal file
@@ -0,0 +1,169 @@
|
||||
import React, { useEffect, useMemo, useState } from 'react';
|
||||
import { useAuth } from '../auth/AuthProvider';
|
||||
import { getServices, getService } from '../services/serviceCatalog';
|
||||
import { callApi } from '../services/apiClient';
|
||||
import CreativePreview from './CreativePreview';
|
||||
import AudiencePreview from './AudiencePreview';
|
||||
|
||||
// localStorage key for persisting tenant ID
|
||||
const TENANT_STORAGE_KEY = 'adp_tenantId';
|
||||
|
||||
export default function ServiceForm({ initialProvider }) {
|
||||
const { sessionUser, getIdToken, getAccessToken } = useAuth();
|
||||
|
||||
const providerId = initialProvider || 'gateway';
|
||||
|
||||
const services = useMemo(() => getServices(providerId), [providerId]);
|
||||
const [serviceId, setServiceId] = useState(services?.[0]?.id || '');
|
||||
|
||||
const [input, setInput] = useState('{}');
|
||||
const [resp, setResp] = useState(null);
|
||||
const [isBusy, setIsBusy] = useState(false);
|
||||
|
||||
// Tenant ID - persisted for convenience
|
||||
const [tenantId, setTenantId] = useState(() =>
|
||||
localStorage.getItem(TENANT_STORAGE_KEY) || ''
|
||||
);
|
||||
|
||||
// Get current service definition for display
|
||||
const currentService = useMemo(() => getService(providerId, serviceId), [providerId, serviceId]);
|
||||
|
||||
// Persist tenant ID changes
|
||||
useEffect(() => {
|
||||
localStorage.setItem(TENANT_STORAGE_KEY, tenantId);
|
||||
}, [tenantId]);
|
||||
|
||||
// Reset service selection + sample when provider changes
|
||||
useEffect(() => {
|
||||
const newServices = getServices(providerId);
|
||||
const firstService = newServices?.[0];
|
||||
setServiceId(firstService?.id || '');
|
||||
if (firstService?.sample) {
|
||||
setInput(JSON.stringify(firstService.sample, null, 2));
|
||||
}
|
||||
setResp(null);
|
||||
}, [providerId]);
|
||||
|
||||
// Update sample input when service changes
|
||||
useEffect(() => {
|
||||
const service = getService(providerId, serviceId);
|
||||
if (service?.sample) {
|
||||
setInput(JSON.stringify(service.sample, null, 2));
|
||||
}
|
||||
}, [serviceId, providerId]);
|
||||
|
||||
const submit = async () => {
|
||||
setIsBusy(true);
|
||||
setResp(null);
|
||||
try {
|
||||
const token = await getIdToken();
|
||||
const entraToken = await getAccessToken();
|
||||
const authConfig = {
|
||||
sessionToken: token, // Gateway + provider calls
|
||||
entraToken: entraToken, // Management / staff-plane calls
|
||||
tenantId: tenantId.trim() || undefined,
|
||||
};
|
||||
const result = await callApi(providerId, serviceId, input, authConfig);
|
||||
setResp(result);
|
||||
} catch (e) {
|
||||
setResp({ ok: false, error: e?.message || String(e) });
|
||||
} finally {
|
||||
setIsBusy(false);
|
||||
}
|
||||
};
|
||||
|
||||
// Tenant ID placeholder varies by provider
|
||||
const tenantPlaceholder = {
|
||||
google: 'Google Ads Customer ID (e.g. 1234567890)',
|
||||
meta: 'Meta Ad Account ID (e.g. act_123456789)',
|
||||
tiktok: 'TikTok Advertiser ID (e.g. 7123456789012345678)',
|
||||
creative: 'Not required for Creative',
|
||||
gateway: 'Not required for Gateway direct calls',
|
||||
}[providerId] || 'Account ID (optional)';
|
||||
|
||||
const showTenant = providerId !== 'gateway';
|
||||
|
||||
return (
|
||||
<div className="service-form">
|
||||
{/* Tenant Configuration */}
|
||||
{showTenant && (
|
||||
<fieldset className="auth-section">
|
||||
<legend>Target Account</legend>
|
||||
<div className="row row-inline">
|
||||
<label>Tenant ID</label>
|
||||
<input
|
||||
type="text"
|
||||
value={tenantId}
|
||||
onChange={(e) => setTenantId(e.target.value)}
|
||||
placeholder={tenantPlaceholder}
|
||||
/>
|
||||
</div>
|
||||
</fieldset>
|
||||
)}
|
||||
|
||||
{/* Operation selector */}
|
||||
<div className="row">
|
||||
<label>Operation</label>
|
||||
<select value={serviceId} onChange={(e) => setServiceId(e.target.value)}>
|
||||
{services.map(s => (
|
||||
<option key={s.id} value={s.id}>{s.label}</option>
|
||||
))}
|
||||
</select>
|
||||
</div>
|
||||
|
||||
{/* Routing indicator for provider calls */}
|
||||
{currentService && !currentService.endpoint && (
|
||||
<div className="row routing-info">
|
||||
<span className="route-badge">
|
||||
<strong>Route:</strong> Gateway → {providerId} → {currentService.service}.{currentService.action}
|
||||
</span>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Endpoint indicator for direct gateway calls */}
|
||||
{currentService?.endpoint && (
|
||||
<div className="row routing-info">
|
||||
<span className="route-badge route-direct">
|
||||
<strong>Direct:</strong> {currentService.method} {currentService.endpoint}
|
||||
</span>
|
||||
</div>
|
||||
)}
|
||||
|
||||
<div className="row">
|
||||
<label>Payload (JSON)</label>
|
||||
<textarea rows={10} value={input} onChange={(e) => setInput(e.target.value)} />
|
||||
</div>
|
||||
|
||||
<button className="submit-btn" onClick={submit} disabled={!serviceId || isBusy}>
|
||||
{isBusy ? 'Calling…' : 'Submit'}
|
||||
</button>
|
||||
|
||||
{resp && (
|
||||
<div className="response-section">
|
||||
{/* Status badge */}
|
||||
<div className={`response-status ${resp.ok !== false ? 'status-ok' : 'status-fail'}`}>
|
||||
{resp.ok !== false ? '✓ Success' : '✗ Error'}
|
||||
</div>
|
||||
|
||||
{/* Visual Preview for Creative responses */}
|
||||
{providerId === 'creative' && resp.ok && (
|
||||
<CreativePreview data={resp} />
|
||||
)}
|
||||
|
||||
{/* Visual Preview for Audience responses */}
|
||||
{providerId === 'google' && currentService?.service === 'audience' && resp.ok && (
|
||||
<AudiencePreview data={resp} />
|
||||
)}
|
||||
|
||||
{/* Raw JSON response */}
|
||||
<details className="json-details" open={
|
||||
(providerId !== 'creative' && currentService?.service !== 'audience') || !resp.ok
|
||||
}>
|
||||
<summary>Raw JSON Response</summary>
|
||||
<pre className="response">{JSON.stringify(resp, null, 2)}</pre>
|
||||
</details>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
128
Client-Tech/src/components/Shell.jsx
Normal file
128
Client-Tech/src/components/Shell.jsx
Normal file
@@ -0,0 +1,128 @@
|
||||
import React, { useState } from 'react';
|
||||
import { useAuth } from '../auth/AuthProvider';
|
||||
import ApiTestingPanel from './ApiTestingPanel';
|
||||
import DocumentsPanel from './DocumentsPanel';
|
||||
import HealthDashboard from './HealthDashboard';
|
||||
import ActivityPanel from './ActivityPanel';
|
||||
import HelpContentPanel from './HelpContentPanel';
|
||||
|
||||
const NAV_SECTIONS = [
|
||||
{ type: 'label', text: 'MONITORING' },
|
||||
{ id: 'health', label: 'Service Health', icon: '⚡' },
|
||||
|
||||
{ type: 'label', text: 'RESOURCES' },
|
||||
{ id: 'documents', label: 'Documents', icon: '📁' },
|
||||
|
||||
{ type: 'label', text: 'DEVELOPMENT' },
|
||||
{ id: 'api-testing', label: 'API Testing', icon: '🔧' },
|
||||
|
||||
{ type: 'label', text: 'ADMINISTRATION' },
|
||||
{ id: 'activity', label: 'Activity Log', icon: '📋' },
|
||||
{ id: 'help-content', label: 'Help Content', icon: '💬' },
|
||||
];
|
||||
|
||||
export default function Shell() {
|
||||
const { sessionUser, signOut } = useAuth();
|
||||
const [activeNav, setActiveNav] = useState('health');
|
||||
const [sidebarCollapsed, setSidebarCollapsed] = useState(false);
|
||||
|
||||
const renderContent = () => {
|
||||
switch (activeNav) {
|
||||
case 'health': return <HealthDashboard />;
|
||||
case 'documents': return <DocumentsPanel />;
|
||||
case 'api-testing': return <ApiTestingPanel />;
|
||||
case 'activity': return <ActivityPanel />;
|
||||
case 'help-content': return <HelpContentPanel />;
|
||||
default: return <HealthDashboard />;
|
||||
}
|
||||
};
|
||||
|
||||
const getPageTitle = () => {
|
||||
const item = NAV_SECTIONS.find(n => n.id === activeNav);
|
||||
return item?.label || 'Service Health';
|
||||
};
|
||||
|
||||
return (
|
||||
<div className={`app-shell ${sidebarCollapsed ? 'sidebar-collapsed' : ''}`}>
|
||||
|
||||
{/* Sidebar */}
|
||||
<aside className="sidebar">
|
||||
<div className="sidebar-header">
|
||||
<div className="sidebar-brand">
|
||||
{!sidebarCollapsed && <span className="brand-text">AdPlatform</span>}
|
||||
<span className="brand-badge">DEV</span>
|
||||
</div>
|
||||
<button
|
||||
className="sidebar-toggle"
|
||||
onClick={() => setSidebarCollapsed(!sidebarCollapsed)}
|
||||
title={sidebarCollapsed ? 'Expand sidebar' : 'Collapse sidebar'}
|
||||
>
|
||||
{sidebarCollapsed ? '▸' : '◂'}
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<nav className="sidebar-nav">
|
||||
{NAV_SECTIONS.map((item, i) => {
|
||||
if (item.type === 'label') {
|
||||
return !sidebarCollapsed
|
||||
? <div key={i} className="nav-section-label">{item.text}</div>
|
||||
: <div key={i} className="nav-section-divider" />;
|
||||
}
|
||||
|
||||
const isActive = activeNav === item.id;
|
||||
return (
|
||||
<button
|
||||
key={item.id}
|
||||
className={`nav-item ${isActive ? 'nav-active' : ''}`}
|
||||
onClick={() => setActiveNav(item.id)}
|
||||
title={sidebarCollapsed ? item.label : undefined}
|
||||
>
|
||||
<span className="nav-icon">{item.icon}</span>
|
||||
{!sidebarCollapsed && <span className="nav-label">{item.label}</span>}
|
||||
</button>
|
||||
);
|
||||
})}
|
||||
</nav>
|
||||
|
||||
<div className="sidebar-footer">
|
||||
<div className="sidebar-user">
|
||||
<span className="user-avatar">
|
||||
{(sessionUser?.name || sessionUser?.email || 'U')[0].toUpperCase()}
|
||||
</span>
|
||||
{!sidebarCollapsed && (
|
||||
<div className="user-details">
|
||||
<span className="user-name-text">
|
||||
{sessionUser?.name || sessionUser?.email || 'User'}
|
||||
</span>
|
||||
{sessionUser?.clientId && (
|
||||
<span className="user-client">Client: {sessionUser.clientId}</span>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
{!sidebarCollapsed && (
|
||||
<button className="signout-btn" onClick={signOut}>
|
||||
Sign Out
|
||||
</button>
|
||||
)}
|
||||
</div>
|
||||
</aside>
|
||||
|
||||
{/* Main Content */}
|
||||
<main className="main-content">
|
||||
<header className="content-header">
|
||||
<h2 className="page-title">{getPageTitle()}</h2>
|
||||
<div className="session-info">
|
||||
<span className="session-badge">
|
||||
<span className="session-dot" />
|
||||
Session Active
|
||||
</span>
|
||||
</div>
|
||||
</header>
|
||||
<div className="content-body">
|
||||
{renderContent()}
|
||||
</div>
|
||||
</main>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
65
Client-Tech/src/components/SignInOverlay.jsx
Normal file
65
Client-Tech/src/components/SignInOverlay.jsx
Normal file
@@ -0,0 +1,65 @@
|
||||
// src/components/SignInOverlay.jsx
|
||||
import React from 'react';
|
||||
import { useAuth } from '../auth/AuthProvider';
|
||||
|
||||
export default function SignInOverlay() {
|
||||
const { signIn, isLoading, error, clearError } = useAuth();
|
||||
|
||||
return (
|
||||
<div className="signin-overlay">
|
||||
<div className="signin-card">
|
||||
<div className="signin-header">
|
||||
<div className="signin-logo">
|
||||
<svg viewBox="0 0 24 24" width="48" height="48" fill="none" stroke="currentColor" strokeWidth="2">
|
||||
<path d="M12 2L2 7l10 5 10-5-10-5z"/>
|
||||
<path d="M2 17l10 5 10-5"/>
|
||||
<path d="M2 12l10 5 10-5"/>
|
||||
</svg>
|
||||
</div>
|
||||
<h1>AdPlatform</h1>
|
||||
<p className="signin-subtitle">API Tech</p>
|
||||
</div>
|
||||
|
||||
<div className="signin-body">
|
||||
{error && (
|
||||
<div className="signin-error">
|
||||
<span>{error}</span>
|
||||
<button className="error-dismiss" onClick={clearError}>×</button>
|
||||
</div>
|
||||
)}
|
||||
|
||||
<p className="signin-message">
|
||||
Sign in with your organization account to access the test dashboard.
|
||||
</p>
|
||||
|
||||
<button
|
||||
className="signin-button"
|
||||
onClick={signIn}
|
||||
disabled={isLoading}
|
||||
>
|
||||
{isLoading ? (
|
||||
<>
|
||||
<span className="signin-spinner" />
|
||||
Signing in...
|
||||
</>
|
||||
) : (
|
||||
<>
|
||||
<svg viewBox="0 0 21 21" width="20" height="20" xmlns="http://www.w3.org/2000/svg">
|
||||
<rect x="1" y="1" width="9" height="9" fill="#f25022"/>
|
||||
<rect x="11" y="1" width="9" height="9" fill="#7fba00"/>
|
||||
<rect x="1" y="11" width="9" height="9" fill="#00a4ef"/>
|
||||
<rect x="11" y="11" width="9" height="9" fill="#ffb900"/>
|
||||
</svg>
|
||||
Sign in with Microsoft
|
||||
</>
|
||||
)}
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<div className="signin-footer">
|
||||
<span>Powered by Azure Entra External ID</span>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
7
Client-Tech/src/index.js
Normal file
7
Client-Tech/src/index.js
Normal file
@@ -0,0 +1,7 @@
|
||||
import React from 'react';
|
||||
import { createRoot } from 'react-dom/client';
|
||||
import App from './app/App';
|
||||
import './styles/app.css';
|
||||
|
||||
const root = createRoot(document.getElementById('root'));
|
||||
root.render(<App />);
|
||||
148
Client-Tech/src/services/apiClient.js
Normal file
148
Client-Tech/src/services/apiClient.js
Normal file
@@ -0,0 +1,148 @@
|
||||
// src/services/apiClient.js
|
||||
|
||||
/**
|
||||
* Centralized API client
|
||||
*
|
||||
* Auth contract:
|
||||
* - POST /api/auth/session → Entra JWT (handled by AuthProvider)
|
||||
* - Gateway + provider calls → session token (Bearer <sessionToken>)
|
||||
* - Management / staff-plane calls → Entra JWT (Bearer <entraToken>)
|
||||
* - Registration health check → anonymous (no auth header needed)
|
||||
*
|
||||
* Token selection is automatic — callers always supply both tokens via authConfig;
|
||||
* the client picks the correct one based on the target baseUrl.
|
||||
*
|
||||
* Routing:
|
||||
* - Services with explicit endpoint → call baseUrl+endpoint directly
|
||||
* - Provider ops (google, creative…) → POST /api/execution/request
|
||||
*/
|
||||
|
||||
import { getService } from './serviceCatalog';
|
||||
import { API_BASE, MGMT_BASE } from '../auth/authConfig';
|
||||
|
||||
// Domains that require a staff Entra JWT rather than a Gateway session token
|
||||
const STAFF_PLANE_ORIGINS = [MGMT_BASE].map(u => new URL(u).origin);
|
||||
|
||||
function isStaffPlane(baseUrl) {
|
||||
if (!baseUrl) return false;
|
||||
try {
|
||||
return STAFF_PLANE_ORIGINS.includes(new URL(baseUrl).origin);
|
||||
} catch {
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
export async function callApi(providerId, serviceId, inputJson, authConfig) {
|
||||
const { sessionToken, entraToken, tenantId } = authConfig || {};
|
||||
|
||||
// Look up the service definition from catalog
|
||||
const serviceDef = getService(providerId, serviceId);
|
||||
if (!serviceDef) {
|
||||
throw new Error(`Unknown service: ${providerId}/${serviceId}`);
|
||||
}
|
||||
|
||||
// Parse the input payload
|
||||
let payload;
|
||||
try {
|
||||
payload = typeof inputJson === 'string' ? JSON.parse(inputJson) : inputJson;
|
||||
} catch (e) {
|
||||
throw new Error(`Invalid JSON payload: ${e.message}`);
|
||||
}
|
||||
|
||||
// Build headers
|
||||
const headers = { 'Content-Type': 'application/json' };
|
||||
|
||||
// Merge any custom headers from service definition (e.g. function key)
|
||||
if (serviceDef.headers) {
|
||||
Object.assign(headers, serviceDef.headers);
|
||||
}
|
||||
|
||||
// ---- ROUTING ----
|
||||
|
||||
// Direct calls (have explicit endpoint)
|
||||
if (serviceDef.endpoint) {
|
||||
if (serviceDef.endpoint.startsWith('/api/auth/session')) {
|
||||
// Session exchange — always Entra JWT
|
||||
if (!entraToken) throw new Error('entraToken required for /api/auth/session');
|
||||
headers['Authorization'] = `Bearer ${entraToken}`;
|
||||
} else if (isStaffPlane(serviceDef.baseUrl)) {
|
||||
// Management API — staff Entra JWT
|
||||
if (entraToken) headers['Authorization'] = `Bearer ${entraToken}`;
|
||||
} else {
|
||||
// Gateway, Registration, or anything else — session token (or anonymous if none)
|
||||
if (sessionToken) headers['Authorization'] = `Bearer ${sessionToken}`;
|
||||
}
|
||||
|
||||
// Handle URL templating: replace {param} with values from payload
|
||||
let endpoint = serviceDef.endpoint;
|
||||
const urlParams = endpoint.match(/\{(\w+)\}/g) || [];
|
||||
for (const param of urlParams) {
|
||||
const paramName = param.slice(1, -1);
|
||||
const value = payload[paramName];
|
||||
if (value !== undefined) {
|
||||
endpoint = endpoint.replace(param, encodeURIComponent(value));
|
||||
delete payload[paramName];
|
||||
}
|
||||
}
|
||||
|
||||
// For GET/DELETE, convert remaining payload to query string
|
||||
let url = `${serviceDef.baseUrl || API_BASE}${endpoint}`;
|
||||
if ((serviceDef.method === 'GET' || serviceDef.method === 'DELETE') && Object.keys(payload).length > 0) {
|
||||
const queryParams = new URLSearchParams();
|
||||
for (const [key, value] of Object.entries(payload)) {
|
||||
if (value !== undefined && value !== null) {
|
||||
queryParams.append(key, value);
|
||||
}
|
||||
}
|
||||
const qs = queryParams.toString();
|
||||
if (qs) url += `?${qs}`;
|
||||
}
|
||||
|
||||
const resp = await fetch(url, {
|
||||
method: serviceDef.method || 'GET',
|
||||
headers,
|
||||
body: (serviceDef.method !== 'GET' && serviceDef.method !== 'DELETE')
|
||||
? JSON.stringify(payload)
|
||||
: undefined
|
||||
});
|
||||
|
||||
return handleResponse(resp);
|
||||
}
|
||||
|
||||
// Provider execution calls → POST /api/execution/request
|
||||
if (!sessionToken) {
|
||||
throw new Error('sessionToken required for provider API calls');
|
||||
}
|
||||
|
||||
headers['Authorization'] = `Bearer ${sessionToken}`;
|
||||
headers['X-Requested-With'] = 'AdPlatform-Client';
|
||||
|
||||
const execBody = {
|
||||
provider: providerId,
|
||||
operation: serviceId,
|
||||
tenantId: tenantId || undefined,
|
||||
payload
|
||||
};
|
||||
|
||||
const resp = await fetch(`${API_BASE}/api/execution/request`, {
|
||||
method: 'POST',
|
||||
headers,
|
||||
body: JSON.stringify(execBody)
|
||||
});
|
||||
|
||||
return handleResponse(resp);
|
||||
}
|
||||
|
||||
async function handleResponse(resp) {
|
||||
if (resp.status === 401) {
|
||||
const text = await resp.text();
|
||||
throw new Error(`API 401: ${text}`);
|
||||
}
|
||||
|
||||
if (!resp.ok) {
|
||||
const text = await resp.text();
|
||||
throw new Error(`API ${resp.status}: ${text}`);
|
||||
}
|
||||
|
||||
return resp.json();
|
||||
}
|
||||
10
Client-Tech/src/services/providerCatalog.js
Normal file
10
Client-Tech/src/services/providerCatalog.js
Normal file
@@ -0,0 +1,10 @@
|
||||
export const providers = [
|
||||
{ id: 'gateway', label: 'Gateway (direct)' },
|
||||
{ id: 'google', label: 'Google Ads' },
|
||||
{ id: 'meta', label: 'Meta Ads' },
|
||||
{ id: 'tiktok', label: 'TikTok Ads' },
|
||||
{ id: 'creative', label: 'Creative' },
|
||||
{ id: 'intelligence', label: 'Intelligence API' },
|
||||
{ id: 'registration', label: 'Registration' },
|
||||
{ id: 'management', label: 'Management API' },
|
||||
];
|
||||
636
Client-Tech/src/services/serviceCatalog.js
Normal file
636
Client-Tech/src/services/serviceCatalog.js
Normal file
@@ -0,0 +1,636 @@
|
||||
// src/services/serviceCatalog.js
|
||||
|
||||
/**
|
||||
* Service catalog organized by Provider → Service → Action
|
||||
*
|
||||
* Structure:
|
||||
* - provider: Ad platform (google, meta, msads)
|
||||
* - service: Sub-module/microservice (system, campaigns, reporting, accounts)
|
||||
* - action: Specific operation (ping, create, list, get, update, delete)
|
||||
*/
|
||||
export const servicesByProvider = {
|
||||
gateway: [
|
||||
{
|
||||
id: 'GatewayPing',
|
||||
service: 'system',
|
||||
action: 'ping',
|
||||
label: 'System: Ping (SQL test)',
|
||||
sample: {},
|
||||
endpoint: '/api/test/ping',
|
||||
method: 'GET'
|
||||
},
|
||||
// Campaign Wizard operations
|
||||
{
|
||||
id: 'WizardCreate',
|
||||
service: 'wizard',
|
||||
action: 'create',
|
||||
label: 'Wizard: Create New',
|
||||
sample: { name: 'My Test Campaign', url: 'https://example.com' },
|
||||
endpoint: '/api/wizard',
|
||||
method: 'POST'
|
||||
},
|
||||
{
|
||||
id: 'WizardGet',
|
||||
service: 'wizard',
|
||||
action: 'get',
|
||||
label: 'Wizard: Get by ID',
|
||||
sample: { wizardId: 'abc123def456' },
|
||||
endpoint: '/api/wizard/{wizardId}',
|
||||
method: 'GET'
|
||||
},
|
||||
{
|
||||
id: 'WizardList',
|
||||
service: 'wizard',
|
||||
action: 'list',
|
||||
label: 'Wizard: List All',
|
||||
sample: { status: 'draft' },
|
||||
endpoint: '/api/wizard',
|
||||
method: 'GET'
|
||||
},
|
||||
{
|
||||
id: 'WizardUpdateStep',
|
||||
service: 'wizard',
|
||||
action: 'updateStep',
|
||||
label: 'Wizard: Update Step 4 (Audience)',
|
||||
sample: {
|
||||
wizardId: 'abc123def456',
|
||||
step: 4,
|
||||
data: {
|
||||
locations: [{ id: 9061285, name: 'Huntington Beach', type: 'City', radiusMiles: 25 }],
|
||||
demographics: { ageRanges: ['AGE_25_34', 'AGE_35_44'], genders: ['MALE', 'FEMALE'] },
|
||||
audiences: { affinity: [80001], inMarket: [90012] }
|
||||
}
|
||||
},
|
||||
endpoint: '/api/wizard/{wizardId}/step/{step}',
|
||||
method: 'PUT'
|
||||
},
|
||||
{
|
||||
id: 'WizardGetSummary',
|
||||
service: 'wizard',
|
||||
action: 'getSummary',
|
||||
label: 'Wizard: Get Summary (Review)',
|
||||
sample: { wizardId: 'abc123def456' },
|
||||
endpoint: '/api/wizard/{wizardId}/summary',
|
||||
method: 'GET'
|
||||
},
|
||||
{
|
||||
id: 'WizardSubmit',
|
||||
service: 'wizard',
|
||||
action: 'submit',
|
||||
label: 'Wizard: Submit Campaign',
|
||||
sample: { wizardId: 'abc123def456', network: 'google' },
|
||||
endpoint: '/api/wizard/{wizardId}/submit',
|
||||
method: 'POST'
|
||||
},
|
||||
{
|
||||
id: 'WizardDelete',
|
||||
service: 'wizard',
|
||||
action: 'delete',
|
||||
label: 'Wizard: Delete',
|
||||
sample: { wizardId: 'abc123def456' },
|
||||
endpoint: '/api/wizard/{wizardId}',
|
||||
method: 'DELETE'
|
||||
},
|
||||
// Campaign Intelligence (Gateway → SQL stored proc)
|
||||
// These endpoints are Gateway-side; they do NOT call IntelligenceApi.
|
||||
// IntelligenceApi is the spend distribution engine (forecast/wizard flow only).
|
||||
{
|
||||
id: 'IntelligenceHealth',
|
||||
service: 'intelligence',
|
||||
action: 'health',
|
||||
label: 'Intelligence: Campaign Health Overview',
|
||||
sample: {},
|
||||
endpoint: '/api/intelligence/health',
|
||||
method: 'GET'
|
||||
},
|
||||
{
|
||||
id: 'IntelligencePacing',
|
||||
service: 'intelligence',
|
||||
action: 'pacing',
|
||||
label: 'Intelligence: Budget Pacing',
|
||||
sample: { initiativeId: 1 },
|
||||
endpoint: '/api/intelligence/{initiativeId}/pacing',
|
||||
method: 'GET'
|
||||
},
|
||||
{
|
||||
id: 'IntelligenceReport',
|
||||
service: 'intelligence',
|
||||
action: 'report',
|
||||
label: 'Intelligence: Post-Campaign Report',
|
||||
sample: { initiativeId: 1 },
|
||||
endpoint: '/api/intelligence/{initiativeId}/report',
|
||||
method: 'GET'
|
||||
},
|
||||
{
|
||||
id: 'IntelligenceSnapshot',
|
||||
service: 'intelligence',
|
||||
action: 'snapshot',
|
||||
label: 'Intelligence: Record Metric Snapshot (Admin)',
|
||||
sample: {
|
||||
channelCampaignId: 1,
|
||||
date: '2026-03-06',
|
||||
impressions: 4200,
|
||||
clicks: 87,
|
||||
spend: 42.50,
|
||||
conversions: 3
|
||||
},
|
||||
endpoint: '/api/intelligence/snapshot',
|
||||
method: 'POST'
|
||||
},
|
||||
{
|
||||
id: 'IntelligenceSnapshotBatch',
|
||||
service: 'intelligence',
|
||||
action: 'snapshotBatch',
|
||||
label: 'Intelligence: Batch Metric Snapshots (Admin)',
|
||||
sample: {
|
||||
snapshots: [
|
||||
{ channelCampaignId: 1, date: '2026-03-06', impressions: 4200, clicks: 87, spend: 42.50, conversions: 3 },
|
||||
{ channelCampaignId: 2, date: '2026-03-06', impressions: 1800, clicks: 34, spend: 18.20, conversions: 1 }
|
||||
]
|
||||
},
|
||||
endpoint: '/api/intelligence/snapshot/batch',
|
||||
method: 'POST'
|
||||
}
|
||||
],
|
||||
google: [
|
||||
// System service
|
||||
{
|
||||
id: 'Ping',
|
||||
service: 'system',
|
||||
action: 'ping',
|
||||
label: 'System: Ping (GoogleApi round trip)',
|
||||
sample: {}
|
||||
},
|
||||
// Campaigns service
|
||||
{
|
||||
id: 'CreateCampaign',
|
||||
service: 'campaigns',
|
||||
action: 'create',
|
||||
label: 'Campaigns: Create',
|
||||
sample: { name: 'Test Campaign', budgetMicros: 10000000, type: 'Search' }
|
||||
},
|
||||
{
|
||||
id: 'ListCampaigns',
|
||||
service: 'campaigns',
|
||||
action: 'list',
|
||||
label: 'Campaigns: List',
|
||||
sample: {}
|
||||
},
|
||||
{
|
||||
id: 'GetCampaign',
|
||||
service: 'campaigns',
|
||||
action: 'get',
|
||||
label: 'Campaigns: Get by ID',
|
||||
sample: { campaignId: 'campaigns/123' }
|
||||
},
|
||||
// Reporting service
|
||||
{
|
||||
id: 'GetCampaignStats',
|
||||
service: 'reporting',
|
||||
action: 'campaignStats',
|
||||
label: 'Reporting: Campaign Stats',
|
||||
sample: { campaignId: 'campaigns/123', startDate: '2026-01-01', endDate: '2026-01-26' }
|
||||
},
|
||||
{
|
||||
id: 'GetAccountStats',
|
||||
service: 'reporting',
|
||||
action: 'accountStats',
|
||||
label: 'Reporting: Account Stats',
|
||||
sample: { startDate: '2026-01-01', endDate: '2026-01-26' }
|
||||
},
|
||||
// Accounts service
|
||||
{
|
||||
id: 'ListAccessibleCustomers',
|
||||
service: 'accounts',
|
||||
action: 'list',
|
||||
label: 'Accounts: List Accessible Customers',
|
||||
sample: {}
|
||||
},
|
||||
{
|
||||
id: 'CreateCustomerClient',
|
||||
service: 'accounts',
|
||||
action: 'createClient',
|
||||
label: 'Accounts: Create Sub-Account (under MCC)',
|
||||
sample: { accountName: 'AdPlatform Test Account', currencyCode: 'USD', timeZone: 'America/Los_Angeles' }
|
||||
},
|
||||
// Audience targeting service
|
||||
{
|
||||
id: 'GetAudienceSegments',
|
||||
service: 'audience',
|
||||
action: 'getSegments',
|
||||
label: 'Audience: Get All Segments',
|
||||
sample: {}
|
||||
},
|
||||
{
|
||||
id: 'SearchGeoTargets',
|
||||
service: 'audience',
|
||||
action: 'searchGeo',
|
||||
label: 'Audience: Search Locations',
|
||||
sample: { query: 'Huntington Beach', countryCode: 'US', maxResults: 10 }
|
||||
}
|
||||
],
|
||||
meta: [
|
||||
// System
|
||||
{
|
||||
id: 'Ping',
|
||||
service: 'system',
|
||||
action: 'ping',
|
||||
label: 'System: Ping (MetaApi round trip)',
|
||||
sample: {}
|
||||
},
|
||||
// Campaigns
|
||||
{
|
||||
id: 'CreateCampaign',
|
||||
service: 'campaigns',
|
||||
action: 'create',
|
||||
label: 'Campaigns: Create',
|
||||
sample: { name: 'Test Meta Campaign', objective: 'Traffic', status: 'Paused' }
|
||||
},
|
||||
{
|
||||
id: 'ListCampaigns',
|
||||
service: 'campaigns',
|
||||
action: 'list',
|
||||
label: 'Campaigns: List',
|
||||
sample: { limit: 50 }
|
||||
},
|
||||
{
|
||||
id: 'GetCampaign',
|
||||
service: 'campaigns',
|
||||
action: 'get',
|
||||
label: 'Campaigns: Get by ID',
|
||||
sample: { campaignId: '123456789' }
|
||||
},
|
||||
// Insights
|
||||
{
|
||||
id: 'GetCampaignInsights',
|
||||
service: 'reporting',
|
||||
action: 'campaignInsights',
|
||||
label: 'Reporting: Campaign Insights',
|
||||
sample: { campaignId: '123456789', datePreset: 'last_7d' }
|
||||
},
|
||||
{
|
||||
id: 'GetAccountInsights',
|
||||
service: 'reporting',
|
||||
action: 'accountInsights',
|
||||
label: 'Reporting: Account Insights',
|
||||
sample: { datePreset: 'last_30d' }
|
||||
},
|
||||
// Accounts
|
||||
{
|
||||
id: 'CreateAdAccount',
|
||||
service: 'accounts',
|
||||
action: 'createAdAccount',
|
||||
label: 'Accounts: Create Ad Account (under BM)',
|
||||
sample: { name: 'Test Client Account', currency: 'USD', timezoneId: 1 }
|
||||
},
|
||||
{
|
||||
id: 'ListAdAccounts',
|
||||
service: 'accounts',
|
||||
action: 'listAdAccounts',
|
||||
label: 'Accounts: List BM Ad Accounts',
|
||||
sample: {}
|
||||
}
|
||||
],
|
||||
tiktok: [
|
||||
// System
|
||||
{
|
||||
id: 'Ping',
|
||||
service: 'system',
|
||||
action: 'ping',
|
||||
label: 'System: Ping (TikTokApi round trip)',
|
||||
sample: {}
|
||||
},
|
||||
// Campaigns
|
||||
{
|
||||
id: 'CreateCampaign',
|
||||
service: 'campaigns',
|
||||
action: 'create',
|
||||
label: 'Campaigns: Create',
|
||||
sample: { name: 'Test TikTok Campaign', objective: 'Traffic', budgetMode: 'Day', budget: 50.00, status: 'Disable' }
|
||||
},
|
||||
{
|
||||
id: 'ListCampaigns',
|
||||
service: 'campaigns',
|
||||
action: 'list',
|
||||
label: 'Campaigns: List',
|
||||
sample: { pageSize: 50, page: 1 }
|
||||
},
|
||||
{
|
||||
id: 'GetCampaign',
|
||||
service: 'campaigns',
|
||||
action: 'get',
|
||||
label: 'Campaigns: Get by ID',
|
||||
sample: { campaignId: '1234567890123456789' }
|
||||
},
|
||||
// Reporting
|
||||
{
|
||||
id: 'GetReport',
|
||||
service: 'reporting',
|
||||
action: 'getReport',
|
||||
label: 'Reporting: Integrated Report',
|
||||
sample: { reportType: 'BASIC', dataLevel: 'AUCTION_CAMPAIGN', dimensions: ['campaign_id', 'stat_time_day'], metrics: ['spend', 'impressions', 'clicks', 'cpc', 'ctr'] }
|
||||
},
|
||||
// Accounts (Business Center)
|
||||
{
|
||||
id: 'CreateAdvertiser',
|
||||
service: 'accounts',
|
||||
action: 'createAdvertiser',
|
||||
label: 'Accounts: Create Advertiser (under BC)',
|
||||
sample: { name: 'Test Client', currency: 'USD', timezone: 'America/Los_Angeles', company: 'Test Company' }
|
||||
},
|
||||
{
|
||||
id: 'ListAdvertisers',
|
||||
service: 'accounts',
|
||||
action: 'listAdvertisers',
|
||||
label: 'Accounts: List BC Advertisers',
|
||||
sample: {}
|
||||
},
|
||||
// Fund Management
|
||||
{
|
||||
id: 'TransferFunds',
|
||||
service: 'finance',
|
||||
action: 'transfer',
|
||||
label: 'Finance: Transfer Funds (BC → Advertiser)',
|
||||
sample: { advertiserId: '1234567890123456789', transferType: 'RECHARGE', amount: 100.00 }
|
||||
}
|
||||
],
|
||||
creative: [
|
||||
{
|
||||
id: 'Ping',
|
||||
service: 'system',
|
||||
action: 'ping',
|
||||
label: 'System: Ping (Creative round trip)',
|
||||
sample: {}
|
||||
},
|
||||
{
|
||||
id: 'AnalyzeUrl',
|
||||
service: 'creative',
|
||||
action: 'analyzeUrl',
|
||||
label: 'Creative: Analyze URL',
|
||||
sample: { url: 'https://example.com' }
|
||||
},
|
||||
{
|
||||
id: 'GenerateAssets',
|
||||
service: 'creative',
|
||||
action: 'generateAssets',
|
||||
label: 'Creative: Generate Assets from Analysis',
|
||||
sample: {
|
||||
analysis: {
|
||||
title: 'Example',
|
||||
description: 'A sample site',
|
||||
headings: ['Welcome'],
|
||||
bodySnippet: 'We sell quality products.',
|
||||
domain: 'example.com'
|
||||
}
|
||||
}
|
||||
},
|
||||
{
|
||||
id: 'GetImages',
|
||||
service: 'creative',
|
||||
action: 'getImages',
|
||||
label: 'Creative: Get Images from Analysis',
|
||||
sample: {
|
||||
analysis: {
|
||||
url: 'https://example.com',
|
||||
title: 'Example Business',
|
||||
metaDescription: 'Quality products and services',
|
||||
headings: ['Welcome', 'Our Services'],
|
||||
bodySnippet: 'We provide excellent service.',
|
||||
inferredCategory: 'Business Services'
|
||||
}
|
||||
}
|
||||
},
|
||||
{
|
||||
id: 'CreateDraft',
|
||||
service: 'creative',
|
||||
action: 'createDraft',
|
||||
label: 'Creative: Full Pipeline (URL → Draft with Images)',
|
||||
sample: { url: 'https://example.com' }
|
||||
}
|
||||
],
|
||||
intelligence: [
|
||||
// System
|
||||
{
|
||||
id: 'Ping',
|
||||
service: 'system',
|
||||
action: 'ping',
|
||||
label: 'System: Ping (IntelligenceApi round trip)',
|
||||
sample: {}
|
||||
},
|
||||
// Spend Distribution — the core engine operation.
|
||||
// Gateway injects clientCategory from ClientContext before forwarding.
|
||||
// Engines: General (default) | Franchisee | Franchisor (+ sub-category fallbacks)
|
||||
{
|
||||
id: 'SpendDistribution',
|
||||
service: 'spendDistribution',
|
||||
action: 'recommend',
|
||||
label: 'Spend Distribution: Recommend (General)',
|
||||
sample: {
|
||||
clientCategory: 'General',
|
||||
objective: 'traffic',
|
||||
businessCategory: 'retail',
|
||||
keywords: ['shoes', 'sneakers', 'footwear'],
|
||||
geoTargeting: {
|
||||
zipCodes: ['92648'],
|
||||
radiusMiles: 25
|
||||
},
|
||||
audience: {
|
||||
ageMin: 25,
|
||||
ageMax: 44,
|
||||
genders: ['MALE', 'FEMALE'],
|
||||
interests: ['fashion', 'sports']
|
||||
},
|
||||
monthlyBudget: 1500,
|
||||
channels: ['google', 'meta', 'tiktok']
|
||||
}
|
||||
},
|
||||
{
|
||||
id: 'SpendDistributionFranchisee',
|
||||
service: 'spendDistribution',
|
||||
action: 'recommendFranchisee',
|
||||
label: 'Spend Distribution: Recommend (Franchisee)',
|
||||
sample: {
|
||||
clientCategory: 'Franchisee',
|
||||
objective: 'leads',
|
||||
businessCategory: 'food',
|
||||
keywords: ['pizza', 'delivery', 'takeout'],
|
||||
geoTargeting: {
|
||||
zipCodes: ['92648', '92649'],
|
||||
radiusMiles: 10
|
||||
},
|
||||
audience: {
|
||||
ageMin: 18,
|
||||
ageMax: 54,
|
||||
genders: ['MALE', 'FEMALE']
|
||||
},
|
||||
monthlyBudget: 800,
|
||||
channels: ['google', 'meta']
|
||||
}
|
||||
},
|
||||
{
|
||||
id: 'SpendDistributionFranchisor',
|
||||
service: 'spendDistribution',
|
||||
action: 'recommendFranchisor',
|
||||
label: 'Spend Distribution: Recommend (Franchisor)',
|
||||
sample: {
|
||||
clientCategory: 'Franchisor',
|
||||
objective: 'awareness',
|
||||
businessCategory: 'food',
|
||||
keywords: ['franchise', 'pizza chain', 'national brand'],
|
||||
geoTargeting: {
|
||||
zipCodes: ['90210', '10001', '60601'],
|
||||
radiusMiles: 50
|
||||
},
|
||||
audience: {
|
||||
ageMin: 30,
|
||||
ageMax: 55,
|
||||
genders: ['MALE', 'FEMALE'],
|
||||
interests: ['business', 'investment', 'entrepreneurship']
|
||||
},
|
||||
monthlyBudget: 15000,
|
||||
channels: ['google', 'meta', 'tiktok']
|
||||
}
|
||||
}
|
||||
],
|
||||
registration: [
|
||||
// Health check — hits Registration Function directly (anonymous endpoint)
|
||||
{
|
||||
id: 'Health',
|
||||
service: 'system',
|
||||
action: 'health',
|
||||
label: 'System: Health Check',
|
||||
sample: {},
|
||||
endpoint: '/api/health',
|
||||
method: 'GET',
|
||||
baseUrl: 'https://adpregapi.usimdev.com'
|
||||
},
|
||||
// ── Admin ops below route through Management proxy (/api/registration/*).
|
||||
// Management validates the Entra JWT, then forwards to Registration Function
|
||||
// using its own server-side function key. No client-side key required.
|
||||
{
|
||||
id: 'GetPending',
|
||||
service: 'registration',
|
||||
action: 'pending',
|
||||
label: 'Registration: List Pending',
|
||||
sample: {},
|
||||
endpoint: '/api/registration/pending',
|
||||
method: 'GET',
|
||||
baseUrl: 'https://adpmgmt.usimdev.com'
|
||||
},
|
||||
{
|
||||
id: 'GetById',
|
||||
service: 'registration',
|
||||
action: 'get',
|
||||
label: 'Registration: Get by ID',
|
||||
sample: { registrationId: 'reg-001' },
|
||||
endpoint: '/api/registration/{registrationId}',
|
||||
method: 'GET',
|
||||
baseUrl: 'https://adpmgmt.usimdev.com'
|
||||
},
|
||||
{
|
||||
id: 'Reject',
|
||||
service: 'registration',
|
||||
action: 'reject',
|
||||
label: 'Registration: Reject Applicant',
|
||||
sample: { registrationId: 'reg-001', reason: 'Incomplete information' },
|
||||
endpoint: '/api/registration/{registrationId}/reject',
|
||||
method: 'POST',
|
||||
baseUrl: 'https://adpmgmt.usimdev.com'
|
||||
},
|
||||
{
|
||||
id: 'Complete',
|
||||
service: 'registration',
|
||||
action: 'complete',
|
||||
label: 'Registration: Approve (Complete)',
|
||||
sample: { registrationId: 'reg-001', platformClientId: 'ADP-TestBusiness-0001' },
|
||||
endpoint: '/api/registration/{registrationId}/complete',
|
||||
method: 'POST',
|
||||
baseUrl: 'https://adpmgmt.usimdev.com'
|
||||
},
|
||||
// Register — still hits Registration Function directly (CIAM JWT, not staff plane)
|
||||
{
|
||||
id: 'Register',
|
||||
service: 'registration',
|
||||
action: 'register',
|
||||
label: 'Registration: New Prospect (test)',
|
||||
sample: {
|
||||
businessName: 'Test Business',
|
||||
websiteUrl: 'https://test.com',
|
||||
businessCategory: 'retail',
|
||||
businessDescription: 'A test registration',
|
||||
contactName: 'Test User',
|
||||
contactEmail: 'test@example.com',
|
||||
contactPhone: '(555) 000-1234'
|
||||
},
|
||||
endpoint: '/api/registration/register',
|
||||
method: 'POST',
|
||||
baseUrl: 'https://adpregapi.usimdev.com'
|
||||
}
|
||||
],
|
||||
management: [
|
||||
{
|
||||
id: 'Ping',
|
||||
service: 'system',
|
||||
action: 'ping',
|
||||
label: 'System: Health Check',
|
||||
sample: {},
|
||||
endpoint: '/health',
|
||||
method: 'GET',
|
||||
baseUrl: 'https://adpmgmt.usimdev.com'
|
||||
},
|
||||
{
|
||||
id: 'HelpGet',
|
||||
service: 'help',
|
||||
action: 'get',
|
||||
label: 'Help: Get Content by Key',
|
||||
sample: { key: 'client.wizard.budget' },
|
||||
endpoint: '/api/help/{key}',
|
||||
method: 'GET',
|
||||
baseUrl: 'https://adpmgmt.usimdev.com'
|
||||
},
|
||||
{
|
||||
id: 'DocumentList',
|
||||
service: 'documents',
|
||||
action: 'list',
|
||||
label: 'Documents: List All',
|
||||
sample: {},
|
||||
endpoint: '/api/documents',
|
||||
method: 'GET',
|
||||
baseUrl: 'https://adpmgmt.usimdev.com'
|
||||
},
|
||||
{
|
||||
id: 'DocumentDelete',
|
||||
service: 'documents',
|
||||
action: 'delete',
|
||||
label: 'Documents: Delete by ID',
|
||||
sample: { docId: 1 },
|
||||
endpoint: '/api/documents/{docId}',
|
||||
method: 'DELETE',
|
||||
baseUrl: 'https://adpmgmt.usimdev.com'
|
||||
}
|
||||
]
|
||||
};
|
||||
|
||||
export function getServices(providerId) {
|
||||
return servicesByProvider[providerId] || [];
|
||||
}
|
||||
|
||||
export function getService(providerId, serviceId) {
|
||||
return getServices(providerId).find(s => s.id === serviceId);
|
||||
}
|
||||
|
||||
/**
|
||||
* Get unique service modules for a provider
|
||||
*/
|
||||
export function getServiceModules(providerId) {
|
||||
const services = getServices(providerId);
|
||||
const modules = [...new Set(services.map(s => s.service))];
|
||||
return modules;
|
||||
}
|
||||
|
||||
/**
|
||||
* Get actions for a specific service module
|
||||
*/
|
||||
export function getActionsForModule(providerId, serviceModule) {
|
||||
return getServices(providerId).filter(s => s.service === serviceModule);
|
||||
}
|
||||
2073
Client-Tech/src/styles/app.css
Normal file
2073
Client-Tech/src/styles/app.css
Normal file
File diff suppressed because it is too large
Load Diff
34
Client-Tech/webpack.config.js
Normal file
34
Client-Tech/webpack.config.js
Normal file
@@ -0,0 +1,34 @@
|
||||
const path = require('path');
|
||||
|
||||
module.exports = {
|
||||
entry: './src/index.js',
|
||||
output: {
|
||||
filename: 'bundle.js',
|
||||
path: path.resolve(__dirname, 'dist')
|
||||
},
|
||||
devServer: {
|
||||
static: './public',
|
||||
port: 3000
|
||||
},
|
||||
module: {
|
||||
rules: [
|
||||
{
|
||||
test: /\.jsx?$/,
|
||||
exclude: /node_modules/,
|
||||
use: {
|
||||
loader: 'babel-loader',
|
||||
options: {
|
||||
presets: ['@babel/preset-env', '@babel/preset-react']
|
||||
}
|
||||
}
|
||||
},
|
||||
{
|
||||
test: /\.css$/,
|
||||
use: ['style-loader', 'css-loader']
|
||||
}
|
||||
]
|
||||
},
|
||||
resolve: {
|
||||
extensions: ['.js', '.jsx']
|
||||
}
|
||||
};
|
||||
Reference in New Issue
Block a user