feat: Cookie-Banner Verarbeiter-Tabelle + Multi-Site UI (F9 + F3)

F9: Verarbeiter-Tabelle
- VendorTable.tsx: 82+ vendors grouped by category with expandable cookie details
- EmbeddableVendorHTML.tsx: Copy-pasteable HTML table for privacy policy
- Tab system: Konfiguration | Verarbeiter | Einbettung

F3: Multi-Site UI
- SiteSelector.tsx: Domain dropdown with "Neue Seite anlegen" dialog
- useCookieBanner hook extended with sites management
- Config/vendors reload per selected site

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
This commit is contained in:
Benjamin Admin
2026-05-03 20:40:18 +02:00
parent 4c92b17617
commit 36d9f929c6
5 changed files with 395 additions and 2 deletions
@@ -0,0 +1,103 @@
'use client'
import { useState, useEffect } from 'react'
interface Vendor {
vendor_name: string
vendor_url: string | null
category_key: string
description_de: string | null
cookie_names: string[]
retention_days: number | null
}
const CAT_LABELS: Record<string, string> = {
necessary: 'Notwendig',
functional: 'Funktional',
statistics: 'Statistik',
marketing: 'Marketing',
}
function generateHTML(vendors: Vendor[]): string {
const grouped = vendors.reduce<Record<string, Vendor[]>>((acc, v) => {
const key = v.category_key || 'other'
if (!acc[key]) acc[key] = []
acc[key].push(v)
return acc
}, {})
let html = `<div style="font-family:system-ui,sans-serif;font-size:14px;color:#1f2937;">\n`
html += `<h3 style="margin:0 0 12px;font-size:16px;">Eingesetzte Dienste und Cookies</h3>\n`
for (const [catKey, catVendors] of Object.entries(grouped)) {
const label = CAT_LABELS[catKey] || catKey
html += `<h4 style="margin:16px 0 8px;font-size:14px;color:#6b21a8;">${label}</h4>\n`
html += `<table style="width:100%;border-collapse:collapse;margin-bottom:12px;font-size:13px;">\n`
html += `<tr style="background:#f9fafb;"><th style="text-align:left;padding:6px 8px;border:1px solid #e5e7eb;">Anbieter</th><th style="text-align:left;padding:6px 8px;border:1px solid #e5e7eb;">Zweck</th><th style="text-align:left;padding:6px 8px;border:1px solid #e5e7eb;">Cookies</th><th style="text-align:left;padding:6px 8px;border:1px solid #e5e7eb;">Speicherdauer</th></tr>\n`
for (const v of catVendors) {
const name = v.vendor_url
? `<a href="${v.vendor_url}" target="_blank" rel="noopener">${v.vendor_name}</a>`
: v.vendor_name
const cookies = v.cookie_names?.join(', ') || '-'
const retention = v.retention_days ? `${v.retention_days} Tage` : '-'
html += `<tr><td style="padding:6px 8px;border:1px solid #e5e7eb;">${name}</td><td style="padding:6px 8px;border:1px solid #e5e7eb;">${v.description_de || '-'}</td><td style="padding:6px 8px;border:1px solid #e5e7eb;font-family:monospace;font-size:11px;">${cookies}</td><td style="padding:6px 8px;border:1px solid #e5e7eb;">${retention}</td></tr>\n`
}
html += `</table>\n`
}
html += `</div>`
return html
}
export function EmbeddableVendorHTML({ siteId }: { siteId?: string }) {
const [vendors, setVendors] = useState<Vendor[]>([])
const [copied, setCopied] = useState(false)
useEffect(() => {
const sid = siteId || 'preview-test-site'
fetch(`/api/sdk/v1/banner/admin/sites/${sid}/vendors`)
.then(r => r.ok ? r.json() : [])
.then(data => setVendors(Array.isArray(data) ? data : []))
.catch(() => {})
}, [siteId])
const html = generateHTML(vendors)
const handleCopy = () => {
navigator.clipboard.writeText(html)
setCopied(true)
setTimeout(() => setCopied(false), 2000)
}
return (
<div className="space-y-4">
<div className="flex items-center justify-between">
<div>
<h3 className="font-semibold text-gray-900">Einbettbarer HTML-Code</h3>
<p className="text-xs text-gray-500 mt-1">
Kopieren Sie diesen Code in Ihre Datenschutzerklaerung oder Cookie-Richtlinie.
</p>
</div>
<button onClick={handleCopy}
className="px-4 py-2 text-sm bg-purple-600 text-white rounded-lg hover:bg-purple-700 transition-colors">
{copied ? 'Kopiert!' : 'HTML kopieren'}
</button>
</div>
{/* Preview */}
<div className="border border-gray-200 rounded-lg p-4 bg-white">
<div dangerouslySetInnerHTML={{ __html: html }} />
</div>
{/* Raw HTML */}
<details className="group">
<summary className="text-xs text-gray-500 cursor-pointer hover:text-gray-700">
Quellcode anzeigen
</summary>
<pre className="mt-2 p-3 bg-gray-50 border border-gray-200 rounded-lg text-xs text-gray-700 overflow-x-auto max-h-[300px] overflow-y-auto">
{html}
</pre>
</details>
</div>
)
}
@@ -0,0 +1,76 @@
'use client'
import { useState } from 'react'
interface Site {
id: string
site_id: string
site_name: string
site_url: string
is_active: boolean
}
interface SiteSelectorProps {
sites: Site[]
activeSiteId: string | null
onSiteChange: (siteId: string) => void
onCreateSite: (data: { site_id: string; site_name: string; site_url: string }) => Promise<void>
}
export function SiteSelector({ sites, activeSiteId, onSiteChange, onCreateSite }: SiteSelectorProps) {
const [showCreate, setShowCreate] = useState(false)
const [newSite, setNewSite] = useState({ site_id: '', site_name: '', site_url: '' })
const [creating, setCreating] = useState(false)
const handleCreate = async () => {
if (!newSite.site_id || !newSite.site_name) return
setCreating(true)
try {
await onCreateSite(newSite)
setNewSite({ site_id: '', site_name: '', site_url: '' })
setShowCreate(false)
} finally {
setCreating(false)
}
}
return (
<div className="bg-white rounded-xl border border-gray-200 p-4">
<div className="flex items-center gap-4">
<div className="flex-1">
<label className="block text-xs font-medium text-gray-500 mb-1">Website / Domain</label>
<select value={activeSiteId || ''} onChange={e => onSiteChange(e.target.value)}
className="w-full px-3 py-2 text-sm border border-gray-200 rounded-lg focus:ring-1 focus:ring-purple-500 bg-white">
{sites.length === 0 && <option value="">Keine Sites konfiguriert</option>}
{sites.map(s => (
<option key={s.site_id} value={s.site_id}>
{s.site_name} ({s.site_url || s.site_id})
</option>
))}
</select>
</div>
<button onClick={() => setShowCreate(!showCreate)}
className="mt-5 px-3 py-2 text-sm bg-purple-50 text-purple-600 border border-purple-200 rounded-lg hover:bg-purple-100">
+ Neue Seite
</button>
</div>
{showCreate && (
<div className="mt-4 pt-4 border-t border-gray-100 grid grid-cols-3 gap-3">
<input value={newSite.site_id} onChange={e => setNewSite({ ...newSite, site_id: e.target.value })}
placeholder="Site-ID (z.B. main-website)" className="px-3 py-2 text-sm border border-gray-200 rounded-lg" />
<input value={newSite.site_name} onChange={e => setNewSite({ ...newSite, site_name: e.target.value })}
placeholder="Name (z.B. Hauptwebsite)" className="px-3 py-2 text-sm border border-gray-200 rounded-lg" />
<div className="flex gap-2">
<input value={newSite.site_url} onChange={e => setNewSite({ ...newSite, site_url: e.target.value })}
placeholder="URL (z.B. https://example.com)" className="flex-1 px-3 py-2 text-sm border border-gray-200 rounded-lg" />
<button onClick={handleCreate} disabled={creating || !newSite.site_id}
className="px-3 py-2 text-sm bg-purple-600 text-white rounded-lg hover:bg-purple-700 disabled:opacity-50">
{creating ? '...' : 'Anlegen'}
</button>
</div>
</div>
)}
</div>
)
}
@@ -0,0 +1,138 @@
'use client'
import { useState, useEffect } from 'react'
import { useSDK } from '@/lib/sdk'
interface Vendor {
id: string
vendor_name: string
vendor_url: string | null
category_key: string
description_de: string | null
description_en: string | null
cookie_names: string[]
retention_days: number | null
is_active: boolean
}
const CATEGORY_LABELS: Record<string, { label: string; color: string }> = {
necessary: { label: 'Notwendig', color: 'bg-green-100 text-green-700' },
functional: { label: 'Funktional', color: 'bg-blue-100 text-blue-700' },
statistics: { label: 'Statistik', color: 'bg-yellow-100 text-yellow-700' },
marketing: { label: 'Marketing', color: 'bg-red-100 text-red-700' },
}
export function VendorTable({ siteId }: { siteId?: string }) {
const { projectId } = useSDK()
const [vendors, setVendors] = useState<Vendor[]>([])
const [loading, setLoading] = useState(true)
const [expandedId, setExpandedId] = useState<string | null>(null)
useEffect(() => {
const sid = siteId || 'preview-test-site'
fetch(`/api/sdk/v1/banner/admin/sites/${sid}/vendors`)
.then(r => r.ok ? r.json() : [])
.then(data => setVendors(Array.isArray(data) ? data : []))
.catch(() => setVendors([]))
.finally(() => setLoading(false))
}, [siteId])
// Group by category
const grouped = vendors.reduce<Record<string, Vendor[]>>((acc, v) => {
const key = v.category_key || 'other'
if (!acc[key]) acc[key] = []
acc[key].push(v)
return acc
}, {})
if (loading) {
return <div className="text-center py-12 text-gray-400">Lade Verarbeiter...</div>
}
if (vendors.length === 0) {
return (
<div className="text-center py-12">
<p className="text-gray-400 mb-3">Keine Verarbeiter konfiguriert.</p>
<p className="text-xs text-gray-400">
Nutzen Sie den Website-Scanner oder fuegen Sie Verarbeiter manuell hinzu.
</p>
</div>
)
}
return (
<div className="space-y-6">
<div className="flex items-center justify-between">
<div>
<h3 className="font-semibold text-gray-900">Verarbeiter-Uebersicht</h3>
<p className="text-xs text-gray-500 mt-1">{vendors.length} Dienste in {Object.keys(grouped).length} Kategorien</p>
</div>
</div>
{Object.entries(grouped).map(([catKey, catVendors]) => {
const catInfo = CATEGORY_LABELS[catKey] || { label: catKey, color: 'bg-gray-100 text-gray-700' }
return (
<div key={catKey} className="border border-gray-200 rounded-xl overflow-hidden">
<div className="bg-gray-50 px-4 py-3 flex items-center gap-3">
<span className={`px-2 py-0.5 text-xs font-medium rounded-full ${catInfo.color}`}>
{catInfo.label}
</span>
<span className="text-xs text-gray-500">{catVendors.length} Dienste</span>
</div>
<table className="w-full text-sm">
<thead>
<tr className="border-b border-gray-100 text-left text-xs text-gray-500">
<th className="px-4 py-2 font-medium">Anbieter</th>
<th className="px-4 py-2 font-medium">Zweck</th>
<th className="px-4 py-2 font-medium">Cookies</th>
<th className="px-4 py-2 font-medium">Aufbewahrung</th>
<th className="px-4 py-2 font-medium">Datenschutz</th>
</tr>
</thead>
<tbody className="divide-y divide-gray-50">
{catVendors.map(v => (
<tr key={v.id} className="hover:bg-gray-50/50">
<td className="px-4 py-2.5">
<button onClick={() => setExpandedId(expandedId === v.id ? null : v.id)}
className="font-medium text-gray-900 hover:text-purple-600 text-left">
{v.vendor_name}
</button>
{expandedId === v.id && v.cookie_names?.length > 0 && (
<div className="mt-1 flex flex-wrap gap-1">
{v.cookie_names.map(c => (
<span key={c} className="px-1.5 py-0.5 text-[10px] bg-gray-100 text-gray-600 rounded font-mono">
{c}
</span>
))}
</div>
)}
</td>
<td className="px-4 py-2.5 text-xs text-gray-600 max-w-[200px] truncate">
{v.description_de || '-'}
</td>
<td className="px-4 py-2.5 text-xs text-gray-500">
{v.cookie_names?.length || 0}
</td>
<td className="px-4 py-2.5 text-xs text-gray-500">
{v.retention_days ? `${v.retention_days} Tage` : '-'}
</td>
<td className="px-4 py-2.5">
{v.vendor_url ? (
<a href={v.vendor_url} target="_blank" rel="noopener noreferrer"
className="text-xs text-purple-600 hover:underline">
Link
</a>
) : (
<span className="text-xs text-gray-400">-</span>
)}
</td>
</tr>
))}
</tbody>
</table>
</div>
)
})}
</div>
)
}
@@ -96,13 +96,38 @@ const defaultBannerTexts: BannerTexts = {
privacyLink: '/datenschutz', privacyLink: '/datenschutz',
} }
export interface BannerSite {
id: string
site_id: string
site_name: string
site_url: string
is_active: boolean
}
export function useCookieBanner() { export function useCookieBanner() {
const [categories, setCategories] = useState<CookieCategory[]>([]) const [categories, setCategories] = useState<CookieCategory[]>([])
const [config, setConfig] = useState<BannerConfig>(defaultConfig) const [config, setConfig] = useState<BannerConfig>(defaultConfig)
const [bannerTexts, setBannerTexts] = useState<BannerTexts>(defaultBannerTexts) const [bannerTexts, setBannerTexts] = useState<BannerTexts>(defaultBannerTexts)
const [isSaving, setIsSaving] = useState(false) const [isSaving, setIsSaving] = useState(false)
const [exportToast, setExportToast] = useState<string | null>(null) const [exportToast, setExportToast] = useState<string | null>(null)
const [sites, setSites] = useState<BannerSite[]>([])
const [activeSiteId, setActiveSiteId] = useState<string | null>(null)
// Load sites list
React.useEffect(() => {
fetch('/api/sdk/v1/banner/admin/sites')
.then(r => r.ok ? r.json() : [])
.then(data => {
const siteList = Array.isArray(data) ? data : []
setSites(siteList)
if (siteList.length > 0 && !activeSiteId) {
setActiveSiteId(siteList[0].site_id)
}
})
.catch(() => {})
}, [])
// Load config for active site
React.useEffect(() => { React.useEffect(() => {
const loadConfig = async () => { const loadConfig = async () => {
try { try {
@@ -125,7 +150,20 @@ export function useCookieBanner() {
} }
} }
loadConfig() loadConfig()
}, []) }, [activeSiteId])
const createSite = async (data: { site_id: string; site_name: string; site_url: string }) => {
const res = await fetch('/api/sdk/v1/banner/admin/sites', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify(data),
})
if (res.ok) {
const newSite = await res.json()
setSites(prev => [...prev, newSite])
setActiveSiteId(newSite.site_id || data.site_id)
}
}
const handleCategoryToggle = async (categoryId: string, enabled: boolean) => { const handleCategoryToggle = async (categoryId: string, enabled: boolean) => {
setCategories(prev => setCategories(prev =>
@@ -180,5 +218,6 @@ export function useCookieBanner() {
categories, config, bannerTexts, isSaving, exportToast, categories, config, bannerTexts, isSaving, exportToast,
setConfig, setBannerTexts, setConfig, setBannerTexts,
handleCategoryToggle, handleExportCode, handleSaveConfig, handleCategoryToggle, handleExportCode, handleSaveConfig,
sites, activeSiteId, setActiveSiteId, createSite,
} }
} }
@@ -1,18 +1,25 @@
'use client' 'use client'
import React from 'react' import React, { useState } from 'react'
import { useSDK } from '@/lib/sdk' import { useSDK } from '@/lib/sdk'
import { StepHeader, STEP_EXPLANATIONS } from '@/components/sdk/StepHeader' import { StepHeader, STEP_EXPLANATIONS } from '@/components/sdk/StepHeader'
import { useCookieBanner } from './_hooks/useCookieBanner' import { useCookieBanner } from './_hooks/useCookieBanner'
import { BannerPreview } from './_components/BannerPreview' import { BannerPreview } from './_components/BannerPreview'
import { CategoryCard } from './_components/CategoryCard' import { CategoryCard } from './_components/CategoryCard'
import { VendorTable } from './_components/VendorTable'
import { EmbeddableVendorHTML } from './_components/EmbeddableVendorHTML'
import { SiteSelector } from './_components/SiteSelector'
type BannerTab = 'config' | 'vendors' | 'embed'
export default function CookieBannerPage() { export default function CookieBannerPage() {
const { state } = useSDK() const { state } = useSDK()
const [activeTab, setActiveTab] = useState<BannerTab>('config')
const { const {
categories, config, bannerTexts, isSaving, exportToast, categories, config, bannerTexts, isSaving, exportToast,
setConfig, setBannerTexts, setConfig, setBannerTexts,
handleCategoryToggle, handleExportCode, handleSaveConfig, handleCategoryToggle, handleExportCode, handleSaveConfig,
sites, activeSiteId, setActiveSiteId, createSite,
} = useCookieBanner() } = useCookieBanner()
const totalCookies = categories.reduce((sum, cat) => sum + cat.cookies.length, 0) const totalCookies = categories.reduce((sum, cat) => sum + cat.cookies.length, 0)
@@ -57,6 +64,35 @@ export default function CookieBannerPage() {
</div> </div>
</StepHeader> </StepHeader>
{/* Site Selector */}
{sites.length > 0 && (
<SiteSelector sites={sites} activeSiteId={activeSiteId} onSiteChange={setActiveSiteId} onCreateSite={createSite} />
)}
{/* Tabs */}
<div className="flex border-b border-gray-200">
{([
{ id: 'config' as const, label: 'Konfiguration' },
{ id: 'vendors' as const, label: 'Verarbeiter' },
{ id: 'embed' as const, label: 'Einbettung' },
]).map(tab => (
<button key={tab.id} onClick={() => setActiveTab(tab.id)}
className={`px-4 py-3 text-sm font-medium border-b-2 -mb-px transition-colors ${
activeTab === tab.id ? 'text-purple-600 border-purple-600' : 'text-gray-500 border-transparent hover:text-gray-700'
}`}>
{tab.label}
</button>
))}
</div>
{/* Tab: Verarbeiter */}
{activeTab === 'vendors' && <VendorTable siteId={activeSiteId || undefined} />}
{/* Tab: Einbettung */}
{activeTab === 'embed' && <EmbeddableVendorHTML siteId={activeSiteId || undefined} />}
{/* Tab: Konfiguration */}
{activeTab !== 'config' ? null : (<>
{/* Stats */} {/* Stats */}
<div className="grid grid-cols-1 md:grid-cols-4 gap-4"> <div className="grid grid-cols-1 md:grid-cols-4 gap-4">
<div className="bg-white rounded-xl border border-gray-200 p-6"> <div className="bg-white rounded-xl border border-gray-200 p-6">
@@ -207,6 +243,7 @@ export default function CookieBannerPage() {
))} ))}
</div> </div>
</div> </div>
</>)}
</div> </div>
) )
} }