refactor(sdk): split hooks, dsr-portal, provider, sync approaching 500 LOC
All four files split into focused sibling modules so every file lands
comfortably under the 300-LOC soft target (hard cap 500):
hooks.ts (474→43) → hooks-core / hooks-dsgvo / hooks-compliance
hooks-rag-security / hooks-ui
dsr-portal.ts (464→129) → dsr-portal-translations / dsr-portal-render
provider.tsx (462→247) → provider-effects / provider-callbacks
sync.ts (435→299) → sync-storage / sync-conflict
Zero behaviour changes. All public APIs remain importable from the
original paths (hooks.ts re-exports every hook, provider.tsx keeps all
named exports, sync.ts preserves StateSyncManager + factory).
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
This commit is contained in:
67
breakpilot-compliance-sdk/packages/core/src/sync-conflict.ts
Normal file
67
breakpilot-compliance-sdk/packages/core/src/sync-conflict.ts
Normal file
@@ -0,0 +1,67 @@
|
|||||||
|
/**
|
||||||
|
* Conflict resolution helpers for StateSyncManager.
|
||||||
|
*
|
||||||
|
* Extracted from sync.ts to stay within the 300-LOC target.
|
||||||
|
*/
|
||||||
|
|
||||||
|
import type { SDKState, ConflictResolution } from '@breakpilot/compliance-sdk-types'
|
||||||
|
|
||||||
|
// =============================================================================
|
||||||
|
// DEFAULT CONFLICT HANDLER
|
||||||
|
// =============================================================================
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Default strategy: if local is newer, keep local; otherwise merge
|
||||||
|
* server as the base but preserve local preferences and deduplicate
|
||||||
|
* commandBarHistory / recentSearches.
|
||||||
|
*/
|
||||||
|
export async function defaultConflictHandler(
|
||||||
|
local: SDKState,
|
||||||
|
server: SDKState
|
||||||
|
): Promise<ConflictResolution> {
|
||||||
|
const localTime = new Date(local.lastModified).getTime()
|
||||||
|
const serverTime = new Date(server.lastModified).getTime()
|
||||||
|
|
||||||
|
if (localTime > serverTime) {
|
||||||
|
return { strategy: 'local' }
|
||||||
|
}
|
||||||
|
|
||||||
|
const mergedState: SDKState = {
|
||||||
|
...server,
|
||||||
|
preferences: local.preferences,
|
||||||
|
commandBarHistory: [
|
||||||
|
...local.commandBarHistory,
|
||||||
|
...server.commandBarHistory.filter(
|
||||||
|
h => !local.commandBarHistory.some(lh => lh.id === h.id)
|
||||||
|
),
|
||||||
|
].slice(0, 50),
|
||||||
|
recentSearches: [...new Set([...local.recentSearches, ...server.recentSearches])].slice(
|
||||||
|
0,
|
||||||
|
20
|
||||||
|
),
|
||||||
|
}
|
||||||
|
|
||||||
|
return { strategy: 'merge', mergedState }
|
||||||
|
}
|
||||||
|
|
||||||
|
// =============================================================================
|
||||||
|
// CONFLICT RESOLUTION APPLIER
|
||||||
|
// =============================================================================
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Given a resolution strategy and both states, returns the winning state.
|
||||||
|
*/
|
||||||
|
export function applyConflictResolution(
|
||||||
|
resolution: ConflictResolution,
|
||||||
|
localState: SDKState,
|
||||||
|
serverState: SDKState
|
||||||
|
): SDKState {
|
||||||
|
switch (resolution.strategy) {
|
||||||
|
case 'local':
|
||||||
|
return localState
|
||||||
|
case 'server':
|
||||||
|
return serverState
|
||||||
|
case 'merge':
|
||||||
|
return resolution.mergedState || localState
|
||||||
|
}
|
||||||
|
}
|
||||||
154
breakpilot-compliance-sdk/packages/core/src/sync-storage.ts
Normal file
154
breakpilot-compliance-sdk/packages/core/src/sync-storage.ts
Normal file
@@ -0,0 +1,154 @@
|
|||||||
|
/**
|
||||||
|
* Local-storage and BroadcastChannel helpers for StateSyncManager.
|
||||||
|
*
|
||||||
|
* Extracted from sync.ts to stay within the 300-LOC target.
|
||||||
|
*/
|
||||||
|
|
||||||
|
import type { SDKState, SyncState } from '@breakpilot/compliance-sdk-types'
|
||||||
|
|
||||||
|
// =============================================================================
|
||||||
|
// CONSTANTS
|
||||||
|
// =============================================================================
|
||||||
|
|
||||||
|
export const STORAGE_KEY_PREFIX = 'breakpilot-compliance-sdk-state'
|
||||||
|
export const SYNC_CHANNEL = 'breakpilot-sdk-state-sync'
|
||||||
|
|
||||||
|
// =============================================================================
|
||||||
|
// TAB ID
|
||||||
|
// =============================================================================
|
||||||
|
|
||||||
|
export function getTabId(): string {
|
||||||
|
if (typeof window === 'undefined') return 'server'
|
||||||
|
|
||||||
|
let tabId = sessionStorage.getItem('breakpilot-sdk-tab-id')
|
||||||
|
if (!tabId) {
|
||||||
|
tabId = `tab-${Date.now()}-${Math.random().toString(36).substr(2, 9)}`
|
||||||
|
sessionStorage.setItem('breakpilot-sdk-tab-id', tabId)
|
||||||
|
}
|
||||||
|
return tabId
|
||||||
|
}
|
||||||
|
|
||||||
|
// =============================================================================
|
||||||
|
// LOCAL STORAGE HELPERS
|
||||||
|
// =============================================================================
|
||||||
|
|
||||||
|
export function getStorageKey(tenantId: string): string {
|
||||||
|
return `${STORAGE_KEY_PREFIX}-${tenantId}`
|
||||||
|
}
|
||||||
|
|
||||||
|
export function saveStateToLocalStorage(
|
||||||
|
tenantId: string,
|
||||||
|
state: SDKState,
|
||||||
|
version: number
|
||||||
|
): void {
|
||||||
|
if (typeof window === 'undefined') return
|
||||||
|
|
||||||
|
try {
|
||||||
|
const data = {
|
||||||
|
state,
|
||||||
|
version,
|
||||||
|
savedAt: new Date().toISOString(),
|
||||||
|
}
|
||||||
|
localStorage.setItem(getStorageKey(tenantId), JSON.stringify(data))
|
||||||
|
} catch (error) {
|
||||||
|
console.error('Failed to save to localStorage:', error)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
export function loadStateFromLocalStorage(
|
||||||
|
tenantId: string,
|
||||||
|
syncState: SyncState
|
||||||
|
): SDKState | null {
|
||||||
|
if (typeof window === 'undefined') return null
|
||||||
|
|
||||||
|
try {
|
||||||
|
const stored = localStorage.getItem(getStorageKey(tenantId))
|
||||||
|
if (stored) {
|
||||||
|
const data = JSON.parse(stored)
|
||||||
|
syncState.localVersion = data.version || 0
|
||||||
|
return data.state
|
||||||
|
}
|
||||||
|
} catch (error) {
|
||||||
|
console.error('Failed to load from localStorage:', error)
|
||||||
|
}
|
||||||
|
return null
|
||||||
|
}
|
||||||
|
|
||||||
|
export function clearStateFromLocalStorage(tenantId: string): void {
|
||||||
|
if (typeof window === 'undefined') return
|
||||||
|
|
||||||
|
try {
|
||||||
|
localStorage.removeItem(getStorageKey(tenantId))
|
||||||
|
} catch (error) {
|
||||||
|
console.error('Failed to clear localStorage:', error)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// =============================================================================
|
||||||
|
// BROADCAST CHANNEL HELPERS
|
||||||
|
// =============================================================================
|
||||||
|
|
||||||
|
export function createBroadcastChannel(
|
||||||
|
tenantId: string
|
||||||
|
): BroadcastChannel | null {
|
||||||
|
if (typeof window === 'undefined' || !('BroadcastChannel' in window)) {
|
||||||
|
return null
|
||||||
|
}
|
||||||
|
|
||||||
|
try {
|
||||||
|
return new BroadcastChannel(`${SYNC_CHANNEL}-${tenantId}`)
|
||||||
|
} catch (error) {
|
||||||
|
console.warn('BroadcastChannel not available:', error)
|
||||||
|
return null
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
export function broadcastStateUpdate(
|
||||||
|
channel: BroadcastChannel | null,
|
||||||
|
state: SDKState,
|
||||||
|
version: number
|
||||||
|
): void {
|
||||||
|
if (!channel) return
|
||||||
|
|
||||||
|
channel.postMessage({
|
||||||
|
type: 'STATE_UPDATED',
|
||||||
|
state,
|
||||||
|
version,
|
||||||
|
tabId: getTabId(),
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
export function broadcastSyncComplete(
|
||||||
|
channel: BroadcastChannel | null,
|
||||||
|
version: number
|
||||||
|
): void {
|
||||||
|
if (!channel) return
|
||||||
|
|
||||||
|
channel.postMessage({
|
||||||
|
type: 'SYNC_COMPLETE',
|
||||||
|
version,
|
||||||
|
tabId: getTabId(),
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
// =============================================================================
|
||||||
|
// ONLINE / OFFLINE LISTENER
|
||||||
|
// =============================================================================
|
||||||
|
|
||||||
|
export interface OnlineListenerHandlers {
|
||||||
|
onOnline: () => void
|
||||||
|
onOffline: () => void
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Registers window online/offline listeners and returns the current online status.
|
||||||
|
* No-ops in non-browser environments.
|
||||||
|
*/
|
||||||
|
export function setupOnlineListener(handlers: OnlineListenerHandlers): boolean {
|
||||||
|
if (typeof window === 'undefined') return true
|
||||||
|
|
||||||
|
window.addEventListener('online', handlers.onOnline)
|
||||||
|
window.addEventListener('offline', handlers.onOffline)
|
||||||
|
|
||||||
|
return navigator.onLine
|
||||||
|
}
|
||||||
@@ -1,23 +1,26 @@
|
|||||||
/**
|
// SDK State Synchronization — offline/online sync, multi-tab coordination.
|
||||||
* SDK State Synchronization
|
// Split: localStorage+BroadcastChannel → sync-storage.ts | conflict → sync-conflict.ts
|
||||||
*
|
|
||||||
* Handles offline/online sync, multi-tab coordination,
|
|
||||||
* and conflict resolution for SDK state.
|
|
||||||
*/
|
|
||||||
|
|
||||||
import type { SDKState, SyncState, SyncStatus, ConflictResolution } from '@breakpilot/compliance-sdk-types'
|
import type { SDKState, SyncState, SyncStatus, ConflictResolution } from '@breakpilot/compliance-sdk-types'
|
||||||
import { ComplianceClient } from './client'
|
import { ComplianceClient } from './client'
|
||||||
|
import {
|
||||||
|
saveStateToLocalStorage,
|
||||||
|
loadStateFromLocalStorage,
|
||||||
|
clearStateFromLocalStorage,
|
||||||
|
createBroadcastChannel,
|
||||||
|
broadcastStateUpdate,
|
||||||
|
broadcastSyncComplete,
|
||||||
|
setupOnlineListener,
|
||||||
|
} from './sync-storage'
|
||||||
|
import { defaultConflictHandler, applyConflictResolution } from './sync-conflict'
|
||||||
|
|
||||||
// =============================================================================
|
// --- Types & Constants -------------------------------------------------------
|
||||||
// TYPES
|
|
||||||
// =============================================================================
|
|
||||||
|
|
||||||
export interface SyncOptions {
|
export interface SyncOptions {
|
||||||
debounceMs?: number
|
debounceMs?: number
|
||||||
maxRetries?: number
|
maxRetries?: number
|
||||||
conflictHandler?: (local: SDKState, server: SDKState) => Promise<ConflictResolution>
|
conflictHandler?: (local: SDKState, server: SDKState) => Promise<ConflictResolution>
|
||||||
}
|
}
|
||||||
|
|
||||||
export interface SyncCallbacks {
|
export interface SyncCallbacks {
|
||||||
onSyncStart?: () => void
|
onSyncStart?: () => void
|
||||||
onSyncComplete?: (state: SDKState) => void
|
onSyncComplete?: (state: SDKState) => void
|
||||||
@@ -27,18 +30,10 @@ export interface SyncCallbacks {
|
|||||||
onOnline?: () => void
|
onOnline?: () => void
|
||||||
}
|
}
|
||||||
|
|
||||||
// =============================================================================
|
|
||||||
// CONSTANTS
|
|
||||||
// =============================================================================
|
|
||||||
|
|
||||||
const STORAGE_KEY_PREFIX = 'breakpilot-compliance-sdk-state'
|
|
||||||
const SYNC_CHANNEL = 'breakpilot-sdk-state-sync'
|
|
||||||
const DEFAULT_DEBOUNCE_MS = 2000
|
const DEFAULT_DEBOUNCE_MS = 2000
|
||||||
const DEFAULT_MAX_RETRIES = 3
|
const DEFAULT_MAX_RETRIES = 3
|
||||||
|
|
||||||
// =============================================================================
|
// --- StateSyncManager --------------------------------------------------------
|
||||||
// STATE SYNC MANAGER
|
|
||||||
// =============================================================================
|
|
||||||
|
|
||||||
export class StateSyncManager {
|
export class StateSyncManager {
|
||||||
private client: ComplianceClient
|
private client: ComplianceClient
|
||||||
@@ -63,7 +58,7 @@ export class StateSyncManager {
|
|||||||
this.options = {
|
this.options = {
|
||||||
debounceMs: options.debounceMs ?? DEFAULT_DEBOUNCE_MS,
|
debounceMs: options.debounceMs ?? DEFAULT_DEBOUNCE_MS,
|
||||||
maxRetries: options.maxRetries ?? DEFAULT_MAX_RETRIES,
|
maxRetries: options.maxRetries ?? DEFAULT_MAX_RETRIES,
|
||||||
conflictHandler: options.conflictHandler ?? this.defaultConflictHandler.bind(this),
|
conflictHandler: options.conflictHandler ?? defaultConflictHandler,
|
||||||
}
|
}
|
||||||
|
|
||||||
this.syncState = {
|
this.syncState = {
|
||||||
@@ -79,52 +74,33 @@ export class StateSyncManager {
|
|||||||
this.setupOnlineListener()
|
this.setupOnlineListener()
|
||||||
}
|
}
|
||||||
|
|
||||||
// ---------------------------------------------------------------------------
|
// -- Setup --
|
||||||
// Setup Methods
|
|
||||||
// ---------------------------------------------------------------------------
|
|
||||||
|
|
||||||
private setupBroadcastChannel(): void {
|
private setupBroadcastChannel(): void {
|
||||||
if (typeof window === 'undefined' || !('BroadcastChannel' in window)) {
|
this.broadcastChannel = createBroadcastChannel(this.tenantId)
|
||||||
return
|
if (this.broadcastChannel) {
|
||||||
}
|
|
||||||
|
|
||||||
try {
|
|
||||||
this.broadcastChannel = new BroadcastChannel(`${SYNC_CHANNEL}-${this.tenantId}`)
|
|
||||||
this.broadcastChannel.onmessage = this.handleBroadcastMessage.bind(this)
|
this.broadcastChannel.onmessage = this.handleBroadcastMessage.bind(this)
|
||||||
} catch (error) {
|
|
||||||
console.warn('BroadcastChannel not available:', error)
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
private setupOnlineListener(): void {
|
private setupOnlineListener(): void {
|
||||||
if (typeof window === 'undefined') {
|
this.isOnline = setupOnlineListener({
|
||||||
return
|
onOnline: () => {
|
||||||
}
|
|
||||||
|
|
||||||
window.addEventListener('online', () => {
|
|
||||||
this.isOnline = true
|
this.isOnline = true
|
||||||
this.syncState.status = 'idle'
|
this.syncState.status = 'idle'
|
||||||
this.callbacks.onOnline?.()
|
this.callbacks.onOnline?.()
|
||||||
if (this.pendingState) {
|
if (this.pendingState) this.syncToServer(this.pendingState)
|
||||||
this.syncToServer(this.pendingState)
|
},
|
||||||
}
|
onOffline: () => {
|
||||||
})
|
|
||||||
|
|
||||||
window.addEventListener('offline', () => {
|
|
||||||
this.isOnline = false
|
this.isOnline = false
|
||||||
this.syncState.status = 'offline'
|
this.syncState.status = 'offline'
|
||||||
this.callbacks.onOffline?.()
|
this.callbacks.onOffline?.()
|
||||||
|
},
|
||||||
})
|
})
|
||||||
|
if (!this.isOnline) this.syncState.status = 'offline'
|
||||||
this.isOnline = navigator.onLine
|
|
||||||
if (!this.isOnline) {
|
|
||||||
this.syncState.status = 'offline'
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|
||||||
// ---------------------------------------------------------------------------
|
// -- Broadcast Channel --
|
||||||
// Broadcast Channel Methods
|
|
||||||
// ---------------------------------------------------------------------------
|
|
||||||
|
|
||||||
private handleBroadcastMessage(event: MessageEvent): void {
|
private handleBroadcastMessage(event: MessageEvent): void {
|
||||||
const { type, state, version } = event.data
|
const { type, state, version } = event.data
|
||||||
@@ -143,98 +119,33 @@ export class StateSyncManager {
|
|||||||
break
|
break
|
||||||
|
|
||||||
case 'REQUEST_STATE':
|
case 'REQUEST_STATE':
|
||||||
this.broadcastState()
|
this.broadcastCurrentState()
|
||||||
break
|
break
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
private broadcastState(): void {
|
private broadcastCurrentState(): void {
|
||||||
if (!this.broadcastChannel) return
|
|
||||||
|
|
||||||
const state = this.loadFromLocalStorage()
|
const state = this.loadFromLocalStorage()
|
||||||
if (state) {
|
if (state) {
|
||||||
this.broadcastChannel.postMessage({
|
broadcastStateUpdate(this.broadcastChannel, state, this.syncState.localVersion)
|
||||||
type: 'STATE_UPDATED',
|
|
||||||
state,
|
|
||||||
version: this.syncState.localVersion,
|
|
||||||
tabId: this.getTabId(),
|
|
||||||
})
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
private broadcastSyncComplete(version: number): void {
|
// -- Local Storage (sync-storage helpers) --
|
||||||
if (!this.broadcastChannel) return
|
|
||||||
|
|
||||||
this.broadcastChannel.postMessage({
|
|
||||||
type: 'SYNC_COMPLETE',
|
|
||||||
version,
|
|
||||||
tabId: this.getTabId(),
|
|
||||||
})
|
|
||||||
}
|
|
||||||
|
|
||||||
private getTabId(): string {
|
|
||||||
if (typeof window === 'undefined') return 'server'
|
|
||||||
|
|
||||||
let tabId = sessionStorage.getItem('breakpilot-sdk-tab-id')
|
|
||||||
if (!tabId) {
|
|
||||||
tabId = `tab-${Date.now()}-${Math.random().toString(36).substr(2, 9)}`
|
|
||||||
sessionStorage.setItem('breakpilot-sdk-tab-id', tabId)
|
|
||||||
}
|
|
||||||
return tabId
|
|
||||||
}
|
|
||||||
|
|
||||||
// ---------------------------------------------------------------------------
|
|
||||||
// Local Storage Methods
|
|
||||||
// ---------------------------------------------------------------------------
|
|
||||||
|
|
||||||
private getStorageKey(): string {
|
|
||||||
return `${STORAGE_KEY_PREFIX}-${this.tenantId}`
|
|
||||||
}
|
|
||||||
|
|
||||||
saveToLocalStorage(state: SDKState): void {
|
saveToLocalStorage(state: SDKState): void {
|
||||||
if (typeof window === 'undefined') return
|
saveStateToLocalStorage(this.tenantId, state, this.syncState.localVersion)
|
||||||
|
|
||||||
try {
|
|
||||||
const data = {
|
|
||||||
state,
|
|
||||||
version: this.syncState.localVersion,
|
|
||||||
savedAt: new Date().toISOString(),
|
|
||||||
}
|
|
||||||
localStorage.setItem(this.getStorageKey(), JSON.stringify(data))
|
|
||||||
} catch (error) {
|
|
||||||
console.error('Failed to save to localStorage:', error)
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|
||||||
loadFromLocalStorage(): SDKState | null {
|
loadFromLocalStorage(): SDKState | null {
|
||||||
if (typeof window === 'undefined') return null
|
return loadStateFromLocalStorage(this.tenantId, this.syncState)
|
||||||
|
|
||||||
try {
|
|
||||||
const stored = localStorage.getItem(this.getStorageKey())
|
|
||||||
if (stored) {
|
|
||||||
const data = JSON.parse(stored)
|
|
||||||
this.syncState.localVersion = data.version || 0
|
|
||||||
return data.state
|
|
||||||
}
|
|
||||||
} catch (error) {
|
|
||||||
console.error('Failed to load from localStorage:', error)
|
|
||||||
}
|
|
||||||
return null
|
|
||||||
}
|
}
|
||||||
|
|
||||||
clearLocalStorage(): void {
|
clearLocalStorage(): void {
|
||||||
if (typeof window === 'undefined') return
|
clearStateFromLocalStorage(this.tenantId)
|
||||||
|
|
||||||
try {
|
|
||||||
localStorage.removeItem(this.getStorageKey())
|
|
||||||
} catch (error) {
|
|
||||||
console.error('Failed to clear localStorage:', error)
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|
||||||
// ---------------------------------------------------------------------------
|
// -- Sync --
|
||||||
// Sync Methods
|
|
||||||
// ---------------------------------------------------------------------------
|
|
||||||
|
|
||||||
queueSync(state: SDKState): void {
|
queueSync(state: SDKState): void {
|
||||||
this.pendingState = state
|
this.pendingState = state
|
||||||
@@ -242,7 +153,7 @@ export class StateSyncManager {
|
|||||||
|
|
||||||
this.syncState.localVersion++
|
this.syncState.localVersion++
|
||||||
this.saveToLocalStorage(state)
|
this.saveToLocalStorage(state)
|
||||||
this.broadcastState()
|
broadcastStateUpdate(this.broadcastChannel, state, this.syncState.localVersion)
|
||||||
|
|
||||||
if (this.debounceTimeout) {
|
if (this.debounceTimeout) {
|
||||||
clearTimeout(this.debounceTimeout)
|
clearTimeout(this.debounceTimeout)
|
||||||
@@ -284,7 +195,7 @@ export class StateSyncManager {
|
|||||||
}
|
}
|
||||||
|
|
||||||
this.pendingState = null
|
this.pendingState = null
|
||||||
this.broadcastSyncComplete(response.version)
|
broadcastSyncComplete(this.broadcastChannel, response.version)
|
||||||
this.callbacks.onSyncComplete?.(state)
|
this.callbacks.onSyncComplete?.(state)
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
if ((error as { status?: number }).status === 409) {
|
if ((error as { status?: number }).status === 409) {
|
||||||
@@ -319,9 +230,7 @@ export class StateSyncManager {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// ---------------------------------------------------------------------------
|
// -- Conflict Resolution (sync-conflict helpers) --
|
||||||
// Conflict Resolution
|
|
||||||
// ---------------------------------------------------------------------------
|
|
||||||
|
|
||||||
private async handleConflict(localState: SDKState): Promise<void> {
|
private async handleConflict(localState: SDKState): Promise<void> {
|
||||||
this.syncState.status = 'conflict'
|
this.syncState.status = 'conflict'
|
||||||
@@ -338,19 +247,7 @@ export class StateSyncManager {
|
|||||||
this.callbacks.onConflict?.(localState, serverState)
|
this.callbacks.onConflict?.(localState, serverState)
|
||||||
|
|
||||||
const resolution = await this.options.conflictHandler(localState, serverState)
|
const resolution = await this.options.conflictHandler(localState, serverState)
|
||||||
|
const resolvedState = applyConflictResolution(resolution, localState, serverState)
|
||||||
let resolvedState: SDKState
|
|
||||||
switch (resolution.strategy) {
|
|
||||||
case 'local':
|
|
||||||
resolvedState = localState
|
|
||||||
break
|
|
||||||
case 'server':
|
|
||||||
resolvedState = serverState
|
|
||||||
break
|
|
||||||
case 'merge':
|
|
||||||
resolvedState = resolution.mergedState || localState
|
|
||||||
break
|
|
||||||
}
|
|
||||||
|
|
||||||
const response = await this.client.saveState(resolvedState)
|
const response = await this.client.saveState(resolvedState)
|
||||||
this.syncState.serverVersion = response.version
|
this.syncState.serverVersion = response.version
|
||||||
@@ -365,38 +262,7 @@ export class StateSyncManager {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
private async defaultConflictHandler(
|
// -- Getters & Cleanup --
|
||||||
local: SDKState,
|
|
||||||
server: SDKState
|
|
||||||
): Promise<ConflictResolution> {
|
|
||||||
const localTime = new Date(local.lastModified).getTime()
|
|
||||||
const serverTime = new Date(server.lastModified).getTime()
|
|
||||||
|
|
||||||
if (localTime > serverTime) {
|
|
||||||
return { strategy: 'local' }
|
|
||||||
}
|
|
||||||
|
|
||||||
const mergedState: SDKState = {
|
|
||||||
...server,
|
|
||||||
preferences: local.preferences,
|
|
||||||
commandBarHistory: [
|
|
||||||
...local.commandBarHistory,
|
|
||||||
...server.commandBarHistory.filter(
|
|
||||||
h => !local.commandBarHistory.some(lh => lh.id === h.id)
|
|
||||||
),
|
|
||||||
].slice(0, 50),
|
|
||||||
recentSearches: [...new Set([...local.recentSearches, ...server.recentSearches])].slice(
|
|
||||||
0,
|
|
||||||
20
|
|
||||||
),
|
|
||||||
}
|
|
||||||
|
|
||||||
return { strategy: 'merge', mergedState }
|
|
||||||
}
|
|
||||||
|
|
||||||
// ---------------------------------------------------------------------------
|
|
||||||
// Getters & Cleanup
|
|
||||||
// ---------------------------------------------------------------------------
|
|
||||||
|
|
||||||
getSyncState(): SyncState {
|
getSyncState(): SyncState {
|
||||||
return { ...this.syncState }
|
return { ...this.syncState }
|
||||||
@@ -421,9 +287,7 @@ export class StateSyncManager {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// =============================================================================
|
// --- Factory -----------------------------------------------------------------
|
||||||
// FACTORY
|
|
||||||
// =============================================================================
|
|
||||||
|
|
||||||
export function createStateSyncManager(
|
export function createStateSyncManager(
|
||||||
client: ComplianceClient,
|
client: ComplianceClient,
|
||||||
|
|||||||
164
breakpilot-compliance-sdk/packages/react/src/hooks-compliance.ts
Normal file
164
breakpilot-compliance-sdk/packages/react/src/hooks-compliance.ts
Normal file
@@ -0,0 +1,164 @@
|
|||||||
|
'use client'
|
||||||
|
|
||||||
|
import { useMemo, useCallback } from 'react'
|
||||||
|
import { useCompliance } from './hooks-core'
|
||||||
|
import type { Control, Evidence, Risk } from '@breakpilot/compliance-sdk-types'
|
||||||
|
|
||||||
|
// =============================================================================
|
||||||
|
// COMPLIANCE HOOKS
|
||||||
|
// =============================================================================
|
||||||
|
|
||||||
|
export function useComplianceModule() {
|
||||||
|
const { compliance, state } = useCompliance()
|
||||||
|
|
||||||
|
return useMemo(
|
||||||
|
() => ({
|
||||||
|
// Controls
|
||||||
|
controls: state.controls,
|
||||||
|
getControlById: compliance.getControlById.bind(compliance),
|
||||||
|
getControlsByDomain: compliance.getControlsByDomain.bind(compliance),
|
||||||
|
getControlsByStatus: compliance.getControlsByStatus.bind(compliance),
|
||||||
|
controlComplianceRate: compliance.getControlComplianceRate(),
|
||||||
|
|
||||||
|
// Evidence
|
||||||
|
evidence: state.evidence,
|
||||||
|
getEvidenceByControlId: compliance.getEvidenceByControlId.bind(compliance),
|
||||||
|
expiringEvidence: compliance.getExpiringEvidence(),
|
||||||
|
|
||||||
|
// Requirements
|
||||||
|
requirements: state.requirements,
|
||||||
|
getRequirementsByRegulation: compliance.getRequirementsByRegulation.bind(compliance),
|
||||||
|
requirementComplianceRate: compliance.getRequirementComplianceRate(),
|
||||||
|
|
||||||
|
// Obligations
|
||||||
|
obligations: state.obligations,
|
||||||
|
upcomingObligations: compliance.getUpcomingObligations(),
|
||||||
|
overdueObligations: compliance.getOverdueObligations(),
|
||||||
|
|
||||||
|
// AI Act
|
||||||
|
aiActClassification: state.aiActClassification,
|
||||||
|
aiActRiskCategory: compliance.getAIActRiskCategory(),
|
||||||
|
isHighRiskAI: compliance.isHighRiskAI(),
|
||||||
|
|
||||||
|
// Score
|
||||||
|
complianceScore: compliance.calculateComplianceScore(),
|
||||||
|
|
||||||
|
// Risks
|
||||||
|
risks: state.risks,
|
||||||
|
criticalRisks: compliance.getCriticalRisks(),
|
||||||
|
averageRiskScore: compliance.getAverageRiskScore(),
|
||||||
|
}),
|
||||||
|
[compliance, state]
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
export function useControls() {
|
||||||
|
const { compliance, state, dispatch } = useCompliance()
|
||||||
|
|
||||||
|
const addControl = useCallback(
|
||||||
|
(control: Control) => {
|
||||||
|
dispatch({ type: 'ADD_CONTROL', payload: control })
|
||||||
|
},
|
||||||
|
[dispatch]
|
||||||
|
)
|
||||||
|
|
||||||
|
const updateControl = useCallback(
|
||||||
|
(id: string, data: Partial<Control>) => {
|
||||||
|
dispatch({ type: 'UPDATE_CONTROL', payload: { id, data } })
|
||||||
|
},
|
||||||
|
[dispatch]
|
||||||
|
)
|
||||||
|
|
||||||
|
return useMemo(
|
||||||
|
() => ({
|
||||||
|
controls: state.controls,
|
||||||
|
addControl,
|
||||||
|
updateControl,
|
||||||
|
getById: compliance.getControlById.bind(compliance),
|
||||||
|
getByDomain: compliance.getControlsByDomain.bind(compliance),
|
||||||
|
getByStatus: compliance.getControlsByStatus.bind(compliance),
|
||||||
|
implementedCount: state.controls.filter(c => c.implementationStatus === 'IMPLEMENTED').length,
|
||||||
|
totalCount: state.controls.length,
|
||||||
|
complianceRate: compliance.getControlComplianceRate(),
|
||||||
|
}),
|
||||||
|
[state.controls, compliance, addControl, updateControl]
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
export function useEvidence() {
|
||||||
|
const { compliance, state, dispatch } = useCompliance()
|
||||||
|
|
||||||
|
const addEvidence = useCallback(
|
||||||
|
(evidence: Evidence) => {
|
||||||
|
dispatch({ type: 'ADD_EVIDENCE', payload: evidence })
|
||||||
|
},
|
||||||
|
[dispatch]
|
||||||
|
)
|
||||||
|
|
||||||
|
const updateEvidence = useCallback(
|
||||||
|
(id: string, data: Partial<Evidence>) => {
|
||||||
|
dispatch({ type: 'UPDATE_EVIDENCE', payload: { id, data } })
|
||||||
|
},
|
||||||
|
[dispatch]
|
||||||
|
)
|
||||||
|
|
||||||
|
const deleteEvidence = useCallback(
|
||||||
|
(id: string) => {
|
||||||
|
dispatch({ type: 'DELETE_EVIDENCE', payload: id })
|
||||||
|
},
|
||||||
|
[dispatch]
|
||||||
|
)
|
||||||
|
|
||||||
|
return useMemo(
|
||||||
|
() => ({
|
||||||
|
evidence: state.evidence,
|
||||||
|
addEvidence,
|
||||||
|
updateEvidence,
|
||||||
|
deleteEvidence,
|
||||||
|
getByControlId: compliance.getEvidenceByControlId.bind(compliance),
|
||||||
|
expiringEvidence: compliance.getExpiringEvidence(),
|
||||||
|
activeCount: state.evidence.filter(e => e.status === 'ACTIVE').length,
|
||||||
|
totalCount: state.evidence.length,
|
||||||
|
}),
|
||||||
|
[state.evidence, compliance, addEvidence, updateEvidence, deleteEvidence]
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
export function useRisks() {
|
||||||
|
const { compliance, state, dispatch } = useCompliance()
|
||||||
|
|
||||||
|
const addRisk = useCallback(
|
||||||
|
(risk: Risk) => {
|
||||||
|
dispatch({ type: 'ADD_RISK', payload: risk })
|
||||||
|
},
|
||||||
|
[dispatch]
|
||||||
|
)
|
||||||
|
|
||||||
|
const updateRisk = useCallback(
|
||||||
|
(id: string, data: Partial<Risk>) => {
|
||||||
|
dispatch({ type: 'UPDATE_RISK', payload: { id, data } })
|
||||||
|
},
|
||||||
|
[dispatch]
|
||||||
|
)
|
||||||
|
|
||||||
|
const deleteRisk = useCallback(
|
||||||
|
(id: string) => {
|
||||||
|
dispatch({ type: 'DELETE_RISK', payload: id })
|
||||||
|
},
|
||||||
|
[dispatch]
|
||||||
|
)
|
||||||
|
|
||||||
|
return useMemo(
|
||||||
|
() => ({
|
||||||
|
risks: state.risks,
|
||||||
|
addRisk,
|
||||||
|
updateRisk,
|
||||||
|
deleteRisk,
|
||||||
|
criticalRisks: compliance.getCriticalRisks(),
|
||||||
|
getByStatus: compliance.getRisksByStatus.bind(compliance),
|
||||||
|
getBySeverity: compliance.getRisksBySeverity.bind(compliance),
|
||||||
|
averageScore: compliance.getAverageRiskScore(),
|
||||||
|
}),
|
||||||
|
[state.risks, compliance, addRisk, updateRisk, deleteRisk]
|
||||||
|
)
|
||||||
|
}
|
||||||
31
breakpilot-compliance-sdk/packages/react/src/hooks-core.ts
Normal file
31
breakpilot-compliance-sdk/packages/react/src/hooks-core.ts
Normal file
@@ -0,0 +1,31 @@
|
|||||||
|
'use client'
|
||||||
|
|
||||||
|
import { useContext } from 'react'
|
||||||
|
import { ComplianceContext, type ComplianceContextValue } from './provider'
|
||||||
|
import type { SDKState, SDKAction } from '@breakpilot/compliance-sdk-types'
|
||||||
|
|
||||||
|
// =============================================================================
|
||||||
|
// MAIN HOOK
|
||||||
|
// =============================================================================
|
||||||
|
|
||||||
|
export function useCompliance(): ComplianceContextValue {
|
||||||
|
const context = useContext(ComplianceContext)
|
||||||
|
if (!context) {
|
||||||
|
throw new Error('useCompliance must be used within ComplianceProvider')
|
||||||
|
}
|
||||||
|
return context
|
||||||
|
}
|
||||||
|
|
||||||
|
// =============================================================================
|
||||||
|
// STATE HOOK
|
||||||
|
// =============================================================================
|
||||||
|
|
||||||
|
export function useComplianceState(): SDKState {
|
||||||
|
const { state } = useCompliance()
|
||||||
|
return state
|
||||||
|
}
|
||||||
|
|
||||||
|
export function useComplianceDispatch(): React.Dispatch<SDKAction> {
|
||||||
|
const { dispatch } = useCompliance()
|
||||||
|
return dispatch
|
||||||
|
}
|
||||||
83
breakpilot-compliance-sdk/packages/react/src/hooks-dsgvo.ts
Normal file
83
breakpilot-compliance-sdk/packages/react/src/hooks-dsgvo.ts
Normal file
@@ -0,0 +1,83 @@
|
|||||||
|
'use client'
|
||||||
|
|
||||||
|
import { useMemo } from 'react'
|
||||||
|
import { useCompliance } from './hooks-core'
|
||||||
|
import type { ConsentPurpose } from '@breakpilot/compliance-sdk-types'
|
||||||
|
|
||||||
|
// =============================================================================
|
||||||
|
// DSGVO HOOKS
|
||||||
|
// =============================================================================
|
||||||
|
|
||||||
|
export function useDSGVO() {
|
||||||
|
const { dsgvo, state } = useCompliance()
|
||||||
|
|
||||||
|
return useMemo(
|
||||||
|
() => ({
|
||||||
|
// DSR
|
||||||
|
dsrRequests: state.dsrRequests,
|
||||||
|
dsrConfig: state.dsrConfig,
|
||||||
|
submitDSR: dsgvo.submitDSR.bind(dsgvo),
|
||||||
|
|
||||||
|
// Consent
|
||||||
|
consents: state.consents,
|
||||||
|
hasConsent: dsgvo.hasConsent.bind(dsgvo),
|
||||||
|
getConsentsByUserId: dsgvo.getConsentsByUserId.bind(dsgvo),
|
||||||
|
|
||||||
|
// VVT
|
||||||
|
processingActivities: state.vvt,
|
||||||
|
getProcessingActivityById: dsgvo.getProcessingActivityById.bind(dsgvo),
|
||||||
|
|
||||||
|
// DSFA
|
||||||
|
dsfa: state.dsfa,
|
||||||
|
isDSFARequired: dsgvo.isDSFARequired.bind(dsgvo),
|
||||||
|
|
||||||
|
// TOMs
|
||||||
|
toms: state.toms,
|
||||||
|
getTOMsByCategory: dsgvo.getTOMsByCategory.bind(dsgvo),
|
||||||
|
getTOMScore: dsgvo.getTOMScore.bind(dsgvo),
|
||||||
|
|
||||||
|
// Retention
|
||||||
|
retentionPolicies: state.retentionPolicies,
|
||||||
|
getUpcomingDeletions: dsgvo.getUpcomingDeletions.bind(dsgvo),
|
||||||
|
|
||||||
|
// Cookie Banner
|
||||||
|
cookieBanner: state.cookieBanner,
|
||||||
|
generateCookieBannerCode: dsgvo.generateCookieBannerCode.bind(dsgvo),
|
||||||
|
}),
|
||||||
|
[dsgvo, state]
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
export function useConsent(userId: string) {
|
||||||
|
const { dsgvo, state } = useCompliance()
|
||||||
|
|
||||||
|
return useMemo(() => {
|
||||||
|
const userConsents = state.consents.filter(c => c.userId === userId)
|
||||||
|
|
||||||
|
return {
|
||||||
|
consents: userConsents,
|
||||||
|
hasConsent: (purpose: ConsentPurpose) => dsgvo.hasConsent(userId, purpose),
|
||||||
|
hasAnalyticsConsent: dsgvo.hasConsent(userId, 'ANALYTICS'),
|
||||||
|
hasMarketingConsent: dsgvo.hasConsent(userId, 'MARKETING'),
|
||||||
|
hasFunctionalConsent: dsgvo.hasConsent(userId, 'FUNCTIONAL'),
|
||||||
|
}
|
||||||
|
}, [dsgvo, state.consents, userId])
|
||||||
|
}
|
||||||
|
|
||||||
|
export function useDSR() {
|
||||||
|
const { dsgvo, state } = useCompliance()
|
||||||
|
|
||||||
|
return useMemo(
|
||||||
|
() => ({
|
||||||
|
requests: state.dsrRequests,
|
||||||
|
config: state.dsrConfig,
|
||||||
|
submitRequest: dsgvo.submitDSR.bind(dsgvo),
|
||||||
|
pendingRequests: state.dsrRequests.filter(r => r.status !== 'COMPLETED' && r.status !== 'REJECTED'),
|
||||||
|
overdueRequests: state.dsrRequests.filter(r => {
|
||||||
|
if (r.status === 'COMPLETED' || r.status === 'REJECTED') return false
|
||||||
|
return new Date(r.dueDate) < new Date()
|
||||||
|
}),
|
||||||
|
}),
|
||||||
|
[dsgvo, state.dsrRequests, state.dsrConfig]
|
||||||
|
)
|
||||||
|
}
|
||||||
@@ -0,0 +1,71 @@
|
|||||||
|
'use client'
|
||||||
|
|
||||||
|
import { useMemo } from 'react'
|
||||||
|
import { useCompliance } from './hooks-core'
|
||||||
|
|
||||||
|
// =============================================================================
|
||||||
|
// RAG HOOKS
|
||||||
|
// =============================================================================
|
||||||
|
|
||||||
|
export function useRAG() {
|
||||||
|
const { rag } = useCompliance()
|
||||||
|
|
||||||
|
return useMemo(
|
||||||
|
() => ({
|
||||||
|
search: rag.search.bind(rag),
|
||||||
|
searchByRegulation: rag.searchByRegulation.bind(rag),
|
||||||
|
searchByArticle: rag.searchByArticle.bind(rag),
|
||||||
|
ask: rag.ask.bind(rag),
|
||||||
|
askAboutRegulation: rag.askAboutRegulation.bind(rag),
|
||||||
|
explainArticle: rag.explainArticle.bind(rag),
|
||||||
|
checkCompliance: rag.checkCompliance.bind(rag),
|
||||||
|
getQuickAnswer: rag.getQuickAnswer.bind(rag),
|
||||||
|
findRelevantArticles: rag.findRelevantArticles.bind(rag),
|
||||||
|
availableRegulations: rag.getAvailableRegulations(),
|
||||||
|
chatHistory: rag.getChatHistory(),
|
||||||
|
clearChatHistory: rag.clearChatHistory.bind(rag),
|
||||||
|
startNewSession: rag.startNewSession.bind(rag),
|
||||||
|
}),
|
||||||
|
[rag]
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
// =============================================================================
|
||||||
|
// SECURITY HOOKS
|
||||||
|
// =============================================================================
|
||||||
|
|
||||||
|
export function useSecurity() {
|
||||||
|
const { security, state } = useCompliance()
|
||||||
|
|
||||||
|
return useMemo(
|
||||||
|
() => ({
|
||||||
|
// SBOM
|
||||||
|
sbom: state.sbom,
|
||||||
|
components: security.getComponents(),
|
||||||
|
vulnerableComponents: security.getVulnerableComponents(),
|
||||||
|
licenseSummary: security.getLicenseSummary(),
|
||||||
|
|
||||||
|
// Issues
|
||||||
|
issues: state.securityIssues,
|
||||||
|
openIssues: security.getOpenIssues(),
|
||||||
|
criticalIssues: security.getCriticalIssues(),
|
||||||
|
getIssuesBySeverity: security.getIssuesBySeverity.bind(security),
|
||||||
|
getIssuesByTool: security.getIssuesByTool.bind(security),
|
||||||
|
|
||||||
|
// Backlog
|
||||||
|
backlog: state.securityBacklog,
|
||||||
|
overdueBacklogItems: security.getOverdueBacklogItems(),
|
||||||
|
|
||||||
|
// Scanning
|
||||||
|
startScan: security.startScan.bind(security),
|
||||||
|
getScanResult: security.getScanResult.bind(security),
|
||||||
|
lastScanResult: security.getLastScanResult(),
|
||||||
|
|
||||||
|
// Summary
|
||||||
|
summary: security.getSecuritySummary(),
|
||||||
|
securityScore: security.getSecurityScore(),
|
||||||
|
availableTools: security.getAvailableTools(),
|
||||||
|
}),
|
||||||
|
[security, state]
|
||||||
|
)
|
||||||
|
}
|
||||||
132
breakpilot-compliance-sdk/packages/react/src/hooks-ui.ts
Normal file
132
breakpilot-compliance-sdk/packages/react/src/hooks-ui.ts
Normal file
@@ -0,0 +1,132 @@
|
|||||||
|
'use client'
|
||||||
|
|
||||||
|
import { useMemo } from 'react'
|
||||||
|
import { useCompliance } from './hooks-core'
|
||||||
|
|
||||||
|
// =============================================================================
|
||||||
|
// NAVIGATION HOOKS
|
||||||
|
// =============================================================================
|
||||||
|
|
||||||
|
export function useSDKNavigation() {
|
||||||
|
const {
|
||||||
|
currentStep,
|
||||||
|
goToStep,
|
||||||
|
goToNextStep,
|
||||||
|
goToPreviousStep,
|
||||||
|
canGoNext,
|
||||||
|
canGoPrevious,
|
||||||
|
completionPercentage,
|
||||||
|
phase1Completion,
|
||||||
|
phase2Completion,
|
||||||
|
state,
|
||||||
|
} = useCompliance()
|
||||||
|
|
||||||
|
return useMemo(
|
||||||
|
() => ({
|
||||||
|
currentStep,
|
||||||
|
currentPhase: state.currentPhase,
|
||||||
|
completedSteps: state.completedSteps,
|
||||||
|
goToStep,
|
||||||
|
goToNextStep,
|
||||||
|
goToPreviousStep,
|
||||||
|
canGoNext,
|
||||||
|
canGoPrevious,
|
||||||
|
completionPercentage,
|
||||||
|
phase1Completion,
|
||||||
|
phase2Completion,
|
||||||
|
}),
|
||||||
|
[
|
||||||
|
currentStep,
|
||||||
|
state.currentPhase,
|
||||||
|
state.completedSteps,
|
||||||
|
goToStep,
|
||||||
|
goToNextStep,
|
||||||
|
goToPreviousStep,
|
||||||
|
canGoNext,
|
||||||
|
canGoPrevious,
|
||||||
|
completionPercentage,
|
||||||
|
phase1Completion,
|
||||||
|
phase2Completion,
|
||||||
|
]
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
// =============================================================================
|
||||||
|
// SYNC HOOKS
|
||||||
|
// =============================================================================
|
||||||
|
|
||||||
|
export function useSync() {
|
||||||
|
const { syncState, forceSyncToServer, isOnline, saveState, loadState } = useCompliance()
|
||||||
|
|
||||||
|
return useMemo(
|
||||||
|
() => ({
|
||||||
|
status: syncState.status,
|
||||||
|
lastSyncedAt: syncState.lastSyncedAt,
|
||||||
|
pendingChanges: syncState.pendingChanges,
|
||||||
|
error: syncState.error,
|
||||||
|
isOnline,
|
||||||
|
isSyncing: syncState.status === 'syncing',
|
||||||
|
hasConflict: syncState.status === 'conflict',
|
||||||
|
forceSyncToServer,
|
||||||
|
saveState,
|
||||||
|
loadState,
|
||||||
|
}),
|
||||||
|
[syncState, isOnline, forceSyncToServer, saveState, loadState]
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
// =============================================================================
|
||||||
|
// CHECKPOINT HOOKS
|
||||||
|
// =============================================================================
|
||||||
|
|
||||||
|
export function useCheckpoints() {
|
||||||
|
const { validateCheckpoint, overrideCheckpoint, getCheckpointStatus, state } = useCompliance()
|
||||||
|
|
||||||
|
return useMemo(
|
||||||
|
() => ({
|
||||||
|
checkpoints: state.checkpoints,
|
||||||
|
validateCheckpoint,
|
||||||
|
overrideCheckpoint,
|
||||||
|
getCheckpointStatus,
|
||||||
|
passedCheckpoints: Object.values(state.checkpoints).filter(c => c.passed).length,
|
||||||
|
totalCheckpoints: Object.keys(state.checkpoints).length,
|
||||||
|
}),
|
||||||
|
[state.checkpoints, validateCheckpoint, overrideCheckpoint, getCheckpointStatus]
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
// =============================================================================
|
||||||
|
// EXPORT HOOKS
|
||||||
|
// =============================================================================
|
||||||
|
|
||||||
|
export function useExport() {
|
||||||
|
const { exportState } = useCompliance()
|
||||||
|
|
||||||
|
return useMemo(
|
||||||
|
() => ({
|
||||||
|
exportJSON: () => exportState('json'),
|
||||||
|
exportPDF: () => exportState('pdf'),
|
||||||
|
exportZIP: () => exportState('zip'),
|
||||||
|
exportState,
|
||||||
|
}),
|
||||||
|
[exportState]
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
// =============================================================================
|
||||||
|
// COMMAND BAR HOOK
|
||||||
|
// =============================================================================
|
||||||
|
|
||||||
|
export function useCommandBar() {
|
||||||
|
const { isCommandBarOpen, setCommandBarOpen } = useCompliance()
|
||||||
|
|
||||||
|
return useMemo(
|
||||||
|
() => ({
|
||||||
|
isOpen: isCommandBarOpen,
|
||||||
|
open: () => setCommandBarOpen(true),
|
||||||
|
close: () => setCommandBarOpen(false),
|
||||||
|
toggle: () => setCommandBarOpen(!isCommandBarOpen),
|
||||||
|
}),
|
||||||
|
[isCommandBarOpen, setCommandBarOpen]
|
||||||
|
)
|
||||||
|
}
|
||||||
@@ -1,474 +1,43 @@
|
|||||||
'use client'
|
'use client'
|
||||||
|
|
||||||
import { useContext, useMemo, useCallback } from 'react'
|
/**
|
||||||
import { ComplianceContext, type ComplianceContextValue } from './provider'
|
* Public re-export barrel for all React hooks.
|
||||||
import type {
|
* All hooks remain importable from this path — split into focused sibling files:
|
||||||
SDKState,
|
* hooks-core.ts — useCompliance, useComplianceState, useComplianceDispatch
|
||||||
SDKAction,
|
* hooks-dsgvo.ts — useDSGVO, useConsent, useDSR
|
||||||
Control,
|
* hooks-compliance.ts — useComplianceModule, useControls, useEvidence, useRisks
|
||||||
Evidence,
|
* hooks-rag-security.ts — useRAG, useSecurity
|
||||||
Risk,
|
* hooks-ui.ts — useSDKNavigation, useSync, useCheckpoints, useExport, useCommandBar
|
||||||
Requirement,
|
*/
|
||||||
Obligation,
|
|
||||||
TOM,
|
export {
|
||||||
ProcessingActivity,
|
useCompliance,
|
||||||
ConsentPurpose,
|
useComplianceState,
|
||||||
} from '@breakpilot/compliance-sdk-types'
|
useComplianceDispatch,
|
||||||
|
} from './hooks-core'
|
||||||
// =============================================================================
|
|
||||||
// MAIN HOOK
|
export {
|
||||||
// =============================================================================
|
useDSGVO,
|
||||||
|
useConsent,
|
||||||
export function useCompliance(): ComplianceContextValue {
|
useDSR,
|
||||||
const context = useContext(ComplianceContext)
|
} from './hooks-dsgvo'
|
||||||
if (!context) {
|
|
||||||
throw new Error('useCompliance must be used within ComplianceProvider')
|
export {
|
||||||
}
|
useComplianceModule,
|
||||||
return context
|
useControls,
|
||||||
}
|
useEvidence,
|
||||||
|
useRisks,
|
||||||
// =============================================================================
|
} from './hooks-compliance'
|
||||||
// STATE HOOK
|
|
||||||
// =============================================================================
|
export {
|
||||||
|
useRAG,
|
||||||
export function useComplianceState(): SDKState {
|
useSecurity,
|
||||||
const { state } = useCompliance()
|
} from './hooks-rag-security'
|
||||||
return state
|
|
||||||
}
|
export {
|
||||||
|
useSDKNavigation,
|
||||||
export function useComplianceDispatch(): React.Dispatch<SDKAction> {
|
useSync,
|
||||||
const { dispatch } = useCompliance()
|
useCheckpoints,
|
||||||
return dispatch
|
useExport,
|
||||||
}
|
useCommandBar,
|
||||||
|
} from './hooks-ui'
|
||||||
// =============================================================================
|
|
||||||
// DSGVO HOOKS
|
|
||||||
// =============================================================================
|
|
||||||
|
|
||||||
export function useDSGVO() {
|
|
||||||
const { dsgvo, state } = useCompliance()
|
|
||||||
|
|
||||||
return useMemo(
|
|
||||||
() => ({
|
|
||||||
// DSR
|
|
||||||
dsrRequests: state.dsrRequests,
|
|
||||||
dsrConfig: state.dsrConfig,
|
|
||||||
submitDSR: dsgvo.submitDSR.bind(dsgvo),
|
|
||||||
|
|
||||||
// Consent
|
|
||||||
consents: state.consents,
|
|
||||||
hasConsent: dsgvo.hasConsent.bind(dsgvo),
|
|
||||||
getConsentsByUserId: dsgvo.getConsentsByUserId.bind(dsgvo),
|
|
||||||
|
|
||||||
// VVT
|
|
||||||
processingActivities: state.vvt,
|
|
||||||
getProcessingActivityById: dsgvo.getProcessingActivityById.bind(dsgvo),
|
|
||||||
|
|
||||||
// DSFA
|
|
||||||
dsfa: state.dsfa,
|
|
||||||
isDSFARequired: dsgvo.isDSFARequired.bind(dsgvo),
|
|
||||||
|
|
||||||
// TOMs
|
|
||||||
toms: state.toms,
|
|
||||||
getTOMsByCategory: dsgvo.getTOMsByCategory.bind(dsgvo),
|
|
||||||
getTOMScore: dsgvo.getTOMScore.bind(dsgvo),
|
|
||||||
|
|
||||||
// Retention
|
|
||||||
retentionPolicies: state.retentionPolicies,
|
|
||||||
getUpcomingDeletions: dsgvo.getUpcomingDeletions.bind(dsgvo),
|
|
||||||
|
|
||||||
// Cookie Banner
|
|
||||||
cookieBanner: state.cookieBanner,
|
|
||||||
generateCookieBannerCode: dsgvo.generateCookieBannerCode.bind(dsgvo),
|
|
||||||
}),
|
|
||||||
[dsgvo, state]
|
|
||||||
)
|
|
||||||
}
|
|
||||||
|
|
||||||
export function useConsent(userId: string) {
|
|
||||||
const { dsgvo, state } = useCompliance()
|
|
||||||
|
|
||||||
return useMemo(() => {
|
|
||||||
const userConsents = state.consents.filter(c => c.userId === userId)
|
|
||||||
|
|
||||||
return {
|
|
||||||
consents: userConsents,
|
|
||||||
hasConsent: (purpose: ConsentPurpose) => dsgvo.hasConsent(userId, purpose),
|
|
||||||
hasAnalyticsConsent: dsgvo.hasConsent(userId, 'ANALYTICS'),
|
|
||||||
hasMarketingConsent: dsgvo.hasConsent(userId, 'MARKETING'),
|
|
||||||
hasFunctionalConsent: dsgvo.hasConsent(userId, 'FUNCTIONAL'),
|
|
||||||
}
|
|
||||||
}, [dsgvo, state.consents, userId])
|
|
||||||
}
|
|
||||||
|
|
||||||
export function useDSR() {
|
|
||||||
const { dsgvo, state, dispatch } = useCompliance()
|
|
||||||
|
|
||||||
return useMemo(
|
|
||||||
() => ({
|
|
||||||
requests: state.dsrRequests,
|
|
||||||
config: state.dsrConfig,
|
|
||||||
submitRequest: dsgvo.submitDSR.bind(dsgvo),
|
|
||||||
pendingRequests: state.dsrRequests.filter(r => r.status !== 'COMPLETED' && r.status !== 'REJECTED'),
|
|
||||||
overdueRequests: state.dsrRequests.filter(r => {
|
|
||||||
if (r.status === 'COMPLETED' || r.status === 'REJECTED') return false
|
|
||||||
return new Date(r.dueDate) < new Date()
|
|
||||||
}),
|
|
||||||
}),
|
|
||||||
[dsgvo, state.dsrRequests, state.dsrConfig]
|
|
||||||
)
|
|
||||||
}
|
|
||||||
|
|
||||||
// =============================================================================
|
|
||||||
// COMPLIANCE HOOKS
|
|
||||||
// =============================================================================
|
|
||||||
|
|
||||||
export function useComplianceModule() {
|
|
||||||
const { compliance, state } = useCompliance()
|
|
||||||
|
|
||||||
return useMemo(
|
|
||||||
() => ({
|
|
||||||
// Controls
|
|
||||||
controls: state.controls,
|
|
||||||
getControlById: compliance.getControlById.bind(compliance),
|
|
||||||
getControlsByDomain: compliance.getControlsByDomain.bind(compliance),
|
|
||||||
getControlsByStatus: compliance.getControlsByStatus.bind(compliance),
|
|
||||||
controlComplianceRate: compliance.getControlComplianceRate(),
|
|
||||||
|
|
||||||
// Evidence
|
|
||||||
evidence: state.evidence,
|
|
||||||
getEvidenceByControlId: compliance.getEvidenceByControlId.bind(compliance),
|
|
||||||
expiringEvidence: compliance.getExpiringEvidence(),
|
|
||||||
|
|
||||||
// Requirements
|
|
||||||
requirements: state.requirements,
|
|
||||||
getRequirementsByRegulation: compliance.getRequirementsByRegulation.bind(compliance),
|
|
||||||
requirementComplianceRate: compliance.getRequirementComplianceRate(),
|
|
||||||
|
|
||||||
// Obligations
|
|
||||||
obligations: state.obligations,
|
|
||||||
upcomingObligations: compliance.getUpcomingObligations(),
|
|
||||||
overdueObligations: compliance.getOverdueObligations(),
|
|
||||||
|
|
||||||
// AI Act
|
|
||||||
aiActClassification: state.aiActClassification,
|
|
||||||
aiActRiskCategory: compliance.getAIActRiskCategory(),
|
|
||||||
isHighRiskAI: compliance.isHighRiskAI(),
|
|
||||||
|
|
||||||
// Score
|
|
||||||
complianceScore: compliance.calculateComplianceScore(),
|
|
||||||
|
|
||||||
// Risks
|
|
||||||
risks: state.risks,
|
|
||||||
criticalRisks: compliance.getCriticalRisks(),
|
|
||||||
averageRiskScore: compliance.getAverageRiskScore(),
|
|
||||||
}),
|
|
||||||
[compliance, state]
|
|
||||||
)
|
|
||||||
}
|
|
||||||
|
|
||||||
export function useControls() {
|
|
||||||
const { compliance, state, dispatch } = useCompliance()
|
|
||||||
|
|
||||||
const addControl = useCallback(
|
|
||||||
(control: Control) => {
|
|
||||||
dispatch({ type: 'ADD_CONTROL', payload: control })
|
|
||||||
},
|
|
||||||
[dispatch]
|
|
||||||
)
|
|
||||||
|
|
||||||
const updateControl = useCallback(
|
|
||||||
(id: string, data: Partial<Control>) => {
|
|
||||||
dispatch({ type: 'UPDATE_CONTROL', payload: { id, data } })
|
|
||||||
},
|
|
||||||
[dispatch]
|
|
||||||
)
|
|
||||||
|
|
||||||
return useMemo(
|
|
||||||
() => ({
|
|
||||||
controls: state.controls,
|
|
||||||
addControl,
|
|
||||||
updateControl,
|
|
||||||
getById: compliance.getControlById.bind(compliance),
|
|
||||||
getByDomain: compliance.getControlsByDomain.bind(compliance),
|
|
||||||
getByStatus: compliance.getControlsByStatus.bind(compliance),
|
|
||||||
implementedCount: state.controls.filter(c => c.implementationStatus === 'IMPLEMENTED').length,
|
|
||||||
totalCount: state.controls.length,
|
|
||||||
complianceRate: compliance.getControlComplianceRate(),
|
|
||||||
}),
|
|
||||||
[state.controls, compliance, addControl, updateControl]
|
|
||||||
)
|
|
||||||
}
|
|
||||||
|
|
||||||
export function useEvidence() {
|
|
||||||
const { compliance, state, dispatch } = useCompliance()
|
|
||||||
|
|
||||||
const addEvidence = useCallback(
|
|
||||||
(evidence: Evidence) => {
|
|
||||||
dispatch({ type: 'ADD_EVIDENCE', payload: evidence })
|
|
||||||
},
|
|
||||||
[dispatch]
|
|
||||||
)
|
|
||||||
|
|
||||||
const updateEvidence = useCallback(
|
|
||||||
(id: string, data: Partial<Evidence>) => {
|
|
||||||
dispatch({ type: 'UPDATE_EVIDENCE', payload: { id, data } })
|
|
||||||
},
|
|
||||||
[dispatch]
|
|
||||||
)
|
|
||||||
|
|
||||||
const deleteEvidence = useCallback(
|
|
||||||
(id: string) => {
|
|
||||||
dispatch({ type: 'DELETE_EVIDENCE', payload: id })
|
|
||||||
},
|
|
||||||
[dispatch]
|
|
||||||
)
|
|
||||||
|
|
||||||
return useMemo(
|
|
||||||
() => ({
|
|
||||||
evidence: state.evidence,
|
|
||||||
addEvidence,
|
|
||||||
updateEvidence,
|
|
||||||
deleteEvidence,
|
|
||||||
getByControlId: compliance.getEvidenceByControlId.bind(compliance),
|
|
||||||
expiringEvidence: compliance.getExpiringEvidence(),
|
|
||||||
activeCount: state.evidence.filter(e => e.status === 'ACTIVE').length,
|
|
||||||
totalCount: state.evidence.length,
|
|
||||||
}),
|
|
||||||
[state.evidence, compliance, addEvidence, updateEvidence, deleteEvidence]
|
|
||||||
)
|
|
||||||
}
|
|
||||||
|
|
||||||
export function useRisks() {
|
|
||||||
const { compliance, state, dispatch } = useCompliance()
|
|
||||||
|
|
||||||
const addRisk = useCallback(
|
|
||||||
(risk: Risk) => {
|
|
||||||
dispatch({ type: 'ADD_RISK', payload: risk })
|
|
||||||
},
|
|
||||||
[dispatch]
|
|
||||||
)
|
|
||||||
|
|
||||||
const updateRisk = useCallback(
|
|
||||||
(id: string, data: Partial<Risk>) => {
|
|
||||||
dispatch({ type: 'UPDATE_RISK', payload: { id, data } })
|
|
||||||
},
|
|
||||||
[dispatch]
|
|
||||||
)
|
|
||||||
|
|
||||||
const deleteRisk = useCallback(
|
|
||||||
(id: string) => {
|
|
||||||
dispatch({ type: 'DELETE_RISK', payload: id })
|
|
||||||
},
|
|
||||||
[dispatch]
|
|
||||||
)
|
|
||||||
|
|
||||||
return useMemo(
|
|
||||||
() => ({
|
|
||||||
risks: state.risks,
|
|
||||||
addRisk,
|
|
||||||
updateRisk,
|
|
||||||
deleteRisk,
|
|
||||||
criticalRisks: compliance.getCriticalRisks(),
|
|
||||||
getByStatus: compliance.getRisksByStatus.bind(compliance),
|
|
||||||
getBySeverity: compliance.getRisksBySeverity.bind(compliance),
|
|
||||||
averageScore: compliance.getAverageRiskScore(),
|
|
||||||
}),
|
|
||||||
[state.risks, compliance, addRisk, updateRisk, deleteRisk]
|
|
||||||
)
|
|
||||||
}
|
|
||||||
|
|
||||||
// =============================================================================
|
|
||||||
// RAG HOOKS
|
|
||||||
// =============================================================================
|
|
||||||
|
|
||||||
export function useRAG() {
|
|
||||||
const { rag } = useCompliance()
|
|
||||||
|
|
||||||
return useMemo(
|
|
||||||
() => ({
|
|
||||||
search: rag.search.bind(rag),
|
|
||||||
searchByRegulation: rag.searchByRegulation.bind(rag),
|
|
||||||
searchByArticle: rag.searchByArticle.bind(rag),
|
|
||||||
ask: rag.ask.bind(rag),
|
|
||||||
askAboutRegulation: rag.askAboutRegulation.bind(rag),
|
|
||||||
explainArticle: rag.explainArticle.bind(rag),
|
|
||||||
checkCompliance: rag.checkCompliance.bind(rag),
|
|
||||||
getQuickAnswer: rag.getQuickAnswer.bind(rag),
|
|
||||||
findRelevantArticles: rag.findRelevantArticles.bind(rag),
|
|
||||||
availableRegulations: rag.getAvailableRegulations(),
|
|
||||||
chatHistory: rag.getChatHistory(),
|
|
||||||
clearChatHistory: rag.clearChatHistory.bind(rag),
|
|
||||||
startNewSession: rag.startNewSession.bind(rag),
|
|
||||||
}),
|
|
||||||
[rag]
|
|
||||||
)
|
|
||||||
}
|
|
||||||
|
|
||||||
// =============================================================================
|
|
||||||
// SECURITY HOOKS
|
|
||||||
// =============================================================================
|
|
||||||
|
|
||||||
export function useSecurity() {
|
|
||||||
const { security, state } = useCompliance()
|
|
||||||
|
|
||||||
return useMemo(
|
|
||||||
() => ({
|
|
||||||
// SBOM
|
|
||||||
sbom: state.sbom,
|
|
||||||
components: security.getComponents(),
|
|
||||||
vulnerableComponents: security.getVulnerableComponents(),
|
|
||||||
licenseSummary: security.getLicenseSummary(),
|
|
||||||
|
|
||||||
// Issues
|
|
||||||
issues: state.securityIssues,
|
|
||||||
openIssues: security.getOpenIssues(),
|
|
||||||
criticalIssues: security.getCriticalIssues(),
|
|
||||||
getIssuesBySeverity: security.getIssuesBySeverity.bind(security),
|
|
||||||
getIssuesByTool: security.getIssuesByTool.bind(security),
|
|
||||||
|
|
||||||
// Backlog
|
|
||||||
backlog: state.securityBacklog,
|
|
||||||
overdueBacklogItems: security.getOverdueBacklogItems(),
|
|
||||||
|
|
||||||
// Scanning
|
|
||||||
startScan: security.startScan.bind(security),
|
|
||||||
getScanResult: security.getScanResult.bind(security),
|
|
||||||
lastScanResult: security.getLastScanResult(),
|
|
||||||
|
|
||||||
// Summary
|
|
||||||
summary: security.getSecuritySummary(),
|
|
||||||
securityScore: security.getSecurityScore(),
|
|
||||||
availableTools: security.getAvailableTools(),
|
|
||||||
}),
|
|
||||||
[security, state]
|
|
||||||
)
|
|
||||||
}
|
|
||||||
|
|
||||||
// =============================================================================
|
|
||||||
// NAVIGATION HOOKS
|
|
||||||
// =============================================================================
|
|
||||||
|
|
||||||
export function useSDKNavigation() {
|
|
||||||
const {
|
|
||||||
currentStep,
|
|
||||||
goToStep,
|
|
||||||
goToNextStep,
|
|
||||||
goToPreviousStep,
|
|
||||||
canGoNext,
|
|
||||||
canGoPrevious,
|
|
||||||
completionPercentage,
|
|
||||||
phase1Completion,
|
|
||||||
phase2Completion,
|
|
||||||
state,
|
|
||||||
} = useCompliance()
|
|
||||||
|
|
||||||
return useMemo(
|
|
||||||
() => ({
|
|
||||||
currentStep,
|
|
||||||
currentPhase: state.currentPhase,
|
|
||||||
completedSteps: state.completedSteps,
|
|
||||||
goToStep,
|
|
||||||
goToNextStep,
|
|
||||||
goToPreviousStep,
|
|
||||||
canGoNext,
|
|
||||||
canGoPrevious,
|
|
||||||
completionPercentage,
|
|
||||||
phase1Completion,
|
|
||||||
phase2Completion,
|
|
||||||
}),
|
|
||||||
[
|
|
||||||
currentStep,
|
|
||||||
state.currentPhase,
|
|
||||||
state.completedSteps,
|
|
||||||
goToStep,
|
|
||||||
goToNextStep,
|
|
||||||
goToPreviousStep,
|
|
||||||
canGoNext,
|
|
||||||
canGoPrevious,
|
|
||||||
completionPercentage,
|
|
||||||
phase1Completion,
|
|
||||||
phase2Completion,
|
|
||||||
]
|
|
||||||
)
|
|
||||||
}
|
|
||||||
|
|
||||||
// =============================================================================
|
|
||||||
// SYNC HOOKS
|
|
||||||
// =============================================================================
|
|
||||||
|
|
||||||
export function useSync() {
|
|
||||||
const { syncState, forceSyncToServer, isOnline, saveState, loadState } = useCompliance()
|
|
||||||
|
|
||||||
return useMemo(
|
|
||||||
() => ({
|
|
||||||
status: syncState.status,
|
|
||||||
lastSyncedAt: syncState.lastSyncedAt,
|
|
||||||
pendingChanges: syncState.pendingChanges,
|
|
||||||
error: syncState.error,
|
|
||||||
isOnline,
|
|
||||||
isSyncing: syncState.status === 'syncing',
|
|
||||||
hasConflict: syncState.status === 'conflict',
|
|
||||||
forceSyncToServer,
|
|
||||||
saveState,
|
|
||||||
loadState,
|
|
||||||
}),
|
|
||||||
[syncState, isOnline, forceSyncToServer, saveState, loadState]
|
|
||||||
)
|
|
||||||
}
|
|
||||||
|
|
||||||
// =============================================================================
|
|
||||||
// CHECKPOINT HOOKS
|
|
||||||
// =============================================================================
|
|
||||||
|
|
||||||
export function useCheckpoints() {
|
|
||||||
const { validateCheckpoint, overrideCheckpoint, getCheckpointStatus, state } = useCompliance()
|
|
||||||
|
|
||||||
return useMemo(
|
|
||||||
() => ({
|
|
||||||
checkpoints: state.checkpoints,
|
|
||||||
validateCheckpoint,
|
|
||||||
overrideCheckpoint,
|
|
||||||
getCheckpointStatus,
|
|
||||||
passedCheckpoints: Object.values(state.checkpoints).filter(c => c.passed).length,
|
|
||||||
totalCheckpoints: Object.keys(state.checkpoints).length,
|
|
||||||
}),
|
|
||||||
[state.checkpoints, validateCheckpoint, overrideCheckpoint, getCheckpointStatus]
|
|
||||||
)
|
|
||||||
}
|
|
||||||
|
|
||||||
// =============================================================================
|
|
||||||
// EXPORT HOOKS
|
|
||||||
// =============================================================================
|
|
||||||
|
|
||||||
export function useExport() {
|
|
||||||
const { exportState } = useCompliance()
|
|
||||||
|
|
||||||
return useMemo(
|
|
||||||
() => ({
|
|
||||||
exportJSON: () => exportState('json'),
|
|
||||||
exportPDF: () => exportState('pdf'),
|
|
||||||
exportZIP: () => exportState('zip'),
|
|
||||||
exportState,
|
|
||||||
}),
|
|
||||||
[exportState]
|
|
||||||
)
|
|
||||||
}
|
|
||||||
|
|
||||||
// =============================================================================
|
|
||||||
// COMMAND BAR HOOK
|
|
||||||
// =============================================================================
|
|
||||||
|
|
||||||
export function useCommandBar() {
|
|
||||||
const { isCommandBarOpen, setCommandBarOpen } = useCompliance()
|
|
||||||
|
|
||||||
return useMemo(
|
|
||||||
() => ({
|
|
||||||
isOpen: isCommandBarOpen,
|
|
||||||
open: () => setCommandBarOpen(true),
|
|
||||||
close: () => setCommandBarOpen(false),
|
|
||||||
toggle: () => setCommandBarOpen(!isCommandBarOpen),
|
|
||||||
}),
|
|
||||||
[isCommandBarOpen, setCommandBarOpen]
|
|
||||||
)
|
|
||||||
}
|
|
||||||
|
|||||||
@@ -0,0 +1,226 @@
|
|||||||
|
'use client'
|
||||||
|
|
||||||
|
/**
|
||||||
|
* useCallback factories extracted from ComplianceProvider.
|
||||||
|
*
|
||||||
|
* Each function creates and returns a memoised callback so provider.tsx
|
||||||
|
* stays under 300 LOC.
|
||||||
|
*/
|
||||||
|
|
||||||
|
import { useCallback, RefObject } from 'react'
|
||||||
|
import type {
|
||||||
|
SDKState,
|
||||||
|
CheckpointStatus,
|
||||||
|
UseCaseAssessment,
|
||||||
|
Risk,
|
||||||
|
Control,
|
||||||
|
} from '@breakpilot/compliance-sdk-types'
|
||||||
|
import { ComplianceClient, StateSyncManager } from '@breakpilot/compliance-sdk-core'
|
||||||
|
import { SDK_STORAGE_KEY } from './provider-context'
|
||||||
|
|
||||||
|
// =============================================================================
|
||||||
|
// CHECKPOINT CALLBACKS
|
||||||
|
// =============================================================================
|
||||||
|
|
||||||
|
export function useValidateCheckpoint(
|
||||||
|
state: SDKState,
|
||||||
|
enableBackendSync: boolean,
|
||||||
|
client: ComplianceClient,
|
||||||
|
dispatch: React.Dispatch<{ type: string; payload?: unknown }>
|
||||||
|
) {
|
||||||
|
return useCallback(
|
||||||
|
async (checkpointId: string): Promise<CheckpointStatus> => {
|
||||||
|
if (enableBackendSync) {
|
||||||
|
try {
|
||||||
|
const result = await client.validateCheckpoint(checkpointId, state)
|
||||||
|
const status: CheckpointStatus = {
|
||||||
|
checkpointId: result.checkpointId,
|
||||||
|
passed: result.passed,
|
||||||
|
validatedAt: new Date(result.validatedAt),
|
||||||
|
validatedBy: result.validatedBy,
|
||||||
|
errors: result.errors,
|
||||||
|
warnings: result.warnings,
|
||||||
|
}
|
||||||
|
dispatch({ type: 'SET_CHECKPOINT_STATUS', payload: { id: checkpointId, status } })
|
||||||
|
return status
|
||||||
|
} catch {
|
||||||
|
// Fall through to local validation
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
const status: CheckpointStatus = {
|
||||||
|
checkpointId,
|
||||||
|
passed: true,
|
||||||
|
validatedAt: new Date(),
|
||||||
|
validatedBy: 'SYSTEM',
|
||||||
|
errors: [],
|
||||||
|
warnings: [],
|
||||||
|
}
|
||||||
|
dispatch({ type: 'SET_CHECKPOINT_STATUS', payload: { id: checkpointId, status } })
|
||||||
|
return status
|
||||||
|
},
|
||||||
|
[state, enableBackendSync, client, dispatch]
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
export function useOverrideCheckpoint(
|
||||||
|
state: SDKState,
|
||||||
|
dispatch: React.Dispatch<{ type: string; payload?: unknown }>
|
||||||
|
) {
|
||||||
|
return useCallback(
|
||||||
|
async (checkpointId: string, reason: string): Promise<void> => {
|
||||||
|
const existingStatus = state.checkpoints[checkpointId]
|
||||||
|
const overriddenStatus: CheckpointStatus = {
|
||||||
|
...existingStatus,
|
||||||
|
checkpointId,
|
||||||
|
passed: true,
|
||||||
|
overrideReason: reason,
|
||||||
|
overriddenBy: state.userId,
|
||||||
|
overriddenAt: new Date(),
|
||||||
|
errors: [],
|
||||||
|
warnings: existingStatus?.warnings || [],
|
||||||
|
}
|
||||||
|
dispatch({ type: 'SET_CHECKPOINT_STATUS', payload: { id: checkpointId, status: overriddenStatus } })
|
||||||
|
},
|
||||||
|
[state.checkpoints, state.userId, dispatch]
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
export function useGetCheckpointStatus(state: SDKState) {
|
||||||
|
return useCallback(
|
||||||
|
(checkpointId: string): CheckpointStatus | undefined => state.checkpoints[checkpointId],
|
||||||
|
[state.checkpoints]
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
// =============================================================================
|
||||||
|
// STATE UPDATE CALLBACKS
|
||||||
|
// =============================================================================
|
||||||
|
|
||||||
|
export function useUpdateUseCase(dispatch: React.Dispatch<{ type: string; payload?: unknown }>) {
|
||||||
|
return useCallback(
|
||||||
|
(id: string, data: Partial<UseCaseAssessment>) => {
|
||||||
|
dispatch({ type: 'UPDATE_USE_CASE', payload: { id, data } })
|
||||||
|
},
|
||||||
|
[dispatch]
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
export function useAddRisk(dispatch: React.Dispatch<{ type: string; payload?: unknown }>) {
|
||||||
|
return useCallback(
|
||||||
|
(risk: Risk) => {
|
||||||
|
dispatch({ type: 'ADD_RISK', payload: risk })
|
||||||
|
},
|
||||||
|
[dispatch]
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
export function useUpdateControl(dispatch: React.Dispatch<{ type: string; payload?: unknown }>) {
|
||||||
|
return useCallback(
|
||||||
|
(id: string, data: Partial<Control>) => {
|
||||||
|
dispatch({ type: 'UPDATE_CONTROL', payload: { id, data } })
|
||||||
|
},
|
||||||
|
[dispatch]
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
// =============================================================================
|
||||||
|
// PERSISTENCE CALLBACKS
|
||||||
|
// =============================================================================
|
||||||
|
|
||||||
|
export function useSaveState(
|
||||||
|
state: SDKState,
|
||||||
|
tenantId: string,
|
||||||
|
enableBackendSync: boolean,
|
||||||
|
syncManagerRef: RefObject<StateSyncManager | null>,
|
||||||
|
setError: (e: Error) => void
|
||||||
|
) {
|
||||||
|
return useCallback(async (): Promise<void> => {
|
||||||
|
try {
|
||||||
|
if (typeof window !== 'undefined') {
|
||||||
|
localStorage.setItem(`${SDK_STORAGE_KEY}-${tenantId}`, JSON.stringify(state))
|
||||||
|
}
|
||||||
|
|
||||||
|
if (enableBackendSync && syncManagerRef.current) {
|
||||||
|
await syncManagerRef.current.forceSync(state)
|
||||||
|
}
|
||||||
|
} catch (err) {
|
||||||
|
setError(err as Error)
|
||||||
|
throw err
|
||||||
|
}
|
||||||
|
}, [state, tenantId, enableBackendSync, syncManagerRef, setError])
|
||||||
|
}
|
||||||
|
|
||||||
|
export function useLoadState(
|
||||||
|
tenantId: string,
|
||||||
|
enableBackendSync: boolean,
|
||||||
|
syncManagerRef: RefObject<StateSyncManager | null>,
|
||||||
|
setIsLoading: (v: boolean) => void,
|
||||||
|
dispatch: React.Dispatch<{ type: string; payload?: unknown }>,
|
||||||
|
setError: (e: Error) => void
|
||||||
|
) {
|
||||||
|
return useCallback(async (): Promise<void> => {
|
||||||
|
setIsLoading(true)
|
||||||
|
try {
|
||||||
|
if (enableBackendSync && syncManagerRef.current) {
|
||||||
|
const serverState = await syncManagerRef.current.loadFromServer()
|
||||||
|
if (serverState) {
|
||||||
|
dispatch({ type: 'SET_STATE', payload: serverState })
|
||||||
|
return
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if (typeof window !== 'undefined') {
|
||||||
|
const stored = localStorage.getItem(`${SDK_STORAGE_KEY}-${tenantId}`)
|
||||||
|
if (stored) {
|
||||||
|
dispatch({ type: 'SET_STATE', payload: JSON.parse(stored) })
|
||||||
|
}
|
||||||
|
}
|
||||||
|
} catch (err) {
|
||||||
|
setError(err as Error)
|
||||||
|
throw err
|
||||||
|
} finally {
|
||||||
|
setIsLoading(false)
|
||||||
|
}
|
||||||
|
}, [tenantId, enableBackendSync, syncManagerRef, setIsLoading, dispatch, setError])
|
||||||
|
}
|
||||||
|
|
||||||
|
export function useResetState(
|
||||||
|
tenantId: string,
|
||||||
|
dispatch: React.Dispatch<{ type: string; payload?: unknown }>
|
||||||
|
) {
|
||||||
|
return useCallback(() => {
|
||||||
|
dispatch({ type: 'RESET_STATE' })
|
||||||
|
if (typeof window !== 'undefined') {
|
||||||
|
localStorage.removeItem(`${SDK_STORAGE_KEY}-${tenantId}`)
|
||||||
|
}
|
||||||
|
}, [tenantId, dispatch])
|
||||||
|
}
|
||||||
|
|
||||||
|
// =============================================================================
|
||||||
|
// SYNC & EXPORT CALLBACKS
|
||||||
|
// =============================================================================
|
||||||
|
|
||||||
|
export function useForceSyncToServer(
|
||||||
|
state: SDKState,
|
||||||
|
enableBackendSync: boolean,
|
||||||
|
syncManagerRef: RefObject<StateSyncManager | null>
|
||||||
|
) {
|
||||||
|
return useCallback(async (): Promise<void> => {
|
||||||
|
if (enableBackendSync && syncManagerRef.current) {
|
||||||
|
await syncManagerRef.current.forceSync(state)
|
||||||
|
}
|
||||||
|
}, [state, enableBackendSync, syncManagerRef])
|
||||||
|
}
|
||||||
|
|
||||||
|
export function useExportState(state: SDKState, client: ComplianceClient) {
|
||||||
|
return useCallback(
|
||||||
|
async (format: 'json' | 'pdf' | 'zip'): Promise<Blob> => {
|
||||||
|
if (format === 'json') {
|
||||||
|
return new Blob([JSON.stringify(state, null, 2)], { type: 'application/json' })
|
||||||
|
}
|
||||||
|
return client.exportState(format)
|
||||||
|
},
|
||||||
|
[state, client]
|
||||||
|
)
|
||||||
|
}
|
||||||
203
breakpilot-compliance-sdk/packages/react/src/provider-effects.ts
Normal file
203
breakpilot-compliance-sdk/packages/react/src/provider-effects.ts
Normal file
@@ -0,0 +1,203 @@
|
|||||||
|
'use client'
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Side-effect hooks extracted from ComplianceProvider.
|
||||||
|
*
|
||||||
|
* Each function encapsulates one useEffect concern so provider.tsx
|
||||||
|
* stays under 300 LOC.
|
||||||
|
*/
|
||||||
|
|
||||||
|
import { useEffect, RefObject } from 'react'
|
||||||
|
import type { SDKState } from '@breakpilot/compliance-sdk-types'
|
||||||
|
import {
|
||||||
|
ComplianceClient,
|
||||||
|
StateSyncManager,
|
||||||
|
createStateSyncManager,
|
||||||
|
} from '@breakpilot/compliance-sdk-core'
|
||||||
|
import { SDK_STORAGE_KEY } from './provider-context'
|
||||||
|
|
||||||
|
// =============================================================================
|
||||||
|
// TYPES
|
||||||
|
// =============================================================================
|
||||||
|
|
||||||
|
export interface SyncStateSetters {
|
||||||
|
setSyncState: React.Dispatch<React.SetStateAction<{
|
||||||
|
status: string
|
||||||
|
lastSyncedAt: Date | null
|
||||||
|
localVersion: number
|
||||||
|
serverVersion: number
|
||||||
|
pendingChanges: number
|
||||||
|
error: string | null
|
||||||
|
}>>
|
||||||
|
setIsOnline: (v: boolean) => void
|
||||||
|
setError: (e: Error | null) => void
|
||||||
|
dispatch: React.Dispatch<{ type: string; payload?: unknown }>
|
||||||
|
}
|
||||||
|
|
||||||
|
// =============================================================================
|
||||||
|
// SYNC MANAGER EFFECT
|
||||||
|
// =============================================================================
|
||||||
|
|
||||||
|
export function useSyncManagerEffect(
|
||||||
|
enableBackendSync: boolean,
|
||||||
|
tenantId: string,
|
||||||
|
client: ComplianceClient,
|
||||||
|
state: SDKState,
|
||||||
|
syncManagerRef: RefObject<StateSyncManager | null>,
|
||||||
|
{ setSyncState, setIsOnline, setError, dispatch }: SyncStateSetters
|
||||||
|
): void {
|
||||||
|
useEffect(() => {
|
||||||
|
if (enableBackendSync && typeof window !== 'undefined') {
|
||||||
|
syncManagerRef.current = createStateSyncManager(
|
||||||
|
client,
|
||||||
|
tenantId,
|
||||||
|
{ debounceMs: 2000, maxRetries: 3 },
|
||||||
|
{
|
||||||
|
onSyncStart: () => {
|
||||||
|
setSyncState(prev => ({ ...prev, status: 'syncing' }))
|
||||||
|
},
|
||||||
|
onSyncComplete: syncedState => {
|
||||||
|
setSyncState(prev => ({
|
||||||
|
...prev,
|
||||||
|
status: 'idle',
|
||||||
|
lastSyncedAt: new Date(),
|
||||||
|
pendingChanges: 0,
|
||||||
|
}))
|
||||||
|
if (new Date(syncedState.lastModified) > new Date(state.lastModified)) {
|
||||||
|
dispatch({ type: 'SET_STATE', payload: syncedState })
|
||||||
|
}
|
||||||
|
},
|
||||||
|
onSyncError: err => {
|
||||||
|
setSyncState(prev => ({ ...prev, status: 'error', error: err.message }))
|
||||||
|
setError(err)
|
||||||
|
},
|
||||||
|
onConflict: () => {
|
||||||
|
setSyncState(prev => ({ ...prev, status: 'conflict' }))
|
||||||
|
},
|
||||||
|
onOffline: () => {
|
||||||
|
setIsOnline(false)
|
||||||
|
setSyncState(prev => ({ ...prev, status: 'offline' }))
|
||||||
|
},
|
||||||
|
onOnline: () => {
|
||||||
|
setIsOnline(true)
|
||||||
|
setSyncState(prev => ({ ...prev, status: 'idle' }))
|
||||||
|
},
|
||||||
|
}
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
return () => {
|
||||||
|
syncManagerRef.current?.destroy()
|
||||||
|
}
|
||||||
|
// eslint-disable-next-line react-hooks/exhaustive-deps
|
||||||
|
}, [enableBackendSync, tenantId, client])
|
||||||
|
}
|
||||||
|
|
||||||
|
// =============================================================================
|
||||||
|
// LOAD INITIAL STATE EFFECT
|
||||||
|
// =============================================================================
|
||||||
|
|
||||||
|
export interface LoadStateSetters {
|
||||||
|
setIsLoading: (v: boolean) => void
|
||||||
|
setIsInitialized: (v: boolean) => void
|
||||||
|
setError: (e: Error | null) => void
|
||||||
|
dispatch: React.Dispatch<{ type: string; payload?: unknown }>
|
||||||
|
onError?: (e: Error) => void
|
||||||
|
}
|
||||||
|
|
||||||
|
export function useLoadInitialStateEffect(
|
||||||
|
tenantId: string,
|
||||||
|
enableBackendSync: boolean,
|
||||||
|
syncManagerRef: RefObject<StateSyncManager | null>,
|
||||||
|
{ setIsLoading, setIsInitialized, setError, dispatch, onError }: LoadStateSetters
|
||||||
|
): void {
|
||||||
|
useEffect(() => {
|
||||||
|
const loadInitialState = async () => {
|
||||||
|
setIsLoading(true)
|
||||||
|
try {
|
||||||
|
if (typeof window !== 'undefined') {
|
||||||
|
const stored = localStorage.getItem(`${SDK_STORAGE_KEY}-${tenantId}`)
|
||||||
|
if (stored) {
|
||||||
|
const parsed = JSON.parse(stored)
|
||||||
|
if (parsed.lastModified) {
|
||||||
|
parsed.lastModified = new Date(parsed.lastModified)
|
||||||
|
}
|
||||||
|
dispatch({ type: 'SET_STATE', payload: parsed })
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if (enableBackendSync && syncManagerRef.current) {
|
||||||
|
const serverState = await syncManagerRef.current.loadFromServer()
|
||||||
|
if (serverState) {
|
||||||
|
dispatch({ type: 'SET_STATE', payload: serverState })
|
||||||
|
}
|
||||||
|
}
|
||||||
|
} catch (err) {
|
||||||
|
setError(err as Error)
|
||||||
|
onError?.(err as Error)
|
||||||
|
} finally {
|
||||||
|
setIsLoading(false)
|
||||||
|
setIsInitialized(true)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
loadInitialState()
|
||||||
|
// eslint-disable-next-line react-hooks/exhaustive-deps
|
||||||
|
}, [tenantId, enableBackendSync])
|
||||||
|
}
|
||||||
|
|
||||||
|
// =============================================================================
|
||||||
|
// AUTO-SAVE EFFECT
|
||||||
|
// =============================================================================
|
||||||
|
|
||||||
|
export function useAutoSaveEffect(
|
||||||
|
state: SDKState,
|
||||||
|
tenantId: string,
|
||||||
|
isInitialized: boolean,
|
||||||
|
enableBackendSync: boolean,
|
||||||
|
syncManagerRef: RefObject<StateSyncManager | null>
|
||||||
|
): void {
|
||||||
|
useEffect(() => {
|
||||||
|
if (!isInitialized || !state.preferences.autoSave) return
|
||||||
|
|
||||||
|
const saveTimeout = setTimeout(() => {
|
||||||
|
try {
|
||||||
|
if (typeof window !== 'undefined') {
|
||||||
|
localStorage.setItem(`${SDK_STORAGE_KEY}-${tenantId}`, JSON.stringify(state))
|
||||||
|
}
|
||||||
|
|
||||||
|
if (enableBackendSync && syncManagerRef.current) {
|
||||||
|
syncManagerRef.current.queueSync(state)
|
||||||
|
}
|
||||||
|
} catch (err) {
|
||||||
|
console.error('Failed to save state:', err)
|
||||||
|
}
|
||||||
|
}, 1000)
|
||||||
|
|
||||||
|
return () => clearTimeout(saveTimeout)
|
||||||
|
}, [state, tenantId, isInitialized, enableBackendSync])
|
||||||
|
}
|
||||||
|
|
||||||
|
// =============================================================================
|
||||||
|
// KEYBOARD SHORTCUTS EFFECT
|
||||||
|
// =============================================================================
|
||||||
|
|
||||||
|
export function useKeyboardShortcutsEffect(
|
||||||
|
isCommandBarOpen: boolean,
|
||||||
|
setCommandBarOpen: (fn: (prev: boolean) => boolean) => void
|
||||||
|
): void {
|
||||||
|
useEffect(() => {
|
||||||
|
const handleKeyDown = (e: KeyboardEvent) => {
|
||||||
|
if ((e.metaKey || e.ctrlKey) && e.key === 'k') {
|
||||||
|
e.preventDefault()
|
||||||
|
setCommandBarOpen(prev => !prev)
|
||||||
|
}
|
||||||
|
if (e.key === 'Escape' && isCommandBarOpen) {
|
||||||
|
setCommandBarOpen(() => false)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
window.addEventListener('keydown', handleKeyDown)
|
||||||
|
return () => window.removeEventListener('keydown', handleKeyDown)
|
||||||
|
}, [isCommandBarOpen, setCommandBarOpen])
|
||||||
|
}
|
||||||
@@ -2,8 +2,6 @@
|
|||||||
|
|
||||||
import React, {
|
import React, {
|
||||||
useReducer,
|
useReducer,
|
||||||
useEffect,
|
|
||||||
useCallback,
|
|
||||||
useMemo,
|
useMemo,
|
||||||
useRef,
|
useRef,
|
||||||
useState,
|
useState,
|
||||||
@@ -13,19 +11,12 @@ import {
|
|||||||
sdkReducer,
|
sdkReducer,
|
||||||
initialState,
|
initialState,
|
||||||
StateSyncManager,
|
StateSyncManager,
|
||||||
createStateSyncManager,
|
|
||||||
createDSGVOModule,
|
createDSGVOModule,
|
||||||
createComplianceModule,
|
createComplianceModule,
|
||||||
createRAGModule,
|
createRAGModule,
|
||||||
createSecurityModule,
|
createSecurityModule,
|
||||||
} from '@breakpilot/compliance-sdk-core'
|
} from '@breakpilot/compliance-sdk-core'
|
||||||
import type {
|
import type { SyncState } from '@breakpilot/compliance-sdk-types'
|
||||||
CheckpointStatus,
|
|
||||||
SyncState,
|
|
||||||
UseCaseAssessment,
|
|
||||||
Risk,
|
|
||||||
Control,
|
|
||||||
} from '@breakpilot/compliance-sdk-types'
|
|
||||||
import {
|
import {
|
||||||
getStepById,
|
getStepById,
|
||||||
getNextStep,
|
getNextStep,
|
||||||
@@ -35,10 +26,29 @@ import {
|
|||||||
} from '@breakpilot/compliance-sdk-types'
|
} from '@breakpilot/compliance-sdk-types'
|
||||||
import {
|
import {
|
||||||
ComplianceContext,
|
ComplianceContext,
|
||||||
SDK_STORAGE_KEY,
|
|
||||||
type ComplianceContextValue,
|
type ComplianceContextValue,
|
||||||
type ComplianceProviderProps,
|
type ComplianceProviderProps,
|
||||||
} from './provider-context'
|
} from './provider-context'
|
||||||
|
import {
|
||||||
|
useSyncManagerEffect,
|
||||||
|
useLoadInitialStateEffect,
|
||||||
|
useAutoSaveEffect,
|
||||||
|
useKeyboardShortcutsEffect,
|
||||||
|
} from './provider-effects'
|
||||||
|
import {
|
||||||
|
useValidateCheckpoint,
|
||||||
|
useOverrideCheckpoint,
|
||||||
|
useGetCheckpointStatus,
|
||||||
|
useUpdateUseCase,
|
||||||
|
useAddRisk,
|
||||||
|
useUpdateControl,
|
||||||
|
useSaveState,
|
||||||
|
useLoadState,
|
||||||
|
useResetState,
|
||||||
|
useForceSyncToServer,
|
||||||
|
useExportState,
|
||||||
|
} from './provider-callbacks'
|
||||||
|
import { useCallback, useMemo as useMemoReact } from 'react'
|
||||||
|
|
||||||
export {
|
export {
|
||||||
ComplianceContext,
|
ComplianceContext,
|
||||||
@@ -69,7 +79,7 @@ export function ComplianceProvider({
|
|||||||
tenantId,
|
tenantId,
|
||||||
userId,
|
userId,
|
||||||
})
|
})
|
||||||
const [isCommandBarOpen, setCommandBarOpen] = useState(false)
|
const [isCommandBarOpen, setCommandBarOpenRaw] = useState(false)
|
||||||
const [isInitialized, setIsInitialized] = useState(false)
|
const [isInitialized, setIsInitialized] = useState(false)
|
||||||
const [isLoading, setIsLoading] = useState(true)
|
const [isLoading, setIsLoading] = useState(true)
|
||||||
const [error, setError] = useState<Error | null>(null)
|
const [error, setError] = useState<Error | null>(null)
|
||||||
@@ -87,7 +97,7 @@ export function ComplianceProvider({
|
|||||||
const clientRef = useRef<ComplianceClient | null>(null)
|
const clientRef = useRef<ComplianceClient | null>(null)
|
||||||
const syncManagerRef = useRef<StateSyncManager | null>(null)
|
const syncManagerRef = useRef<StateSyncManager | null>(null)
|
||||||
|
|
||||||
// Initialize client
|
// Initialize client (once)
|
||||||
if (!clientRef.current) {
|
if (!clientRef.current) {
|
||||||
clientRef.current = new ComplianceClient({
|
clientRef.current = new ComplianceClient({
|
||||||
apiEndpoint,
|
apiEndpoint,
|
||||||
@@ -103,144 +113,46 @@ export function ComplianceProvider({
|
|||||||
const client = clientRef.current
|
const client = clientRef.current
|
||||||
|
|
||||||
// Modules
|
// Modules
|
||||||
const dsgvo = useMemo(
|
const dsgvo = useMemo(() => createDSGVOModule(client, () => state), [client, state])
|
||||||
() => createDSGVOModule(client, () => state),
|
const compliance = useMemo(() => createComplianceModule(client, () => state), [client, state])
|
||||||
[client, state]
|
|
||||||
)
|
|
||||||
const compliance = useMemo(
|
|
||||||
() => createComplianceModule(client, () => state),
|
|
||||||
[client, state]
|
|
||||||
)
|
|
||||||
const rag = useMemo(() => createRAGModule(client), [client])
|
const rag = useMemo(() => createRAGModule(client), [client])
|
||||||
const security = useMemo(
|
const security = useMemo(() => createSecurityModule(client, () => state), [client, state])
|
||||||
() => createSecurityModule(client, () => state),
|
|
||||||
[client, state]
|
// -------------------------------------------------------------------------
|
||||||
|
// Effects (extracted to provider-effects.ts)
|
||||||
|
// -------------------------------------------------------------------------
|
||||||
|
|
||||||
|
const syncStateSetters = useMemo(
|
||||||
|
() => ({ setSyncState, setIsOnline, setError, dispatch }),
|
||||||
|
// eslint-disable-next-line react-hooks/exhaustive-deps
|
||||||
|
[]
|
||||||
)
|
)
|
||||||
|
|
||||||
// Initialize sync manager
|
useSyncManagerEffect(enableBackendSync, tenantId, client, state, syncManagerRef, syncStateSetters)
|
||||||
useEffect(() => {
|
|
||||||
if (enableBackendSync && typeof window !== 'undefined') {
|
useLoadInitialStateEffect(tenantId, enableBackendSync, syncManagerRef, {
|
||||||
syncManagerRef.current = createStateSyncManager(
|
setIsLoading,
|
||||||
client,
|
setIsInitialized,
|
||||||
tenantId,
|
setError,
|
||||||
{ debounceMs: 2000, maxRetries: 3 },
|
dispatch,
|
||||||
{
|
onError,
|
||||||
onSyncStart: () => {
|
})
|
||||||
setSyncState(prev => ({ ...prev, status: 'syncing' }))
|
|
||||||
|
useAutoSaveEffect(state, tenantId, isInitialized, enableBackendSync, syncManagerRef)
|
||||||
|
|
||||||
|
const setCommandBarOpen = useCallback(
|
||||||
|
(fn: boolean | ((prev: boolean) => boolean)) => {
|
||||||
|
setCommandBarOpenRaw(typeof fn === 'function' ? fn : () => fn)
|
||||||
},
|
},
|
||||||
onSyncComplete: syncedState => {
|
[]
|
||||||
setSyncState(prev => ({
|
|
||||||
...prev,
|
|
||||||
status: 'idle',
|
|
||||||
lastSyncedAt: new Date(),
|
|
||||||
pendingChanges: 0,
|
|
||||||
}))
|
|
||||||
if (new Date(syncedState.lastModified) > new Date(state.lastModified)) {
|
|
||||||
dispatch({ type: 'SET_STATE', payload: syncedState })
|
|
||||||
}
|
|
||||||
},
|
|
||||||
onSyncError: err => {
|
|
||||||
setSyncState(prev => ({
|
|
||||||
...prev,
|
|
||||||
status: 'error',
|
|
||||||
error: err.message,
|
|
||||||
}))
|
|
||||||
setError(err)
|
|
||||||
},
|
|
||||||
onConflict: () => {
|
|
||||||
setSyncState(prev => ({ ...prev, status: 'conflict' }))
|
|
||||||
},
|
|
||||||
onOffline: () => {
|
|
||||||
setIsOnline(false)
|
|
||||||
setSyncState(prev => ({ ...prev, status: 'offline' }))
|
|
||||||
},
|
|
||||||
onOnline: () => {
|
|
||||||
setIsOnline(true)
|
|
||||||
setSyncState(prev => ({ ...prev, status: 'idle' }))
|
|
||||||
},
|
|
||||||
}
|
|
||||||
)
|
)
|
||||||
}
|
|
||||||
|
|
||||||
return () => {
|
useKeyboardShortcutsEffect(isCommandBarOpen, setCommandBarOpen as (fn: (prev: boolean) => boolean) => void)
|
||||||
syncManagerRef.current?.destroy()
|
|
||||||
}
|
|
||||||
}, [enableBackendSync, tenantId, client])
|
|
||||||
|
|
||||||
// Load initial state
|
|
||||||
useEffect(() => {
|
|
||||||
const loadInitialState = async () => {
|
|
||||||
setIsLoading(true)
|
|
||||||
try {
|
|
||||||
// Load from localStorage first
|
|
||||||
if (typeof window !== 'undefined') {
|
|
||||||
const stored = localStorage.getItem(`${SDK_STORAGE_KEY}-${tenantId}`)
|
|
||||||
if (stored) {
|
|
||||||
const parsed = JSON.parse(stored)
|
|
||||||
if (parsed.lastModified) {
|
|
||||||
parsed.lastModified = new Date(parsed.lastModified)
|
|
||||||
}
|
|
||||||
dispatch({ type: 'SET_STATE', payload: parsed })
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// Then load from server if enabled
|
|
||||||
if (enableBackendSync && syncManagerRef.current) {
|
|
||||||
const serverState = await syncManagerRef.current.loadFromServer()
|
|
||||||
if (serverState) {
|
|
||||||
dispatch({ type: 'SET_STATE', payload: serverState })
|
|
||||||
}
|
|
||||||
}
|
|
||||||
} catch (err) {
|
|
||||||
setError(err as Error)
|
|
||||||
onError?.(err as Error)
|
|
||||||
} finally {
|
|
||||||
setIsLoading(false)
|
|
||||||
setIsInitialized(true)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
loadInitialState()
|
|
||||||
}, [tenantId, enableBackendSync])
|
|
||||||
|
|
||||||
// Auto-save
|
|
||||||
useEffect(() => {
|
|
||||||
if (!isInitialized || !state.preferences.autoSave) return
|
|
||||||
|
|
||||||
const saveTimeout = setTimeout(() => {
|
|
||||||
try {
|
|
||||||
if (typeof window !== 'undefined') {
|
|
||||||
localStorage.setItem(`${SDK_STORAGE_KEY}-${tenantId}`, JSON.stringify(state))
|
|
||||||
}
|
|
||||||
|
|
||||||
if (enableBackendSync && syncManagerRef.current) {
|
|
||||||
syncManagerRef.current.queueSync(state)
|
|
||||||
}
|
|
||||||
} catch (err) {
|
|
||||||
console.error('Failed to save state:', err)
|
|
||||||
}
|
|
||||||
}, 1000)
|
|
||||||
|
|
||||||
return () => clearTimeout(saveTimeout)
|
|
||||||
}, [state, tenantId, isInitialized, enableBackendSync])
|
|
||||||
|
|
||||||
// Keyboard shortcuts
|
|
||||||
useEffect(() => {
|
|
||||||
const handleKeyDown = (e: KeyboardEvent) => {
|
|
||||||
if ((e.metaKey || e.ctrlKey) && e.key === 'k') {
|
|
||||||
e.preventDefault()
|
|
||||||
setCommandBarOpen(prev => !prev)
|
|
||||||
}
|
|
||||||
if (e.key === 'Escape' && isCommandBarOpen) {
|
|
||||||
setCommandBarOpen(false)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
window.addEventListener('keydown', handleKeyDown)
|
|
||||||
return () => window.removeEventListener('keydown', handleKeyDown)
|
|
||||||
}, [isCommandBarOpen])
|
|
||||||
|
|
||||||
|
// -------------------------------------------------------------------------
|
||||||
// Navigation
|
// Navigation
|
||||||
|
// -------------------------------------------------------------------------
|
||||||
|
|
||||||
const currentStep = useMemo(() => getStepById(state.currentStep), [state.currentStep])
|
const currentStep = useMemo(() => getStepById(state.currentStep), [state.currentStep])
|
||||||
|
|
||||||
const goToStep = useCallback(
|
const goToStep = useCallback(
|
||||||
@@ -256,16 +168,12 @@ export function ComplianceProvider({
|
|||||||
|
|
||||||
const goToNextStep = useCallback(() => {
|
const goToNextStep = useCallback(() => {
|
||||||
const nextStep = getNextStep(state.currentStep)
|
const nextStep = getNextStep(state.currentStep)
|
||||||
if (nextStep) {
|
if (nextStep) goToStep(nextStep.id)
|
||||||
goToStep(nextStep.id)
|
|
||||||
}
|
|
||||||
}, [state.currentStep, goToStep])
|
}, [state.currentStep, goToStep])
|
||||||
|
|
||||||
const goToPreviousStep = useCallback(() => {
|
const goToPreviousStep = useCallback(() => {
|
||||||
const prevStep = getPreviousStep(state.currentStep)
|
const prevStep = getPreviousStep(state.currentStep)
|
||||||
if (prevStep) {
|
if (prevStep) goToStep(prevStep.id)
|
||||||
goToStep(prevStep.id)
|
|
||||||
}
|
|
||||||
}, [state.currentStep, goToStep])
|
}, [state.currentStep, goToStep])
|
||||||
|
|
||||||
const canGoNext = useMemo(() => getNextStep(state.currentStep) !== undefined, [state.currentStep])
|
const canGoNext = useMemo(() => getNextStep(state.currentStep) !== undefined, [state.currentStep])
|
||||||
@@ -274,152 +182,29 @@ export function ComplianceProvider({
|
|||||||
[state.currentStep]
|
[state.currentStep]
|
||||||
)
|
)
|
||||||
|
|
||||||
// Progress
|
|
||||||
const completionPercentage = useMemo(() => getCompletionPercentage(state), [state])
|
const completionPercentage = useMemo(() => getCompletionPercentage(state), [state])
|
||||||
const phase1Completion = useMemo(() => getPhaseCompletionPercentage(state, 1), [state])
|
const phase1Completion = useMemo(() => getPhaseCompletionPercentage(state, 1), [state])
|
||||||
const phase2Completion = useMemo(() => getPhaseCompletionPercentage(state, 2), [state])
|
const phase2Completion = useMemo(() => getPhaseCompletionPercentage(state, 2), [state])
|
||||||
|
|
||||||
// Checkpoints
|
// -------------------------------------------------------------------------
|
||||||
const validateCheckpoint = useCallback(
|
// Callbacks (extracted to provider-callbacks.ts)
|
||||||
async (checkpointId: string): Promise<CheckpointStatus> => {
|
// -------------------------------------------------------------------------
|
||||||
if (enableBackendSync) {
|
|
||||||
try {
|
|
||||||
const result = await client.validateCheckpoint(checkpointId, state)
|
|
||||||
const status: CheckpointStatus = {
|
|
||||||
checkpointId: result.checkpointId,
|
|
||||||
passed: result.passed,
|
|
||||||
validatedAt: new Date(result.validatedAt),
|
|
||||||
validatedBy: result.validatedBy,
|
|
||||||
errors: result.errors,
|
|
||||||
warnings: result.warnings,
|
|
||||||
}
|
|
||||||
dispatch({ type: 'SET_CHECKPOINT_STATUS', payload: { id: checkpointId, status } })
|
|
||||||
return status
|
|
||||||
} catch {
|
|
||||||
// Fall through to local validation
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// Local validation
|
const validateCheckpoint = useValidateCheckpoint(state, enableBackendSync, client, dispatch)
|
||||||
const status: CheckpointStatus = {
|
const overrideCheckpoint = useOverrideCheckpoint(state, dispatch)
|
||||||
checkpointId,
|
const getCheckpointStatus = useGetCheckpointStatus(state)
|
||||||
passed: true,
|
const updateUseCase = useUpdateUseCase(dispatch)
|
||||||
validatedAt: new Date(),
|
const addRisk = useAddRisk(dispatch)
|
||||||
validatedBy: 'SYSTEM',
|
const updateControl = useUpdateControl(dispatch)
|
||||||
errors: [],
|
const saveState = useSaveState(state, tenantId, enableBackendSync, syncManagerRef, e => setError(e))
|
||||||
warnings: [],
|
const loadState = useLoadState(tenantId, enableBackendSync, syncManagerRef, setIsLoading, dispatch, e => setError(e))
|
||||||
}
|
const resetState = useResetState(tenantId, dispatch)
|
||||||
|
const forceSyncToServer = useForceSyncToServer(state, enableBackendSync, syncManagerRef)
|
||||||
|
const exportState = useExportState(state, client)
|
||||||
|
|
||||||
dispatch({ type: 'SET_CHECKPOINT_STATUS', payload: { id: checkpointId, status } })
|
// -------------------------------------------------------------------------
|
||||||
return status
|
// Context value
|
||||||
},
|
// -------------------------------------------------------------------------
|
||||||
[state, enableBackendSync, client]
|
|
||||||
)
|
|
||||||
|
|
||||||
const overrideCheckpoint = useCallback(
|
|
||||||
async (checkpointId: string, reason: string): Promise<void> => {
|
|
||||||
const existingStatus = state.checkpoints[checkpointId]
|
|
||||||
const overriddenStatus: CheckpointStatus = {
|
|
||||||
...existingStatus,
|
|
||||||
checkpointId,
|
|
||||||
passed: true,
|
|
||||||
overrideReason: reason,
|
|
||||||
overriddenBy: state.userId,
|
|
||||||
overriddenAt: new Date(),
|
|
||||||
errors: [],
|
|
||||||
warnings: existingStatus?.warnings || [],
|
|
||||||
}
|
|
||||||
|
|
||||||
dispatch({ type: 'SET_CHECKPOINT_STATUS', payload: { id: checkpointId, status: overriddenStatus } })
|
|
||||||
},
|
|
||||||
[state.checkpoints, state.userId]
|
|
||||||
)
|
|
||||||
|
|
||||||
const getCheckpointStatus = useCallback(
|
|
||||||
(checkpointId: string): CheckpointStatus | undefined => {
|
|
||||||
return state.checkpoints[checkpointId]
|
|
||||||
},
|
|
||||||
[state.checkpoints]
|
|
||||||
)
|
|
||||||
|
|
||||||
// State Updates
|
|
||||||
const updateUseCase = useCallback((id: string, data: Partial<UseCaseAssessment>) => {
|
|
||||||
dispatch({ type: 'UPDATE_USE_CASE', payload: { id, data } })
|
|
||||||
}, [])
|
|
||||||
|
|
||||||
const addRisk = useCallback((risk: Risk) => {
|
|
||||||
dispatch({ type: 'ADD_RISK', payload: risk })
|
|
||||||
}, [])
|
|
||||||
|
|
||||||
const updateControl = useCallback((id: string, data: Partial<Control>) => {
|
|
||||||
dispatch({ type: 'UPDATE_CONTROL', payload: { id, data } })
|
|
||||||
}, [])
|
|
||||||
|
|
||||||
// Persistence
|
|
||||||
const saveState = useCallback(async (): Promise<void> => {
|
|
||||||
try {
|
|
||||||
if (typeof window !== 'undefined') {
|
|
||||||
localStorage.setItem(`${SDK_STORAGE_KEY}-${tenantId}`, JSON.stringify(state))
|
|
||||||
}
|
|
||||||
|
|
||||||
if (enableBackendSync && syncManagerRef.current) {
|
|
||||||
await syncManagerRef.current.forceSync(state)
|
|
||||||
}
|
|
||||||
} catch (err) {
|
|
||||||
setError(err as Error)
|
|
||||||
throw err
|
|
||||||
}
|
|
||||||
}, [state, tenantId, enableBackendSync])
|
|
||||||
|
|
||||||
const loadState = useCallback(async (): Promise<void> => {
|
|
||||||
setIsLoading(true)
|
|
||||||
try {
|
|
||||||
if (enableBackendSync && syncManagerRef.current) {
|
|
||||||
const serverState = await syncManagerRef.current.loadFromServer()
|
|
||||||
if (serverState) {
|
|
||||||
dispatch({ type: 'SET_STATE', payload: serverState })
|
|
||||||
return
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
if (typeof window !== 'undefined') {
|
|
||||||
const stored = localStorage.getItem(`${SDK_STORAGE_KEY}-${tenantId}`)
|
|
||||||
if (stored) {
|
|
||||||
dispatch({ type: 'SET_STATE', payload: JSON.parse(stored) })
|
|
||||||
}
|
|
||||||
}
|
|
||||||
} catch (err) {
|
|
||||||
setError(err as Error)
|
|
||||||
throw err
|
|
||||||
} finally {
|
|
||||||
setIsLoading(false)
|
|
||||||
}
|
|
||||||
}, [tenantId, enableBackendSync])
|
|
||||||
|
|
||||||
const resetState = useCallback(() => {
|
|
||||||
dispatch({ type: 'RESET_STATE' })
|
|
||||||
if (typeof window !== 'undefined') {
|
|
||||||
localStorage.removeItem(`${SDK_STORAGE_KEY}-${tenantId}`)
|
|
||||||
}
|
|
||||||
}, [tenantId])
|
|
||||||
|
|
||||||
// Sync
|
|
||||||
const forceSyncToServer = useCallback(async (): Promise<void> => {
|
|
||||||
if (enableBackendSync && syncManagerRef.current) {
|
|
||||||
await syncManagerRef.current.forceSync(state)
|
|
||||||
}
|
|
||||||
}, [state, enableBackendSync])
|
|
||||||
|
|
||||||
// Export
|
|
||||||
const exportState = useCallback(
|
|
||||||
async (format: 'json' | 'pdf' | 'zip'): Promise<Blob> => {
|
|
||||||
if (format === 'json') {
|
|
||||||
return new Blob([JSON.stringify(state, null, 2)], { type: 'application/json' })
|
|
||||||
}
|
|
||||||
return client.exportState(format)
|
|
||||||
},
|
|
||||||
[state, client]
|
|
||||||
)
|
|
||||||
|
|
||||||
const value: ComplianceContextValue = {
|
const value: ComplianceContextValue = {
|
||||||
state,
|
state,
|
||||||
@@ -452,7 +237,7 @@ export function ComplianceProvider({
|
|||||||
isOnline,
|
isOnline,
|
||||||
exportState,
|
exportState,
|
||||||
isCommandBarOpen,
|
isCommandBarOpen,
|
||||||
setCommandBarOpen,
|
setCommandBarOpen: setCommandBarOpenRaw,
|
||||||
isInitialized,
|
isInitialized,
|
||||||
isLoading,
|
isLoading,
|
||||||
error,
|
error,
|
||||||
|
|||||||
@@ -0,0 +1,286 @@
|
|||||||
|
/**
|
||||||
|
* Render helpers for <breakpilot-dsr-portal>
|
||||||
|
*
|
||||||
|
* Provides DSR_PORTAL_STYLES, buildFormHtml, and buildSuccessHtml.
|
||||||
|
* Kept separate from the element class to stay under the 300-LOC target.
|
||||||
|
*/
|
||||||
|
|
||||||
|
import type { DSRRequestType } from '@breakpilot/compliance-sdk-types'
|
||||||
|
import { COMMON_STYLES } from './base'
|
||||||
|
import type { DSRTranslations } from './dsr-portal-translations'
|
||||||
|
|
||||||
|
// =============================================================================
|
||||||
|
// STYLES
|
||||||
|
// =============================================================================
|
||||||
|
|
||||||
|
export const DSR_PORTAL_STYLES = `
|
||||||
|
${COMMON_STYLES}
|
||||||
|
|
||||||
|
:host {
|
||||||
|
max-width: 600px;
|
||||||
|
margin: 0 auto;
|
||||||
|
padding: 20px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.portal {
|
||||||
|
background: #fff;
|
||||||
|
}
|
||||||
|
|
||||||
|
.title {
|
||||||
|
margin: 0 0 10px;
|
||||||
|
font-size: 24px;
|
||||||
|
font-weight: 600;
|
||||||
|
color: #1a1a1a;
|
||||||
|
}
|
||||||
|
|
||||||
|
.subtitle {
|
||||||
|
margin: 0 0 20px;
|
||||||
|
color: #666;
|
||||||
|
}
|
||||||
|
|
||||||
|
.form-group {
|
||||||
|
margin-bottom: 20px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.label {
|
||||||
|
display: block;
|
||||||
|
font-weight: 500;
|
||||||
|
margin-bottom: 10px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.type-options {
|
||||||
|
display: grid;
|
||||||
|
gap: 10px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.type-option {
|
||||||
|
display: flex;
|
||||||
|
padding: 15px;
|
||||||
|
border: 2px solid #ddd;
|
||||||
|
border-radius: 8px;
|
||||||
|
cursor: pointer;
|
||||||
|
background: #fff;
|
||||||
|
transition: all 0.2s;
|
||||||
|
}
|
||||||
|
|
||||||
|
.type-option:hover {
|
||||||
|
border-color: #999;
|
||||||
|
}
|
||||||
|
|
||||||
|
.type-option.selected {
|
||||||
|
border-color: #1a1a1a;
|
||||||
|
background: #f5f5f5;
|
||||||
|
}
|
||||||
|
|
||||||
|
.type-option input {
|
||||||
|
margin-right: 15px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.type-name {
|
||||||
|
font-weight: 500;
|
||||||
|
}
|
||||||
|
|
||||||
|
.type-description {
|
||||||
|
font-size: 13px;
|
||||||
|
color: #666;
|
||||||
|
}
|
||||||
|
|
||||||
|
.input {
|
||||||
|
width: 100%;
|
||||||
|
padding: 12px;
|
||||||
|
font-size: 14px;
|
||||||
|
border: 1px solid #ddd;
|
||||||
|
border-radius: 4px;
|
||||||
|
box-sizing: border-box;
|
||||||
|
}
|
||||||
|
|
||||||
|
.input:focus {
|
||||||
|
outline: none;
|
||||||
|
border-color: #1a1a1a;
|
||||||
|
}
|
||||||
|
|
||||||
|
.textarea {
|
||||||
|
resize: vertical;
|
||||||
|
min-height: 100px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.error {
|
||||||
|
padding: 12px;
|
||||||
|
background: #fef2f2;
|
||||||
|
color: #dc2626;
|
||||||
|
border-radius: 4px;
|
||||||
|
margin-bottom: 15px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.btn-submit {
|
||||||
|
width: 100%;
|
||||||
|
padding: 12px 24px;
|
||||||
|
font-size: 16px;
|
||||||
|
font-weight: 500;
|
||||||
|
color: #fff;
|
||||||
|
background: #1a1a1a;
|
||||||
|
border: none;
|
||||||
|
border-radius: 4px;
|
||||||
|
cursor: pointer;
|
||||||
|
}
|
||||||
|
|
||||||
|
.btn-submit:disabled {
|
||||||
|
opacity: 0.5;
|
||||||
|
cursor: not-allowed;
|
||||||
|
}
|
||||||
|
|
||||||
|
.disclaimer {
|
||||||
|
margin-top: 20px;
|
||||||
|
font-size: 12px;
|
||||||
|
color: #666;
|
||||||
|
}
|
||||||
|
|
||||||
|
.success {
|
||||||
|
text-align: center;
|
||||||
|
padding: 40px 20px;
|
||||||
|
background: #f0fdf4;
|
||||||
|
border-radius: 8px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.success-icon {
|
||||||
|
font-size: 48px;
|
||||||
|
margin-bottom: 20px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.success-title {
|
||||||
|
margin: 0 0 10px;
|
||||||
|
color: #166534;
|
||||||
|
}
|
||||||
|
|
||||||
|
.success-message {
|
||||||
|
margin: 0;
|
||||||
|
color: #166534;
|
||||||
|
}
|
||||||
|
`
|
||||||
|
|
||||||
|
// =============================================================================
|
||||||
|
// HTML BUILDERS
|
||||||
|
// =============================================================================
|
||||||
|
|
||||||
|
export interface FormRenderOptions {
|
||||||
|
t: DSRTranslations
|
||||||
|
selectedType: DSRRequestType | null
|
||||||
|
name: string
|
||||||
|
email: string
|
||||||
|
additionalInfo: string
|
||||||
|
isSubmitting: boolean
|
||||||
|
error: string | null
|
||||||
|
}
|
||||||
|
|
||||||
|
export function buildFormHtml(styles: string, opts: FormRenderOptions): string {
|
||||||
|
const { t, selectedType, name, email, additionalInfo, isSubmitting, error } = opts
|
||||||
|
|
||||||
|
const types: DSRRequestType[] = [
|
||||||
|
'ACCESS',
|
||||||
|
'RECTIFICATION',
|
||||||
|
'ERASURE',
|
||||||
|
'PORTABILITY',
|
||||||
|
'RESTRICTION',
|
||||||
|
'OBJECTION',
|
||||||
|
]
|
||||||
|
|
||||||
|
const typesHtml = types
|
||||||
|
.map(
|
||||||
|
type => `
|
||||||
|
<label class="type-option ${selectedType === type ? 'selected' : ''}">
|
||||||
|
<input
|
||||||
|
type="radio"
|
||||||
|
name="dsrType"
|
||||||
|
value="${type}"
|
||||||
|
${selectedType === type ? 'checked' : ''}
|
||||||
|
/>
|
||||||
|
<div>
|
||||||
|
<div class="type-name">${t.types[type].name}</div>
|
||||||
|
<div class="type-description">${t.types[type].description}</div>
|
||||||
|
</div>
|
||||||
|
</label>
|
||||||
|
`
|
||||||
|
)
|
||||||
|
.join('')
|
||||||
|
|
||||||
|
const isValid = selectedType && email && name
|
||||||
|
const isDisabled = !isValid || isSubmitting
|
||||||
|
|
||||||
|
return `
|
||||||
|
<style>${styles}</style>
|
||||||
|
<div class="portal">
|
||||||
|
<h2 class="title">${t.title}</h2>
|
||||||
|
<p class="subtitle">${t.subtitle}</p>
|
||||||
|
|
||||||
|
<form id="dsr-form">
|
||||||
|
<div class="form-group">
|
||||||
|
<label class="label">${t.requestType} *</label>
|
||||||
|
<div class="type-options">
|
||||||
|
${typesHtml}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="form-group">
|
||||||
|
<label class="label">${t.name} *</label>
|
||||||
|
<input
|
||||||
|
type="text"
|
||||||
|
class="input"
|
||||||
|
id="name-input"
|
||||||
|
value="${name}"
|
||||||
|
placeholder="${t.namePlaceholder}"
|
||||||
|
required
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="form-group">
|
||||||
|
<label class="label">${t.email} *</label>
|
||||||
|
<input
|
||||||
|
type="email"
|
||||||
|
class="input"
|
||||||
|
id="email-input"
|
||||||
|
value="${email}"
|
||||||
|
placeholder="${t.emailPlaceholder}"
|
||||||
|
required
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="form-group">
|
||||||
|
<label class="label">${t.additionalInfo}</label>
|
||||||
|
<textarea
|
||||||
|
class="input textarea"
|
||||||
|
id="info-input"
|
||||||
|
placeholder="${t.additionalInfoPlaceholder}"
|
||||||
|
rows="4"
|
||||||
|
>${additionalInfo}</textarea>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
${error ? `<div class="error">${error}</div>` : ''}
|
||||||
|
|
||||||
|
<button
|
||||||
|
type="submit"
|
||||||
|
class="btn-submit"
|
||||||
|
${isDisabled ? 'disabled' : ''}
|
||||||
|
>
|
||||||
|
${isSubmitting ? t.submitting : t.submit}
|
||||||
|
</button>
|
||||||
|
</form>
|
||||||
|
|
||||||
|
<p class="disclaimer">${t.disclaimer}</p>
|
||||||
|
</div>
|
||||||
|
`
|
||||||
|
}
|
||||||
|
|
||||||
|
export function buildSuccessHtml(styles: string, t: DSRTranslations, email: string): string {
|
||||||
|
return `
|
||||||
|
<style>${styles}</style>
|
||||||
|
<div class="portal">
|
||||||
|
<div class="success">
|
||||||
|
<div class="success-icon">✓</div>
|
||||||
|
<h2 class="success-title">${t.successTitle}</h2>
|
||||||
|
<p class="success-message">
|
||||||
|
${t.successMessage} ${email}.
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
`
|
||||||
|
}
|
||||||
@@ -0,0 +1,101 @@
|
|||||||
|
/**
|
||||||
|
* Translations for <breakpilot-dsr-portal>
|
||||||
|
*
|
||||||
|
* Supported languages: 'de' | 'en'
|
||||||
|
*/
|
||||||
|
|
||||||
|
export const DSR_TRANSLATIONS = {
|
||||||
|
de: {
|
||||||
|
title: 'Betroffenenrechte-Portal',
|
||||||
|
subtitle:
|
||||||
|
'Hier können Sie Ihre Rechte gemäß DSGVO wahrnehmen. Wählen Sie die gewünschte Anfrage und füllen Sie das Formular aus.',
|
||||||
|
requestType: 'Art der Anfrage',
|
||||||
|
name: 'Ihr Name',
|
||||||
|
namePlaceholder: 'Max Mustermann',
|
||||||
|
email: 'E-Mail-Adresse',
|
||||||
|
emailPlaceholder: 'max@example.com',
|
||||||
|
additionalInfo: 'Zusätzliche Informationen (optional)',
|
||||||
|
additionalInfoPlaceholder: 'Weitere Details zu Ihrer Anfrage...',
|
||||||
|
submit: 'Anfrage einreichen',
|
||||||
|
submitting: 'Wird gesendet...',
|
||||||
|
successTitle: 'Anfrage eingereicht',
|
||||||
|
successMessage:
|
||||||
|
'Wir werden Ihre Anfrage innerhalb von 30 Tagen bearbeiten. Sie erhalten eine Bestätigung per E-Mail an',
|
||||||
|
disclaimer:
|
||||||
|
'Ihre Anfrage wird gemäß Art. 12 DSGVO innerhalb von einem Monat bearbeitet. In komplexen Fällen kann diese Frist um weitere zwei Monate verlängert werden.',
|
||||||
|
types: {
|
||||||
|
ACCESS: {
|
||||||
|
name: 'Auskunft (Art. 15)',
|
||||||
|
description: 'Welche Daten haben Sie über mich gespeichert?',
|
||||||
|
},
|
||||||
|
RECTIFICATION: {
|
||||||
|
name: 'Berichtigung (Art. 16)',
|
||||||
|
description: 'Korrigieren Sie falsche Daten über mich.',
|
||||||
|
},
|
||||||
|
ERASURE: {
|
||||||
|
name: 'Löschung (Art. 17)',
|
||||||
|
description: 'Löschen Sie alle meine personenbezogenen Daten.',
|
||||||
|
},
|
||||||
|
PORTABILITY: {
|
||||||
|
name: 'Datenübertragbarkeit (Art. 20)',
|
||||||
|
description: 'Exportieren Sie meine Daten in einem maschinenlesbaren Format.',
|
||||||
|
},
|
||||||
|
RESTRICTION: {
|
||||||
|
name: 'Einschränkung (Art. 18)',
|
||||||
|
description: 'Schränken Sie die Verarbeitung meiner Daten ein.',
|
||||||
|
},
|
||||||
|
OBJECTION: {
|
||||||
|
name: 'Widerspruch (Art. 21)',
|
||||||
|
description: 'Ich widerspreche der Verarbeitung meiner Daten.',
|
||||||
|
},
|
||||||
|
},
|
||||||
|
},
|
||||||
|
en: {
|
||||||
|
title: 'Data Subject Rights Portal',
|
||||||
|
subtitle:
|
||||||
|
'Here you can exercise your rights under GDPR. Select the type of request and fill out the form.',
|
||||||
|
requestType: 'Request Type',
|
||||||
|
name: 'Your Name',
|
||||||
|
namePlaceholder: 'John Doe',
|
||||||
|
email: 'Email Address',
|
||||||
|
emailPlaceholder: 'john@example.com',
|
||||||
|
additionalInfo: 'Additional Information (optional)',
|
||||||
|
additionalInfoPlaceholder: 'Any additional details about your request...',
|
||||||
|
submit: 'Submit Request',
|
||||||
|
submitting: 'Submitting...',
|
||||||
|
successTitle: 'Request Submitted',
|
||||||
|
successMessage:
|
||||||
|
'We will process your request within 30 days. You will receive a confirmation email at',
|
||||||
|
disclaimer:
|
||||||
|
'Your request will be processed in accordance with Article 12 GDPR within one month. In complex cases, this period may be extended by up to two additional months.',
|
||||||
|
types: {
|
||||||
|
ACCESS: {
|
||||||
|
name: 'Access (Art. 15)',
|
||||||
|
description: 'What data do you have about me?',
|
||||||
|
},
|
||||||
|
RECTIFICATION: {
|
||||||
|
name: 'Rectification (Art. 16)',
|
||||||
|
description: 'Correct inaccurate data about me.',
|
||||||
|
},
|
||||||
|
ERASURE: {
|
||||||
|
name: 'Erasure (Art. 17)',
|
||||||
|
description: 'Delete all my personal data.',
|
||||||
|
},
|
||||||
|
PORTABILITY: {
|
||||||
|
name: 'Data Portability (Art. 20)',
|
||||||
|
description: 'Export my data in a machine-readable format.',
|
||||||
|
},
|
||||||
|
RESTRICTION: {
|
||||||
|
name: 'Restriction (Art. 18)',
|
||||||
|
description: 'Restrict the processing of my data.',
|
||||||
|
},
|
||||||
|
OBJECTION: {
|
||||||
|
name: 'Objection (Art. 21)',
|
||||||
|
description: 'I object to the processing of my data.',
|
||||||
|
},
|
||||||
|
},
|
||||||
|
},
|
||||||
|
} as const
|
||||||
|
|
||||||
|
export type DSRLanguage = keyof typeof DSR_TRANSLATIONS
|
||||||
|
export type DSRTranslations = (typeof DSR_TRANSLATIONS)[DSRLanguage]
|
||||||
@@ -8,103 +8,15 @@
|
|||||||
* api-key="pk_live_xxx"
|
* api-key="pk_live_xxx"
|
||||||
* language="de">
|
* language="de">
|
||||||
* </breakpilot-dsr-portal>
|
* </breakpilot-dsr-portal>
|
||||||
|
*
|
||||||
|
* Split: translations → dsr-portal-translations.ts
|
||||||
|
* styles + HTML builders → dsr-portal-render.ts
|
||||||
*/
|
*/
|
||||||
|
|
||||||
import type { DSRRequestType } from '@breakpilot/compliance-sdk-types'
|
import type { DSRRequestType } from '@breakpilot/compliance-sdk-types'
|
||||||
import { BreakPilotElement, COMMON_STYLES } from './base'
|
import { BreakPilotElement } from './base'
|
||||||
|
import { DSR_TRANSLATIONS, type DSRLanguage } from './dsr-portal-translations'
|
||||||
const TRANSLATIONS = {
|
import { DSR_PORTAL_STYLES, buildFormHtml, buildSuccessHtml } from './dsr-portal-render'
|
||||||
de: {
|
|
||||||
title: 'Betroffenenrechte-Portal',
|
|
||||||
subtitle:
|
|
||||||
'Hier können Sie Ihre Rechte gemäß DSGVO wahrnehmen. Wählen Sie die gewünschte Anfrage und füllen Sie das Formular aus.',
|
|
||||||
requestType: 'Art der Anfrage',
|
|
||||||
name: 'Ihr Name',
|
|
||||||
namePlaceholder: 'Max Mustermann',
|
|
||||||
email: 'E-Mail-Adresse',
|
|
||||||
emailPlaceholder: 'max@example.com',
|
|
||||||
additionalInfo: 'Zusätzliche Informationen (optional)',
|
|
||||||
additionalInfoPlaceholder: 'Weitere Details zu Ihrer Anfrage...',
|
|
||||||
submit: 'Anfrage einreichen',
|
|
||||||
submitting: 'Wird gesendet...',
|
|
||||||
successTitle: 'Anfrage eingereicht',
|
|
||||||
successMessage:
|
|
||||||
'Wir werden Ihre Anfrage innerhalb von 30 Tagen bearbeiten. Sie erhalten eine Bestätigung per E-Mail an',
|
|
||||||
disclaimer:
|
|
||||||
'Ihre Anfrage wird gemäß Art. 12 DSGVO innerhalb von einem Monat bearbeitet. In komplexen Fällen kann diese Frist um weitere zwei Monate verlängert werden.',
|
|
||||||
types: {
|
|
||||||
ACCESS: {
|
|
||||||
name: 'Auskunft (Art. 15)',
|
|
||||||
description: 'Welche Daten haben Sie über mich gespeichert?',
|
|
||||||
},
|
|
||||||
RECTIFICATION: {
|
|
||||||
name: 'Berichtigung (Art. 16)',
|
|
||||||
description: 'Korrigieren Sie falsche Daten über mich.',
|
|
||||||
},
|
|
||||||
ERASURE: {
|
|
||||||
name: 'Löschung (Art. 17)',
|
|
||||||
description: 'Löschen Sie alle meine personenbezogenen Daten.',
|
|
||||||
},
|
|
||||||
PORTABILITY: {
|
|
||||||
name: 'Datenübertragbarkeit (Art. 20)',
|
|
||||||
description: 'Exportieren Sie meine Daten in einem maschinenlesbaren Format.',
|
|
||||||
},
|
|
||||||
RESTRICTION: {
|
|
||||||
name: 'Einschränkung (Art. 18)',
|
|
||||||
description: 'Schränken Sie die Verarbeitung meiner Daten ein.',
|
|
||||||
},
|
|
||||||
OBJECTION: {
|
|
||||||
name: 'Widerspruch (Art. 21)',
|
|
||||||
description: 'Ich widerspreche der Verarbeitung meiner Daten.',
|
|
||||||
},
|
|
||||||
},
|
|
||||||
},
|
|
||||||
en: {
|
|
||||||
title: 'Data Subject Rights Portal',
|
|
||||||
subtitle:
|
|
||||||
'Here you can exercise your rights under GDPR. Select the type of request and fill out the form.',
|
|
||||||
requestType: 'Request Type',
|
|
||||||
name: 'Your Name',
|
|
||||||
namePlaceholder: 'John Doe',
|
|
||||||
email: 'Email Address',
|
|
||||||
emailPlaceholder: 'john@example.com',
|
|
||||||
additionalInfo: 'Additional Information (optional)',
|
|
||||||
additionalInfoPlaceholder: 'Any additional details about your request...',
|
|
||||||
submit: 'Submit Request',
|
|
||||||
submitting: 'Submitting...',
|
|
||||||
successTitle: 'Request Submitted',
|
|
||||||
successMessage:
|
|
||||||
'We will process your request within 30 days. You will receive a confirmation email at',
|
|
||||||
disclaimer:
|
|
||||||
'Your request will be processed in accordance with Article 12 GDPR within one month. In complex cases, this period may be extended by up to two additional months.',
|
|
||||||
types: {
|
|
||||||
ACCESS: {
|
|
||||||
name: 'Access (Art. 15)',
|
|
||||||
description: 'What data do you have about me?',
|
|
||||||
},
|
|
||||||
RECTIFICATION: {
|
|
||||||
name: 'Rectification (Art. 16)',
|
|
||||||
description: 'Correct inaccurate data about me.',
|
|
||||||
},
|
|
||||||
ERASURE: {
|
|
||||||
name: 'Erasure (Art. 17)',
|
|
||||||
description: 'Delete all my personal data.',
|
|
||||||
},
|
|
||||||
PORTABILITY: {
|
|
||||||
name: 'Data Portability (Art. 20)',
|
|
||||||
description: 'Export my data in a machine-readable format.',
|
|
||||||
},
|
|
||||||
RESTRICTION: {
|
|
||||||
name: 'Restriction (Art. 18)',
|
|
||||||
description: 'Restrict the processing of my data.',
|
|
||||||
},
|
|
||||||
OBJECTION: {
|
|
||||||
name: 'Objection (Art. 21)',
|
|
||||||
description: 'I object to the processing of my data.',
|
|
||||||
},
|
|
||||||
},
|
|
||||||
},
|
|
||||||
}
|
|
||||||
|
|
||||||
export class DSRPortalElement extends BreakPilotElement {
|
export class DSRPortalElement extends BreakPilotElement {
|
||||||
static get observedAttributes(): string[] {
|
static get observedAttributes(): string[] {
|
||||||
@@ -119,12 +31,12 @@ export class DSRPortalElement extends BreakPilotElement {
|
|||||||
private isSubmitted = false
|
private isSubmitted = false
|
||||||
private error: string | null = null
|
private error: string | null = null
|
||||||
|
|
||||||
private get language(): 'de' | 'en' {
|
private get language(): DSRLanguage {
|
||||||
return (this.getAttribute('language') as 'de' | 'en') || 'de'
|
return (this.getAttribute('language') as DSRLanguage) || 'de'
|
||||||
}
|
}
|
||||||
|
|
||||||
private get t() {
|
private get t() {
|
||||||
return TRANSLATIONS[this.language]
|
return DSR_TRANSLATIONS[this.language]
|
||||||
}
|
}
|
||||||
|
|
||||||
private handleTypeSelect = (type: DSRRequestType): void => {
|
private handleTypeSelect = (type: DSRRequestType): void => {
|
||||||
@@ -165,253 +77,23 @@ export class DSRPortalElement extends BreakPilotElement {
|
|||||||
}
|
}
|
||||||
|
|
||||||
protected render(): void {
|
protected render(): void {
|
||||||
const styles = `
|
|
||||||
${COMMON_STYLES}
|
|
||||||
|
|
||||||
:host {
|
|
||||||
max-width: 600px;
|
|
||||||
margin: 0 auto;
|
|
||||||
padding: 20px;
|
|
||||||
}
|
|
||||||
|
|
||||||
.portal {
|
|
||||||
background: #fff;
|
|
||||||
}
|
|
||||||
|
|
||||||
.title {
|
|
||||||
margin: 0 0 10px;
|
|
||||||
font-size: 24px;
|
|
||||||
font-weight: 600;
|
|
||||||
color: #1a1a1a;
|
|
||||||
}
|
|
||||||
|
|
||||||
.subtitle {
|
|
||||||
margin: 0 0 20px;
|
|
||||||
color: #666;
|
|
||||||
}
|
|
||||||
|
|
||||||
.form-group {
|
|
||||||
margin-bottom: 20px;
|
|
||||||
}
|
|
||||||
|
|
||||||
.label {
|
|
||||||
display: block;
|
|
||||||
font-weight: 500;
|
|
||||||
margin-bottom: 10px;
|
|
||||||
}
|
|
||||||
|
|
||||||
.type-options {
|
|
||||||
display: grid;
|
|
||||||
gap: 10px;
|
|
||||||
}
|
|
||||||
|
|
||||||
.type-option {
|
|
||||||
display: flex;
|
|
||||||
padding: 15px;
|
|
||||||
border: 2px solid #ddd;
|
|
||||||
border-radius: 8px;
|
|
||||||
cursor: pointer;
|
|
||||||
background: #fff;
|
|
||||||
transition: all 0.2s;
|
|
||||||
}
|
|
||||||
|
|
||||||
.type-option:hover {
|
|
||||||
border-color: #999;
|
|
||||||
}
|
|
||||||
|
|
||||||
.type-option.selected {
|
|
||||||
border-color: #1a1a1a;
|
|
||||||
background: #f5f5f5;
|
|
||||||
}
|
|
||||||
|
|
||||||
.type-option input {
|
|
||||||
margin-right: 15px;
|
|
||||||
}
|
|
||||||
|
|
||||||
.type-name {
|
|
||||||
font-weight: 500;
|
|
||||||
}
|
|
||||||
|
|
||||||
.type-description {
|
|
||||||
font-size: 13px;
|
|
||||||
color: #666;
|
|
||||||
}
|
|
||||||
|
|
||||||
.input {
|
|
||||||
width: 100%;
|
|
||||||
padding: 12px;
|
|
||||||
font-size: 14px;
|
|
||||||
border: 1px solid #ddd;
|
|
||||||
border-radius: 4px;
|
|
||||||
box-sizing: border-box;
|
|
||||||
}
|
|
||||||
|
|
||||||
.input:focus {
|
|
||||||
outline: none;
|
|
||||||
border-color: #1a1a1a;
|
|
||||||
}
|
|
||||||
|
|
||||||
.textarea {
|
|
||||||
resize: vertical;
|
|
||||||
min-height: 100px;
|
|
||||||
}
|
|
||||||
|
|
||||||
.error {
|
|
||||||
padding: 12px;
|
|
||||||
background: #fef2f2;
|
|
||||||
color: #dc2626;
|
|
||||||
border-radius: 4px;
|
|
||||||
margin-bottom: 15px;
|
|
||||||
}
|
|
||||||
|
|
||||||
.btn-submit {
|
|
||||||
width: 100%;
|
|
||||||
padding: 12px 24px;
|
|
||||||
font-size: 16px;
|
|
||||||
font-weight: 500;
|
|
||||||
color: #fff;
|
|
||||||
background: #1a1a1a;
|
|
||||||
border: none;
|
|
||||||
border-radius: 4px;
|
|
||||||
cursor: pointer;
|
|
||||||
}
|
|
||||||
|
|
||||||
.btn-submit:disabled {
|
|
||||||
opacity: 0.5;
|
|
||||||
cursor: not-allowed;
|
|
||||||
}
|
|
||||||
|
|
||||||
.disclaimer {
|
|
||||||
margin-top: 20px;
|
|
||||||
font-size: 12px;
|
|
||||||
color: #666;
|
|
||||||
}
|
|
||||||
|
|
||||||
.success {
|
|
||||||
text-align: center;
|
|
||||||
padding: 40px 20px;
|
|
||||||
background: #f0fdf4;
|
|
||||||
border-radius: 8px;
|
|
||||||
}
|
|
||||||
|
|
||||||
.success-icon {
|
|
||||||
font-size: 48px;
|
|
||||||
margin-bottom: 20px;
|
|
||||||
}
|
|
||||||
|
|
||||||
.success-title {
|
|
||||||
margin: 0 0 10px;
|
|
||||||
color: #166534;
|
|
||||||
}
|
|
||||||
|
|
||||||
.success-message {
|
|
||||||
margin: 0;
|
|
||||||
color: #166534;
|
|
||||||
}
|
|
||||||
`
|
|
||||||
|
|
||||||
if (this.isSubmitted) {
|
if (this.isSubmitted) {
|
||||||
this.renderSuccess(styles)
|
this.shadow.innerHTML = buildSuccessHtml(DSR_PORTAL_STYLES, this.t, this.email)
|
||||||
} else {
|
} else {
|
||||||
this.renderForm(styles)
|
this.renderForm()
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
private renderForm(styles: string): void {
|
private renderForm(): void {
|
||||||
const t = this.t
|
this.shadow.innerHTML = buildFormHtml(DSR_PORTAL_STYLES, {
|
||||||
const types: DSRRequestType[] = [
|
t: this.t,
|
||||||
'ACCESS',
|
selectedType: this.selectedType,
|
||||||
'RECTIFICATION',
|
name: this.name,
|
||||||
'ERASURE',
|
email: this.email,
|
||||||
'PORTABILITY',
|
additionalInfo: this.additionalInfo,
|
||||||
'RESTRICTION',
|
isSubmitting: this.isSubmitting,
|
||||||
'OBJECTION',
|
error: this.error,
|
||||||
]
|
})
|
||||||
|
|
||||||
const typesHtml = types
|
|
||||||
.map(
|
|
||||||
type => `
|
|
||||||
<label class="type-option ${this.selectedType === type ? 'selected' : ''}">
|
|
||||||
<input
|
|
||||||
type="radio"
|
|
||||||
name="dsrType"
|
|
||||||
value="${type}"
|
|
||||||
${this.selectedType === type ? 'checked' : ''}
|
|
||||||
/>
|
|
||||||
<div>
|
|
||||||
<div class="type-name">${t.types[type].name}</div>
|
|
||||||
<div class="type-description">${t.types[type].description}</div>
|
|
||||||
</div>
|
|
||||||
</label>
|
|
||||||
`
|
|
||||||
)
|
|
||||||
.join('')
|
|
||||||
|
|
||||||
const isValid = this.selectedType && this.email && this.name
|
|
||||||
const isDisabled = !isValid || this.isSubmitting
|
|
||||||
|
|
||||||
this.shadow.innerHTML = `
|
|
||||||
<style>${styles}</style>
|
|
||||||
<div class="portal">
|
|
||||||
<h2 class="title">${t.title}</h2>
|
|
||||||
<p class="subtitle">${t.subtitle}</p>
|
|
||||||
|
|
||||||
<form id="dsr-form">
|
|
||||||
<div class="form-group">
|
|
||||||
<label class="label">${t.requestType} *</label>
|
|
||||||
<div class="type-options">
|
|
||||||
${typesHtml}
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<div class="form-group">
|
|
||||||
<label class="label">${t.name} *</label>
|
|
||||||
<input
|
|
||||||
type="text"
|
|
||||||
class="input"
|
|
||||||
id="name-input"
|
|
||||||
value="${this.name}"
|
|
||||||
placeholder="${t.namePlaceholder}"
|
|
||||||
required
|
|
||||||
/>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<div class="form-group">
|
|
||||||
<label class="label">${t.email} *</label>
|
|
||||||
<input
|
|
||||||
type="email"
|
|
||||||
class="input"
|
|
||||||
id="email-input"
|
|
||||||
value="${this.email}"
|
|
||||||
placeholder="${t.emailPlaceholder}"
|
|
||||||
required
|
|
||||||
/>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<div class="form-group">
|
|
||||||
<label class="label">${t.additionalInfo}</label>
|
|
||||||
<textarea
|
|
||||||
class="input textarea"
|
|
||||||
id="info-input"
|
|
||||||
placeholder="${t.additionalInfoPlaceholder}"
|
|
||||||
rows="4"
|
|
||||||
>${this.additionalInfo}</textarea>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
${this.error ? `<div class="error">${this.error}</div>` : ''}
|
|
||||||
|
|
||||||
<button
|
|
||||||
type="submit"
|
|
||||||
class="btn-submit"
|
|
||||||
${isDisabled ? 'disabled' : ''}
|
|
||||||
>
|
|
||||||
${this.isSubmitting ? t.submitting : t.submit}
|
|
||||||
</button>
|
|
||||||
</form>
|
|
||||||
|
|
||||||
<p class="disclaimer">${t.disclaimer}</p>
|
|
||||||
</div>
|
|
||||||
`
|
|
||||||
|
|
||||||
// Bind events
|
// Bind events
|
||||||
const form = this.shadow.getElementById('dsr-form') as HTMLFormElement
|
const form = this.shadow.getElementById('dsr-form') as HTMLFormElement
|
||||||
@@ -439,23 +121,6 @@ export class DSRPortalElement extends BreakPilotElement {
|
|||||||
}
|
}
|
||||||
})
|
})
|
||||||
}
|
}
|
||||||
|
|
||||||
private renderSuccess(styles: string): void {
|
|
||||||
const t = this.t
|
|
||||||
|
|
||||||
this.shadow.innerHTML = `
|
|
||||||
<style>${styles}</style>
|
|
||||||
<div class="portal">
|
|
||||||
<div class="success">
|
|
||||||
<div class="success-icon">✓</div>
|
|
||||||
<h2 class="success-title">${t.successTitle}</h2>
|
|
||||||
<p class="success-message">
|
|
||||||
${t.successMessage} ${this.email}.
|
|
||||||
</p>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
`
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|
||||||
// Register the custom element
|
// Register the custom element
|
||||||
|
|||||||
Reference in New Issue
Block a user