From 142e2373d3c788c683f0e3a060bc398c22022741 Mon Sep 17 00:00:00 2001 From: James Cottrill <32595786+jamescottrill@users.noreply.github.com> Date: Sat, 18 Apr 2026 21:22:06 +0100 Subject: [PATCH] feat: consent records page, tab persistence, and snippet copy fix (#9) feat: consent records list endpoint and top-level admin page --- apps/admin-ui/src/App.tsx | 2 + apps/admin-ui/src/api/consent.ts | 12 + apps/admin-ui/src/components/Layout.tsx | 1 + .../src/components/SiteConsentTab.tsx | 235 ++++++++++++++++++ .../src/components/SiteOverviewTab.tsx | 40 ++- .../admin-ui/src/pages/ConsentRecordsPage.tsx | 55 ++++ apps/admin-ui/src/pages/SiteDetailPage.tsx | 14 +- apps/admin-ui/src/types/api.ts | 25 ++ apps/api/src/routers/consent.py | 62 ++++- apps/api/src/schemas/consent.py | 9 + 10 files changed, 442 insertions(+), 13 deletions(-) create mode 100644 apps/admin-ui/src/api/consent.ts create mode 100644 apps/admin-ui/src/components/SiteConsentTab.tsx create mode 100644 apps/admin-ui/src/pages/ConsentRecordsPage.tsx diff --git a/apps/admin-ui/src/App.tsx b/apps/admin-ui/src/App.tsx index e551c83..7f6cec6 100644 --- a/apps/admin-ui/src/App.tsx +++ b/apps/admin-ui/src/App.tsx @@ -11,6 +11,7 @@ import { import Layout from './components/Layout'; import { trackPageView } from './services/analytics'; import ProtectedRoute from './components/ProtectedRoute'; +import ConsentRecordsPage from './pages/ConsentRecordsPage'; import LoginPage from './pages/LoginPage'; import SettingsPage from './pages/SettingsPage'; import SiteDetailPage from './pages/SiteDetailPage'; @@ -57,6 +58,7 @@ function AppRoutes() { } /> } /> } /> + } /> } /> {extensionPages .filter((p) => p.protected !== false) diff --git a/apps/admin-ui/src/api/consent.ts b/apps/admin-ui/src/api/consent.ts new file mode 100644 index 0000000..e7dd5ac --- /dev/null +++ b/apps/admin-ui/src/api/consent.ts @@ -0,0 +1,12 @@ +import type { ConsentRecord, PaginatedResponse } from '../types/api'; +import apiClient from './client'; + +export async function listConsentRecords( + siteId: string, + params?: { visitor_id?: string; page?: number; page_size?: number }, +): Promise> { + const { data } = await apiClient.get>('/consent/', { + params: { site_id: siteId, ...params }, + }); + return data; +} diff --git a/apps/admin-ui/src/components/Layout.tsx b/apps/admin-ui/src/components/Layout.tsx index 45cc72c..b84a563 100644 --- a/apps/admin-ui/src/components/Layout.tsx +++ b/apps/admin-ui/src/components/Layout.tsx @@ -6,6 +6,7 @@ import { getNavItems } from '../extensions/registry'; const CORE_NAV_ITEMS = [ { path: '/sites', label: 'Sites', order: 10 }, + { path: '/consent', label: 'Consent Records', order: 15 }, { path: '/settings', label: 'Settings', order: 90 }, ]; diff --git a/apps/admin-ui/src/components/SiteConsentTab.tsx b/apps/admin-ui/src/components/SiteConsentTab.tsx new file mode 100644 index 0000000..0edca86 --- /dev/null +++ b/apps/admin-ui/src/components/SiteConsentTab.tsx @@ -0,0 +1,235 @@ +import { useQuery } from '@tanstack/react-query'; +import { useState } from 'react'; + +import { listConsentRecords } from '../api/consent'; +import type { ConsentRecord } from '../types/api'; +import { Badge } from './ui/badge'; +import { Button } from './ui/button'; +import { Card } from './ui/card'; +import { LoadingState } from './ui/loading-state'; + +interface Props { + siteId: string; +} + +function actionVariant(action: string): 'success' | 'error' | 'warning' | 'neutral' { + const map: Record = { + accept_all: 'success', + reject_all: 'error', + custom: 'warning', + withdraw: 'error', + }; + return map[action] ?? 'neutral'; +} + +function actionLabel(action: string): string { + const map: Record = { + accept_all: 'Accept all', + reject_all: 'Reject all', + custom: 'Custom', + withdraw: 'Withdrawn', + }; + return map[action] ?? action; +} + +function RecordDetail({ record }: { record: ConsentRecord }) { + return ( + + +
+
+ Visitor ID +

{record.visitor_id}

+
+
+ Page URL +

{record.page_url ?? '—'}

+
+
+ Accepted +

{record.categories_accepted.join(', ') || '—'}

+
+
+ Rejected +

{record.categories_rejected?.join(', ') || '—'}

+
+ {record.country_code && ( +
+ Location +

{record.region_code ? `${record.country_code}-${record.region_code}` : record.country_code}

+
+ )} + {record.tc_string && ( +
+ TC String +

{record.tc_string}

+
+ )} + {record.gpc_detected != null && ( +
+ GPC +

+ Detected: {record.gpc_detected ? 'Yes' : 'No'} + {record.gpc_honoured != null && ` · Honoured: ${record.gpc_honoured ? 'Yes' : 'No'}`} +

+
+ )} +
+ + + ); +} + +export default function SiteConsentTab({ siteId }: Props) { + const [search, setSearch] = useState(''); + const [activeSearch, setActiveSearch] = useState(''); + const [page, setPage] = useState(1); + const [expandedId, setExpandedId] = useState(null); + const pageSize = 25; + + const { data, isLoading } = useQuery({ + queryKey: ['consent', siteId, activeSearch, page], + queryFn: () => + listConsentRecords(siteId, { + visitor_id: activeSearch || undefined, + page, + page_size: pageSize, + }), + }); + + const handleSearch = () => { + setActiveSearch(search.trim()); + setPage(1); + }; + + const totalPages = data ? Math.ceil(data.total / pageSize) : 0; + + return ( +
+ {/* Search */} + +

+ Search Consent Records +

+
+ setSearch(e.target.value)} + onKeyDown={(e) => e.key === 'Enter' && handleSearch()} + /> + + {activeSearch && ( + + )} +
+ {activeSearch && ( +

+ Showing results for visitor: {activeSearch} +

+ )} +
+ + {/* Results */} + {isLoading ? ( + + ) : !data || data.items.length === 0 ? ( +
+ {activeSearch + ? 'No consent records found for this visitor.' + : 'No consent records yet.'} +
+ ) : ( + <> +
+ {data.total} record{data.total !== 1 ? 's' : ''} + Page {page} of {totalPages} +
+
+ + + + + + + + + + + {data.items.map((record) => ( + <> + setExpandedId(expandedId === record.id ? null : record.id)} + > + + + + + + + {expandedId === record.id && ( + + )} + + ))} + +
VisitorActionCategoriesDate +
+ {record.visitor_id.length > 16 + ? record.visitor_id.slice(0, 8) + '…' + record.visitor_id.slice(-8) + : record.visitor_id} + + + {actionLabel(record.action)} + + + {record.categories_accepted.join(', ')} + + {new Date(record.consented_at).toLocaleString()} + + {expandedId === record.id ? '▲' : '▼'} +
+
+ + {/* Pagination */} + {totalPages > 1 && ( +
+ + + {page} / {totalPages} + + +
+ )} + + )} +
+ ); +} diff --git a/apps/admin-ui/src/components/SiteOverviewTab.tsx b/apps/admin-ui/src/components/SiteOverviewTab.tsx index ac64338..86d63f8 100644 --- a/apps/admin-ui/src/components/SiteOverviewTab.tsx +++ b/apps/admin-ui/src/components/SiteOverviewTab.tsx @@ -1,3 +1,5 @@ +import { useState } from 'react'; + import { Card } from './ui/card'; import { MetricCard } from './ui/metric-card'; import type { Site, SiteConfig } from '../types/api'; @@ -8,7 +10,8 @@ interface Props { } export default function SiteOverviewTab({ site, config }: Props) { - const scriptTag = ``; + const scriptTag = ``; + const [copied, setCopied] = useState(false); return (
@@ -36,17 +39,38 @@ export default function SiteOverviewTab({ site, config }: Props) {

Add this script tag to the {''} of your website, before any other scripts.

-
-
-            {scriptTag}
-          
+
+
+

+ Must be the first {'