feat: Enhance provider components with detailed documentation and comments

- Added comprehensive comments and JSDoc-style documentation to NotificationProvider, ThemeProvider, UserProvider, and WebsiteThemeProvider for better clarity and maintainability.
- Improved type definitions in index.ts for better code understanding and usage.
- Introduced Docker support with Dockerfiles for various services including AI server, signaling server, and browser-use service.
- Created a docker-compose.yml file to orchestrate multiple services including PostgreSQL, AI, scraper, and frontend.
- Added a startup guide (startup.txt) for setting up the CRM environment on Ubuntu with Docker.
- Included a .dockerignore file to exclude unnecessary files from Docker builds.
This commit is contained in:
Ace
2026-07-13 13:05:30 +02:00
parent dba4c84cd5
commit d35c806d5b
167 changed files with 3474 additions and 102 deletions
+41
View File
@@ -1,9 +1,17 @@
// ── AI Assistant: Main Chat Component ──
// Full conversational AI chat interface with message history, GIF support,
// server-health polling, localStorage persistence, and an animated boot sequence.
"use client"
import { useState, useRef, useEffect, Fragment, forwardRef, useImperativeHandle, useCallback } from "react"
import { Bot, Terminal } from "lucide-react"
import { GifPicker } from "./gif-picker"
/**
* Converts URLs in text into clickable anchor elements.
* Splits text by URL regex and renders each part as either a link or plain text fragment.
*/
function linkifyText(text: string) {
const urlRegex = /(https?:\/\/[^\s<]+[^\s<.,;:!?)\]}>])/
const parts = text.split(urlRegex)
@@ -15,6 +23,10 @@ function linkifyText(text: string) {
})
}
/**
* Renders AI response text with support for bullet lists (• or -) and paragraphs.
* Each line is transformed: bullets get a colored dot prefix, empty lines become spacing.
*/
function formatContent(text: string) {
const lines = text.split("\n")
return lines.map((line, i) => {
@@ -33,6 +45,7 @@ function formatContent(text: string) {
})
}
/** A single chat message, either from the user or the AI assistant */
interface ChatMessage {
role: "user" | "assistant"
content: string
@@ -45,16 +58,24 @@ interface ChatMessage {
}
interface AIChatProps {
/** Called when the user sends a message */
onMessageSent?: (msg: string) => void
}
/** Exposed imperative methods for parent components */
export interface AIChatHandle {
fillInput: (text: string) => void
addAssistantMessage: (content: string) => void
}
/**
* AIChat — the main conversational AI assistant interface.
* Features: boot animation, message history with localStorage, GIF picker,
* markdown-like formatting, and automatic scroll-to-bottom.
*/
export const AIChat = forwardRef<AIChatHandle, AIChatProps>(({ onMessageSent }, ref) => {
// ── State & refs ──
const [messages, setMessages] = useState<ChatMessage[]>([])
const [input, setInput] = useState("")
const [loading, setLoading] = useState(false)
@@ -67,6 +88,7 @@ export const AIChat = forwardRef<AIChatHandle, AIChatProps>(({ onMessageSent },
const hasUserMessage = messages.some(m => m.role === "user")
const loadedFromStorage = useRef(false)
// ── Expose imperative methods to parent via ref ──
useImperativeHandle(ref, () => ({
fillInput(text: string) {
setInput(text)
@@ -85,11 +107,13 @@ export const AIChat = forwardRef<AIChatHandle, AIChatProps>(({ onMessageSent },
},
}), [])
// ── Handle GIF selection from the GIF picker ──
const handleGifSelect = useCallback((gif: { url: string; previewUrl: string; title: string }) => {
setMessages((prev) => [...prev, { role: "user", content: gif.title || "Sent a GIF", gif }])
setShowGifPicker(false)
}, [])
// ── Close GIF picker on outside click ──
useEffect(() => {
if (showGifPicker) {
const handler = (e: MouseEvent) => {
@@ -102,6 +126,7 @@ export const AIChat = forwardRef<AIChatHandle, AIChatProps>(({ onMessageSent },
}
}, [showGifPicker])
// ── Load messages from localStorage, filtering out messages older than 1 week ──
const _WEEK_MS = 604800000
useEffect(() => {
@@ -123,6 +148,7 @@ export const AIChat = forwardRef<AIChatHandle, AIChatProps>(({ onMessageSent },
}
}, [])
// ── Persist messages to localStorage whenever they change ──
useEffect(() => {
if (messages.length > 0) {
const now = Date.now()
@@ -130,6 +156,7 @@ export const AIChat = forwardRef<AIChatHandle, AIChatProps>(({ onMessageSent },
}
}, [messages])
// ── Poll the AI server until it is no longer returning 503 (booting) ──
const checkServer = useCallback(async () => {
try {
const res = await fetch("/api/ai/chat", {
@@ -147,6 +174,7 @@ export const AIChat = forwardRef<AIChatHandle, AIChatProps>(({ onMessageSent },
}
}, [])
// ── On mount: poll server, load jobs to build welcome message ──
useEffect(() => {
checkServer()
if (loadedFromStorage.current) return
@@ -180,10 +208,12 @@ export const AIChat = forwardRef<AIChatHandle, AIChatProps>(({ onMessageSent },
})
}, [checkServer])
// ── Auto-scroll to bottom when new messages arrive ──
useEffect(() => {
messagesEndRef.current?.scrollIntoView({ behavior: "smooth" })
}, [messages])
// ── Send a message to the AI API ──
const sendMessage = useCallback(async (text?: string) => {
const msg = (text || input).trim()
if (!msg || loading) return
@@ -220,6 +250,7 @@ export const AIChat = forwardRef<AIChatHandle, AIChatProps>(({ onMessageSent },
}
}, [input, loading, onMessageSent])
// ── Enter key submits; Shift+Enter inserts newline ──
const handleKeyDown = useCallback((e: React.KeyboardEvent) => {
if (e.key === "Enter" && !e.shiftKey) {
e.preventDefault()
@@ -227,12 +258,14 @@ export const AIChat = forwardRef<AIChatHandle, AIChatProps>(({ onMessageSent },
}
}, [sendMessage])
// ── Auto-resize textarea on input ──
const handleTextareaInput = useCallback((e: React.FormEvent<HTMLTextAreaElement>) => {
const t = e.target as HTMLTextAreaElement
t.style.height = "auto"
t.style.height = t.scrollHeight + "px"
}, [])
// ── Boot screen ──
if (bootState === "booting") {
return (
<div className="flex items-center justify-center h-full">
@@ -259,6 +292,7 @@ export const AIChat = forwardRef<AIChatHandle, AIChatProps>(({ onMessageSent },
)
}
// ── Welcome screen (ready, no user messages yet) ──
if (bootState === "ready" && !hasUserMessage) {
return (
<div className="flex-1 flex flex-col overflow-y-auto" style={{ backgroundImage: "radial-gradient(circle, hsl(var(--border)) 1px, transparent 1px)", backgroundSize: "24px 24px" }}>
@@ -273,6 +307,7 @@ export const AIChat = forwardRef<AIChatHandle, AIChatProps>(({ onMessageSent },
</div>
</div>
</div>
{/* ── Input area (sticky bottom) ── */}
<div className="sticky bottom-0 bg-background/95 backdrop-blur-md px-4 py-4">
<div className="max-w-3xl mx-auto">
<div className="bg-card rounded-2xl border border-border focus-within:border-primary/40 focus-within:shadow-[0_0_20px_hsl(var(--primary)_/_0.08)] transition-all duration-200 flex items-end gap-3 px-4 py-3 relative" ref={containerRef}>
@@ -307,13 +342,16 @@ export const AIChat = forwardRef<AIChatHandle, AIChatProps>(({ onMessageSent },
)
}
// ── Chat view (messages present) ──
return (
<div className="flex-1 flex flex-col min-h-0">
{/* ── Message list ── */}
<div className="flex-1 overflow-y-auto px-4 py-6" style={{ backgroundImage: "radial-gradient(circle, hsl(var(--border)) 1px, transparent 1px)", backgroundSize: "24px 24px" }}>
<div className="max-w-3xl mx-auto space-y-6">
{messages.map((msg, i) => (
<div key={i}>
{msg.role === "assistant" ? (
/* ── AI assistant bubble (left-aligned) ── */
<div className="flex gap-3 items-start">
<div className="w-8 h-8 rounded-lg bg-gradient-to-br from-primary to-primary flex items-center justify-center text-white text-sm flex-shrink-0">
<Bot className="h-4 w-4" />
@@ -326,6 +364,7 @@ export const AIChat = forwardRef<AIChatHandle, AIChatProps>(({ onMessageSent },
</div>
</div>
) : (
/* ── User bubble (right-aligned) ── */
<div className="flex gap-3 items-start flex-row-reverse">
<div className="flex-1 min-w-0 flex justify-end">
<div className="bg-gradient-to-br from-primary to-primary rounded-2xl rounded-tr-sm px-5 py-4 max-w-[75%]">
@@ -345,6 +384,7 @@ export const AIChat = forwardRef<AIChatHandle, AIChatProps>(({ onMessageSent },
)}
</div>
))}
{/* ── Typing indicator (bouncing dots) ── */}
{loading && (
<div className="flex gap-3 items-start">
<div className="w-8 h-8 rounded-lg bg-gradient-to-br from-primary to-primary flex items-center justify-center text-white text-sm flex-shrink-0">
@@ -360,6 +400,7 @@ export const AIChat = forwardRef<AIChatHandle, AIChatProps>(({ onMessageSent },
<div ref={messagesEndRef} />
</div>
</div>
{/* ── Input area (sticky bottom) ── */}
<div className="sticky bottom-0 bg-background/95 backdrop-blur-md px-4 py-4">
<div className="max-w-3xl mx-auto">
{error && (
+23
View File
@@ -1,8 +1,13 @@
// ── AI Assistant: GIF Picker Component ──
// Inline GIF search and selection UI backed by GIPHY.
// Supports trending, search with debounce, infinite scroll, and client-side caching.
"use client"
import { useState, useRef, useEffect, useCallback } from "react"
import { Search, Loader2, ImageIcon } from "lucide-react"
/** A single GIF result from the GIPHY API */
interface GifResult {
id: string
title: string
@@ -14,12 +19,19 @@ interface GifResult {
}
interface GifPickerProps {
/** Called when a GIF is selected */
onSelect: (gif: { url: string; previewUrl: string; title: string }) => void
/** Called to close the picker */
onClose: () => void
}
/** In-memory cache keyed by search query or "__trending__" */
const SEARCH_CACHE = new Map<string, GifResult[]>()
/**
* GifPicker — inline GIF search/selection popover.
* Fetches trending GIFs on open, supports debounced search and infinite scroll via IntersectionObserver.
*/
export function GifPicker({ onSelect, onClose }: GifPickerProps) {
const [gifs, setGifs] = useState<GifResult[]>([])
const [loading, setLoading] = useState(true)
@@ -32,6 +44,7 @@ export function GifPicker({ onSelect, onClose }: GifPickerProps) {
const inputRef = useRef<HTMLInputElement>(null)
const searchTimeoutRef = useRef<ReturnType<typeof setTimeout>>()
// ── Fetch GIFs from the API, with optional cache hit ──
const fetchGifs = useCallback(async (q: string, off: number, append: boolean) => {
if (off === 0) {
const cacheKey = q || "__trending__"
@@ -45,6 +58,7 @@ export function GifPicker({ onSelect, onClose }: GifPickerProps) {
}
}
// ── Perform the API request ──
try {
if (!append) setLoading(true)
else setLoadingMore(true)
@@ -67,6 +81,7 @@ export function GifPicker({ onSelect, onClose }: GifPickerProps) {
const data = await res.json()
const newGifs: GifResult[] = data.gifs || []
// Append or replace results, caching on first page
if (append) {
setGifs((prev) => [...prev, ...newGifs])
} else {
@@ -89,14 +104,17 @@ export function GifPicker({ onSelect, onClose }: GifPickerProps) {
}
}, [])
// ── Load trending GIFs on mount ──
useEffect(() => {
fetchGifs("", 0, false)
}, [fetchGifs])
// ── Auto-focus the search input ──
useEffect(() => {
inputRef.current?.focus()
}, [])
// ── Debounced search: 400ms after the user stops typing ──
useEffect(() => {
if (searchTimeoutRef.current) clearTimeout(searchTimeoutRef.current)
if (!search.trim()) {
@@ -111,6 +129,7 @@ export function GifPicker({ onSelect, onClose }: GifPickerProps) {
}
}, [search, fetchGifs])
// ── Infinite scroll via IntersectionObserver on the sentinel ──
useEffect(() => {
const sentinel = sentinelRef.current
if (!sentinel) return
@@ -129,7 +148,9 @@ export function GifPicker({ onSelect, onClose }: GifPickerProps) {
}, [hasMore, loadingMore, loading, offset, search, fetchGifs])
return (
// ── Popover container ──
<div className="absolute bottom-full left-0 right-0 mb-2 bg-card border border-border rounded-2xl shadow-xl shadow-black/20 overflow-hidden z-50">
{/* ── Search bar ── */}
<div className="p-3 border-b border-border">
<div className="relative">
<Search className="absolute left-3 top-1/2 -translate-y-1/2 h-4 w-4 text-muted-foreground" />
@@ -144,6 +165,7 @@ export function GifPicker({ onSelect, onClose }: GifPickerProps) {
</div>
</div>
{/* ── GIF grid with loading / error / empty states ── */}
<div className="overflow-y-auto" style={{ maxHeight: "360px" }}>
{loading ? (
<div className="flex items-center justify-center py-16">
@@ -184,6 +206,7 @@ export function GifPicker({ onSelect, onClose }: GifPickerProps) {
<Loader2 className="h-5 w-5 animate-spin text-primary" />
</div>
)}
{/* Sentinel element for infinite scroll detection */}
{hasMore && <div ref={sentinelRef} className="h-4" />}
</>
)}
+18
View File
@@ -1,8 +1,13 @@
// ── AI Assistant: Job Selector Component ──
// Dropdown that fetches and displays job categories from the API.
// Allows the user to pick a job, then triggers a Facebook search.
"use client"
import { useState, useEffect } from "react"
import { Briefcase, ChevronDown, Loader2, Search } from "lucide-react"
/** A job category returned from the API */
interface Job {
job_title: string
keywords: string[]
@@ -11,17 +16,25 @@ interface Job {
}
interface JobSelectorProps {
/** Called when a job is selected or cleared (null) */
onSelect: (job: Job | null) => void
/** Called when the user clicks the "Search Facebook" button */
onSearch?: (job: Job) => void
/** Whether a search is currently in progress */
searching?: boolean
}
/**
* JobSelector — dropdown to pick a job category and initiate a Facebook search.
* Fetches available jobs on mount and provides a "Search Facebook" CTA once a job is selected.
*/
export function JobSelector({ onSelect, onSearch, searching }: JobSelectorProps) {
const [jobs, setJobs] = useState<Job[]>([])
const [loading, setLoading] = useState(true)
const [open, setOpen] = useState(false)
const [selected, setSelected] = useState<Job | null>(null)
// ── Fetch available job categories on mount ──
useEffect(() => {
fetch("/api/ai/jobs")
.then((r) => r.json())
@@ -30,6 +43,7 @@ export function JobSelector({ onSelect, onSearch, searching }: JobSelectorProps)
.finally(() => setLoading(false))
}, [])
// ── Select a job, close dropdown, notify parent ──
const handleSelect = (job: Job) => {
setSelected(job)
setOpen(false)
@@ -38,6 +52,7 @@ export function JobSelector({ onSelect, onSearch, searching }: JobSelectorProps)
return (
<div className="relative">
{/* ── Dropdown trigger button ── */}
<button
type="button"
onClick={() => setOpen(!open)}
@@ -50,8 +65,10 @@ export function JobSelector({ onSelect, onSearch, searching }: JobSelectorProps)
{loading ? <Loader2 className="h-3.5 w-3.5 animate-spin text-primary" /> : <ChevronDown className={`h-3.5 w-3.5 text-muted-foreground/60 transition-transform duration-200 ${open ? "rotate-180" : ""}`} />}
</button>
{/* ── Dropdown menu — visible when open ── */}
{open && (
<>
{/* Backdrop to close on click outside */}
<div className="fixed inset-0 z-10" onClick={() => setOpen(false)} />
<div className="absolute top-full left-0 right-0 mt-1.5 z-20 bg-card border border-border rounded-xl shadow-xl max-h-60 overflow-y-auto">
{jobs.map((job, i) => (
@@ -71,6 +88,7 @@ export function JobSelector({ onSelect, onSearch, searching }: JobSelectorProps)
</div>
</>
)}
{/* ── "Search Facebook" CTA — shown only when a job is selected ── */}
{selected && onSearch && (
<button
type="button"