'use client' import { createContext, useContext, useState, useEffect, useCallback, ReactNode } from 'react' // ============================================ // TYPES // ============================================ export type ImportanceLabel = 'KRITISCH' | 'DRINGEND' | 'WICHTIG' | 'PRUEFEN' | 'INFO' export type DecisionLabel = 'relevant' | 'irrelevant' | 'needs_review' export type SourceType = 'email' | 'rss' export type NoiseMode = 'STRICT' | 'BALANCED' | 'BROAD' export type Package = 'PARKING' | 'EV_CHARGING' | 'FUEL' | 'TANK_MONITORING' export interface AlertSource { id: string tenantId: string type: SourceType inboundAddress?: string rssUrl?: string label: string active: boolean createdAt: Date } export interface B2BHit { id: string tenantId: string topicId: string sourceType: SourceType sourceRef: string originalUrl: string canonicalUrl: string title: string snippet: string fullText?: string foundAt: Date language?: string countryGuess?: string buyerGuess?: string deadlineGuess?: string importanceScore: number importanceLabel: ImportanceLabel decisionLabel: DecisionLabel decisionConfidence: number decisionTrace?: DecisionTrace isRead: boolean userFeedback?: 'relevant' | 'irrelevant' createdAt: Date } export interface DecisionTrace { rulesTriggered: string[] llmUsed: boolean llmConfidence?: number signals: { procurementSignalsFound: string[] publicBuyerSignalsFound: string[] productSignalsFound: string[] negativesFound: string[] } } export interface B2BTopic { id: string tenantId: string name: string package: Package intent: TopicIntent filters: TopicFilters decisionPolicy: DecisionPolicy importanceModelId: string status: 'active' | 'paused' createdAt: Date } export interface TopicIntent { goalStatement: string mustHaveAnyN: number mustHave: { type: 'keyword'; value: string }[] publicBuyerSignalsAny: string[] procurementSignalsAny: string[] } export interface TopicFilters { hardExcludesAny: string[] softExcludesAny: string[] } export interface DecisionPolicy { rulesFirst: boolean llmMode: 'ALWAYS' | 'GRAYZONE_ONLY' | 'NEVER' autoRelevantMinConf: number needsReviewRange: [number, number] autoIrrelevantMaxConf: number } export interface ImportanceModel { modelId: string scoreRange: [number, number] weights: { deadlineProximity: number procurementIntentStrength: number publicBuyerStrength: number productMatchStrength: number sourceTrust: number novelty: number } thresholds: { infoMax: number reviewMin: number importantMin: number urgentMin: number criticalMin: number } topNCaps: { dailyDigestMax: number weeklyDigestMax: number perTopicDailyMax: number } } export interface B2BTemplate { templateId: string templateName: string templateDescription: string targetRoles: string[] industry?: string companyExample?: string topics: Omit[] importanceModel: ImportanceModel guidedConfig: GuidedConfig notificationPreset: NotificationPreset } export interface GuidedConfig { regionSelector: { type: 'multi_select' default: string[] options: string[] } languageSelector: { type: 'multi_select' default: string[] options: string[] } packageSelector: { type: 'multi_select' default: Package[] options: Package[] } noiseMode: { type: 'single_select' default: NoiseMode options: NoiseMode[] } } export interface NotificationPreset { presetId: string channels: string[] cadence: 'REALTIME' | 'HOURLY' | 'DAILY' | 'WEEKLY' timeLocal: string includeStatuses: DecisionLabel[] maxItems: number } export interface B2BTenant { id: string name: string companyName: string industry: string plan: 'trial' | 'starter' | 'professional' | 'enterprise' inboundEmailDomain: string createdAt: Date settings: B2BSettings } export interface B2BSettings { migrationCompleted: boolean wizardCompleted: boolean selectedTemplateId?: string selectedRegions: string[] selectedLanguages: string[] selectedPackages: Package[] noiseMode: NoiseMode notificationCadence: 'REALTIME' | 'HOURLY' | 'DAILY' | 'WEEKLY' notificationTime: string maxDigestItems: number } // ============================================ // HECTRONIC TEMPLATE (Real Example) // ============================================ export const hectronicTemplate: B2BTemplate = { templateId: 'tpl_hectronic_public_procurement_v1', templateName: 'Hectronic – Kommunale Ausschreibungen (Parking • EV Charging • Fuel)', templateDescription: 'Findet weltweit kommunale Ausschreibungen für Parkscheinautomaten/Parkraummanagement, Payment an Ladepunkten und Fuel-Terminals. Reduziert News/Jobs/Zubehör-Rauschen stark.', targetRoles: ['sales_ops', 'bid_management', 'product_marketing', 'regional_sales'], industry: 'Payment & Infrastructure', companyExample: 'Hectronic GmbH', guidedConfig: { regionSelector: { type: 'multi_select', default: ['EUROPE'], options: ['EUROPE', 'DACH', 'NORTH_AMERICA', 'MIDDLE_EAST', 'APAC', 'GLOBAL'] }, languageSelector: { type: 'multi_select', default: ['de', 'en'], options: ['de', 'en', 'fr', 'es', 'it', 'nl', 'pl', 'sv', 'da', 'no', 'fi', 'pt'] }, packageSelector: { type: 'multi_select', default: ['PARKING', 'EV_CHARGING'], options: ['PARKING', 'EV_CHARGING', 'FUEL', 'TANK_MONITORING'] }, noiseMode: { type: 'single_select', default: 'STRICT', options: ['STRICT', 'BALANCED', 'BROAD'] } }, topics: [ { name: 'Parking – Parkscheinautomaten & Parkraummanagement (Kommunen)', package: 'PARKING', intent: { goalStatement: 'Finde kommunale Ausschreibungen/Procurements für Parkscheinautomaten, Pay Stations, Parkraummanagement-Software und zugehörige Services.', mustHaveAnyN: 2, mustHave: [ { type: 'keyword', value: 'parking meter' }, { type: 'keyword', value: 'pay and display' }, { type: 'keyword', value: 'parking pay station' }, { type: 'keyword', value: 'Parkscheinautomat' }, { type: 'keyword', value: 'Parkautomat' }, { type: 'keyword', value: 'Parkraumbewirtschaftung' }, { type: 'keyword', value: 'backoffice' }, { type: 'keyword', value: 'management software' } ], publicBuyerSignalsAny: [ 'municipality', 'city', 'city council', 'local authority', 'public tender', 'Kommune', 'Stadt', 'Gemeinde', 'Landkreis', 'öffentliche Ausschreibung', 'Vergabe' ], procurementSignalsAny: [ 'tender', 'procurement', 'RFP', 'RFQ', 'invitation to tender', 'contract notice', 'Ausschreibung', 'Vergabe', 'EU-weite Ausschreibung', 'Bekanntmachung' ] }, filters: { hardExcludesAny: [ 'keyboard', 'mouse', 'headset', 'toner', 'office supplies', 'Tastatur', 'Maus', 'Headset', 'Büromaterial', 'job', 'hiring', 'stellenanzeige', 'bewerbung', 'parking fine', 'parking ticket', 'Strafzettel', 'Knöllchen' ], softExcludesAny: [ 'press release', 'marketing', 'blog', 'opinion', 'pressemitteilung', 'werbung', 'meinung', 'maintenance only', 'consulting only', 'support only' ] }, decisionPolicy: { rulesFirst: true, llmMode: 'GRAYZONE_ONLY', autoRelevantMinConf: 0.80, needsReviewRange: [0.50, 0.79], autoIrrelevantMaxConf: 0.49 }, importanceModelId: 'imp_hectronic_v1', status: 'active' }, { name: 'EV Charging – Payment/Terminals in kommunaler Ladeinfrastruktur', package: 'EV_CHARGING', intent: { goalStatement: 'Finde kommunale Ausschreibungen für Ladeinfrastruktur mit Payment-Terminals, Ad-hoc-Kartenzahlung, Backend/Management.', mustHaveAnyN: 2, mustHave: [ { type: 'keyword', value: 'EV charging' }, { type: 'keyword', value: 'charging station' }, { type: 'keyword', value: 'charge point' }, { type: 'keyword', value: 'Ladesäule' }, { type: 'keyword', value: 'Ladeinfrastruktur' }, { type: 'keyword', value: 'payment terminal' }, { type: 'keyword', value: 'card payment' }, { type: 'keyword', value: 'contactless' }, { type: 'keyword', value: 'Bezahlsystem' }, { type: 'keyword', value: 'Kartenzahlung' } ], publicBuyerSignalsAny: [ 'municipality', 'city', 'public works', 'local authority', 'Kommune', 'Stadt', 'Gemeinde', 'Kommunal' ], procurementSignalsAny: [ 'tender', 'procurement', 'RFP', 'RFQ', 'contract notice', 'Ausschreibung', 'Vergabe', 'Bekanntmachung' ] }, filters: { hardExcludesAny: [ 'stock', 'investor', 'funding round', 'press release', 'Aktie', 'Investor', 'Finanzierung', 'Pressemitteilung', 'job', 'hiring', 'stellenanzeige', 'private home charger', 'wallbox' ], softExcludesAny: [ 'funding program', 'grant', 'Förderprogramm', 'Zuschuss', 'pilot project', 'research project', 'Modellprojekt' ] }, decisionPolicy: { rulesFirst: true, llmMode: 'GRAYZONE_ONLY', autoRelevantMinConf: 0.80, needsReviewRange: [0.50, 0.79], autoIrrelevantMaxConf: 0.49 }, importanceModelId: 'imp_hectronic_v1', status: 'active' }, { name: 'Fuel – Fleet/Public Fuel Terminals', package: 'FUEL', intent: { goalStatement: 'Finde Ausschreibungen für (un)bemannte Tankautomaten/Fuel-Terminals, Fuel-Management, Flottenbetankung.', mustHaveAnyN: 2, mustHave: [ { type: 'keyword', value: 'fuel terminal' }, { type: 'keyword', value: 'fuel management' }, { type: 'keyword', value: 'unmanned fuel station' }, { type: 'keyword', value: 'fleet fueling' }, { type: 'keyword', value: 'Tankautomat' }, { type: 'keyword', value: 'Tankterminal' }, { type: 'keyword', value: 'Flottenbetankung' }, { type: 'keyword', value: 'Betriebstankstelle' } ], publicBuyerSignalsAny: ['municipality', 'utilities', 'public works', 'Stadtwerke', 'Kommunal'], procurementSignalsAny: ['tender', 'procurement', 'RFP', 'Ausschreibung', 'Vergabe'] }, filters: { hardExcludesAny: ['fuel price', 'oil price', 'Spritpreise', 'Ölpreis', 'job', 'hiring', 'stellenanzeige'], softExcludesAny: ['market news', 'commodity', 'Börse', 'Pressemitteilung'] }, decisionPolicy: { rulesFirst: true, llmMode: 'GRAYZONE_ONLY', autoRelevantMinConf: 0.80, needsReviewRange: [0.50, 0.79], autoIrrelevantMaxConf: 0.49 }, importanceModelId: 'imp_hectronic_v1', status: 'active' } ], importanceModel: { modelId: 'imp_hectronic_v1', scoreRange: [0, 100], weights: { deadlineProximity: 28, procurementIntentStrength: 22, publicBuyerStrength: 18, productMatchStrength: 18, sourceTrust: 8, novelty: 6 }, thresholds: { infoMax: 29, reviewMin: 30, importantMin: 50, urgentMin: 70, criticalMin: 85 }, topNCaps: { dailyDigestMax: 10, weeklyDigestMax: 20, perTopicDailyMax: 6 } }, notificationPreset: { presetId: 'notif_hectronic_digest_v1', channels: ['email'], cadence: 'DAILY', timeLocal: '08:00', includeStatuses: ['relevant', 'needs_review'], maxItems: 10 } } // ============================================ // MOCK DATA (Demo Hits for Hectronic) // ============================================ const mockHectronicHits: B2BHit[] = [ { id: 'hit-hec-001', tenantId: 'hectronic-demo', topicId: 'hec_parking_v1', sourceType: 'email', sourceRef: 'ga-msg-001', originalUrl: 'https://www.evergabe.de/auftraege/parkscheinautomaten-stadt-muenchen', canonicalUrl: 'https://evergabe.de/auftraege/parkscheinautomaten-muenchen', title: 'Stadt München: Lieferung und Installation von 150 Parkscheinautomaten', snippet: 'Die Landeshauptstadt München schreibt die Beschaffung von 150 modernen Parkscheinautomaten inkl. Backoffice-Software für das Stadtgebiet aus. Frist: 15.03.2026.', foundAt: new Date(Date.now() - 2 * 60 * 60 * 1000), language: 'de', countryGuess: 'Germany', buyerGuess: 'Landeshauptstadt München', deadlineGuess: '2026-03-15', importanceScore: 92, importanceLabel: 'KRITISCH', decisionLabel: 'relevant', decisionConfidence: 0.95, decisionTrace: { rulesTriggered: ['procurement_signal_match', 'public_buyer_match', 'product_match'], llmUsed: false, signals: { procurementSignalsFound: ['Ausschreibung', 'Beschaffung', 'Vergabe'], publicBuyerSignalsFound: ['Landeshauptstadt', 'Stadt München'], productSignalsFound: ['Parkscheinautomaten', 'Backoffice-Software'], negativesFound: [] } }, isRead: false, createdAt: new Date(Date.now() - 2 * 60 * 60 * 1000) }, { id: 'hit-hec-002', tenantId: 'hectronic-demo', topicId: 'hec_ev_charging_v1', sourceType: 'rss', sourceRef: 'rss-item-002', originalUrl: 'https://ted.europa.eu/notice/2026-12345', canonicalUrl: 'https://ted.europa.eu/notice/2026-12345', title: 'EU Tender: Supply of EV Charging Infrastructure with Payment Terminals - City of Amsterdam', snippet: 'Contract notice for the supply, installation and maintenance of 200 public EV charging points with integrated contactless payment terminals. Deadline: 28.02.2026.', foundAt: new Date(Date.now() - 5 * 60 * 60 * 1000), language: 'en', countryGuess: 'Netherlands', buyerGuess: 'City of Amsterdam', deadlineGuess: '2026-02-28', importanceScore: 88, importanceLabel: 'KRITISCH', decisionLabel: 'relevant', decisionConfidence: 0.92, decisionTrace: { rulesTriggered: ['procurement_signal_match', 'public_buyer_match', 'product_match'], llmUsed: false, signals: { procurementSignalsFound: ['Contract notice', 'tender', 'supply'], publicBuyerSignalsFound: ['City of Amsterdam', 'public'], productSignalsFound: ['EV charging', 'charging points', 'payment terminals', 'contactless'], negativesFound: [] } }, isRead: false, createdAt: new Date(Date.now() - 5 * 60 * 60 * 1000) }, { id: 'hit-hec-003', tenantId: 'hectronic-demo', topicId: 'hec_parking_v1', sourceType: 'email', sourceRef: 'ga-msg-003', originalUrl: 'https://vergabe.niedersachsen.de/NetServer/notice/8765432', canonicalUrl: 'https://vergabe.niedersachsen.de/notice/8765432', title: 'Gemeinde Seevetal: Erneuerung Parkraumbewirtschaftungssystem', snippet: 'Öffentliche Ausschreibung zur Erneuerung des Parkraumbewirtschaftungssystems mit 25 Pay-Stations und zentraler Management-Software.', foundAt: new Date(Date.now() - 8 * 60 * 60 * 1000), language: 'de', countryGuess: 'Germany', buyerGuess: 'Gemeinde Seevetal', deadlineGuess: '2026-04-01', importanceScore: 78, importanceLabel: 'DRINGEND', decisionLabel: 'relevant', decisionConfidence: 0.88, decisionTrace: { rulesTriggered: ['procurement_signal_match', 'public_buyer_match', 'product_match'], llmUsed: false, signals: { procurementSignalsFound: ['Öffentliche Ausschreibung', 'Erneuerung'], publicBuyerSignalsFound: ['Gemeinde'], productSignalsFound: ['Parkraumbewirtschaftungssystem', 'Pay-Stations', 'Management-Software'], negativesFound: [] } }, isRead: false, createdAt: new Date(Date.now() - 8 * 60 * 60 * 1000) }, { id: 'hit-hec-004', tenantId: 'hectronic-demo', topicId: 'hec_parking_v1', sourceType: 'email', sourceRef: 'ga-msg-004', originalUrl: 'https://news.example.com/parking-industry-trends-2026', canonicalUrl: 'https://news.example.com/parking-trends-2026', title: 'Parking Industry Trends 2026: Smart Cities investieren in digitale Lösungen', snippet: 'Branchenanalyse zeigt wachsende Nachfrage nach vernetzten Parkscheinautomaten. Experten erwarten 15% Marktwachstum.', foundAt: new Date(Date.now() - 12 * 60 * 60 * 1000), language: 'de', countryGuess: 'Germany', buyerGuess: undefined, deadlineGuess: undefined, importanceScore: 15, importanceLabel: 'INFO', decisionLabel: 'irrelevant', decisionConfidence: 0.91, decisionTrace: { rulesTriggered: ['soft_exclude_triggered'], llmUsed: false, signals: { procurementSignalsFound: [], publicBuyerSignalsFound: [], productSignalsFound: ['Parkscheinautomaten'], negativesFound: ['Branchenanalyse', 'Experten', 'Marktwachstum'] } }, isRead: true, createdAt: new Date(Date.now() - 12 * 60 * 60 * 1000) }, { id: 'hit-hec-005', tenantId: 'hectronic-demo', topicId: 'hec_ev_charging_v1', sourceType: 'rss', sourceRef: 'rss-item-005', originalUrl: 'https://ausschreibungen.at/ladeinfrastruktur-wien', canonicalUrl: 'https://ausschreibungen.at/ladeinfrastruktur-wien', title: 'Stadt Wien: Rahmenvertrag Ladeinfrastruktur öffentlicher Raum', snippet: 'Vergabeverfahren für Rahmenvertrag über Lieferung und Betrieb von Ladeinfrastruktur im öffentlichen Raum. Details hinter Login.', foundAt: new Date(Date.now() - 18 * 60 * 60 * 1000), language: 'de', countryGuess: 'Austria', buyerGuess: 'Stadt Wien', deadlineGuess: undefined, importanceScore: 55, importanceLabel: 'WICHTIG', decisionLabel: 'needs_review', decisionConfidence: 0.62, decisionTrace: { rulesTriggered: ['procurement_signal_match', 'public_buyer_match'], llmUsed: true, llmConfidence: 0.62, signals: { procurementSignalsFound: ['Vergabeverfahren', 'Rahmenvertrag'], publicBuyerSignalsFound: ['Stadt Wien', 'öffentlicher Raum'], productSignalsFound: ['Ladeinfrastruktur'], negativesFound: ['Details hinter Login'] } }, isRead: false, createdAt: new Date(Date.now() - 18 * 60 * 60 * 1000) }, { id: 'hit-hec-006', tenantId: 'hectronic-demo', topicId: 'hec_fuel_v1', sourceType: 'email', sourceRef: 'ga-msg-006', originalUrl: 'https://vergabe.bund.de/tankautomat-bundeswehr', canonicalUrl: 'https://vergabe.bund.de/tankautomat-bw', title: 'Bundesamt für Ausrüstung: Tankautomaten für Liegenschaften', snippet: 'Beschaffung von unbemannten Tankautomaten für Flottenbetankung an 12 Bundeswehr-Standorten. EU-weite Ausschreibung.', foundAt: new Date(Date.now() - 24 * 60 * 60 * 1000), language: 'de', countryGuess: 'Germany', buyerGuess: 'Bundesamt für Ausrüstung', deadlineGuess: '2026-05-15', importanceScore: 72, importanceLabel: 'DRINGEND', decisionLabel: 'relevant', decisionConfidence: 0.86, decisionTrace: { rulesTriggered: ['procurement_signal_match', 'public_buyer_match', 'product_match'], llmUsed: false, signals: { procurementSignalsFound: ['Beschaffung', 'EU-weite Ausschreibung'], publicBuyerSignalsFound: ['Bundesamt'], productSignalsFound: ['Tankautomaten', 'Flottenbetankung', 'unbemannt'], negativesFound: [] } }, isRead: true, createdAt: new Date(Date.now() - 24 * 60 * 60 * 1000) } ] // ============================================ // DEFAULT SETTINGS // ============================================ const defaultB2BSettings: B2BSettings = { migrationCompleted: false, wizardCompleted: false, selectedRegions: ['EUROPE'], selectedLanguages: ['de', 'en'], selectedPackages: ['PARKING', 'EV_CHARGING'], noiseMode: 'STRICT', notificationCadence: 'DAILY', notificationTime: '08:00', maxDigestItems: 10 } const defaultTenant: B2BTenant = { id: 'demo-tenant', name: 'Demo Account', companyName: 'Meine Firma GmbH', industry: 'Payment & Infrastructure', plan: 'trial', inboundEmailDomain: 'alerts.breakpilot.de', createdAt: new Date(), settings: defaultB2BSettings } // ============================================ // CONTEXT // ============================================ interface AlertsB2BContextType { // Tenant tenant: B2BTenant updateTenant: (updates: Partial) => void // Settings settings: B2BSettings updateSettings: (updates: Partial) => void // Templates availableTemplates: B2BTemplate[] selectedTemplate: B2BTemplate | null selectTemplate: (templateId: string) => void // Sources sources: AlertSource[] addSource: (source: Omit) => void removeSource: (id: string) => void generateInboundEmail: () => string // Topics topics: B2BTopic[] addTopic: (topic: Omit) => void updateTopic: (id: string, updates: Partial) => void removeTopic: (id: string) => void // Hits hits: B2BHit[] unreadCount: number relevantCount: number needsReviewCount: number markAsRead: (id: string) => void markAllAsRead: () => void submitFeedback: (id: string, feedback: 'relevant' | 'irrelevant') => void processEmailContent: (emailContent: string, emailSubject?: string) => B2BHit // Digest getDigest: (maxItems?: number) => B2BHit[] // Loading/Error isLoading: boolean error: string | null } const AlertsB2BContext = createContext(null) // LocalStorage Keys const B2B_TENANT_KEY = 'bp_b2b_tenant' const B2B_SETTINGS_KEY = 'bp_b2b_settings' const B2B_SOURCES_KEY = 'bp_b2b_sources' const B2B_TOPICS_KEY = 'bp_b2b_topics' const B2B_HITS_KEY = 'bp_b2b_hits' export function AlertsB2BProvider({ children }: { children: ReactNode }) { const [tenant, setTenant] = useState(defaultTenant) const [settings, setSettings] = useState(defaultB2BSettings) const [sources, setSources] = useState([]) const [topics, setTopics] = useState([]) const [hits, setHits] = useState([]) const [selectedTemplate, setSelectedTemplate] = useState(null) const [isLoading, setIsLoading] = useState(false) const [error, setError] = useState(null) const [mounted, setMounted] = useState(false) // Available templates const availableTemplates: B2BTemplate[] = [hectronicTemplate] // Load from localStorage useEffect(() => { const storedTenant = localStorage.getItem(B2B_TENANT_KEY) const storedSettings = localStorage.getItem(B2B_SETTINGS_KEY) const storedSources = localStorage.getItem(B2B_SOURCES_KEY) const storedTopics = localStorage.getItem(B2B_TOPICS_KEY) const storedHits = localStorage.getItem(B2B_HITS_KEY) if (storedTenant) { try { const parsed = JSON.parse(storedTenant) setTenant({ ...defaultTenant, ...parsed, createdAt: new Date(parsed.createdAt) }) } catch (e) { console.error('Error parsing tenant:', e) } } if (storedSettings) { try { setSettings({ ...defaultB2BSettings, ...JSON.parse(storedSettings) }) } catch (e) { console.error('Error parsing settings:', e) } } if (storedSources) { try { const parsed = JSON.parse(storedSources) setSources(parsed.map((s: any) => ({ ...s, createdAt: new Date(s.createdAt) }))) } catch (e) { console.error('Error parsing sources:', e) } } if (storedTopics) { try { const parsed = JSON.parse(storedTopics) setTopics(parsed.map((t: any) => ({ ...t, createdAt: new Date(t.createdAt) }))) } catch (e) { console.error('Error parsing topics:', e) } } if (storedHits) { try { const parsed = JSON.parse(storedHits) setHits(parsed.map((h: any) => ({ ...h, foundAt: new Date(h.foundAt), createdAt: new Date(h.createdAt) }))) } catch (e) { console.error('Error parsing hits:', e) // Load mock data setHits(mockHectronicHits) } } else { // Load mock data for demo setHits(mockHectronicHits) } setMounted(true) }, []) // Save to localStorage useEffect(() => { if (mounted) { localStorage.setItem(B2B_TENANT_KEY, JSON.stringify(tenant)) } }, [tenant, mounted]) useEffect(() => { if (mounted) { localStorage.setItem(B2B_SETTINGS_KEY, JSON.stringify(settings)) } }, [settings, mounted]) useEffect(() => { if (mounted) { localStorage.setItem(B2B_SOURCES_KEY, JSON.stringify(sources)) } }, [sources, mounted]) useEffect(() => { if (mounted) { localStorage.setItem(B2B_TOPICS_KEY, JSON.stringify(topics)) } }, [topics, mounted]) useEffect(() => { if (mounted && hits.length > 0) { localStorage.setItem(B2B_HITS_KEY, JSON.stringify(hits)) } }, [hits, mounted]) // Tenant operations const updateTenant = useCallback((updates: Partial) => { setTenant(prev => ({ ...prev, ...updates })) }, []) // Settings operations const updateSettings = useCallback((updates: Partial) => { setSettings(prev => ({ ...prev, ...updates })) }, []) // Template operations const selectTemplate = useCallback((templateId: string) => { const template = availableTemplates.find(t => t.templateId === templateId) if (template) { setSelectedTemplate(template) // Auto-apply template settings updateSettings({ selectedTemplateId: templateId, selectedRegions: template.guidedConfig.regionSelector.default, selectedLanguages: template.guidedConfig.languageSelector.default, selectedPackages: template.guidedConfig.packageSelector.default, noiseMode: template.guidedConfig.noiseMode.default, notificationCadence: template.notificationPreset.cadence, notificationTime: template.notificationPreset.timeLocal, maxDigestItems: template.notificationPreset.maxItems }) // Create topics from template const newTopics: B2BTopic[] = template.topics.map((t, idx) => ({ ...t, id: `topic-${Date.now()}-${idx}`, tenantId: tenant.id, createdAt: new Date() })) setTopics(newTopics) } }, [availableTemplates, tenant.id, updateSettings]) // Source operations const generateInboundEmail = useCallback(() => { const token = Math.random().toString(36).substring(2, 10) return `alerts+${tenant.id}-${token}@${tenant.inboundEmailDomain}` }, [tenant.id, tenant.inboundEmailDomain]) const addSource = useCallback((source: Omit) => { const newSource: AlertSource = { ...source, id: `source-${Date.now()}-${Math.random().toString(36).substr(2, 9)}`, createdAt: new Date() } setSources(prev => [...prev, newSource]) }, []) const removeSource = useCallback((id: string) => { setSources(prev => prev.filter(s => s.id !== id)) }, []) // Topic operations const addTopic = useCallback((topic: Omit) => { const newTopic: B2BTopic = { ...topic, id: `topic-${Date.now()}-${Math.random().toString(36).substr(2, 9)}`, createdAt: new Date() } setTopics(prev => [...prev, newTopic]) }, []) const updateTopic = useCallback((id: string, updates: Partial) => { setTopics(prev => prev.map(t => t.id === id ? { ...t, ...updates } : t)) }, []) const removeTopic = useCallback((id: string) => { setTopics(prev => prev.filter(t => t.id !== id)) }, []) // Hit operations const markAsRead = useCallback((id: string) => { setHits(prev => prev.map(h => h.id === id ? { ...h, isRead: true } : h)) }, []) const markAllAsRead = useCallback(() => { setHits(prev => prev.map(h => ({ ...h, isRead: true }))) }, []) const submitFeedback = useCallback((id: string, feedback: 'relevant' | 'irrelevant') => { setHits(prev => prev.map(h => h.id === id ? { ...h, userFeedback: feedback } : h)) }, []) // Process Email Content (Manual Import for Testing) const processEmailContent = useCallback((emailContent: string, emailSubject?: string): B2BHit => { // Parse email content to extract useful information const lines = emailContent.split('\n').filter(l => l.trim()) const title = emailSubject || lines[0]?.slice(0, 100) || 'Manuell eingefuegter Alert' // Try to extract URLs from content const urlRegex = /(https?:\/\/[^\s<>"]+)/g const urls = emailContent.match(urlRegex) || [] const firstUrl = urls[0] || 'https://example.com/manual-import' // Extract snippet (first meaningful paragraph) const snippet = lines.slice(0, 3).join(' ').slice(0, 300) || emailContent.slice(0, 300) // Simulate AI analysis - look for procurement signals const procurementSignals = ['ausschreibung', 'tender', 'vergabe', 'beschaffung', 'auftrag', 'angebot', 'submission', 'procurement', 'rfp', 'rfq', 'bid'] const productSignals = ['parking', 'parkschein', 'ladesäule', 'ev charging', 'ladestation', 'tankstelle', 'fuel', 'bezahlterminal', 'payment'] const buyerSignals = ['stadt', 'kommune', 'gemeinde', 'city', 'municipality', 'council', 'stadtwerke', 'öffentlich', 'public'] const negativeSignals = ['stellenangebot', 'job', 'karriere', 'news', 'blog', 'press release'] const lowerContent = emailContent.toLowerCase() const foundProcurement = procurementSignals.filter(s => lowerContent.includes(s)) const foundProducts = productSignals.filter(s => lowerContent.includes(s)) const foundBuyers = buyerSignals.filter(s => lowerContent.includes(s)) const foundNegatives = negativeSignals.filter(s => lowerContent.includes(s)) // Calculate importance score (0-100) let score = 30 // base score score += foundProcurement.length * 15 score += foundProducts.length * 10 score += foundBuyers.length * 12 score -= foundNegatives.length * 20 score = Math.max(0, Math.min(100, score)) // Determine importance label let importanceLabel: ImportanceLabel = 'INFO' if (score >= 80) importanceLabel = 'KRITISCH' else if (score >= 65) importanceLabel = 'DRINGEND' else if (score >= 50) importanceLabel = 'WICHTIG' else if (score >= 30) importanceLabel = 'PRUEFEN' // Determine decision label let decisionLabel: DecisionLabel = 'irrelevant' let decisionConfidence = 0.5 if (foundNegatives.length > 1) { decisionLabel = 'irrelevant' decisionConfidence = 0.8 } else if (foundProcurement.length >= 2 && foundProducts.length >= 1) { decisionLabel = 'relevant' decisionConfidence = 0.85 } else if (foundProcurement.length >= 1 || foundProducts.length >= 1) { decisionLabel = 'needs_review' decisionConfidence = 0.6 } // Try to guess buyer and country let buyerGuess: string | undefined let countryGuess: string | undefined const countryPatterns = [ { pattern: /deutschland|germany|german/i, country: 'DE' }, { pattern: /österreich|austria|austrian/i, country: 'AT' }, { pattern: /schweiz|switzerland|swiss/i, country: 'CH' }, { pattern: /frankreich|france|french/i, country: 'FR' }, { pattern: /niederlande|netherlands|dutch/i, country: 'NL' }, ] for (const { pattern, country } of countryPatterns) { if (pattern.test(emailContent)) { countryGuess = country break } } // Extract potential buyer name (look for "Stadt X" or "Kommune Y") const buyerMatch = emailContent.match(/(?:stadt|kommune|gemeinde|city of|municipality of)\s+([A-Za-zäöüß]+)/i) if (buyerMatch) { buyerGuess = buyerMatch[0] } // Try to find deadline let deadlineGuess: string | undefined const dateMatch = emailContent.match(/(\d{1,2})[.\/](\d{1,2})[.\/](\d{2,4})/); if (dateMatch) { const day = parseInt(dateMatch[1]) const month = parseInt(dateMatch[2]) const year = dateMatch[3].length === 2 ? 2000 + parseInt(dateMatch[3]) : parseInt(dateMatch[3]) const date = new Date(year, month - 1, day) if (date > new Date()) { deadlineGuess = date.toISOString() } } // Create new hit const newHit: B2BHit = { id: `manual_${Date.now()}_${Math.random().toString(36).substr(2, 9)}`, tenantId: tenant.id, topicId: topics[0]?.id || 'default', sourceType: 'email', sourceRef: 'manual_import', originalUrl: firstUrl, canonicalUrl: firstUrl, title, snippet, fullText: emailContent, foundAt: new Date(), language: 'de', countryGuess, buyerGuess, deadlineGuess, importanceScore: score, importanceLabel, decisionLabel, decisionConfidence, decisionTrace: { rulesTriggered: [ ...(foundProcurement.length > 0 ? ['procurement_signal_detected'] : []), ...(foundProducts.length > 0 ? ['product_match_found'] : []), ...(foundBuyers.length > 0 ? ['public_buyer_signal'] : []), ...(foundNegatives.length > 0 ? ['negative_signal_detected'] : []), ], llmUsed: true, llmConfidence: decisionConfidence, signals: { procurementSignalsFound: foundProcurement, publicBuyerSignalsFound: foundBuyers, productSignalsFound: foundProducts, negativesFound: foundNegatives } }, isRead: false, createdAt: new Date() } // Add to hits (prepend to show at top) setHits(prev => [newHit, ...prev]) return newHit }, [tenant.id, topics]) // Digest const getDigest = useCallback((maxItems: number = settings.maxDigestItems) => { return hits .filter(h => h.decisionLabel === 'relevant' || h.decisionLabel === 'needs_review') .sort((a, b) => b.importanceScore - a.importanceScore) .slice(0, maxItems) }, [hits, settings.maxDigestItems]) // Computed values const unreadCount = hits.filter(h => !h.isRead && h.decisionLabel !== 'irrelevant').length const relevantCount = hits.filter(h => h.decisionLabel === 'relevant').length const needsReviewCount = hits.filter(h => h.decisionLabel === 'needs_review').length // SSR safety if (!mounted) { return ( {}, settings: defaultB2BSettings, updateSettings: () => {}, availableTemplates: [], selectedTemplate: null, selectTemplate: () => {}, sources: [], addSource: () => {}, removeSource: () => {}, generateInboundEmail: () => '', topics: [], addTopic: () => {}, updateTopic: () => {}, removeTopic: () => {}, hits: [], unreadCount: 0, relevantCount: 0, needsReviewCount: 0, markAsRead: () => {}, markAllAsRead: () => {}, submitFeedback: () => {}, processEmailContent: () => ({} as B2BHit), getDigest: () => [], isLoading: false, error: null }} > {children} ) } return ( {children} ) } export function useAlertsB2B() { const context = useContext(AlertsB2BContext) if (!context) { throw new Error('useAlertsB2B must be used within an AlertsB2BProvider') } return context } // ============================================ // HELPER FUNCTIONS // ============================================ export function getImportanceLabelColor(label: ImportanceLabel, isDark: boolean): string { const colors = { 'KRITISCH': isDark ? 'bg-red-500/20 text-red-300 border-red-500/30' : 'bg-red-100 text-red-700 border-red-200', 'DRINGEND': isDark ? 'bg-orange-500/20 text-orange-300 border-orange-500/30' : 'bg-orange-100 text-orange-700 border-orange-200', 'WICHTIG': isDark ? 'bg-yellow-500/20 text-yellow-300 border-yellow-500/30' : 'bg-yellow-100 text-yellow-700 border-yellow-200', 'PRUEFEN': isDark ? 'bg-blue-500/20 text-blue-300 border-blue-500/30' : 'bg-blue-100 text-blue-700 border-blue-200', 'INFO': isDark ? 'bg-slate-500/20 text-slate-300 border-slate-500/30' : 'bg-slate-100 text-slate-600 border-slate-200', } return colors[label] } export function getDecisionLabelColor(label: DecisionLabel, isDark: boolean): string { const colors = { 'relevant': isDark ? 'bg-green-500/20 text-green-300' : 'bg-green-100 text-green-700', 'irrelevant': isDark ? 'bg-slate-500/20 text-slate-400' : 'bg-slate-100 text-slate-500', 'needs_review': isDark ? 'bg-amber-500/20 text-amber-300' : 'bg-amber-100 text-amber-700', } return colors[label] } export function formatDeadline(dateStr: string | null | undefined): string { if (!dateStr) return 'Keine Frist' const date = new Date(dateStr) const now = new Date() const diffDays = Math.ceil((date.getTime() - now.getTime()) / (1000 * 60 * 60 * 24)) if (diffDays < 0) return 'Abgelaufen' if (diffDays === 0) return 'Heute!' if (diffDays === 1) return 'Morgen' if (diffDays <= 7) return `${diffDays} Tage` if (diffDays <= 30) return `${Math.ceil(diffDays / 7)} Wochen` return date.toLocaleDateString('de-DE', { day: '2-digit', month: '2-digit', year: 'numeric' }) } export function getPackageIcon(pkg: Package): string { const icons = { 'PARKING': '🅿️', 'EV_CHARGING': '⚡', 'FUEL': '⛽', 'TANK_MONITORING': '📊' } return icons[pkg] } export function getPackageLabel(pkg: Package): string { const labels = { 'PARKING': 'Parking', 'EV_CHARGING': 'EV Charging', 'FUEL': 'Fuel', 'TANK_MONITORING': 'Tank Monitoring' } return labels[pkg] }