diff --git a/Web_Backgrounds/default-theme.css b/Web_Backgrounds/default-theme.css new file mode 100644 index 0000000..288e733 --- /dev/null +++ b/Web_Backgrounds/default-theme.css @@ -0,0 +1,35 @@ +/* ============================================================================ + Default Theme — Clean, Professional SaaS Design Tokens + This is the standard CRM appearance. No Spidey branding or assets. + ============================================================================ */ + +.theme-default { + --background: 222.2 84% 4.9%; + --foreground: 210 40% 98%; + --card: 222.2 84% 4.9%; + --card-foreground: 210 40% 98%; + --popover: 222.2 84% 4.9%; + --popover-foreground: 210 40% 98%; + --primary: 210 80% 52%; + --primary-foreground: 222.2 47.4% 11.2%; + --secondary: 217.2 32.6% 17.5%; + --secondary-foreground: 210 40% 98%; + --muted: 217.2 32.6% 17.5%; + --muted-foreground: 215 20.2% 65.1%; + --accent: 217.2 32.6% 17.5%; + --accent-foreground: 210 40% 98%; + --destructive: 0 62.8% 30.6%; + --destructive-foreground: 210 40% 98%; + --border: 215 25% 22%; + --input: 215 25% 22%; + --ring: 210 80% 52%; + --radius: 0.5rem; + --sidebar: 222.2 84% 4.9%; + --sidebar-foreground: 210 40% 98%; + --sidebar-primary: 210 80% 52%; + --sidebar-primary-foreground: 0 0% 100%; + --sidebar-accent: 217.2 32.6% 17.5%; + --sidebar-accent-foreground: 210 40% 98%; + --sidebar-border: 215 25% 22%; + --sidebar-ring: 210 80% 52%; +} diff --git a/database/migrations/002_seed.sql b/database/migrations/002_seed.sql index 6120547..987dd7e 100644 --- a/database/migrations/002_seed.sql +++ b/database/migrations/002_seed.sql @@ -195,6 +195,9 @@ ON CONFLICT DO NOTHING; -- admin_demo / AdminAccess@2026 -- sales_demo / SalesAccess@2026 -- dev_demo / DevTesting@2026 +-- ewan (password change required on first login) +-- caitlin (password change required on first login) +-- dillen (password change required on first login) INSERT INTO users (id, username, email, password_hash, first_name, last_name, is_active, password_change_required) VALUES ('00000000-0000-0000-0000-000000000001', 'superadmin_demo', 'superadmin@coastit.co.za', @@ -208,7 +211,16 @@ INSERT INTO users (id, username, email, password_hash, first_name, last_name, is 'Sales', 'User', TRUE, TRUE), ('00000000-0000-0000-0000-000000000004', 'dev_demo', 'dev@coastit.co.za', '$2b$12$ghyJFb17lXoFOCYUPB6Fk.q8wDNOJhq9OUPNzd5DKaZsDjCF2NBJa', - 'Dev', 'User', TRUE, TRUE) + 'Dev', 'User', TRUE, TRUE), + ('00000000-0000-0000-0000-000000000005', 'ewan', 'ewan@coastit.co.za', + '$2b$12$nO.9p.f4oWFhfScxM8MGQuiR9YjU85YTIqcb1kS.kyDBMHdmQ.EyG', + 'Ewan', 'Scheepers', TRUE, TRUE), + ('00000000-0000-0000-0000-000000000006', 'caitlin', 'caitlin@coastit.co.za', + '$2b$12$I5FHjje4OA5raP3642twT.Wmcl00rA1/wDPiQjRK14yDgybUjmrYG', + 'Caitlin', 'Hermanus', TRUE, TRUE), + ('00000000-0000-0000-0000-000000000007', 'dillen', 'dillen@coastit.co.za', + '$2b$12$QnvaRzdJEV/Bq8tp5vguM.ad.oeAcV2bjdzndIFdA4Opn5vY2WVaW', + 'Dillen', 'van der Merwe', TRUE, TRUE) ON CONFLICT (username) WHERE (deleted_at IS NULL) DO NOTHING; -- Update the SUPER_ADMIN to set created_by to self (post-bootstrap) @@ -217,7 +229,7 @@ WHERE id = '00000000-0000-0000-0000-000000000001' AND created_by IS NULL; -- Set created_by for other users (SUPER_ADMIN created them) UPDATE users SET created_by = '00000000-0000-0000-0000-000000000001' -WHERE id IN ('00000000-0000-0000-0000-000000000002','00000000-0000-0000-0000-000000000003','00000000-0000-0000-0000-000000000004') +WHERE id IN ('00000000-0000-0000-0000-000000000002','00000000-0000-0000-0000-000000000003','00000000-0000-0000-0000-000000000004','00000000-0000-0000-0000-000000000005','00000000-0000-0000-0000-000000000006','00000000-0000-0000-0000-000000000007') AND created_by IS NULL; -- ============================================================================ @@ -233,7 +245,10 @@ ON CONFLICT DO NOTHING; INSERT INTO user_roles (user_id, role_id, assigned_by) VALUES ('00000000-0000-0000-0000-000000000002', '00000002-0000-0000-0000-000000000000', '00000000-0000-0000-0000-000000000001'), ('00000000-0000-0000-0000-000000000003', '00000003-0000-0000-0000-000000000000', '00000000-0000-0000-0000-000000000001'), - ('00000000-0000-0000-0000-000000000004', '00000004-0000-0000-0000-000000000000', '00000000-0000-0000-0000-000000000001') + ('00000000-0000-0000-0000-000000000004', '00000004-0000-0000-0000-000000000000', '00000000-0000-0000-0000-000000000001'), + ('00000000-0000-0000-0000-000000000005', '00000002-0000-0000-0000-000000000000', '00000000-0000-0000-0000-000000000001'), + ('00000000-0000-0000-0000-000000000006', '00000002-0000-0000-0000-000000000000', '00000000-0000-0000-0000-000000000001'), + ('00000000-0000-0000-0000-000000000007', '00000002-0000-0000-0000-000000000000', '00000000-0000-0000-0000-000000000001') ON CONFLICT DO NOTHING; -- ============================================================================ diff --git a/database/migrations/013_security_upgrade.sql b/database/migrations/013_security_upgrade.sql index 62ebc44..f020ff2 100644 --- a/database/migrations/013_security_upgrade.sql +++ b/database/migrations/013_security_upgrade.sql @@ -595,6 +595,11 @@ WHERE id = '00000000-0000-0000-0000-000000000003' AND password_encrypted IS NULL UPDATE users SET password_encrypted = encrypt_password('DevTesting@2026') WHERE id = '00000000-0000-0000-0000-000000000004' AND password_encrypted IS NULL; +-- NOTE: New admin users (ewan, caitlin, dillen) have password_encrypted=NULL. +-- They will set their own passwords on first login (password_change_required=TRUE). +-- SUPER_ADMIN can populate password_encrypted later via the recovery endpoint +-- after users have set their chosen passwords. + -- ============================================================================ -- FUTURE REQUIREMENT NOTE: -- If this system is ever exposed publicly, remove reversible password storage diff --git a/src/app/(dashboard)/ai-assistant/page.tsx b/src/app/(dashboard)/ai-assistant/page.tsx index cee5296..4cb4f81 100644 --- a/src/app/(dashboard)/ai-assistant/page.tsx +++ b/src/app/(dashboard)/ai-assistant/page.tsx @@ -1,82 +1,134 @@ "use client" -import { useState, useCallback } from "react" +import { useState, useCallback, useRef, useEffect } from "react" import { AIChat } from "@/components/ai/ai-chat" import { JobSelector } from "@/components/ai/job-selector" -import { Bot, Lightbulb, Target, MessageSquare } from "lucide-react" +import { Bot, ChevronRight } from "lucide-react" + +const tips = [ + "Ask for cold email templates for a specific job", + "Request objection handling tips", + "Ask for outreach strategies per industry", + "Generate a follow up sequence", + "Get LinkedIn connection message templates", +] export default function AIAssistantPage() { const [selectedJob, setSelectedJob] = useState<{ job_title: string; keywords: string[]; industry: string; description: string } | null>(null) + const [recentPrompts, setRecentPrompts] = useState([]) + const aiChatRef = useRef<{ fillInput: (text: string) => void } | null>(null) const handleJobSelect = useCallback((job: typeof selectedJob) => { setSelectedJob(job) }, []) + const handleTipClick = useCallback((tip: string) => { + aiChatRef.current?.fillInput(tip) + }, []) + + const handleRecentPromptClick = useCallback((prompt: string) => { + aiChatRef.current?.fillInput(prompt) + }, []) + + const handleMessageSent = useCallback((msg: string) => { + setRecentPrompts((prev) => { + const next = [msg, ...prev.filter((p) => p !== msg)].slice(0, 3) + return next + }) + }, []) + return ( -
+
-
-
-
- +
+
+
+
+ +
+
+

AI Sales Assistant

+

Powered by local AI

+
-
-

AI Sales Assistant

-

Uncensored sales tips and strategies powered by local AI

+
+
+ + Online +
+
+
+ Local AI Model +
- -
-
- + +
+
+
+
+ + Target Job
- -
-
-

- - Target Job -

- -
- - {selectedJob && ( -
-

{selectedJob.job_title}

-
- {selectedJob.industry} -
-

{selectedJob.description}

-
- {selectedJob.keywords.map((kw, i) => ( - - {kw} - - ))} -
+ + {selectedJob && ( +
+

{selectedJob.job_title}

+ {selectedJob.industry} +

{selectedJob.description}

+
+ {selectedJob.keywords.map((kw, i) => ( + {kw} + ))}
- )} - -
-

- - Tips -

-
    -
  • - - Ask for cold email templates for a specific job -
  • -
  • - - Request objection handling tips -
  • -
  • - - Ask for outreach strategies per industry -
  • -
+
+ )} +
+
+
+ + Quick Tips +
+ {tips.map((tip, i) => ( +
handleTipClick(tip)} + className="flex items-start gap-2.5 bg-[#1a1d2e]/50 hover:bg-[#1a1d2e] border border-[#ffffff08] hover:border-[#f97316]/20 rounded-xl p-3.5 mb-2 cursor-pointer transition-all duration-200 group" + > + + {tip} +
+ ))} +
+
+
+ + Recent Prompts +
+ {recentPrompts.length > 0 ? ( + recentPrompts.map((prompt, i) => ( +
handleRecentPromptClick(prompt)} + className="bg-[#1a1d2e]/50 rounded-xl p-3 mb-2 border border-[#ffffff08] text-[#6b7280] text-xs truncate hover:text-[#9ca3af] cursor-pointer transition-colors duration-200" + > + {prompt} +
+ )) + ) : ( +
Your recent prompts will appear here
+ )} +
+
+
+
+
Messages today
+
24
+
+
+
Tokens used
+
12.4k
diff --git a/src/app/api/settings/website-theme/route.ts b/src/app/api/settings/website-theme/route.ts index 4996f7d..b67875f 100644 --- a/src/app/api/settings/website-theme/route.ts +++ b/src/app/api/settings/website-theme/route.ts @@ -12,7 +12,7 @@ export async function GET() { [user.id], ) - const websiteTheme = result.rows[0]?.website_theme || "spidey" + const websiteTheme = result.rows[0]?.website_theme || "default" return NextResponse.json({ websiteTheme }) } catch (error) { console.error("Website theme GET error:", error) @@ -26,7 +26,7 @@ export async function PUT(request: NextRequest) { if (!user) return NextResponse.json({ error: "Unauthorized" }, { status: 401 }) const body = await request.json() - const theme = body.websiteTheme || "spidey" + const theme = body.websiteTheme || "default" await query( `UPDATE users SET preferences = preferences || $2::jsonb WHERE id = $1`, diff --git a/src/app/globals.css b/src/app/globals.css index 44aa3c7..4e4f58f 100644 --- a/src/app/globals.css +++ b/src/app/globals.css @@ -2,6 +2,8 @@ @import url('https://fonts.googleapis.com/css2?family=Raleway:wght@800&display=swap'); @import url('https://fonts.googleapis.com/css2?family=Cormorant+Garamond:ital,wght@0,400;0,500;0,600;0,700;1,400;1,500;1,600;1,700&family=Audiowide&display=swap'); +@import "../styles/ai-assistant.css"; + @tailwind base; @tailwind components; @tailwind utilities; @@ -728,3 +730,4 @@ main { .checkbox-text { color: rgba(232,232,239,0.38); } .footer-text { color: rgba(232,232,239,0.2); } + diff --git a/src/app/layout.tsx b/src/app/layout.tsx index 77f23f2..ad977a6 100644 --- a/src/app/layout.tsx +++ b/src/app/layout.tsx @@ -4,6 +4,7 @@ import { ThemeProvider } from "@/providers/theme-provider" import { WebsiteThemeProvider } from "@/providers/website-theme-provider" import { Toaster } from "@/components/ui/sonner" import "./globals.css" +import "../../Web_Backgrounds/default-theme.css" import "../../Web_Backgrounds/spidey-theme.css" const inter = Inter({ diff --git a/src/components/ai/ai-chat.tsx b/src/components/ai/ai-chat.tsx index 14081ea..0befa06 100644 --- a/src/components/ai/ai-chat.tsx +++ b/src/components/ai/ai-chat.tsx @@ -1,33 +1,80 @@ "use client" -import { useState, useRef, useEffect, Fragment } from "react" -import { Send, Bot, User, RefreshCw, AlertCircle, Check, Terminal } from "lucide-react" +import { useState, useRef, useEffect, Fragment, forwardRef, useImperativeHandle, useCallback } from "react" +import { Bot, Terminal } from "lucide-react" function linkifyText(text: string) { const urlRegex = /(https?:\/\/[^\s<]+[^\s<.,;:!?)\]}>])/ const parts = text.split(urlRegex) return parts.map((part, i) => { if (part.startsWith("http://") || part.startsWith("https://")) { - return {part} + return {part} } return {part} }) } +function formatContent(text: string) { + const lines = text.split("\n") + return lines.map((line, i) => { + const trimmed = line.trim() + if (trimmed.startsWith("•") || trimmed.startsWith("-")) { + const content = trimmed.replace(/^[•\-]\s*/, "") + return ( +
+ + {linkifyText(content)} +
+ ) + } + if (line === "") return
+ return

{linkifyText(line)}

+ }) +} + interface ChatMessage { role: "user" | "assistant" content: string } -export function AIChat() { +interface AIChatProps { + onMessageSent?: (msg: string) => void +} + +const quickActions = [ + { icon: "✉️", iconBg: "bg-[#f97316]/10", iconColor: "text-[#f97316]", title: "Cold Email Template", desc: "Generate targeted outreach emails", prompt: "Write a cold email template for a Software Developer" }, + { icon: "🛡️", iconBg: "bg-[#3b82f6]/10", iconColor: "text-[#3b82f6]", title: "Handle Objections", desc: "Get scripts for common objections", prompt: "Give me objection handling scripts for sales" }, + { icon: "🎯", iconBg: "bg-[#8b5cf6]/10", iconColor: "text-[#8b5cf6]", title: "Target Industry", desc: "Find leads in specific industries", prompt: "How do I target leads in the tech industry" }, + { icon: "📋", iconBg: "bg-[#22c55e]/10", iconColor: "text-[#22c55e]", title: "Build Lead List", desc: "Strategies to grow your pipeline", prompt: "Help me build a lead list strategy" }, + { icon: "📞", iconBg: "bg-[#f97316]/10", iconColor: "text-[#f97316]", title: "Call Scripts", desc: "Proven phone sales scripts", prompt: "Give me a cold call script for outreach" }, + { icon: "📊", iconBg: "bg-[#ec4899]/10", iconColor: "text-[#ec4899]", title: "Sales Strategy", desc: "Industry specific sales tactics", prompt: "What sales strategies work best per industry" }, +] + +const commandPills = [ + { icon: "📋", label: "Lists", prompt: "lists" }, + { icon: "👥", label: "Leads", prompt: "leads" }, + { icon: "💡", label: "Tips", prompt: "Give me some sales tips" }, + { icon: "✉️", label: "Templates", prompt: "Show me email templates" }, +] + +export const AIChat = forwardRef<{ fillInput: (text: string) => void }, AIChatProps>(({ onMessageSent }, ref) => { const [messages, setMessages] = useState([]) const [input, setInput] = useState("") const [loading, setLoading] = useState(false) const [error, setError] = useState("") const [bootState, setBootState] = useState<"booting" | "ready" | "error">("booting") const messagesEndRef = useRef(null) + const textareaRef = useRef(null) + const hasUserMessage = messages.some(m => m.role === "user") - const checkServer = async () => { + useImperativeHandle(ref, () => ({ + fillInput(text: string) { + setInput(text) + setTimeout(() => textareaRef.current?.focus(), 50) + }, + }), []) + + const checkServer = useCallback(async () => { try { const res = await fetch("/api/ai/chat", { method: "POST", @@ -42,7 +89,7 @@ export function AIChat() { } catch { setTimeout(checkServer, 2000) } - } + }, []) useEffect(() => { fetch("/api/ai/jobs") @@ -74,18 +121,19 @@ export function AIChat() { ]) }) checkServer() - }, []) + }, [checkServer]) useEffect(() => { messagesEndRef.current?.scrollIntoView({ behavior: "smooth" }) }, [messages]) - const sendMessage = async () => { - const msg = input.trim() + const sendMessage = useCallback(async (text?: string) => { + const msg = (text || input).trim() if (!msg || loading) return setInput("") setError("") + onMessageSent?.(msg) setMessages((prev) => [...prev, { role: "user", content: msg }]) setLoading(true) @@ -113,167 +161,211 @@ export function AIChat() { } finally { setLoading(false) } - } + }, [input, loading, onMessageSent]) - const handleKeyDown = (e: React.KeyboardEvent) => { + const handleKeyDown = useCallback((e: React.KeyboardEvent) => { if (e.key === "Enter" && !e.shiftKey) { e.preventDefault() sendMessage() } + }, [sendMessage]) + + const handleTextareaInput = useCallback((e: React.FormEvent) => { + const t = e.target as HTMLTextAreaElement + t.style.height = "auto" + t.style.height = t.scrollHeight + "px" + }, []) + + const handleQuickAction = useCallback((prompt: string) => { + setInput(prompt) + setTimeout(() => sendMessage(prompt), 50) + }, [sendMessage]) + + const handleCommandPill = useCallback((prompt: string) => { + setInput(prompt) + setTimeout(() => sendMessage(prompt), 50) + }, [sendMessage]) + + if (bootState === "booting") { + return ( +
+
+

Servers booting...

+
+
+ + + + + + + + + + + + +
+
+
+
+ ) } - const bootOverlay = ( -
- -
-

Servers booting...

-
-
- - - - - - - - - - - - + if (bootState === "ready" && !hasUserMessage) { + return ( +
+
+
+
+
+ +
+

What can I help you with?

+

Your AI sales assistant is ready. Choose a quick action or type your question below.

+
+
+ {quickActions.map((action, i) => ( +
handleQuickAction(action.prompt)} + className="float-in bg-[#1a1d2e] hover:bg-[#1f2437] rounded-xl p-4 border border-[#ffffff0a] hover:border-[#f97316]/30 cursor-pointer transition-all duration-200 hover:shadow-[0_4px_20px_rgba(249,115,22,0.1)] hover:-translate-y-0.5" + style={{ animationDelay: `${0.1 + i * 0.05}s` }} + > +
{action.icon}
+

{action.title}

+

{action.desc}

+
+ ))} +
+
+ {commandPills.map((pill, i) => ( +
handleCommandPill(pill.prompt)} + className="bg-[#1a1d2e] border border-[#ffffff0f] hover:border-[#f97316]/40 hover:bg-[#1f2437] rounded-full px-4 py-2 text-xs text-[#9ca3af] hover:text-[#f97316] transition-all duration-200 cursor-pointer flex items-center gap-1.5" + > + {pill.icon} + {pill.label} +
+ ))} +
+
+
+
+
+
+ +