mirror of
https://git.coastit.co.za/caitlin/CRM_ENVR.git
synced 2026-07-10 11:15:43 +02:00
Getting the 10 syncs and adding my AI that works but has issues
This commit is contained in:
@@ -4,14 +4,37 @@
|
|||||||
============================================================================ */
|
============================================================================ */
|
||||||
|
|
||||||
.theme-spidey {
|
.theme-spidey {
|
||||||
|
--background: 240 18% 97%;
|
||||||
|
--foreground: 240 8% 14%;
|
||||||
|
--card: 0 0% 100%;
|
||||||
|
--card-foreground: 240 8% 14%;
|
||||||
|
--popover: 0 0% 100%;
|
||||||
|
--popover-foreground: 240 8% 14%;
|
||||||
|
--secondary: 220 75% 48%;
|
||||||
|
--secondary-foreground: 0 0% 100%;
|
||||||
|
--muted: 240 8% 92%;
|
||||||
|
--muted-foreground: 240 5% 50%;
|
||||||
|
--accent: 220 15% 90%;
|
||||||
|
--accent-foreground: 220 60% 28%;
|
||||||
|
--destructive: 0 70% 46%;
|
||||||
|
--destructive-foreground: 0 0% 100%;
|
||||||
|
--border: 240 8% 84%;
|
||||||
|
--input: 240 8% 84%;
|
||||||
|
--radius: 0.5rem;
|
||||||
|
--sidebar: 0 0% 99%;
|
||||||
|
--sidebar-foreground: 240 8% 14%;
|
||||||
|
--sidebar-accent: 220 20% 93%;
|
||||||
|
--sidebar-accent-foreground: 220 70% 28%;
|
||||||
|
--sidebar-border: 240 8% 84%;
|
||||||
|
}
|
||||||
|
|
||||||
|
.dark.theme-spidey {
|
||||||
--background: 222.2 84% 4.9%;
|
--background: 222.2 84% 4.9%;
|
||||||
--foreground: 210 40% 98%;
|
--foreground: 210 40% 98%;
|
||||||
--card: 222.2 84% 4.9%;
|
--card: 222.2 84% 4.9%;
|
||||||
--card-foreground: 210 40% 98%;
|
--card-foreground: 210 40% 98%;
|
||||||
--popover: 222.2 84% 4.9%;
|
--popover: 222.2 84% 4.9%;
|
||||||
--popover-foreground: 210 40% 98%;
|
--popover-foreground: 210 40% 98%;
|
||||||
--primary: 0 100% 53%;
|
|
||||||
--primary-foreground: 222.2 47.4% 11.2%;
|
|
||||||
--secondary: 217.2 32.6% 17.5%;
|
--secondary: 217.2 32.6% 17.5%;
|
||||||
--secondary-foreground: 210 40% 98%;
|
--secondary-foreground: 210 40% 98%;
|
||||||
--muted: 217.2 32.6% 17.5%;
|
--muted: 217.2 32.6% 17.5%;
|
||||||
@@ -22,16 +45,12 @@
|
|||||||
--destructive-foreground: 210 40% 98%;
|
--destructive-foreground: 210 40% 98%;
|
||||||
--border: 217.2 32.6% 17.5%;
|
--border: 217.2 32.6% 17.5%;
|
||||||
--input: 217.2 32.6% 17.5%;
|
--input: 217.2 32.6% 17.5%;
|
||||||
--ring: 0 100% 53%;
|
|
||||||
--radius: 0.5rem;
|
--radius: 0.5rem;
|
||||||
--sidebar: 222.2 84% 4.9%;
|
--sidebar: 222.2 84% 4.9%;
|
||||||
--sidebar-foreground: 210 40% 98%;
|
--sidebar-foreground: 210 40% 98%;
|
||||||
--sidebar-primary: 0 100% 53%;
|
|
||||||
--sidebar-primary-foreground: 0 0% 100%;
|
|
||||||
--sidebar-accent: 217.2 32.6% 17.5%;
|
--sidebar-accent: 217.2 32.6% 17.5%;
|
||||||
--sidebar-accent-foreground: 210 40% 98%;
|
--sidebar-accent-foreground: 210 40% 98%;
|
||||||
--sidebar-border: 217.2 32.6% 17.5%;
|
--sidebar-border: 217.2 32.6% 17.5%;
|
||||||
--sidebar-ring: 0 100% 53%;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
.theme-spidey body::before {
|
.theme-spidey body::before {
|
||||||
|
|||||||
@@ -76,17 +76,17 @@ export default function AIAssistantPage() {
|
|||||||
}, [])
|
}, [])
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className="flex h-[calc(100vh-3.5rem)] bg-[#0f1117]">
|
<div className="flex h-[calc(100vh-3.5rem)] bg-background">
|
||||||
<div className="flex-1 flex flex-col min-w-0">
|
<div className="flex-1 flex flex-col min-w-0 border border-foreground transition-colors overflow-hidden">
|
||||||
<div className="bg-[#1a1d2e]/80 backdrop-blur-md border-b border-[#ffffff08] px-6 py-4">
|
<div className="bg-card/80 backdrop-blur-md border-b border-border px-6 py-4">
|
||||||
<div className="flex items-center justify-between">
|
<div className="flex items-center justify-between">
|
||||||
<div className="flex items-center gap-4">
|
<div className="flex items-center gap-4">
|
||||||
<div className="w-10 h-10 rounded-xl bg-gradient-to-br from-[#f97316] to-[#ea580c] flex items-center justify-center shadow-[0_0_40px_rgba(249,115,22,0.3)]">
|
<div className="w-10 h-10 rounded-xl bg-gradient-to-br from-primary to-primary flex items-center justify-center" style={{boxShadow: "0 0 40px hsl(var(--primary) / 0.3)"}}>
|
||||||
<Bot className="h-5 w-5 text-white" />
|
<Bot className="h-5 w-5 text-white" />
|
||||||
</div>
|
</div>
|
||||||
<div>
|
<div>
|
||||||
<h1 className="text-white font-bold text-lg">AI Sales Assistant</h1>
|
<h1 className="text-foreground font-bold text-lg">AI Sales Assistant</h1>
|
||||||
<p className="text-[#6b7280] text-xs mt-0.5">Powered by local AI</p>
|
<p className="text-muted-foreground text-xs mt-0.5">Powered by local AI</p>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
<div className="flex items-center gap-4">
|
<div className="flex items-center gap-4">
|
||||||
@@ -94,30 +94,30 @@ export default function AIAssistantPage() {
|
|||||||
<span className="w-2 h-2 rounded-full bg-[#22c55e] animate-pulse" />
|
<span className="w-2 h-2 rounded-full bg-[#22c55e] animate-pulse" />
|
||||||
<span className="text-[#22c55e] text-xs font-medium">Online</span>
|
<span className="text-[#22c55e] text-xs font-medium">Online</span>
|
||||||
</div>
|
</div>
|
||||||
<div className="w-px h-5 bg-[#ffffff08]" />
|
<div className="w-px h-5 bg-border" />
|
||||||
<div className="bg-[#ffffff08] rounded-lg px-3 py-1.5">
|
<div className="bg-muted/50 rounded-lg px-3 py-1.5">
|
||||||
<span className="text-[#9ca3af] text-xs">Local AI Model</span>
|
<span className="text-muted-foreground text-xs">Local AI Model</span>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
<AIChat ref={aiChatRef} onMessageSent={handleMessageSent} />
|
<AIChat ref={aiChatRef} onMessageSent={handleMessageSent} />
|
||||||
</div>
|
</div>
|
||||||
<div className="w-[300px] flex-none bg-[#13151f] border-l border-[#ffffff08] p-5 overflow-y-auto h-full flex flex-col">
|
<div className="w-[300px] flex-none bg-sidebar border border-foreground transition-colors p-5 overflow-y-auto h-full flex flex-col">
|
||||||
<div>
|
<div>
|
||||||
<div className="flex items-center gap-2 mb-3">
|
<div className="flex items-center gap-2 mb-3">
|
||||||
<span className="w-1.5 h-1.5 rounded-full bg-[#f97316]" />
|
<span className="w-1.5 h-1.5 rounded-full bg-primary" />
|
||||||
<span className="text-[#f97316] text-[10px] font-bold uppercase tracking-[0.15em]">Target Job</span>
|
<span className="text-primary text-[10px] font-bold uppercase tracking-[0.15em]">Target Job</span>
|
||||||
</div>
|
</div>
|
||||||
<JobSelector onSelect={handleJobSelect} onSearch={handleSearch} searching={searching} />
|
<JobSelector onSelect={handleJobSelect} onSearch={handleSearch} searching={searching} />
|
||||||
{selectedJob && (
|
{selectedJob && (
|
||||||
<div className="bg-[#1a1d2e]/50 border border-[#ffffff08] rounded-xl p-3.5 mt-3 space-y-2">
|
<div className="bg-card/50 border border-border rounded-xl p-3.5 mt-3 space-y-2">
|
||||||
<h4 className="text-sm font-semibold text-[#e5e7eb]">{selectedJob.job_title}</h4>
|
<h4 className="text-sm font-semibold text-foreground">{selectedJob.job_title}</h4>
|
||||||
<span className="text-xs px-2 py-0.5 rounded-md bg-[#f97316]/10 text-[#f97316] font-medium inline-block">{selectedJob.industry}</span>
|
<span className="text-xs px-2 py-0.5 rounded-md bg-primary/10 text-primary font-medium inline-block">{selectedJob.industry}</span>
|
||||||
<p className="text-xs text-[#6b7280] leading-relaxed">{selectedJob.description}</p>
|
<p className="text-xs text-muted-foreground leading-relaxed">{selectedJob.description}</p>
|
||||||
<div className="flex flex-wrap gap-1.5">
|
<div className="flex flex-wrap gap-1.5">
|
||||||
{selectedJob.keywords.map((kw, i) => (
|
{selectedJob.keywords.map((kw, i) => (
|
||||||
<span key={i} className="text-xs px-2 py-0.5 rounded-md bg-[#ffffff08] text-[#4b5563]">{kw}</span>
|
<span key={i} className="text-xs px-2 py-0.5 rounded-md bg-muted/50 text-muted-foreground">{kw}</span>
|
||||||
))}
|
))}
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
@@ -125,48 +125,48 @@ export default function AIAssistantPage() {
|
|||||||
</div>
|
</div>
|
||||||
<div className="mt-6">
|
<div className="mt-6">
|
||||||
<div className="flex items-center gap-2 mb-3">
|
<div className="flex items-center gap-2 mb-3">
|
||||||
<span className="w-1.5 h-1.5 rounded-full bg-[#f97316]" />
|
<span className="w-1.5 h-1.5 rounded-full bg-primary" />
|
||||||
<span className="text-[#f97316] text-[10px] font-bold uppercase tracking-[0.15em]">Quick Tips</span>
|
<span className="text-primary text-[10px] font-bold uppercase tracking-[0.15em]">Quick Tips</span>
|
||||||
</div>
|
</div>
|
||||||
{tips.map((tip, i) => (
|
{tips.map((tip, i) => (
|
||||||
<div
|
<div
|
||||||
key={i}
|
key={i}
|
||||||
onClick={() => handleTipClick(tip)}
|
onClick={() => 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"
|
className="flex items-start gap-2.5 bg-card/50 hover:bg-card border border-border hover:border-primary/20 rounded-xl p-3.5 mb-2 cursor-pointer transition-all duration-200 group"
|
||||||
>
|
>
|
||||||
<ChevronRight className="h-3.5 w-3.5 mt-0.5 text-[#374151] group-hover:text-[#f97316] transition-colors duration-200 flex-shrink-0" />
|
<ChevronRight className="h-3.5 w-3.5 mt-0.5 text-muted-foreground group-hover:text-primary transition-colors duration-200 flex-shrink-0" />
|
||||||
<span className="text-[#9ca3af] text-xs leading-5 group-hover:text-[#e5e7eb] transition-colors duration-200">{tip}</span>
|
<span className="text-muted-foreground text-xs leading-5 group-hover:text-foreground transition-colors duration-200">{tip}</span>
|
||||||
</div>
|
</div>
|
||||||
))}
|
))}
|
||||||
</div>
|
</div>
|
||||||
<div className="mt-6">
|
<div className="mt-6">
|
||||||
<div className="flex items-center gap-2 mb-3">
|
<div className="flex items-center gap-2 mb-3">
|
||||||
<span className="w-1.5 h-1.5 rounded-full bg-[#f97316]" />
|
<span className="w-1.5 h-1.5 rounded-full bg-primary" />
|
||||||
<span className="text-[#f97316] text-[10px] font-bold uppercase tracking-[0.15em]">Recent Prompts</span>
|
<span className="text-primary text-[10px] font-bold uppercase tracking-[0.15em]">Recent Prompts</span>
|
||||||
</div>
|
</div>
|
||||||
{recentPrompts.length > 0 ? (
|
{recentPrompts.length > 0 ? (
|
||||||
recentPrompts.map((prompt, i) => (
|
recentPrompts.map((prompt, i) => (
|
||||||
<div
|
<div
|
||||||
key={i}
|
key={i}
|
||||||
onClick={() => handleRecentPromptClick(prompt)}
|
onClick={() => 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"
|
className="bg-card/50 rounded-xl p-3 mb-2 border border-border text-muted-foreground text-xs truncate hover:text-foreground cursor-pointer transition-colors duration-200"
|
||||||
>
|
>
|
||||||
{prompt}
|
{prompt}
|
||||||
</div>
|
</div>
|
||||||
))
|
))
|
||||||
) : (
|
) : (
|
||||||
<div className="text-[#374151] text-xs text-center py-4">Your recent prompts will appear here</div>
|
<div className="text-muted-foreground text-xs text-center py-4">Your recent prompts will appear here</div>
|
||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
<div className="mt-auto border-t border-[#ffffff08] pt-4">
|
<div className="mt-auto border-t border-border pt-4">
|
||||||
<div className="flex gap-4">
|
<div className="flex gap-4">
|
||||||
<div className="flex-1">
|
<div className="flex-1">
|
||||||
<div className="text-[#4b5563] text-[10px] uppercase tracking-wide">Messages today</div>
|
<div className="text-muted-foreground text-[10px] uppercase tracking-wide">Messages today</div>
|
||||||
<div className="text-[#e5e7eb] text-sm font-semibold mt-0.5">24</div>
|
<div className="text-foreground text-sm font-semibold mt-0.5">24</div>
|
||||||
</div>
|
</div>
|
||||||
<div className="flex-1">
|
<div className="flex-1">
|
||||||
<div className="text-[#4b5563] text-[10px] uppercase tracking-wide">Tokens used</div>
|
<div className="text-muted-foreground text-[10px] uppercase tracking-wide">Tokens used</div>
|
||||||
<div className="text-[#e5e7eb] text-sm font-semibold mt-0.5">12.4k</div>
|
<div className="text-foreground text-sm font-semibold mt-0.5">12.4k</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
@@ -24,8 +24,10 @@ import {
|
|||||||
SelectValue,
|
SelectValue,
|
||||||
} from "@/components/ui/select"
|
} from "@/components/ui/select"
|
||||||
import { DashboardStats } from "@/types"
|
import { DashboardStats } from "@/types"
|
||||||
|
import { useWebsiteTheme } from "@/providers/website-theme-provider"
|
||||||
|
|
||||||
export default function DashboardPage() {
|
export default function DashboardPage() {
|
||||||
|
const { websiteTheme } = useWebsiteTheme()
|
||||||
const [period, setPeriod] = useState("6months")
|
const [period, setPeriod] = useState("6months")
|
||||||
const [stats, setStats] = useState<DashboardStats | null>(null)
|
const [stats, setStats] = useState<DashboardStats | null>(null)
|
||||||
const pollingRef = useRef<NodeJS.Timeout | null>(null)
|
const pollingRef = useRef<NodeJS.Timeout | null>(null)
|
||||||
@@ -84,7 +86,7 @@ export default function DashboardPage() {
|
|||||||
</PageHeader>
|
</PageHeader>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<p className="text-xs font-semibold tracking-widest uppercase text-[#888888] dark:text-[#666666]">Pipeline Overview</p>
|
<p className="text-xs font-semibold tracking-widest uppercase text-[#8A9078] dark:text-[#666666]">Pipeline Overview</p>
|
||||||
|
|
||||||
<div className="grid gap-4 sm:grid-cols-2 lg:grid-cols-3 xl:grid-cols-6">
|
<div className="grid gap-4 sm:grid-cols-2 lg:grid-cols-3 xl:grid-cols-6">
|
||||||
{stats
|
{stats
|
||||||
@@ -95,7 +97,7 @@ export default function DashboardPage() {
|
|||||||
}
|
}
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<p className="text-xs font-semibold tracking-widest uppercase text-[#888888] dark:text-[#666666]">Analytics</p>
|
<p className="text-xs font-semibold tracking-widest uppercase text-[#8A9078] dark:text-[#666666]">Analytics</p>
|
||||||
|
|
||||||
<div className="grid gap-6 lg:grid-cols-2">
|
<div className="grid gap-6 lg:grid-cols-2">
|
||||||
<LeadStatusChart data={stats?.statusDistribution ?? []} />
|
<LeadStatusChart data={stats?.statusDistribution ?? []} />
|
||||||
@@ -104,12 +106,13 @@ export default function DashboardPage() {
|
|||||||
|
|
||||||
<RecentLeadsTable leads={stats?.recentLeads ?? []} />
|
<RecentLeadsTable leads={stats?.recentLeads ?? []} />
|
||||||
</div>
|
</div>
|
||||||
{/* Daily Bugle watermark */}
|
{websiteTheme === "spidey" && (
|
||||||
<div className="absolute top-12 right-4 pointer-events-none select-none z-0 opacity-[0.09] dark:opacity-[0.15]">
|
<div className="absolute top-12 right-4 pointer-events-none select-none z-0 opacity-[0.09] dark:opacity-[0.15]">
|
||||||
<span className="text-[96px] font-['Bangers',cursive] leading-none text-[#CC0000] dark:text-[#FF1111] tracking-wider">
|
<span className="text-[96px] font-['Bangers',cursive] leading-none text-[#CC0000] dark:text-[#FF1111] tracking-wider">
|
||||||
DAILY BUGLE
|
DAILY BUGLE
|
||||||
</span>
|
</span>
|
||||||
</div>
|
</div>
|
||||||
|
)}
|
||||||
</div>
|
</div>
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -0,0 +1,57 @@
|
|||||||
|
import { NextRequest, NextResponse } from "next/server"
|
||||||
|
import { getSessionUser } from "@/lib/auth"
|
||||||
|
|
||||||
|
const GIPHY_API_KEY = process.env.GIPHY_API_KEY
|
||||||
|
const GIPHY_BASE = "https://api.giphy.com/v1/gifs"
|
||||||
|
|
||||||
|
export async function GET(request: NextRequest) {
|
||||||
|
try {
|
||||||
|
const user = await getSessionUser()
|
||||||
|
if (!user) {
|
||||||
|
return NextResponse.json({ error: "Unauthorized" }, { status: 401 })
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!GIPHY_API_KEY) {
|
||||||
|
return NextResponse.json({ error: "GIPHY API key not configured" }, { status: 500 })
|
||||||
|
}
|
||||||
|
|
||||||
|
const { searchParams } = new URL(request.url)
|
||||||
|
const type = searchParams.get("type") || "trending"
|
||||||
|
const query = searchParams.get("q") || ""
|
||||||
|
const offset = parseInt(searchParams.get("offset") || "0", 10)
|
||||||
|
const limit = Math.min(parseInt(searchParams.get("limit") || "20", 10), 50)
|
||||||
|
|
||||||
|
let url: string
|
||||||
|
if (type === "search" && query) {
|
||||||
|
url = `${GIPHY_BASE}/search?api_key=${GIPHY_API_KEY}&q=${encodeURIComponent(query)}&limit=${limit}&offset=${offset}&rating=g`
|
||||||
|
} else {
|
||||||
|
url = `${GIPHY_BASE}/trending?api_key=${GIPHY_API_KEY}&limit=${limit}&offset=${offset}&rating=g`
|
||||||
|
}
|
||||||
|
|
||||||
|
const res = await fetch(url)
|
||||||
|
if (!res.ok) {
|
||||||
|
const errBody = await res.text()
|
||||||
|
console.error("GIPHY API error:", res.status, errBody)
|
||||||
|
return NextResponse.json({ error: `GIPHY API error (${res.status}): ${errBody}` }, { status: res.status })
|
||||||
|
}
|
||||||
|
|
||||||
|
const data = await res.json()
|
||||||
|
const gifs = (data.data || []).map((gif: any) => ({
|
||||||
|
id: gif.id,
|
||||||
|
title: gif.title,
|
||||||
|
url: gif.images?.original?.url || "",
|
||||||
|
previewUrl: gif.images?.fixed_width?.url || "",
|
||||||
|
previewHeight: gif.images?.fixed_width?.height || 150,
|
||||||
|
width: gif.images?.original?.width || 0,
|
||||||
|
height: gif.images?.original?.height || 0,
|
||||||
|
}))
|
||||||
|
|
||||||
|
return NextResponse.json({
|
||||||
|
gifs,
|
||||||
|
pagination: data.pagination || { total_count: 0, count: gifs.length, offset },
|
||||||
|
})
|
||||||
|
} catch (error: any) {
|
||||||
|
console.error("GIPHY proxy error:", error)
|
||||||
|
return NextResponse.json({ error: error.message || "Internal server error" }, { status: 500 })
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -12,6 +12,7 @@ import {
|
|||||||
setSessionContext,
|
setSessionContext,
|
||||||
SESSION_COOKIE,
|
SESSION_COOKIE,
|
||||||
} from "@/lib/auth"
|
} from "@/lib/auth"
|
||||||
|
import { query } from "@/lib/db"
|
||||||
|
|
||||||
function jsonResponse(data: unknown, status: number) {
|
function jsonResponse(data: unknown, status: number) {
|
||||||
return new Response(JSON.stringify(data), {
|
return new Response(JSON.stringify(data), {
|
||||||
@@ -114,6 +115,11 @@ export async function POST(request: NextRequest) {
|
|||||||
true
|
true
|
||||||
)
|
)
|
||||||
|
|
||||||
|
await query(
|
||||||
|
`UPDATE users SET preferences = preferences || $2::jsonb WHERE id = $1`,
|
||||||
|
[dbUser.id, JSON.stringify({ website_theme: "default" })],
|
||||||
|
)
|
||||||
|
|
||||||
const token = await createSession(dbUser.id, dbUser.role_name)
|
const token = await createSession(dbUser.id, dbUser.role_name)
|
||||||
await setSessionContext(dbUser.id, ipAddress)
|
await setSessionContext(dbUser.id, ipAddress)
|
||||||
|
|
||||||
|
|||||||
@@ -80,8 +80,8 @@ function countStatuses(leads: any[]) {
|
|||||||
return counts
|
return counts
|
||||||
}
|
}
|
||||||
|
|
||||||
function buildMonthlyBreakdown(leads: any[], period: string) {
|
function buildMonthlyBreakdown(leads: any[], period: string, rangeOverride?: { start: Date; end: Date }) {
|
||||||
const { start, end } = getPeriodDateRange(period)
|
const { start, end } = rangeOverride || getPeriodDateRange(period)
|
||||||
const result: { label: string; total: number; open: number; contacted: number; pending: number; closed: number; ignored: number }[] = []
|
const result: { label: string; total: number; open: number; contacted: number; pending: number; closed: number; ignored: number }[] = []
|
||||||
const current = new Date(start)
|
const current = new Date(start)
|
||||||
const isMonthly = period === "6months" || period === "12months"
|
const isMonthly = period === "6months" || period === "12months"
|
||||||
@@ -170,7 +170,7 @@ export async function GET(request: NextRequest) {
|
|||||||
updatedAt: r.updated_at,
|
updatedAt: r.updated_at,
|
||||||
}))
|
}))
|
||||||
|
|
||||||
const monthlyBreakdown = buildMonthlyBreakdown(currentLeads, period)
|
const monthlyBreakdown = buildMonthlyBreakdown(currentLeads, period, yearParam ? { start, end } : undefined)
|
||||||
|
|
||||||
const trends = {
|
const trends = {
|
||||||
totalLeads: computeTrend(currentCounts.open + currentCounts.contacted + currentCounts.pending + currentCounts.closed + currentCounts.ignored,
|
totalLeads: computeTrend(currentCounts.open + currentCounts.contacted + currentCounts.pending + currentCounts.closed + currentCounts.ignored,
|
||||||
|
|||||||
+62
-25
@@ -1,8 +1,8 @@
|
|||||||
import { NextRequest, NextResponse } from "next/server"
|
import { NextRequest, NextResponse } from "next/server"
|
||||||
import { getSessionUser } from "@/lib/auth"
|
import { getSessionUser } from "@/lib/auth"
|
||||||
|
|
||||||
const TENOR_API_KEY = process.env.TENOR_API_KEY || ""
|
const GIPHY_API_KEY = process.env.GIPHY_API_KEY
|
||||||
const TENOR_BASE = "https://tenor.googleapis.com/v2"
|
const GIPHY_BASE = "https://api.giphy.com/v1/gifs"
|
||||||
|
|
||||||
export async function GET(request: NextRequest) {
|
export async function GET(request: NextRequest) {
|
||||||
try {
|
try {
|
||||||
@@ -12,41 +12,78 @@ export async function GET(request: NextRequest) {
|
|||||||
const { searchParams } = new URL(request.url)
|
const { searchParams } = new URL(request.url)
|
||||||
const q = searchParams.get("q") || ""
|
const q = searchParams.get("q") || ""
|
||||||
const limit = Math.min(parseInt(searchParams.get("limit") || "20", 10), 50)
|
const limit = Math.min(parseInt(searchParams.get("limit") || "20", 10), 50)
|
||||||
const pos = searchParams.get("pos") || ""
|
const pos = parseInt(searchParams.get("pos") || "0", 10) || 0
|
||||||
|
|
||||||
if (!TENOR_API_KEY) {
|
if (!GIPHY_API_KEY) {
|
||||||
|
console.error("Missing GIPHY_API_KEY environment variable")
|
||||||
return NextResponse.json({ results: [], noKey: true })
|
return NextResponse.json({ results: [], noKey: true })
|
||||||
}
|
}
|
||||||
|
|
||||||
const endpoint = q
|
let data: any
|
||||||
? `${TENOR_BASE}/search?q=${encodeURIComponent(q)}&key=${TENOR_API_KEY}&limit=${limit}&media_filter=minimal`
|
|
||||||
: `${TENOR_BASE}/featured?key=${TENOR_API_KEY}&limit=${limit}&media_filter=minimal`
|
|
||||||
|
|
||||||
const url = pos ? `${endpoint}&pos=${pos}` : endpoint
|
if (q) {
|
||||||
|
const url = `${GIPHY_BASE}/search?api_key=${GIPHY_API_KEY}&q=${encodeURIComponent(q)}&limit=${limit}&offset=${pos}&rating=g`
|
||||||
|
const res = await fetch(url, { signal: AbortSignal.timeout(8000) })
|
||||||
|
if (!res.ok) {
|
||||||
|
const errBody = await res.text()
|
||||||
|
console.error("GIPHY API search error:", res.status, errBody)
|
||||||
|
return NextResponse.json({ results: [], error: `GIPHY error: ${errBody}` }, { status: 502 })
|
||||||
|
}
|
||||||
|
data = await res.json()
|
||||||
|
} else {
|
||||||
|
const trendingUrl = `${GIPHY_BASE}/trending?api_key=${GIPHY_API_KEY}&limit=${limit}&offset=${pos}&rating=g`
|
||||||
|
const trendingRes = await fetch(trendingUrl, { signal: AbortSignal.timeout(8000) })
|
||||||
|
|
||||||
const res = await fetch(url, { signal: AbortSignal.timeout(8000) })
|
if (trendingRes.ok) {
|
||||||
if (!res.ok) {
|
data = await trendingRes.json()
|
||||||
return NextResponse.json({ results: [], error: "Tenor API error" }, { status: 502 })
|
if (!data.data?.length && pos === 0) {
|
||||||
|
console.log("Trending returned no results, falling back to default search")
|
||||||
|
const fallbackUrl = `${GIPHY_BASE}/search?api_key=${GIPHY_API_KEY}&q=funny&limit=${limit}&offset=${pos}&rating=g`
|
||||||
|
const fallbackRes = await fetch(fallbackUrl, { signal: AbortSignal.timeout(8000) })
|
||||||
|
if (!fallbackRes.ok) {
|
||||||
|
const errBody = await fallbackRes.text()
|
||||||
|
console.error("GIPHY fallback search error:", fallbackRes.status, errBody)
|
||||||
|
return NextResponse.json({ results: [], error: `GIPHY error: ${errBody}` }, { status: 502 })
|
||||||
|
}
|
||||||
|
data = await fallbackRes.json()
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
const errBody = await trendingRes.text()
|
||||||
|
console.error("GIPHY trending error:", trendingRes.status, errBody)
|
||||||
|
console.log("Trending failed, falling back to default search")
|
||||||
|
const fallbackUrl = `${GIPHY_BASE}/search?api_key=${GIPHY_API_KEY}&q=funny&limit=${limit}&offset=${pos}&rating=g`
|
||||||
|
const fallbackRes = await fetch(fallbackUrl, { signal: AbortSignal.timeout(8000) })
|
||||||
|
if (!fallbackRes.ok) {
|
||||||
|
const fallbackErr = await fallbackRes.text()
|
||||||
|
console.error("GIPHY fallback search error:", fallbackRes.status, fallbackErr)
|
||||||
|
return NextResponse.json({ results: [], error: `GIPHY error: ${fallbackErr}` }, { status: 502 })
|
||||||
|
}
|
||||||
|
data = await fallbackRes.json()
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
const results = (data.data || []).map((item: any) => {
|
||||||
const data = await res.json()
|
const dims = item.images?.original
|
||||||
|
|
||||||
const results = (data.results || []).map((item: any) => {
|
|
||||||
const media = item.media_formats?.gif || item.media_formats?.tinygif || {}
|
|
||||||
const preview = item.media_formats?.tinygif || item.media_formats?.gif || {}
|
|
||||||
return {
|
return {
|
||||||
id: item.id,
|
id: item.id,
|
||||||
title: item.title || "",
|
title: item.title || "",
|
||||||
url: media.url || "",
|
url: dims?.url || "",
|
||||||
previewUrl: preview.url || "",
|
previewUrl: item.images?.fixed_width?.url || "",
|
||||||
width: media.dims?.[0] || 200,
|
width: dims?.width ? parseInt(dims.width, 10) : 200,
|
||||||
height: media.dims?.[1] || 200,
|
height: dims?.height ? parseInt(dims.height, 10) : 200,
|
||||||
}
|
}
|
||||||
})
|
})
|
||||||
|
|
||||||
return NextResponse.json({ results, next: data.next || "" })
|
const nextOffset = pos + results.length
|
||||||
} catch (error) {
|
const total = data.pagination?.total_count || 0
|
||||||
console.error("GIF API error:", error)
|
const hasMore = total ? nextOffset < total : results.length === limit
|
||||||
return NextResponse.json({ results: [], error: "Failed to fetch GIFs" }, { status: 500 })
|
const nextPos = hasMore ? String(nextOffset) : ""
|
||||||
|
|
||||||
|
return NextResponse.json({ results, next: nextPos })
|
||||||
|
} catch (error: any) {
|
||||||
|
console.error("GIF proxy error:", error)
|
||||||
|
const message = error?.message === "Failed to fetch"
|
||||||
|
? "Could not reach the GIF service."
|
||||||
|
: "Failed to fetch GIFs."
|
||||||
|
return NextResponse.json({ results: [], error: message }, { status: 500 })
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -8,9 +8,6 @@ let prevTime = Date.now()
|
|||||||
export async function GET() {
|
export async function GET() {
|
||||||
const sessionUser = await getSessionUser()
|
const sessionUser = await getSessionUser()
|
||||||
if (!sessionUser) return NextResponse.json({ error: "Unauthorized" }, { status: 401 })
|
if (!sessionUser) return NextResponse.json({ error: "Unauthorized" }, { status: 401 })
|
||||||
if (sessionUser.role !== "admin" && sessionUser.role !== "super_admin") {
|
|
||||||
return NextResponse.json({ error: "Forbidden" }, { status: 403 })
|
|
||||||
}
|
|
||||||
|
|
||||||
const now = Date.now()
|
const now = Date.now()
|
||||||
const elapsed = now - prevTime
|
const elapsed = now - prevTime
|
||||||
|
|||||||
@@ -53,24 +53,24 @@ export default function CallRoomPage({ params }: { params: Promise<{ roomId: str
|
|||||||
|
|
||||||
if (!joined) {
|
if (!joined) {
|
||||||
return (
|
return (
|
||||||
<div className="min-h-screen flex items-center justify-center bg-gradient-to-br from-[#CC0000]/10 to-[#990000]/10 p-4">
|
<div className="min-h-screen flex items-center justify-center bg-gradient-to-br from-[#C84B4B]/10 to-[#C84B4B]/10 p-4">
|
||||||
<div className="bg-white dark:bg-[#141414] rounded-2xl p-8 w-full max-w-md border border-[#E0E0E0] dark:border-[#CC0000]/20 shadow-lg text-center">
|
<div className="bg-white dark:bg-[#141414] rounded-2xl p-8 w-full max-w-md border border-[#D4D8CC] dark:border-[#CC0000]/20 shadow-lg text-center">
|
||||||
{connectionTimeout ? (
|
{connectionTimeout ? (
|
||||||
<>
|
<>
|
||||||
<h1 className="text-2xl font-bold text-[#111111] dark:text-white mb-3">This call is no longer available.</h1>
|
<h1 className="text-2xl font-bold text-[#2D3020] dark:text-white mb-3">This call is no longer available.</h1>
|
||||||
</>
|
</>
|
||||||
) : !isReady ? (
|
) : !isReady ? (
|
||||||
<>
|
<>
|
||||||
<div className="flex flex-col items-center gap-4 py-6">
|
<div className="flex flex-col items-center gap-4 py-6">
|
||||||
<Loader2 className="h-8 w-8 animate-spin text-[#CC0000]" />
|
<Loader2 className="h-8 w-8 animate-spin text-[#C84B4B]" />
|
||||||
<p className="text-[#888888] text-sm">Connecting to call...</p>
|
<p className="text-[#8A9078] text-sm">Connecting to call...</p>
|
||||||
</div>
|
</div>
|
||||||
</>
|
</>
|
||||||
) : (
|
) : (
|
||||||
<>
|
<>
|
||||||
<h1 className="text-2xl font-bold text-[#111111] dark:text-white mb-3">Join Call</h1>
|
<h1 className="text-2xl font-bold text-[#2D3020] dark:text-white mb-3">Join Call</h1>
|
||||||
{micError && (
|
{micError && (
|
||||||
<p className="text-[#CC0000] text-xs mb-4">{micError}</p>
|
<p className="text-[#C84B4B] text-xs mb-4">{micError}</p>
|
||||||
)}
|
)}
|
||||||
<input
|
<input
|
||||||
type="text"
|
type="text"
|
||||||
@@ -78,12 +78,12 @@ export default function CallRoomPage({ params }: { params: Promise<{ roomId: str
|
|||||||
onChange={(e) => setDisplayName(e.target.value)}
|
onChange={(e) => setDisplayName(e.target.value)}
|
||||||
onKeyDown={(e) => { if (e.key === "Enter") handleJoin() }}
|
onKeyDown={(e) => { if (e.key === "Enter") handleJoin() }}
|
||||||
placeholder="Enter your name"
|
placeholder="Enter your name"
|
||||||
className="bg-[#F5F5F5] dark:bg-[#1A1A1A] border border-[#E0E0E0] dark:border-[#333333] text-[#111111] dark:text-white placeholder-[#AAAAAA] dark:placeholder-[#555555] rounded-xl px-4 py-2.5 text-sm w-full mb-4 outline-none transition-colors focus:border-[#CC0000] dark:focus:border-[#FF4444]"
|
className="bg-[#FAFAF6] dark:bg-[#1A1A1A] border border-[#D4D8CC] dark:border-[#333333] text-[#2D3020] dark:text-white placeholder-[#8A9078] dark:placeholder-[#555555] rounded-xl px-4 py-2.5 text-sm w-full mb-4 outline-none transition-colors focus:border-[#C84B4B] dark:focus:border-[#FF4444]"
|
||||||
/>
|
/>
|
||||||
<button
|
<button
|
||||||
onClick={handleJoin}
|
onClick={handleJoin}
|
||||||
disabled={!displayName.trim()}
|
disabled={!displayName.trim()}
|
||||||
className="w-full bg-[#CC0000] hover:bg-[#990000] disabled:bg-[#CCCCCC] disabled:cursor-not-allowed dark:bg-[#FF1111] dark:hover:bg-[#CC0000] dark:disabled:bg-[#333333] text-white font-semibold text-sm rounded-xl py-2.5 flex items-center justify-center gap-2 transition-all duration-200"
|
className="w-full bg-[#C84B4B] hover:bg-[#C84B4B]/80 disabled:bg-[#8A9078] disabled:cursor-not-allowed dark:bg-[#FF1111] dark:hover:bg-[#CC0000] dark:disabled:bg-[#333333] text-white font-semibold text-sm rounded-xl py-2.5 flex items-center justify-center gap-2 transition-all duration-200"
|
||||||
>
|
>
|
||||||
<Phone className="h-4 w-4" />
|
<Phone className="h-4 w-4" />
|
||||||
Join Call
|
Join Call
|
||||||
@@ -96,14 +96,14 @@ export default function CallRoomPage({ params }: { params: Promise<{ roomId: str
|
|||||||
}
|
}
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className="min-h-screen flex items-center justify-center bg-gradient-to-br from-[#CC0000]/10 to-[#990000]/10 p-4">
|
<div className="min-h-screen flex items-center justify-center bg-gradient-to-br from-[#C84B4B]/10 to-[#C84B4B]/10 p-4">
|
||||||
<div className="bg-white dark:bg-[#141414] rounded-2xl p-8 w-full max-w-md border border-[#E0E0E0] dark:border-[#CC0000]/20 shadow-lg text-center">
|
<div className="bg-white dark:bg-[#141414] rounded-2xl p-8 w-full max-w-md border border-[#D4D8CC] dark:border-[#CC0000]/20 shadow-lg text-center">
|
||||||
{callState === "calling" && (
|
{callState === "calling" && (
|
||||||
<div>
|
<div>
|
||||||
<h1 className="text-2xl font-bold text-[#111111] dark:text-white mb-4">Calling</h1>
|
<h1 className="text-2xl font-bold text-[#2D3020] dark:text-white mb-4">Calling</h1>
|
||||||
<p className="text-[#888888] text-sm animate-pulse">Connecting to call room...</p>
|
<p className="text-[#8A9078] text-sm animate-pulse">Connecting to call room...</p>
|
||||||
<div className="flex justify-center mt-6">
|
<div className="flex justify-center mt-6">
|
||||||
<button onClick={handleEnd} className="w-14 h-14 rounded-full bg-[#CC0000] hover:bg-[#990000] dark:bg-[#FF1111] flex items-center justify-center text-white font-bold transition-all duration-200">
|
<button onClick={handleEnd} className="w-14 h-14 rounded-full bg-[#C84B4B] hover:bg-[#C84B4B]/80 dark:bg-[#FF1111] flex items-center justify-center text-white font-bold transition-all duration-200">
|
||||||
<PhoneOff className="h-5 w-5" />
|
<PhoneOff className="h-5 w-5" />
|
||||||
</button>
|
</button>
|
||||||
</div>
|
</div>
|
||||||
@@ -112,21 +112,21 @@ export default function CallRoomPage({ params }: { params: Promise<{ roomId: str
|
|||||||
|
|
||||||
{callState === "waiting" && (
|
{callState === "waiting" && (
|
||||||
<div>
|
<div>
|
||||||
<h1 className="text-2xl font-bold text-[#111111] dark:text-white mb-4">Waiting for participant</h1>
|
<h1 className="text-2xl font-bold text-[#2D3020] dark:text-white mb-4">Waiting for participant</h1>
|
||||||
<p className="text-[#888888] text-sm animate-pulse">Share the call link to invite someone...</p>
|
<p className="text-[#8A9078] text-sm animate-pulse">Share the call link to invite someone...</p>
|
||||||
{participants.length > 0 && (
|
{participants.length > 0 && (
|
||||||
<div className="mt-4 bg-[#F5F5F5] dark:bg-[#1A1A1A] rounded-xl p-3">
|
<div className="mt-4 bg-[#FAFAF6] dark:bg-[#1A1A1A] rounded-xl p-3">
|
||||||
<div className="flex items-center gap-2 text-xs text-[#888888] mb-2">
|
<div className="flex items-center gap-2 text-xs text-[#8A9078] mb-2">
|
||||||
<Users className="h-3 w-3" />
|
<Users className="h-3 w-3" />
|
||||||
Participants ({participants.length})
|
Participants ({participants.length})
|
||||||
</div>
|
</div>
|
||||||
{participants.map((p) => (
|
{participants.map((p) => (
|
||||||
<div key={p.id} className="text-sm text-[#111111] dark:text-white text-left">{p.label}</div>
|
<div key={p.id} className="text-sm text-[#2D3020] dark:text-white text-left">{p.label}</div>
|
||||||
))}
|
))}
|
||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
<div className="flex justify-center mt-6">
|
<div className="flex justify-center mt-6">
|
||||||
<button onClick={handleEnd} className="w-14 h-14 rounded-full bg-[#CC0000] hover:bg-[#990000] dark:bg-[#FF1111] flex items-center justify-center text-white font-bold transition-all duration-200">
|
<button onClick={handleEnd} className="w-14 h-14 rounded-full bg-[#C84B4B] hover:bg-[#C84B4B]/80 dark:bg-[#FF1111] flex items-center justify-center text-white font-bold transition-all duration-200">
|
||||||
<PhoneOff className="h-5 w-5" />
|
<PhoneOff className="h-5 w-5" />
|
||||||
</button>
|
</button>
|
||||||
</div>
|
</div>
|
||||||
@@ -135,21 +135,21 @@ export default function CallRoomPage({ params }: { params: Promise<{ roomId: str
|
|||||||
|
|
||||||
{callState === "participant_joined" && (
|
{callState === "participant_joined" && (
|
||||||
<div>
|
<div>
|
||||||
<h1 className="text-2xl font-bold text-[#111111] dark:text-white mb-4">Participant joined</h1>
|
<h1 className="text-2xl font-bold text-[#2D3020] dark:text-white mb-4">Participant joined</h1>
|
||||||
{participants.length > 0 && (
|
{participants.length > 0 && (
|
||||||
<div className="mb-4 bg-[#F5F5F5] dark:bg-[#1A1A1A] rounded-xl p-3">
|
<div className="mb-4 bg-[#FAFAF6] dark:bg-[#1A1A1A] rounded-xl p-3">
|
||||||
<div className="flex items-center gap-2 text-xs text-[#888888] mb-2">
|
<div className="flex items-center gap-2 text-xs text-[#8A9078] mb-2">
|
||||||
<Users className="h-3 w-3" />
|
<Users className="h-3 w-3" />
|
||||||
Participants ({participants.length})
|
Participants ({participants.length})
|
||||||
</div>
|
</div>
|
||||||
{participants.map((p) => (
|
{participants.map((p) => (
|
||||||
<div key={p.id} className="text-sm text-[#111111] dark:text-white text-left">{p.label}</div>
|
<div key={p.id} className="text-sm text-[#2D3020] dark:text-white text-left">{p.label}</div>
|
||||||
))}
|
))}
|
||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
<p className="text-[#888888] text-sm animate-pulse">Connecting...</p>
|
<p className="text-[#8A9078] text-sm animate-pulse">Connecting...</p>
|
||||||
<div className="flex justify-center mt-6">
|
<div className="flex justify-center mt-6">
|
||||||
<button onClick={handleEnd} className="w-14 h-14 rounded-full bg-[#CC0000] hover:bg-[#990000] dark:bg-[#FF1111] flex items-center justify-center text-white font-bold transition-all duration-200">
|
<button onClick={handleEnd} className="w-14 h-14 rounded-full bg-[#C84B4B] hover:bg-[#C84B4B]/80 dark:bg-[#FF1111] flex items-center justify-center text-white font-bold transition-all duration-200">
|
||||||
<PhoneOff className="h-5 w-5" />
|
<PhoneOff className="h-5 w-5" />
|
||||||
</button>
|
</button>
|
||||||
</div>
|
</div>
|
||||||
@@ -158,10 +158,10 @@ export default function CallRoomPage({ params }: { params: Promise<{ roomId: str
|
|||||||
|
|
||||||
{callState === "connecting" && (
|
{callState === "connecting" && (
|
||||||
<div>
|
<div>
|
||||||
<h1 className="text-2xl font-bold text-[#111111] dark:text-white mb-4">Connecting</h1>
|
<h1 className="text-2xl font-bold text-[#2D3020] dark:text-white mb-4">Connecting</h1>
|
||||||
<p className="text-[#888888] text-sm animate-pulse">Establishing secure connection...</p>
|
<p className="text-[#8A9078] text-sm animate-pulse">Establishing secure connection...</p>
|
||||||
<div className="flex justify-center mt-6">
|
<div className="flex justify-center mt-6">
|
||||||
<button onClick={handleEnd} className="w-14 h-14 rounded-full bg-[#CC0000] hover:bg-[#990000] dark:bg-[#FF1111] flex items-center justify-center text-white font-bold transition-all duration-200">
|
<button onClick={handleEnd} className="w-14 h-14 rounded-full bg-[#C84B4B] hover:bg-[#C84B4B]/80 dark:bg-[#FF1111] flex items-center justify-center text-white font-bold transition-all duration-200">
|
||||||
<PhoneOff className="h-5 w-5" />
|
<PhoneOff className="h-5 w-5" />
|
||||||
</button>
|
</button>
|
||||||
</div>
|
</div>
|
||||||
@@ -170,37 +170,37 @@ export default function CallRoomPage({ params }: { params: Promise<{ roomId: str
|
|||||||
|
|
||||||
{callState === "connected" && (
|
{callState === "connected" && (
|
||||||
<div>
|
<div>
|
||||||
<h1 className="text-2xl font-bold text-[#111111] dark:text-white mb-1">Connected</h1>
|
<h1 className="text-2xl font-bold text-[#2D3020] dark:text-white mb-1">Connected</h1>
|
||||||
{participants.length > 0 && (
|
{participants.length > 0 && (
|
||||||
<div className="mb-4 bg-[#F5F5F5] dark:bg-[#1A1A1A] rounded-xl p-3">
|
<div className="mb-4 bg-[#FAFAF6] dark:bg-[#1A1A1A] rounded-xl p-3">
|
||||||
<div className="flex items-center gap-2 text-xs text-[#888888] mb-2">
|
<div className="flex items-center gap-2 text-xs text-[#8A9078] mb-2">
|
||||||
<Users className="h-3 w-3" />
|
<Users className="h-3 w-3" />
|
||||||
Participants ({participants.length})
|
Participants ({participants.length})
|
||||||
</div>
|
</div>
|
||||||
{participants.map((p) => (
|
{participants.map((p) => (
|
||||||
<div key={p.id} className="text-sm text-[#111111] dark:text-white text-left">{p.label}</div>
|
<div key={p.id} className="text-sm text-[#2D3020] dark:text-white text-left">{p.label}</div>
|
||||||
))}
|
))}
|
||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
<div className="text-[#CC0000] font-mono text-lg mb-6">
|
<div className="text-[#C84B4B] font-mono text-lg mb-6">
|
||||||
{formatDuration(callDuration)}
|
{formatDuration(callDuration)}
|
||||||
</div>
|
</div>
|
||||||
<div className="flex justify-center gap-4">
|
<div className="flex justify-center gap-4">
|
||||||
<button
|
<button
|
||||||
onClick={toggleSpeaker}
|
onClick={toggleSpeaker}
|
||||||
className={`w-14 h-14 rounded-full flex items-center justify-center text-white font-bold transition-all duration-200 ${isSpeakerOn ? 'bg-[#0033CC] dark:bg-[#1144FF]' : 'bg-[#444444] dark:bg-[#333333]'}`}
|
className={`w-14 h-14 rounded-full flex items-center justify-center text-white font-bold transition-all duration-200 ${isSpeakerOn ? 'bg-[#5A8FC4] dark:bg-[#1144FF]' : 'bg-[#8A9078] dark:bg-[#333333]'}`}
|
||||||
>
|
>
|
||||||
{isSpeakerOn ? <Volume2 className="h-5 w-5" /> : <VolumeX className="h-5 w-5" />}
|
{isSpeakerOn ? <Volume2 className="h-5 w-5" /> : <VolumeX className="h-5 w-5" />}
|
||||||
</button>
|
</button>
|
||||||
<button
|
<button
|
||||||
onClick={toggleMute}
|
onClick={toggleMute}
|
||||||
className={`w-14 h-14 rounded-full flex items-center justify-center text-white font-bold transition-all duration-200 ${isMuted ? 'bg-[#0033CC] dark:bg-[#1144FF]' : 'bg-[#444444] dark:bg-[#333333]'}`}
|
className={`w-14 h-14 rounded-full flex items-center justify-center text-white font-bold transition-all duration-200 ${isMuted ? 'bg-[#5A8FC4] dark:bg-[#1144FF]' : 'bg-[#8A9078] dark:bg-[#333333]'}`}
|
||||||
>
|
>
|
||||||
{isMuted ? <MicOff className="h-5 w-5" /> : <Mic className="h-5 w-5" />}
|
{isMuted ? <MicOff className="h-5 w-5" /> : <Mic className="h-5 w-5" />}
|
||||||
</button>
|
</button>
|
||||||
<button
|
<button
|
||||||
onClick={handleEnd}
|
onClick={handleEnd}
|
||||||
className="w-14 h-14 rounded-full bg-[#CC0000] hover:bg-[#990000] dark:bg-[#FF1111] flex items-center justify-center text-white font-bold transition-all duration-200"
|
className="w-14 h-14 rounded-full bg-[#C84B4B] hover:bg-[#C84B4B]/80 dark:bg-[#FF1111] flex items-center justify-center text-white font-bold transition-all duration-200"
|
||||||
>
|
>
|
||||||
<PhoneOff className="h-5 w-5" />
|
<PhoneOff className="h-5 w-5" />
|
||||||
</button>
|
</button>
|
||||||
@@ -210,15 +210,15 @@ export default function CallRoomPage({ params }: { params: Promise<{ roomId: str
|
|||||||
|
|
||||||
{(callState === "ended" || callState === "idle") && (
|
{(callState === "ended" || callState === "idle") && (
|
||||||
<div>
|
<div>
|
||||||
<h1 className="text-2xl font-bold text-[#111111] dark:text-white mb-4">Call ended</h1>
|
<h1 className="text-2xl font-bold text-[#2D3020] dark:text-white mb-4">Call ended</h1>
|
||||||
{callDuration > 0 && (
|
{callDuration > 0 && (
|
||||||
<p className="text-[#888888] text-sm mb-6">Duration: {formatDuration(callDuration)}</p>
|
<p className="text-[#8A9078] text-sm mb-6">Duration: {formatDuration(callDuration)}</p>
|
||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
{error && (
|
{error && (
|
||||||
<p className="text-[#CC0000] text-xs mt-4">{error}</p>
|
<p className="text-[#C84B4B] text-xs mt-4">{error}</p>
|
||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
+69
-50
@@ -78,69 +78,88 @@
|
|||||||
}
|
}
|
||||||
|
|
||||||
:root {
|
:root {
|
||||||
--background: 220 14% 84%;
|
--background: 60 20% 95%;
|
||||||
--foreground: 222.2 84% 4.9%;
|
--foreground: 71 20% 16%;
|
||||||
--card: 220 10% 91%;
|
--card: 60 29% 97%;
|
||||||
--card-foreground: 222.2 84% 4.9%;
|
--card-foreground: 71 20% 16%;
|
||||||
--popover: 220 14% 96%;
|
--popover: 60 29% 97%;
|
||||||
--popover-foreground: 222.2 84% 4.9%;
|
--popover-foreground: 71 20% 16%;
|
||||||
--primary: 120 100% 50%;
|
--primary: 0 53% 54%;
|
||||||
--primary-foreground: 0 0% 100%;
|
--primary-foreground: 0 0% 100%;
|
||||||
--secondary: 220 14% 88%;
|
--secondary: 210 47% 56%;
|
||||||
--secondary-foreground: 222.2 47.4% 11.2%;
|
--secondary-foreground: 0 0% 100%;
|
||||||
--muted: 220 14% 88%;
|
--muted: 75 10% 93%;
|
||||||
--muted-foreground: 215.4 16.3% 46.9%;
|
--muted-foreground: 75 10% 52%;
|
||||||
--accent: 220 14% 88%;
|
--accent: 159 32% 43%;
|
||||||
--accent-foreground: 222.2 47.4% 11.2%;
|
--accent-foreground: 0 0% 100%;
|
||||||
--destructive: 0 84.2% 60.2%;
|
--destructive: 0 53% 54%;
|
||||||
--destructive-foreground: 210 40% 98%;
|
--destructive-foreground: 0 0% 100%;
|
||||||
--border: 214.3 31.8% 91.4%;
|
--border: 80 13% 82%;
|
||||||
--input: 214.3 31.8% 91.4%;
|
--input: 80 13% 82%;
|
||||||
--ring: 221.2 83.2% 53.3%;
|
--ring: 0 53% 54%;
|
||||||
--sidebar: 216 12% 92%;
|
--sidebar: 80 17% 92%;
|
||||||
--sidebar-foreground: 0 0% 20%;
|
--sidebar-foreground: 71 20% 16%;
|
||||||
--sidebar-primary: 0 91.2% 59.8%;
|
--sidebar-primary: 0 53% 54%;
|
||||||
--sidebar-primary-foreground: 0 0% 100%;
|
--sidebar-primary-foreground: 0 0% 100%;
|
||||||
--sidebar-accent: 218 16% 87%;
|
--sidebar-accent: 80 17% 92%;
|
||||||
--sidebar-accent-foreground: 0 0% 20%;
|
--sidebar-accent-foreground: 71 20% 16%;
|
||||||
--sidebar-border: 220 11% 84%;
|
--sidebar-border: 80 13% 82%;
|
||||||
--sidebar-ring: 0 76.3% 48%;
|
--sidebar-ring: 0 53% 54%;
|
||||||
--radius: 0.5rem;
|
--radius: 0.5rem;
|
||||||
--theme-primary: hsl(var(--primary));
|
--theme-primary: hsl(var(--primary));
|
||||||
|
--color-blue: 210 47% 56%;
|
||||||
|
--color-teal: 159 32% 43%;
|
||||||
|
--color-red-tint: 0 62% 95%;
|
||||||
|
--color-blue-tint: 208 54% 93%;
|
||||||
|
--color-teal-tint: 157 35% 91%;
|
||||||
|
--color-avatar: 102 21% 52%;
|
||||||
--event-call: 160 84% 39%;
|
--event-call: 160 84% 39%;
|
||||||
--event-follow_up: 38 92% 48%;
|
--event-follow_up: 38 92% 48%;
|
||||||
--event-website_creation: 263 70% 50%;
|
--event-website_creation: 263 70% 50%;
|
||||||
}
|
}
|
||||||
|
|
||||||
.dark {
|
.dark {
|
||||||
--background: 0 0% 10%;
|
/* ── Slate palette tokens ── */
|
||||||
--foreground: 210 40% 98%;
|
--bg-page: 225 18% 9%;
|
||||||
--card: 0 0% 9%;
|
--bg-sidebar: 227 25% 7%;
|
||||||
--card-foreground: 210 40% 98%;
|
--bg-card: 224 24% 11%;
|
||||||
--popover: 222.2 84% 11%;
|
--bg-card-inset: 224 24% 13%;
|
||||||
--popover-foreground: 210 40% 98%;
|
--bg-nav-active: 221 43% 20%;
|
||||||
--primary: 0 100% 53%;
|
--border: 228 21% 21%;
|
||||||
|
--border-strong: 220 69% 59%;
|
||||||
|
--text-primary: 226 30% 95%;
|
||||||
|
--text-secondary: 228 13% 59%;
|
||||||
|
--text-active: 221 100% 87%;
|
||||||
|
--accent-hover: 220 70% 66%;
|
||||||
|
|
||||||
|
/* ── Existing CSS variable overrides ── */
|
||||||
|
--background: 225 18% 9%;
|
||||||
|
--foreground: 226 30% 95%;
|
||||||
|
--card: 224 24% 11%;
|
||||||
|
--card-foreground: 226 30% 95%;
|
||||||
|
--popover: 224 24% 13%;
|
||||||
|
--popover-foreground: 226 30% 95%;
|
||||||
|
--primary: 220 69% 59%;
|
||||||
--primary-foreground: 222.2 47.4% 11.2%;
|
--primary-foreground: 222.2 47.4% 11.2%;
|
||||||
--secondary: 217.2 32.6% 17.5%;
|
--secondary: 224 24% 13%;
|
||||||
--secondary-foreground: 210 40% 98%;
|
--secondary-foreground: 226 30% 95%;
|
||||||
--muted: 217.2 32.6% 17.5%;
|
--muted: 224 24% 13%;
|
||||||
--muted-foreground: 215 20.2% 65.1%;
|
--muted-foreground: 228 13% 59%;
|
||||||
--accent: 217.2 32.6% 17.5%;
|
--accent: 224 24% 13%;
|
||||||
--accent-foreground: 210 40% 98%;
|
--accent-foreground: 226 30% 95%;
|
||||||
--destructive: 0 62.8% 30.6%;
|
--destructive: 0 62.8% 30.6%;
|
||||||
--destructive-foreground: 210 40% 98%;
|
--destructive-foreground: 210 40% 98%;
|
||||||
--border: 217.2 32.6% 17.5%;
|
--border: 228 21% 21%;
|
||||||
--input: 217.2 32.6% 17.5%;
|
--input: 228 21% 21%;
|
||||||
--ring: 0 100% 53%;
|
--ring: 220 69% 59%;
|
||||||
--sidebar: 0 0% 11%;
|
--sidebar: 227 25% 7%;
|
||||||
--sidebar-foreground: 210 40% 98%;
|
--sidebar-foreground: 226 30% 95%;
|
||||||
--sidebar-primary: 145 65% 50%;
|
--sidebar-primary: 221 43% 20%;
|
||||||
--sidebar-primary-foreground: 0 0% 100%;
|
--sidebar-primary-foreground: 221 100% 87%;
|
||||||
--sidebar-accent: 217.2 32.6% 17.5%;
|
--sidebar-accent: 224 24% 13%;
|
||||||
--sidebar-accent-foreground: 210 40% 98%;
|
--sidebar-accent-foreground: 226 30% 95%;
|
||||||
--sidebar-border: 217.2 32.6% 17.5%;
|
--sidebar-border: 228 21% 21%;
|
||||||
--sidebar-ring: 0 100% 53%;
|
--sidebar-ring: 220 69% 59%;
|
||||||
|
|
||||||
}
|
}
|
||||||
|
|
||||||
.ocean {
|
.ocean {
|
||||||
|
|||||||
@@ -7,9 +7,9 @@ interface Props {
|
|||||||
|
|
||||||
function ErrorPage({ message }: { message: string }) {
|
function ErrorPage({ message }: { message: string }) {
|
||||||
return (
|
return (
|
||||||
<div className="min-h-screen flex items-center justify-center bg-gradient-to-br from-[#CC0000]/10 to-[#990000]/10 p-4">
|
<div className="min-h-screen flex items-center justify-center bg-gradient-to-br from-[#C84B4B]/10 to-[#C84B4B]/10 p-4">
|
||||||
<div className="bg-white dark:bg-[#141414] rounded-2xl p-8 w-full max-w-md border border-[#E0E0E0] dark:border-[#CC0000]/20 shadow-lg text-center">
|
<div className="bg-white dark:bg-[#141414] rounded-2xl p-8 w-full max-w-md border border-[#D4D8CC] dark:border-[#CC0000]/20 shadow-lg text-center">
|
||||||
<h1 className="text-2xl font-bold text-[#111111] dark:text-white mb-3">
|
<h1 className="text-2xl font-bold text-[#2D3020] dark:text-white mb-3">
|
||||||
{message}
|
{message}
|
||||||
</h1>
|
</h1>
|
||||||
</div>
|
</div>
|
||||||
@@ -42,24 +42,24 @@ export default async function JoinPage({ params }: Props) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className="min-h-screen flex items-center justify-center bg-gradient-to-br from-[#CC0000]/10 to-[#990000]/10 p-4">
|
<div className="min-h-screen flex items-center justify-center bg-gradient-to-br from-[#C84B4B]/10 to-[#C84B4B]/10 p-4">
|
||||||
<div className="bg-white dark:bg-[#141414] rounded-2xl p-8 w-full max-w-md border border-[#E0E0E0] dark:border-[#CC0000]/20 shadow-lg text-center">
|
<div className="bg-white dark:bg-[#141414] rounded-2xl p-8 w-full max-w-md border border-[#D4D8CC] dark:border-[#CC0000]/20 shadow-lg text-center">
|
||||||
<h1 className="text-2xl font-bold text-[#111111] dark:text-white mb-3">
|
<h1 className="text-2xl font-bold text-[#2D3020] dark:text-white mb-3">
|
||||||
You're Invited!
|
You're Invited!
|
||||||
</h1>
|
</h1>
|
||||||
<p className="text-[#888888] text-sm mb-6">
|
<p className="text-[#8A9078] text-sm mb-6">
|
||||||
Someone wants to connect with you on our platform.
|
Someone wants to connect with you on our platform.
|
||||||
</p>
|
</p>
|
||||||
<div className="bg-[#F5F5F5] dark:bg-[#1A1A1A] rounded-xl p-4 mb-6 text-left">
|
<div className="bg-[#FAFAF6] dark:bg-[#1A1A1A] rounded-xl p-4 mb-6 text-left">
|
||||||
<p className="text-xs text-[#888888] mb-1">Contact Number</p>
|
<p className="text-xs text-[#8A9078] mb-1">Contact Number</p>
|
||||||
<p className="text-sm font-medium text-[#111111] dark:text-white">{invite.phone}</p>
|
<p className="text-sm font-medium text-[#2D3020] dark:text-white">{invite.phone}</p>
|
||||||
</div>
|
</div>
|
||||||
<p className="text-xs text-[#888888] mb-6">
|
<p className="text-xs text-[#8A9078] mb-6">
|
||||||
Create an account to start making free voice calls to this person and others on the platform.
|
Create an account to start making free voice calls to this person and others on the platform.
|
||||||
</p>
|
</p>
|
||||||
<a
|
<a
|
||||||
href="/register"
|
href="/register"
|
||||||
className="block w-full bg-[#CC0000] hover:bg-[#990000] dark:bg-[#FF1111] dark:hover:bg-[#CC0000] text-white font-semibold text-sm rounded-xl py-3 transition-all duration-200"
|
className="block w-full bg-[#C84B4B] hover:bg-[#C84B4B]/80 dark:bg-[#FF1111] dark:hover:bg-[#CC0000] text-white font-semibold text-sm rounded-xl py-3 transition-all duration-200"
|
||||||
>
|
>
|
||||||
Create Account
|
Create Account
|
||||||
</a>
|
</a>
|
||||||
|
|||||||
+1
-1
@@ -33,7 +33,7 @@ export default function RootLayout({
|
|||||||
return (
|
return (
|
||||||
<html lang="en" suppressHydrationWarning>
|
<html lang="en" suppressHydrationWarning>
|
||||||
<body className={`${inter.variable} min-h-screen antialiased`}>
|
<body className={`${inter.variable} min-h-screen antialiased`}>
|
||||||
<ThemeProvider attribute="class" defaultTheme="dark" disableTransitionOnChange>
|
<ThemeProvider attribute="class" defaultTheme="light" disableTransitionOnChange>
|
||||||
<WebsiteThemeProvider>
|
<WebsiteThemeProvider>
|
||||||
{children}
|
{children}
|
||||||
<Toaster />
|
<Toaster />
|
||||||
|
|||||||
@@ -4,6 +4,8 @@ import { useState, useEffect, useRef, Suspense } from "react"
|
|||||||
import { useSearchParams, useRouter } from "next/navigation"
|
import { useSearchParams, useRouter } from "next/navigation"
|
||||||
import { Eye, EyeOff, Loader2 } from "lucide-react"
|
import { Eye, EyeOff, Loader2 } from "lucide-react"
|
||||||
|
|
||||||
|
const WEBSITE_THEME_KEY = "crm-website-theme"
|
||||||
|
|
||||||
const waves = [
|
const waves = [
|
||||||
{ a: 16, f: 0.011, s: 0.018, y: 0.38, fill: "rgba(27,176,206,0.18)" },
|
{ a: 16, f: 0.011, s: 0.018, y: 0.38, fill: "rgba(27,176,206,0.18)" },
|
||||||
{ a: 20, f: 0.008, s: 0.013, y: 0.52, fill: "rgba(27,176,206,0.25)" },
|
{ a: 20, f: 0.008, s: 0.013, y: 0.52, fill: "rgba(27,176,206,0.25)" },
|
||||||
@@ -227,6 +229,9 @@ function LoginForm() {
|
|||||||
})
|
})
|
||||||
|
|
||||||
if (res.ok) {
|
if (res.ok) {
|
||||||
|
localStorage.removeItem(WEBSITE_THEME_KEY)
|
||||||
|
const root = document.documentElement
|
||||||
|
root.className = root.className.split(" ").filter((c) => !c.startsWith("theme-")).join(" ").trim()
|
||||||
const redirectTo = searchParams.get("redirect") || "/dashboard"
|
const redirectTo = searchParams.get("redirect") || "/dashboard"
|
||||||
router.push(redirectTo)
|
router.push(redirectTo)
|
||||||
} else {
|
} else {
|
||||||
|
|||||||
+104
-96
@@ -2,13 +2,14 @@
|
|||||||
|
|
||||||
import { useState, useRef, useEffect, Fragment, forwardRef, useImperativeHandle, useCallback } from "react"
|
import { useState, useRef, useEffect, Fragment, forwardRef, useImperativeHandle, useCallback } from "react"
|
||||||
import { Bot, Terminal } from "lucide-react"
|
import { Bot, Terminal } from "lucide-react"
|
||||||
|
import { GifPicker } from "./gif-picker"
|
||||||
|
|
||||||
function linkifyText(text: string) {
|
function linkifyText(text: string) {
|
||||||
const urlRegex = /(https?:\/\/[^\s<]+[^\s<.,;:!?)\]}>])/
|
const urlRegex = /(https?:\/\/[^\s<]+[^\s<.,;:!?)\]}>])/
|
||||||
const parts = text.split(urlRegex)
|
const parts = text.split(urlRegex)
|
||||||
return parts.map((part, i) => {
|
return parts.map((part, i) => {
|
||||||
if (part.startsWith("http://") || part.startsWith("https://")) {
|
if (part.startsWith("http://") || part.startsWith("https://")) {
|
||||||
return <a key={i} href={part} target="_blank" rel="noopener noreferrer" className="underline text-[#f97316] hover:text-[#f97316]/80">{part}</a>
|
return <a key={i} href={part} target="_blank" rel="noopener noreferrer" className="underline text-primary hover:text-primary/80">{part}</a>
|
||||||
}
|
}
|
||||||
return <Fragment key={i}>{part}</Fragment>
|
return <Fragment key={i}>{part}</Fragment>
|
||||||
})
|
})
|
||||||
@@ -22,7 +23,7 @@ function formatContent(text: string) {
|
|||||||
const content = trimmed.replace(/^[•\-]\s*/, "")
|
const content = trimmed.replace(/^[•\-]\s*/, "")
|
||||||
return (
|
return (
|
||||||
<div key={i} className="flex items-start gap-2.5 my-1.5">
|
<div key={i} className="flex items-start gap-2.5 my-1.5">
|
||||||
<span className="w-1.5 h-1.5 bg-[#f97316] rounded-sm inline-block mt-2 flex-shrink-0" />
|
<span className="w-1.5 h-1.5 bg-primary rounded-sm inline-block mt-2 flex-shrink-0" />
|
||||||
<span>{linkifyText(content)}</span>
|
<span>{linkifyText(content)}</span>
|
||||||
</div>
|
</div>
|
||||||
)
|
)
|
||||||
@@ -35,27 +36,17 @@ function formatContent(text: string) {
|
|||||||
interface ChatMessage {
|
interface ChatMessage {
|
||||||
role: "user" | "assistant"
|
role: "user" | "assistant"
|
||||||
content: string
|
content: string
|
||||||
|
gif?: {
|
||||||
|
url: string
|
||||||
|
previewUrl: string
|
||||||
|
title: string
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
interface AIChatProps {
|
interface AIChatProps {
|
||||||
onMessageSent?: (msg: string) => void
|
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 interface AIChatHandle {
|
export interface AIChatHandle {
|
||||||
fillInput: (text: string) => void
|
fillInput: (text: string) => void
|
||||||
@@ -68,9 +59,12 @@ export const AIChat = forwardRef<AIChatHandle, AIChatProps>(({ onMessageSent },
|
|||||||
const [loading, setLoading] = useState(false)
|
const [loading, setLoading] = useState(false)
|
||||||
const [error, setError] = useState("")
|
const [error, setError] = useState("")
|
||||||
const [bootState, setBootState] = useState<"booting" | "ready" | "error">("booting")
|
const [bootState, setBootState] = useState<"booting" | "ready" | "error">("booting")
|
||||||
|
const [showGifPicker, setShowGifPicker] = useState(false)
|
||||||
const messagesEndRef = useRef<HTMLDivElement>(null)
|
const messagesEndRef = useRef<HTMLDivElement>(null)
|
||||||
const textareaRef = useRef<HTMLTextAreaElement>(null)
|
const textareaRef = useRef<HTMLTextAreaElement>(null)
|
||||||
|
const containerRef = useRef<HTMLDivElement>(null)
|
||||||
const hasUserMessage = messages.some(m => m.role === "user")
|
const hasUserMessage = messages.some(m => m.role === "user")
|
||||||
|
const loadedFromStorage = useRef(false)
|
||||||
|
|
||||||
useImperativeHandle(ref, () => ({
|
useImperativeHandle(ref, () => ({
|
||||||
fillInput(text: string) {
|
fillInput(text: string) {
|
||||||
@@ -90,6 +84,44 @@ export const AIChat = forwardRef<AIChatHandle, AIChatProps>(({ onMessageSent },
|
|||||||
},
|
},
|
||||||
}), [])
|
}), [])
|
||||||
|
|
||||||
|
const handleGifSelect = useCallback((gif: { url: string; previewUrl: string; title: string }) => {
|
||||||
|
setMessages((prev) => [...prev, { role: "user", content: gif.title || "Sent a GIF", gif }])
|
||||||
|
setShowGifPicker(false)
|
||||||
|
}, [])
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
if (showGifPicker) {
|
||||||
|
const handler = (e: MouseEvent) => {
|
||||||
|
if (containerRef.current && !containerRef.current.contains(e.target as Node)) {
|
||||||
|
setShowGifPicker(false)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
document.addEventListener("mousedown", handler)
|
||||||
|
return () => document.removeEventListener("mousedown", handler)
|
||||||
|
}
|
||||||
|
}, [showGifPicker])
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
const saved = localStorage.getItem("ai-chat-messages")
|
||||||
|
if (saved) {
|
||||||
|
try {
|
||||||
|
const parsed = JSON.parse(saved)
|
||||||
|
if (Array.isArray(parsed) && parsed.length > 0) {
|
||||||
|
setMessages(parsed)
|
||||||
|
loadedFromStorage.current = true
|
||||||
|
}
|
||||||
|
} catch {}
|
||||||
|
} else {
|
||||||
|
loadedFromStorage.current = false
|
||||||
|
}
|
||||||
|
}, [])
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
if (messages.length > 0) {
|
||||||
|
localStorage.setItem("ai-chat-messages", JSON.stringify(messages))
|
||||||
|
}
|
||||||
|
}, [messages])
|
||||||
|
|
||||||
const checkServer = useCallback(async () => {
|
const checkServer = useCallback(async () => {
|
||||||
try {
|
try {
|
||||||
const res = await fetch("/api/ai/chat", {
|
const res = await fetch("/api/ai/chat", {
|
||||||
@@ -108,6 +140,7 @@ export const AIChat = forwardRef<AIChatHandle, AIChatProps>(({ onMessageSent },
|
|||||||
}, [])
|
}, [])
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
|
if (loadedFromStorage.current) return
|
||||||
fetch("/api/ai/jobs")
|
fetch("/api/ai/jobs")
|
||||||
.then((r) => r.json())
|
.then((r) => r.json())
|
||||||
.then((data) => {
|
.then((data) => {
|
||||||
@@ -192,34 +225,24 @@ export const AIChat = forwardRef<AIChatHandle, AIChatProps>(({ onMessageSent },
|
|||||||
t.style.height = t.scrollHeight + "px"
|
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") {
|
if (bootState === "booting") {
|
||||||
return (
|
return (
|
||||||
<div className="flex items-center justify-center h-full">
|
<div className="flex items-center justify-center h-full">
|
||||||
<div className="flex flex-col items-center gap-5">
|
<div className="flex flex-col items-center gap-5">
|
||||||
<p className="text-sm text-[#6b7280] animate-pulse">Servers booting...</p>
|
<p className="text-sm text-muted-foreground animate-pulse">Servers booting...</p>
|
||||||
<div className="h-[56px] w-[110px] bg-[#1a1d2e] border border-[#ffffff0f] rounded-xl flex items-center justify-center overflow-hidden">
|
<div className="h-[56px] w-[110px] bg-card border border-border rounded-xl flex items-center justify-center overflow-hidden">
|
||||||
<div className="robot-walk relative">
|
<div className="robot-walk relative">
|
||||||
<svg width="40" height="36" viewBox="0 0 40 36" fill="none">
|
<svg width="40" height="36" viewBox="0 0 40 36" fill="none">
|
||||||
<rect x="10" y="2" width="20" height="16" rx="3" fill="#f97316" opacity="0.9"/>
|
<rect x="10" y="2" width="20" height="16" rx="3" fill="hsl(var(--primary))" opacity="0.9"/>
|
||||||
<rect x="6" y="6" width="6" height="2" rx="1" className="robot-arm-l" fill="#f97316" opacity="0.7"/>
|
<rect x="6" y="6" width="6" height="2" rx="1" className="robot-arm-l" fill="hsl(var(--primary))" opacity="0.7"/>
|
||||||
<rect x="28" y="6" width="6" height="2" rx="1" className="robot-arm-r" fill="#f97316" opacity="0.7"/>
|
<rect x="28" y="6" width="6" height="2" rx="1" className="robot-arm-r" fill="hsl(var(--primary))" opacity="0.7"/>
|
||||||
<rect x="14" y="5" width="4" height="4" rx="1" fill="#0f1117"/>
|
<rect x="14" y="5" width="4" height="4" rx="1" style={{fill: "hsl(var(--background))"}}/>
|
||||||
<rect x="22" y="5" width="4" height="4" rx="1" fill="#0f1117"/>
|
<rect x="22" y="5" width="4" height="4" rx="1" style={{fill: "hsl(var(--background))"}}/>
|
||||||
<circle cx="16" cy="7" r="1.5" className="robot-eye" fill="#f97316"/>
|
<circle cx="16" cy="7" r="1.5" className="robot-eye" fill="hsl(var(--primary))"/>
|
||||||
<circle cx="24" cy="7" r="1.5" className="robot-eye" fill="#f97316"/>
|
<circle cx="24" cy="7" r="1.5" className="robot-eye" fill="hsl(var(--primary))"/>
|
||||||
<rect x="17" y="10" width="6" height="2" rx="1" fill="#0f1117"/>
|
<rect x="17" y="10" width="6" height="2" rx="1" style={{fill: "hsl(var(--background))"}}/>
|
||||||
<rect x="12" y="18" width="5" height="10" rx="2" className="robot-leg-l" fill="#f97316" opacity="0.8"/>
|
<rect x="12" y="18" width="5" height="10" rx="2" className="robot-leg-l" fill="hsl(var(--primary))" opacity="0.8"/>
|
||||||
<rect x="23" y="18" width="5" height="10" rx="2" className="robot-leg-r" fill="#f97316" opacity="0.8"/>
|
<rect x="23" y="18" width="5" height="10" rx="2" className="robot-leg-r" fill="hsl(var(--primary))" opacity="0.8"/>
|
||||||
</svg>
|
</svg>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
@@ -230,48 +253,23 @@ export const AIChat = forwardRef<AIChatHandle, AIChatProps>(({ onMessageSent },
|
|||||||
|
|
||||||
if (bootState === "ready" && !hasUserMessage) {
|
if (bootState === "ready" && !hasUserMessage) {
|
||||||
return (
|
return (
|
||||||
<div className="flex-1 flex flex-col overflow-y-auto" style={{ backgroundImage: "radial-gradient(circle, #ffffff08 1px, transparent 1px)", backgroundSize: "24px 24px" }}>
|
<div className="flex-1 flex flex-col overflow-y-auto" style={{ backgroundImage: "radial-gradient(circle, hsl(var(--border)) 1px, transparent 1px)", backgroundSize: "24px 24px" }}>
|
||||||
<div className="flex-1 flex items-center justify-center">
|
<div className="flex-1 flex items-center justify-center">
|
||||||
<div className="max-w-lg mx-auto pt-12 pb-6 px-4">
|
<div className="max-w-lg mx-auto px-4">
|
||||||
<div className="float-in">
|
<div className="float-in">
|
||||||
<div className="w-16 h-16 rounded-2xl mx-auto mb-6 bg-gradient-to-br from-[#f97316] to-[#ea580c] flex items-center justify-center shadow-[0_0_40px_rgba(249,115,22,0.3)]">
|
<div className="w-16 h-16 rounded-2xl mx-auto mb-6 bg-gradient-to-br from-primary to-primary flex items-center justify-center shadow-[0_0_40px_hsl(var(--primary)_/_0.3)]">
|
||||||
<Bot className="h-8 w-8 text-white" />
|
<Bot className="h-8 w-8 text-white" />
|
||||||
</div>
|
</div>
|
||||||
<h2 className="text-white font-bold text-2xl text-center mb-2">What can I help you with?</h2>
|
<h2 className="text-foreground font-bold text-2xl text-center mb-2">What can I help you with?</h2>
|
||||||
<p className="text-[#6b7280] text-sm text-center mb-8 leading-relaxed">Your AI sales assistant is ready. Choose a quick action or type your question below.</p>
|
<p className="text-muted-foreground text-sm text-center leading-relaxed">Your AI sales assistant is ready. Choose a quick action or type your question below.</p>
|
||||||
</div>
|
|
||||||
<div className="grid grid-cols-2 gap-3">
|
|
||||||
{quickActions.map((action, i) => (
|
|
||||||
<div
|
|
||||||
key={i}
|
|
||||||
onClick={() => 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` }}
|
|
||||||
>
|
|
||||||
<div className={`w-9 h-9 rounded-lg ${action.iconBg} ${action.iconColor} flex items-center justify-center text-lg`}>{action.icon}</div>
|
|
||||||
<h3 className="text-white text-sm font-medium mt-3">{action.title}</h3>
|
|
||||||
<p className="text-[#6b7280] text-xs mt-1">{action.desc}</p>
|
|
||||||
</div>
|
|
||||||
))}
|
|
||||||
</div>
|
|
||||||
<div className="flex gap-2 justify-center mt-6 flex-wrap">
|
|
||||||
{commandPills.map((pill, i) => (
|
|
||||||
<div
|
|
||||||
key={i}
|
|
||||||
onClick={() => 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"
|
|
||||||
>
|
|
||||||
<span>{pill.icon}</span>
|
|
||||||
<span>{pill.label}</span>
|
|
||||||
</div>
|
|
||||||
))}
|
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
<div className="sticky bottom-0 bg-[#0f1117]/95 backdrop-blur-md border-t border-[#ffffff08] px-4 py-4">
|
<div className="sticky bottom-0 bg-background/95 backdrop-blur-md px-4 py-4">
|
||||||
<div className="max-w-3xl mx-auto">
|
<div className="max-w-3xl mx-auto">
|
||||||
<div className="bg-[#1a1d2e] rounded-2xl border border-[#ffffff0a] focus-within:border-[#f97316]/40 focus-within:shadow-[0_0_20px_rgba(249,115,22,0.08)] transition-all duration-200 flex items-end gap-3 px-4 py-3">
|
<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}>
|
||||||
<button type="button" className="w-8 h-8 rounded-lg text-[#4b5563] hover:text-[#f97316] hover:bg-[#ffffff08] flex items-center justify-center transition-colors duration-200 flex-shrink-0 text-lg">📎</button>
|
<button type="button" className="w-8 h-8 rounded-lg text-muted-foreground hover:text-primary hover:bg-muted/50 flex items-center justify-center transition-colors duration-200 flex-shrink-0 text-[11px] font-semibold tracking-wide" onClick={() => setShowGifPicker((v) => !v)}>GIF</button>
|
||||||
|
{showGifPicker && <GifPicker onSelect={handleGifSelect} onClose={() => setShowGifPicker(false)} />}
|
||||||
<textarea
|
<textarea
|
||||||
ref={textareaRef}
|
ref={textareaRef}
|
||||||
value={input}
|
value={input}
|
||||||
@@ -280,20 +278,20 @@ export const AIChat = forwardRef<AIChatHandle, AIChatProps>(({ onMessageSent },
|
|||||||
onKeyDown={handleKeyDown}
|
onKeyDown={handleKeyDown}
|
||||||
placeholder="Ask for sales tips..."
|
placeholder="Ask for sales tips..."
|
||||||
rows={1}
|
rows={1}
|
||||||
className="bg-transparent flex-1 text-[#e5e7eb] text-sm placeholder-[#4b5563] resize-none outline-none min-h-[24px] max-h-[200px] overflow-y-auto leading-6"
|
className="bg-transparent flex-1 text-foreground text-sm placeholder-muted-foreground resize-none outline-none min-h-[24px] max-h-[200px] overflow-y-auto leading-6"
|
||||||
/>
|
/>
|
||||||
<button
|
<button
|
||||||
type="button"
|
type="button"
|
||||||
onClick={() => sendMessage()}
|
onClick={() => sendMessage()}
|
||||||
disabled={!input.trim()}
|
disabled={!input.trim()}
|
||||||
className={`w-9 h-9 rounded-xl flex-shrink-0 bg-gradient-to-br from-[#f97316] to-[#ea580c] flex items-center justify-center text-white transition-all duration-200 ${input.trim() ? "hover:shadow-[0_0_20px_rgba(249,115,22,0.4)] hover:scale-105 active:scale-95" : "opacity-40 cursor-not-allowed"}`}
|
className={`w-9 h-9 rounded-xl flex-shrink-0 bg-gradient-to-br from-primary to-primary flex items-center justify-center text-white transition-all duration-200 ${input.trim() ? "hover:shadow-[0_0_20px_hsl(var(--primary)_/_0.4)] hover:scale-105 active:scale-95" : "opacity-40 cursor-not-allowed"}`}
|
||||||
>
|
>
|
||||||
➤
|
➤
|
||||||
</button>
|
</button>
|
||||||
</div>
|
</div>
|
||||||
<div className="flex justify-between items-center mt-2 px-1">
|
<div className="flex justify-between items-center mt-2 px-1">
|
||||||
<span className="text-[#374151] text-[10px]">Shift + Enter for new line</span>
|
<span className="text-muted-foreground text-[10px]">Shift + Enter for new line</span>
|
||||||
<span className="text-[#374151] text-[10px]">{input.length} / 2000</span>
|
<span className="text-muted-foreground text-[10px]">{input.length} / 2000</span>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
@@ -303,27 +301,36 @@ export const AIChat = forwardRef<AIChatHandle, AIChatProps>(({ onMessageSent },
|
|||||||
|
|
||||||
return (
|
return (
|
||||||
<div className="flex-1 flex flex-col min-h-0">
|
<div className="flex-1 flex flex-col min-h-0">
|
||||||
<div className="flex-1 overflow-y-auto px-4 py-6" style={{ backgroundImage: "radial-gradient(circle, #ffffff08 1px, transparent 1px)", backgroundSize: "24px 24px" }}>
|
<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">
|
<div className="max-w-3xl mx-auto space-y-6">
|
||||||
{messages.map((msg, i) => (
|
{messages.map((msg, i) => (
|
||||||
<div key={i}>
|
<div key={i}>
|
||||||
{msg.role === "assistant" ? (
|
{msg.role === "assistant" ? (
|
||||||
<div className="flex gap-3 items-start">
|
<div className="flex gap-3 items-start">
|
||||||
<div className="w-8 h-8 rounded-lg bg-gradient-to-br from-[#f97316] to-[#ea580c] flex items-center justify-center text-white text-sm flex-shrink-0">
|
<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" />
|
<Bot className="h-4 w-4" />
|
||||||
</div>
|
</div>
|
||||||
<div className="flex-1 min-w-0">
|
<div className="flex-1 min-w-0">
|
||||||
<div className="bg-[#1a1d2e] rounded-2xl rounded-tl-sm border border-[#ffffff08] border-l-2 border-l-[#f97316] px-5 py-4 max-w-[85%]">
|
<div className="bg-card rounded-2xl rounded-tl-sm border border-border border-l-2 border-l-primary px-5 py-4 max-w-[85%]">
|
||||||
<div className="text-[#e5e7eb] text-sm leading-7 whitespace-pre-wrap">{formatContent(msg.content)}</div>
|
<div className="text-foreground text-sm leading-7 whitespace-pre-wrap">{formatContent(msg.content)}</div>
|
||||||
</div>
|
</div>
|
||||||
<div className="text-[#4b5563] text-[10px] mt-1">AI Assistant</div>
|
<div className="text-muted-foreground text-[10px] mt-1">AI Assistant</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
) : (
|
) : (
|
||||||
<div className="flex gap-3 items-start flex-row-reverse">
|
<div className="flex gap-3 items-start flex-row-reverse">
|
||||||
<div className="flex-1 min-w-0 flex justify-end">
|
<div className="flex-1 min-w-0 flex justify-end">
|
||||||
<div className="bg-gradient-to-br from-[#f97316] to-[#ea580c] rounded-2xl rounded-tr-sm px-5 py-4 max-w-[75%]">
|
<div className="bg-gradient-to-br from-primary to-primary rounded-2xl rounded-tr-sm px-5 py-4 max-w-[75%]">
|
||||||
<div className="text-white text-sm leading-7 whitespace-pre-wrap">{msg.content}</div>
|
{msg.gif ? (
|
||||||
|
<img
|
||||||
|
src={msg.gif.url}
|
||||||
|
alt={msg.gif.title || "GIF"}
|
||||||
|
className="w-full rounded-lg max-h-64 object-cover"
|
||||||
|
loading="lazy"
|
||||||
|
/>
|
||||||
|
) : (
|
||||||
|
<div className="text-white text-sm leading-7 whitespace-pre-wrap">{msg.content}</div>
|
||||||
|
)}
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
@@ -332,20 +339,20 @@ export const AIChat = forwardRef<AIChatHandle, AIChatProps>(({ onMessageSent },
|
|||||||
))}
|
))}
|
||||||
{loading && (
|
{loading && (
|
||||||
<div className="flex gap-3 items-start">
|
<div className="flex gap-3 items-start">
|
||||||
<div className="w-8 h-8 rounded-lg bg-gradient-to-br from-[#f97316] to-[#ea580c] flex items-center justify-center text-white text-sm flex-shrink-0">
|
<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" />
|
<Bot className="h-4 w-4" />
|
||||||
</div>
|
</div>
|
||||||
<div className="bg-[#1a1d2e] rounded-2xl rounded-tl-sm border border-[#ffffff08] border-l-2 border-l-[#f97316] px-5 py-4 inline-flex items-center gap-1.5">
|
<div className="bg-card rounded-2xl rounded-tl-sm border border-border border-l-2 border-l-primary px-5 py-4 inline-flex items-center gap-1.5">
|
||||||
<span className="w-2 h-2 rounded-full bg-[#f97316] dot-1" />
|
<span className="w-2 h-2 rounded-full bg-primary dot-1" />
|
||||||
<span className="w-2 h-2 rounded-full bg-[#f97316] dot-2" />
|
<span className="w-2 h-2 rounded-full bg-primary dot-2" />
|
||||||
<span className="w-2 h-2 rounded-full bg-[#f97316] dot-3" />
|
<span className="w-2 h-2 rounded-full bg-primary dot-3" />
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
<div ref={messagesEndRef} />
|
<div ref={messagesEndRef} />
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
<div className="sticky bottom-0 bg-[#0f1117]/95 backdrop-blur-md border-t border-[#ffffff08] px-4 py-4">
|
<div className="sticky bottom-0 bg-background/95 backdrop-blur-md px-4 py-4">
|
||||||
<div className="max-w-3xl mx-auto">
|
<div className="max-w-3xl mx-auto">
|
||||||
{error && (
|
{error && (
|
||||||
<div className="mb-2.5 text-xs text-red-400 flex items-center gap-1.5">
|
<div className="mb-2.5 text-xs text-red-400 flex items-center gap-1.5">
|
||||||
@@ -353,8 +360,9 @@ export const AIChat = forwardRef<AIChatHandle, AIChatProps>(({ onMessageSent },
|
|||||||
{error}
|
{error}
|
||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
<div className="bg-[#1a1d2e] rounded-2xl border border-[#ffffff0a] focus-within:border-[#f97316]/40 focus-within:shadow-[0_0_20px_rgba(249,115,22,0.08)] transition-all duration-200 flex items-end gap-3 px-4 py-3">
|
<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}>
|
||||||
<button type="button" className="w-8 h-8 rounded-lg text-[#4b5563] hover:text-[#f97316] hover:bg-[#ffffff08] flex items-center justify-center transition-colors duration-200 flex-shrink-0 text-lg">📎</button>
|
<button type="button" className="w-8 h-8 rounded-lg text-muted-foreground hover:text-primary hover:bg-muted/50 flex items-center justify-center transition-colors duration-200 flex-shrink-0 text-[11px] font-semibold tracking-wide" onClick={() => setShowGifPicker((v) => !v)}>GIF</button>
|
||||||
|
{showGifPicker && <GifPicker onSelect={handleGifSelect} onClose={() => setShowGifPicker(false)} />}
|
||||||
<textarea
|
<textarea
|
||||||
ref={textareaRef}
|
ref={textareaRef}
|
||||||
value={input}
|
value={input}
|
||||||
@@ -363,20 +371,20 @@ export const AIChat = forwardRef<AIChatHandle, AIChatProps>(({ onMessageSent },
|
|||||||
onKeyDown={handleKeyDown}
|
onKeyDown={handleKeyDown}
|
||||||
placeholder="Ask for sales tips..."
|
placeholder="Ask for sales tips..."
|
||||||
rows={1}
|
rows={1}
|
||||||
className="bg-transparent flex-1 text-[#e5e7eb] text-sm placeholder-[#4b5563] resize-none outline-none min-h-[24px] max-h-[200px] overflow-y-auto leading-6"
|
className="bg-transparent flex-1 text-foreground text-sm placeholder-muted-foreground resize-none outline-none min-h-[24px] max-h-[200px] overflow-y-auto leading-6"
|
||||||
/>
|
/>
|
||||||
<button
|
<button
|
||||||
type="button"
|
type="button"
|
||||||
onClick={() => sendMessage()}
|
onClick={() => sendMessage()}
|
||||||
disabled={!input.trim()}
|
disabled={!input.trim()}
|
||||||
className={`w-9 h-9 rounded-xl flex-shrink-0 bg-gradient-to-br from-[#f97316] to-[#ea580c] flex items-center justify-center text-white transition-all duration-200 ${input.trim() ? "hover:shadow-[0_0_20px_rgba(249,115,22,0.4)] hover:scale-105 active:scale-95" : "opacity-40 cursor-not-allowed"}`}
|
className={`w-9 h-9 rounded-xl flex-shrink-0 bg-gradient-to-br from-primary to-primary flex items-center justify-center text-white transition-all duration-200 ${input.trim() ? "hover:shadow-[0_0_20px_hsl(var(--primary)_/_0.4)] hover:scale-105 active:scale-95" : "opacity-40 cursor-not-allowed"}`}
|
||||||
>
|
>
|
||||||
➤
|
➤
|
||||||
</button>
|
</button>
|
||||||
</div>
|
</div>
|
||||||
<div className="flex justify-between items-center mt-2 px-1">
|
<div className="flex justify-between items-center mt-2 px-1">
|
||||||
<span className="text-[#374151] text-[10px]">Shift + Enter for new line</span>
|
<span className="text-muted-foreground text-[10px]">Shift + Enter for new line</span>
|
||||||
<span className="text-[#374151] text-[10px]">{input.length} / 2000</span>
|
<span className="text-muted-foreground text-[10px]">{input.length} / 2000</span>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
@@ -0,0 +1,193 @@
|
|||||||
|
"use client"
|
||||||
|
|
||||||
|
import { useState, useRef, useEffect, useCallback } from "react"
|
||||||
|
import { Search, Loader2, ImageIcon } from "lucide-react"
|
||||||
|
|
||||||
|
interface GifResult {
|
||||||
|
id: string
|
||||||
|
title: string
|
||||||
|
url: string
|
||||||
|
previewUrl: string
|
||||||
|
previewHeight: number
|
||||||
|
width: number
|
||||||
|
height: number
|
||||||
|
}
|
||||||
|
|
||||||
|
interface GifPickerProps {
|
||||||
|
onSelect: (gif: { url: string; previewUrl: string; title: string }) => void
|
||||||
|
onClose: () => void
|
||||||
|
}
|
||||||
|
|
||||||
|
const SEARCH_CACHE = new Map<string, GifResult[]>()
|
||||||
|
|
||||||
|
export function GifPicker({ onSelect, onClose }: GifPickerProps) {
|
||||||
|
const [gifs, setGifs] = useState<GifResult[]>([])
|
||||||
|
const [loading, setLoading] = useState(true)
|
||||||
|
const [loadingMore, setLoadingMore] = useState(false)
|
||||||
|
const [search, setSearch] = useState("")
|
||||||
|
const [offset, setOffset] = useState(0)
|
||||||
|
const [hasMore, setHasMore] = useState(true)
|
||||||
|
const [error, setError] = useState("")
|
||||||
|
const sentinelRef = useRef<HTMLDivElement>(null)
|
||||||
|
const inputRef = useRef<HTMLInputElement>(null)
|
||||||
|
const searchTimeoutRef = useRef<ReturnType<typeof setTimeout>>()
|
||||||
|
|
||||||
|
const fetchGifs = useCallback(async (q: string, off: number, append: boolean) => {
|
||||||
|
if (off === 0) {
|
||||||
|
const cacheKey = q || "__trending__"
|
||||||
|
const cached = SEARCH_CACHE.get(cacheKey)
|
||||||
|
if (cached) {
|
||||||
|
setGifs(cached)
|
||||||
|
setLoading(false)
|
||||||
|
setHasMore(true)
|
||||||
|
setOffset(cached.length)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
try {
|
||||||
|
if (!append) setLoading(true)
|
||||||
|
else setLoadingMore(true)
|
||||||
|
setError("")
|
||||||
|
|
||||||
|
const params = new URLSearchParams()
|
||||||
|
if (q) {
|
||||||
|
params.set("type", "search")
|
||||||
|
params.set("q", q)
|
||||||
|
}
|
||||||
|
params.set("offset", String(off))
|
||||||
|
params.set("limit", "20")
|
||||||
|
|
||||||
|
const res = await fetch(`/api/ai/giphy?${params}`)
|
||||||
|
if (!res.ok) {
|
||||||
|
const data = await res.json()
|
||||||
|
throw new Error(data.error || "Failed to fetch GIFs")
|
||||||
|
}
|
||||||
|
|
||||||
|
const data = await res.json()
|
||||||
|
const newGifs: GifResult[] = data.gifs || []
|
||||||
|
|
||||||
|
if (append) {
|
||||||
|
setGifs((prev) => [...prev, ...newGifs])
|
||||||
|
} else {
|
||||||
|
setGifs(newGifs)
|
||||||
|
if (off === 0 && !q) {
|
||||||
|
SEARCH_CACHE.set("__trending__", newGifs)
|
||||||
|
}
|
||||||
|
if (q) {
|
||||||
|
SEARCH_CACHE.set(q, newGifs)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
setOffset(off + newGifs.length)
|
||||||
|
setHasMore(newGifs.length === 20)
|
||||||
|
} catch (err: any) {
|
||||||
|
setError(err.message || "Failed to load GIFs")
|
||||||
|
} finally {
|
||||||
|
setLoading(false)
|
||||||
|
setLoadingMore(false)
|
||||||
|
}
|
||||||
|
}, [])
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
fetchGifs("", 0, false)
|
||||||
|
}, [fetchGifs])
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
inputRef.current?.focus()
|
||||||
|
}, [])
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
if (searchTimeoutRef.current) clearTimeout(searchTimeoutRef.current)
|
||||||
|
if (!search.trim()) {
|
||||||
|
fetchGifs("", 0, false)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
searchTimeoutRef.current = setTimeout(() => {
|
||||||
|
fetchGifs(search.trim(), 0, false)
|
||||||
|
}, 400)
|
||||||
|
return () => {
|
||||||
|
if (searchTimeoutRef.current) clearTimeout(searchTimeoutRef.current)
|
||||||
|
}
|
||||||
|
}, [search, fetchGifs])
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
const sentinel = sentinelRef.current
|
||||||
|
if (!sentinel) return
|
||||||
|
|
||||||
|
const observer = new IntersectionObserver(
|
||||||
|
(entries) => {
|
||||||
|
if (entries[0].isIntersecting && hasMore && !loadingMore && !loading) {
|
||||||
|
fetchGifs(search.trim(), offset, true)
|
||||||
|
}
|
||||||
|
},
|
||||||
|
{ rootMargin: "200px" }
|
||||||
|
)
|
||||||
|
|
||||||
|
observer.observe(sentinel)
|
||||||
|
return () => observer.disconnect()
|
||||||
|
}, [hasMore, loadingMore, loading, offset, search, fetchGifs])
|
||||||
|
|
||||||
|
return (
|
||||||
|
<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">
|
||||||
|
<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" />
|
||||||
|
<input
|
||||||
|
ref={inputRef}
|
||||||
|
type="text"
|
||||||
|
value={search}
|
||||||
|
onChange={(e) => setSearch(e.target.value)}
|
||||||
|
placeholder="Search GIFs..."
|
||||||
|
className="w-full bg-muted/50 text-foreground text-sm rounded-xl pl-9 pr-4 py-2.5 outline-none focus:ring-2 focus:ring-primary/30 placeholder:text-muted-foreground/60"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="overflow-y-auto" style={{ maxHeight: "360px" }}>
|
||||||
|
{loading ? (
|
||||||
|
<div className="flex items-center justify-center py-16">
|
||||||
|
<Loader2 className="h-6 w-6 animate-spin text-primary" />
|
||||||
|
</div>
|
||||||
|
) : error ? (
|
||||||
|
<div className="flex flex-col items-center justify-center py-16 px-4 text-center">
|
||||||
|
<ImageIcon className="h-8 w-8 text-muted-foreground mb-2" />
|
||||||
|
<p className="text-sm text-muted-foreground">{error}</p>
|
||||||
|
</div>
|
||||||
|
) : gifs.length === 0 ? (
|
||||||
|
<div className="flex flex-col items-center justify-center py-16 px-4 text-center">
|
||||||
|
<ImageIcon className="h-8 w-8 text-muted-foreground mb-2" />
|
||||||
|
<p className="text-sm text-muted-foreground">No GIFs found.</p>
|
||||||
|
</div>
|
||||||
|
) : (
|
||||||
|
<>
|
||||||
|
<div className="grid grid-cols-2 gap-2 p-3">
|
||||||
|
{gifs.map((gif) => (
|
||||||
|
<button
|
||||||
|
key={gif.id}
|
||||||
|
type="button"
|
||||||
|
onClick={() => onSelect({ url: gif.url, previewUrl: gif.previewUrl, title: gif.title })}
|
||||||
|
className="relative rounded-xl overflow-hidden bg-muted/30 hover:ring-2 hover:ring-primary/50 transition-all duration-200 aspect-video"
|
||||||
|
title={gif.title}
|
||||||
|
>
|
||||||
|
<img
|
||||||
|
src={gif.previewUrl}
|
||||||
|
alt={gif.title || "GIF"}
|
||||||
|
loading="lazy"
|
||||||
|
className="w-full h-full object-cover"
|
||||||
|
/>
|
||||||
|
</button>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
{loadingMore && (
|
||||||
|
<div className="flex items-center justify-center py-4">
|
||||||
|
<Loader2 className="h-5 w-5 animate-spin text-primary" />
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
{hasMore && <div ref={sentinelRef} className="h-4" />}
|
||||||
|
</>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
}
|
||||||
@@ -37,53 +37,39 @@ export function JobSelector({ onSelect, onSearch, searching }: JobSelectorProps)
|
|||||||
}
|
}
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div>
|
<div className="relative">
|
||||||
<div className="relative">
|
<button
|
||||||
<button
|
type="button"
|
||||||
type="button"
|
onClick={() => setOpen(!open)}
|
||||||
onClick={() => setOpen(!open)}
|
className="w-full flex items-center gap-2.5 bg-[#1a1d2e] border border-[#ffffff0f] hover:border-[#f97316]/30 rounded-xl px-4 py-3 text-sm text-[#9ca3af] hover:text-[#e5e7eb] transition-all duration-200"
|
||||||
className="w-full flex items-center gap-2.5 bg-[#1a1d2e] border border-[#ffffff0f] hover:border-[#f97316]/30 rounded-xl px-4 py-3 text-sm text-[#9ca3af] hover:text-[#e5e7eb] transition-all duration-200"
|
>
|
||||||
>
|
<Briefcase className="h-4 w-4 text-[#f97316] flex-none" />
|
||||||
<Briefcase className="h-4 w-4 text-[#f97316] flex-none" />
|
<span className="flex-1 text-left truncate">
|
||||||
<span className="flex-1 text-left truncate">
|
{selected ? selected.job_title : loading ? "Loading jobs..." : "Select a job category"}
|
||||||
{selected ? selected.job_title : loading ? "Loading jobs..." : "Select a job category to target"}
|
</span>
|
||||||
</span>
|
{loading ? <Loader2 className="h-3.5 w-3.5 animate-spin text-[#f97316]" /> : <ChevronDown className={`h-3.5 w-3.5 text-[#4b5563] transition-transform duration-200 ${open ? "rotate-180" : ""}`} />}
|
||||||
{loading ? <Loader2 className="h-3.5 w-3.5 animate-spin text-[#f97316]" /> : <ChevronDown className={`h-3.5 w-3.5 text-[#4b5563] transition-transform duration-200 ${open ? "rotate-180" : ""}`} />}
|
</button>
|
||||||
</button>
|
|
||||||
|
|
||||||
{open && (
|
{open && (
|
||||||
<>
|
<>
|
||||||
<div className="fixed inset-0 z-10" onClick={() => setOpen(false)} />
|
<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-[#1a1d2e] border border-[#ffffff0f] rounded-xl shadow-xl shadow-black/40 max-h-60 overflow-y-auto">
|
<div className="absolute top-full left-0 right-0 mt-1.5 z-20 bg-[#1a1d2e] border border-[#ffffff0f] rounded-xl shadow-xl shadow-black/40 max-h-60 overflow-y-auto">
|
||||||
{jobs.map((job, i) => (
|
{jobs.map((job, i) => (
|
||||||
<button
|
<button
|
||||||
key={i}
|
key={i}
|
||||||
type="button"
|
type="button"
|
||||||
onClick={() => handleSelect(job)}
|
onClick={() => handleSelect(job)}
|
||||||
className="w-full text-left px-4 py-3 text-sm text-[#9ca3af] hover:bg-[#1f2437] hover:text-[#e5e7eb] transition-all duration-150 border-b border-[#ffffff08] last:border-0 border-l-2 border-l-transparent hover:border-l-[#f97316]/40"
|
className="w-full text-left px-4 py-3 text-sm text-[#9ca3af] hover:bg-[#1f2437] hover:text-[#e5e7eb] transition-all duration-150 border-b border-[#ffffff08] last:border-0 border-l-2 border-l-transparent hover:border-l-[#f97316]/40"
|
||||||
>
|
>
|
||||||
<div className="font-medium">{job.job_title}</div>
|
<div className="font-medium">{job.job_title}</div>
|
||||||
<div className="text-xs text-[#4b5563] mt-0.5">{job.industry} — {job.description}</div>
|
<div className="text-xs text-[#4b5563] mt-0.5">{job.industry} — {job.description}</div>
|
||||||
</button>
|
</button>
|
||||||
))}
|
))}
|
||||||
{jobs.length === 0 && !loading && (
|
{jobs.length === 0 && !loading && (
|
||||||
<div className="px-4 py-4 text-xs text-[#4b5563] text-center">No job categories loaded</div>
|
<div className="px-4 py-4 text-xs text-[#4b5563] text-center">No job categories loaded</div>
|
||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
</>
|
</>
|
||||||
)}
|
|
||||||
</div>
|
|
||||||
|
|
||||||
{selected && (
|
|
||||||
<button
|
|
||||||
type="button"
|
|
||||||
onClick={() => onSearch?.(selected)}
|
|
||||||
disabled={searching}
|
|
||||||
className="w-full mt-2 flex items-center justify-center gap-2 bg-gradient-to-br from-[#f97316] to-[#ea580c] hover:from-[#ea580c] hover:to-[#d97706] disabled:opacity-50 disabled:cursor-not-allowed rounded-xl px-4 py-2.5 text-sm font-semibold text-white transition-all duration-200 shadow-[0_0_20px_rgba(249,115,22,0.2)]"
|
|
||||||
>
|
|
||||||
{searching ? <Loader2 className="h-4 w-4 animate-spin" /> : <Search className="h-4 w-4" />}
|
|
||||||
{searching ? "Searching Facebook..." : "Search Facebook"}
|
|
||||||
</button>
|
|
||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
)
|
)
|
||||||
|
|||||||
@@ -57,7 +57,13 @@ export default function MediaPicker({ onEmojiSelect, onMediaSelect, onClose, the
|
|||||||
return () => document.removeEventListener("mousedown", handleClick)
|
return () => document.removeEventListener("mousedown", handleClick)
|
||||||
}, [onClose])
|
}, [onClose])
|
||||||
|
|
||||||
useEffect(() => { setRecentGifs(getRecent(RECENT_GIFS_KEY)) }, [])
|
useEffect(() => {
|
||||||
|
setRecentGifs(getRecent(RECENT_GIFS_KEY))
|
||||||
|
if (!navigator.onLine) {
|
||||||
|
console.warn("[GIF Picker] Browser is offline — GIF service may be unavailable")
|
||||||
|
}
|
||||||
|
}, [])
|
||||||
|
|
||||||
useEffect(() => { setRecentStickers(getRecent(RECENT_STICKERS_KEY)) }, [])
|
useEffect(() => { setRecentStickers(getRecent(RECENT_STICKERS_KEY)) }, [])
|
||||||
|
|
||||||
const fetchGifs = useCallback(async (query: string, pos = "") => {
|
const fetchGifs = useCallback(async (query: string, pos = "") => {
|
||||||
@@ -76,6 +82,7 @@ export default function MediaPicker({ onEmojiSelect, onMediaSelect, onClose, the
|
|||||||
const res = await fetch(`/api/gifs?${params}`)
|
const res = await fetch(`/api/gifs?${params}`)
|
||||||
const data = await res.json()
|
const data = await res.json()
|
||||||
if (data.noKey) {
|
if (data.noKey) {
|
||||||
|
console.error("[GIF Picker] GIPHY_API_KEY is not configured on the server. Add GIPHY_API_KEY to .env.local and restart the dev server.")
|
||||||
setGifUnavailable(true)
|
setGifUnavailable(true)
|
||||||
setGifResults([])
|
setGifResults([])
|
||||||
} else if (data.results) {
|
} else if (data.results) {
|
||||||
@@ -103,6 +110,15 @@ export default function MediaPicker({ onEmojiSelect, onMediaSelect, onClose, the
|
|||||||
return () => { if (searchTimer.current) clearTimeout(searchTimer.current) }
|
return () => { if (searchTimer.current) clearTimeout(searchTimer.current) }
|
||||||
}, [tab, gifQuery, fetchGifs])
|
}, [tab, gifQuery, fetchGifs])
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
if (tab !== "gif" || !gifError) return
|
||||||
|
const interval = setInterval(() => {
|
||||||
|
if (gifUnavailable) return
|
||||||
|
fetchGifs(gifQuery.trim())
|
||||||
|
}, 15000)
|
||||||
|
return () => clearInterval(interval)
|
||||||
|
}, [tab, gifError, gifUnavailable, gifQuery, fetchGifs])
|
||||||
|
|
||||||
const handleGifSelect = (gif: any) => {
|
const handleGifSelect = (gif: any) => {
|
||||||
const content = JSON.stringify({ gif: gif.url, w: gif.width, h: gif.height })
|
const content = JSON.stringify({ gif: gif.url, w: gif.width, h: gif.height })
|
||||||
addRecent(RECENT_GIFS_KEY, gif.id)
|
addRecent(RECENT_GIFS_KEY, gif.id)
|
||||||
@@ -208,7 +224,7 @@ export default function MediaPicker({ onEmojiSelect, onMediaSelect, onClose, the
|
|||||||
) : gifUnavailable ? (
|
) : gifUnavailable ? (
|
||||||
<div className="flex flex-col items-center justify-center h-48 text-muted-foreground text-sm gap-2">
|
<div className="flex flex-col items-center justify-center h-48 text-muted-foreground text-sm gap-2">
|
||||||
<Image className="h-8 w-8 opacity-40" />
|
<Image className="h-8 w-8 opacity-40" />
|
||||||
<span>GIFs are temporarily unavailable.</span>
|
<span>GIF service not configured.</span>
|
||||||
</div>
|
</div>
|
||||||
) : gifError ? (
|
) : gifError ? (
|
||||||
<div className="flex flex-col items-center justify-center h-48 text-muted-foreground text-sm gap-2">
|
<div className="flex flex-col items-center justify-center h-48 text-muted-foreground text-sm gap-2">
|
||||||
|
|||||||
@@ -162,11 +162,11 @@ export default function VoiceCallModal({ open, onClose }: VoiceCallModalProps) {
|
|||||||
className="fixed inset-0 z-50 bg-black/60 backdrop-blur-sm flex items-center justify-center"
|
className="fixed inset-0 z-50 bg-black/60 backdrop-blur-sm flex items-center justify-center"
|
||||||
onClick={(e) => { if (e.target === e.currentTarget) onClose() }}
|
onClick={(e) => { if (e.target === e.currentTarget) onClose() }}
|
||||||
>
|
>
|
||||||
<div className="bg-white dark:bg-[#141414] rounded-2xl p-6 w-full max-w-md border border-[#E0E0E0] dark:border-[#CC0000]/20 shadow-[0_8px_40px_rgba(0,0,0,0.18)] relative">
|
<div className="bg-white dark:bg-[#141414] rounded-2xl p-6 w-full max-w-md border border-[#D4D8CC] dark:border-[#CC0000]/20 shadow-[0_8px_40px_rgba(0,0,0,0.18)] relative">
|
||||||
<button
|
<button
|
||||||
type="button"
|
type="button"
|
||||||
onClick={onClose}
|
onClick={onClose}
|
||||||
className="absolute top-4 right-4 text-[#888888] hover:text-[#111111] dark:hover:text-white transition-colors"
|
className="absolute top-4 right-4 text-[#8A9078] hover:text-[#2D3020] dark:hover:text-white transition-colors"
|
||||||
>
|
>
|
||||||
<X className="h-5 w-5" />
|
<X className="h-5 w-5" />
|
||||||
</button>
|
</button>
|
||||||
@@ -174,27 +174,27 @@ export default function VoiceCallModal({ open, onClose }: VoiceCallModalProps) {
|
|||||||
{callLink ? (
|
{callLink ? (
|
||||||
shareSent ? (
|
shareSent ? (
|
||||||
<>
|
<>
|
||||||
<p className="text-sm text-[#00AA00] font-semibold text-center mb-3">Call link copied.</p>
|
<p className="text-sm text-[#4A9078] font-semibold text-center mb-3">Call link copied.</p>
|
||||||
<h2 className="font-bold text-lg text-[#111111] dark:text-white">Waiting for participant</h2>
|
<h2 className="font-bold text-lg text-[#2D3020] dark:text-white">Waiting for participant</h2>
|
||||||
<p className="text-[#888888] text-sm mt-1 mb-4">
|
<p className="text-[#8A9078] text-sm mt-1 mb-4">
|
||||||
The recipient must receive and open the call link.
|
The recipient must receive and open the call link.
|
||||||
</p>
|
</p>
|
||||||
|
|
||||||
<div className="bg-[#F5F5F5] dark:bg-[#1A1A1A] rounded-xl p-3 mb-4 break-all text-xs text-[#111111] dark:text-white">
|
<div className="bg-card dark:bg-[#1A1A1A] rounded-xl p-3 mb-4 break-all text-xs text-foreground dark:text-white">
|
||||||
{callLink}
|
{callLink}
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div className="flex gap-2">
|
<div className="flex gap-2">
|
||||||
<button
|
<button
|
||||||
onClick={copyLink}
|
onClick={copyLink}
|
||||||
className="flex-1 flex items-center justify-center gap-2 bg-[#444444] hover:bg-[#555555] dark:bg-[#333333] dark:hover:bg-[#444444] text-white font-semibold text-sm rounded-xl py-2.5 transition-all duration-200"
|
className="flex-1 flex items-center justify-center gap-2 bg-[#8A9078] hover:bg-[#8A9078]/80 dark:bg-[#333333] dark:hover:bg-[#444444] text-white font-semibold text-sm rounded-xl py-2.5 transition-all duration-200"
|
||||||
>
|
>
|
||||||
<Copy className="h-4 w-4" />
|
<Copy className="h-4 w-4" />
|
||||||
Copy URL
|
Copy URL
|
||||||
</button>
|
</button>
|
||||||
<button
|
<button
|
||||||
onClick={() => { setShareSent(false); setShareError(null) }}
|
onClick={() => { setShareSent(false); setShareError(null) }}
|
||||||
className="flex-1 flex items-center justify-center gap-2 bg-[#CC0000] hover:bg-[#990000] dark:bg-[#FF1111] dark:hover:bg-[#CC0000] text-white font-semibold text-sm rounded-xl py-2.5 transition-all duration-200"
|
className="flex-1 flex items-center justify-center gap-2 bg-[#C84B4B] hover:bg-[#C84B4B]/80 dark:bg-[#FF1111] dark:hover:bg-[#CC0000] text-white font-semibold text-sm rounded-xl py-2.5 transition-all duration-200"
|
||||||
>
|
>
|
||||||
<MessageSquare className="h-4 w-4" />
|
<MessageSquare className="h-4 w-4" />
|
||||||
WhatsApp
|
WhatsApp
|
||||||
@@ -205,24 +205,24 @@ export default function VoiceCallModal({ open, onClose }: VoiceCallModalProps) {
|
|||||||
href={callLink}
|
href={callLink}
|
||||||
target="_blank"
|
target="_blank"
|
||||||
rel="noopener noreferrer"
|
rel="noopener noreferrer"
|
||||||
className="block w-full mt-4 bg-[#444444] hover:bg-[#555555] dark:bg-[#333333] dark:hover:bg-[#444444] text-white font-semibold text-sm rounded-xl py-2.5 text-center transition-all duration-200"
|
className="block w-full mt-4 bg-muted-foreground hover:bg-muted-foreground/80 dark:bg-[#333333] dark:hover:bg-[#444444] text-white font-semibold text-sm rounded-xl py-2.5 text-center transition-all duration-200"
|
||||||
>
|
>
|
||||||
Open Call
|
Open Call
|
||||||
</a>
|
</a>
|
||||||
</>
|
</>
|
||||||
) : (
|
) : (
|
||||||
<>
|
<>
|
||||||
<h2 className="font-bold text-lg text-[#111111] dark:text-white">Choose how to notify this person</h2>
|
<h2 className="font-bold text-lg text-[#2D3020] dark:text-white">Choose how to notify this person</h2>
|
||||||
<p className="text-[#888888] text-sm mt-1 mb-4">
|
<p className="text-[#8A9078] text-sm mt-1 mb-4">
|
||||||
Select a method to send the call link
|
Select a method to send the call link
|
||||||
</p>
|
</p>
|
||||||
|
|
||||||
<div className="bg-[#F5F5F5] dark:bg-[#1A1A1A] rounded-xl p-3 mb-4 break-all text-xs text-[#111111] dark:text-white">
|
<div className="bg-[#FAFAF6] dark:bg-[#1A1A1A] rounded-xl p-3 mb-4 break-all text-xs text-[#2D3020] dark:text-white">
|
||||||
{callLink}
|
{callLink}
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
{showWaUrl && (
|
{showWaUrl && (
|
||||||
<div className="bg-[#1A1A1A] dark:bg-[#000000] rounded-xl p-2 mb-3 break-all text-[10px] text-[#00AA00] font-mono">
|
<div className="bg-card dark:bg-[#000000] rounded-xl p-2 mb-3 break-all text-[10px] text-[#4A9078] font-mono">
|
||||||
{showWaUrl}
|
{showWaUrl}
|
||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
@@ -237,7 +237,7 @@ export default function VoiceCallModal({ open, onClose }: VoiceCallModalProps) {
|
|||||||
</button>
|
</button>
|
||||||
<button
|
<button
|
||||||
onClick={copyLink}
|
onClick={copyLink}
|
||||||
className="flex items-center justify-center gap-2 bg-[#444444] hover:bg-[#555555] dark:bg-[#333333] dark:hover:bg-[#444444] text-white font-semibold text-sm rounded-xl py-2.5 transition-all duration-200"
|
className="flex items-center justify-center gap-2 bg-[#8A9078] hover:bg-[#8A9078]/80 dark:bg-[#333333] dark:hover:bg-[#444444] text-white font-semibold text-sm rounded-xl py-2.5 transition-all duration-200"
|
||||||
>
|
>
|
||||||
<Copy className="h-4 w-4" />
|
<Copy className="h-4 w-4" />
|
||||||
Copy URL
|
Copy URL
|
||||||
@@ -245,16 +245,16 @@ export default function VoiceCallModal({ open, onClose }: VoiceCallModalProps) {
|
|||||||
</div>
|
</div>
|
||||||
|
|
||||||
{shareError && (
|
{shareError && (
|
||||||
<p className="text-[#CC0000] text-xs text-center">{shareError}</p>
|
<p className="text-[#C84B4B] text-xs text-center">{shareError}</p>
|
||||||
)}
|
)}
|
||||||
</>
|
</>
|
||||||
)
|
)
|
||||||
) : (
|
) : (
|
||||||
<>
|
<>
|
||||||
<h2 className="font-bold text-lg text-[#111111] dark:text-white">Start a Call</h2>
|
<h2 className="font-bold text-lg text-[#2D3020] dark:text-white">Start a Call</h2>
|
||||||
<p className="text-[#888888] text-sm mt-1 mb-5">Search contacts or dial a number</p>
|
<p className="text-[#8A9078] text-sm mt-1 mb-5">Search contacts or dial a number</p>
|
||||||
|
|
||||||
<p className="text-[10px] font-bold uppercase tracking-widest text-[#888888] dark:text-[#666666] mb-2">
|
<p className="text-[10px] font-bold uppercase tracking-widest text-[#8A9078] dark:text-[#666666] mb-2">
|
||||||
CONTACTS
|
CONTACTS
|
||||||
</p>
|
</p>
|
||||||
|
|
||||||
@@ -262,25 +262,25 @@ export default function VoiceCallModal({ open, onClose }: VoiceCallModalProps) {
|
|||||||
value={searchQuery}
|
value={searchQuery}
|
||||||
onChange={(e) => setSearchQuery(e.target.value)}
|
onChange={(e) => setSearchQuery(e.target.value)}
|
||||||
placeholder="Search contacts..."
|
placeholder="Search contacts..."
|
||||||
className="bg-[#F5F5F5] dark:bg-[#1A1A1A] border border-[#E0E0E0] dark:border-[#333333] text-[#111111] dark:text-white placeholder-[#AAAAAA] dark:placeholder-[#555555] rounded-xl px-4 py-2.5 text-sm w-full mb-3 outline-none transition-colors focus:border-[#CC0000] dark:focus:border-[#FF4444]"
|
className="bg-[#FAFAF6] dark:bg-[#1A1A1A] border border-[#D4D8CC] dark:border-[#333333] text-[#2D3020] dark:text-white placeholder-[#8A9078] dark:placeholder-[#555555] rounded-xl px-4 py-2.5 text-sm w-full mb-3 outline-none transition-colors focus:border-[#C84B4B] dark:focus:border-[#FF4444]"
|
||||||
/>
|
/>
|
||||||
|
|
||||||
<div className="max-h-[200px] overflow-y-auto space-y-1 mb-4">
|
<div className="max-h-[200px] overflow-y-auto space-y-1 mb-4">
|
||||||
{contactsLoading && (
|
{contactsLoading && (
|
||||||
<p className="text-[#AAAAAA] text-sm text-center py-4 animate-pulse">Loading contacts...</p>
|
<p className="text-[#8A9078] text-sm text-center py-4 animate-pulse">Loading contacts...</p>
|
||||||
)}
|
)}
|
||||||
{contactsError && (
|
{contactsError && (
|
||||||
<p className="text-[#CC0000] text-sm text-center py-4">Could not load contacts</p>
|
<p className="text-[#C84B4B] text-sm text-center py-4">Could not load contacts</p>
|
||||||
)}
|
)}
|
||||||
{!contactsLoading && !contactsError && filteredContacts.length === 0 && (
|
{!contactsLoading && !contactsError && filteredContacts.length === 0 && (
|
||||||
<p className="text-[#AAAAAA] text-sm text-center py-4">No contacts found</p>
|
<p className="text-[#8A9078] text-sm text-center py-4">No contacts found</p>
|
||||||
)}
|
)}
|
||||||
{!contactsLoading && !contactsError && filteredContacts.map((contact) => (
|
{!contactsLoading && !contactsError && filteredContacts.map((contact) => (
|
||||||
<div
|
<div
|
||||||
key={contact.id}
|
key={contact.id}
|
||||||
className="flex items-center gap-3 px-3 py-2.5 rounded-xl cursor-pointer transition-all duration-150 hover:bg-[#F5F5F5] dark:hover:bg-[#1A1A1A]"
|
className="flex items-center gap-3 px-3 py-2.5 rounded-xl cursor-pointer transition-all duration-150 hover:bg-[#FAFAF6] dark:hover:bg-[#1A1A1A]"
|
||||||
>
|
>
|
||||||
<div className="w-9 h-9 rounded-full flex items-center justify-center text-sm font-bold shrink-0 bg-[#FFF0F0] dark:bg-[#CC0000]/15 text-[#CC0000] dark:text-[#FF4444]">
|
<div className="w-9 h-9 rounded-full flex items-center justify-center text-sm font-bold shrink-0 bg-[#FAEAEA] dark:bg-[#CC0000]/15 text-[#C84B4B] dark:text-[#FF4444]">
|
||||||
{contact.avatar_url ? (
|
{contact.avatar_url ? (
|
||||||
<img
|
<img
|
||||||
src={contact.avatar_url}
|
src={contact.avatar_url}
|
||||||
@@ -292,13 +292,13 @@ export default function VoiceCallModal({ open, onClose }: VoiceCallModalProps) {
|
|||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
<div className="flex-1 min-w-0">
|
<div className="flex-1 min-w-0">
|
||||||
<p className="text-sm font-medium text-[#111111] dark:text-white truncate">{contact.name}</p>
|
<p className="text-sm font-medium text-[#2D3020] dark:text-white truncate">{contact.name}</p>
|
||||||
<p className="text-xs text-[#888888] dark:[#666666] truncate">{contact.phone}</p>
|
<p className="text-xs text-[#8A9078] dark:[#666666] truncate">{contact.phone}</p>
|
||||||
</div>
|
</div>
|
||||||
<button
|
<button
|
||||||
type="button"
|
type="button"
|
||||||
onClick={() => handleCall(contact.phone)}
|
onClick={() => handleCall(contact.phone)}
|
||||||
className="shrink-0 text-[#CC0000] dark:text-[#FF4444] hover:opacity-80 transition-opacity"
|
className="shrink-0 text-[#C84B4B] dark:text-[#FF4444] hover:opacity-80 transition-opacity"
|
||||||
>
|
>
|
||||||
<Phone className="h-4 w-4" />
|
<Phone className="h-4 w-4" />
|
||||||
</button>
|
</button>
|
||||||
@@ -307,12 +307,12 @@ export default function VoiceCallModal({ open, onClose }: VoiceCallModalProps) {
|
|||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div className="flex items-center gap-3 my-4">
|
<div className="flex items-center gap-3 my-4">
|
||||||
<hr className="flex-1 border-t border-[#E0E0E0] dark:border-[#222222]" />
|
<hr className="flex-1 border-t border-[#D4D8CC] dark:border-[#222222]" />
|
||||||
<span className="text-xs text-[#AAAAAA] dark:text-[#555555]">OR</span>
|
<span className="text-xs text-[#8A9078] dark:text-[#555555]">OR</span>
|
||||||
<hr className="flex-1 border-t border-[#E0E0E0] dark:border-[#222222]" />
|
<hr className="flex-1 border-t border-[#D4D8CC] dark:border-[#222222]" />
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<p className="text-[10px] font-bold uppercase tracking-widest text-[#888888] dark:text-[#666666] mb-2">
|
<p className="text-[10px] font-bold uppercase tracking-widest text-[#8A9078] dark:text-[#666666] mb-2">
|
||||||
DIAL A NUMBER
|
DIAL A NUMBER
|
||||||
</p>
|
</p>
|
||||||
|
|
||||||
@@ -321,10 +321,10 @@ export default function VoiceCallModal({ open, onClose }: VoiceCallModalProps) {
|
|||||||
value={phoneNumber}
|
value={phoneNumber}
|
||||||
onChange={(e) => { setPhoneNumber(e.target.value); setPhoneError(false) }}
|
onChange={(e) => { setPhoneNumber(e.target.value); setPhoneError(false) }}
|
||||||
placeholder="+27 000 000 0000"
|
placeholder="+27 000 000 0000"
|
||||||
className="bg-[#F5F5F5] dark:bg-[#1A1A1A] border border-[#E0E0E0] dark:border-[#333333] text-[#111111] dark:text-white placeholder-[#AAAAAA] dark:placeholder-[#555555] rounded-xl px-4 py-2.5 text-sm w-full outline-none transition-colors focus:border-[#CC0000] dark:focus:border-[#FF4444]"
|
className="bg-[#FAFAF6] dark:bg-[#1A1A1A] border border-[#D4D8CC] dark:border-[#333333] text-[#2D3020] dark:text-white placeholder-[#8A9078] dark:placeholder-[#555555] rounded-xl px-4 py-2.5 text-sm w-full outline-none transition-colors focus:border-[#C84B4B] dark:focus:border-[#FF4444]"
|
||||||
/>
|
/>
|
||||||
{phoneError && (
|
{phoneError && (
|
||||||
<p className="text-[#CC0000] text-xs mt-1">Please enter a phone number</p>
|
<p className="text-[#C84B4B] text-xs mt-1">Please enter a phone number</p>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
<button
|
<button
|
||||||
@@ -337,7 +337,7 @@ export default function VoiceCallModal({ open, onClose }: VoiceCallModalProps) {
|
|||||||
setPhoneError(false)
|
setPhoneError(false)
|
||||||
handleCall(trimmed)
|
handleCall(trimmed)
|
||||||
}}
|
}}
|
||||||
className="w-full mt-3 bg-[#CC0000] hover:bg-[#990000] dark:bg-[#FF1111] dark:hover:bg-[#CC0000] text-white font-semibold text-sm rounded-xl py-2.5 flex items-center justify-center gap-2 transition-all duration-200"
|
className="w-full mt-3 bg-[#C84B4B] hover:bg-[#C84B4B]/80 dark:bg-[#FF1111] dark:hover:bg-[#CC0000] text-white font-semibold text-sm rounded-xl py-2.5 flex items-center justify-center gap-2 transition-all duration-200"
|
||||||
>
|
>
|
||||||
<Phone className="h-4 w-4" />
|
<Phone className="h-4 w-4" />
|
||||||
Call Now
|
Call Now
|
||||||
|
|||||||
@@ -21,14 +21,14 @@ export function CrossedLightsabers() {
|
|||||||
<circle cx="100" cy="73" r="24" fill="url(#purpleGlow)" className="animate-pulse-intersection" />
|
<circle cx="100" cy="73" r="24" fill="url(#purpleGlow)" className="animate-pulse-intersection" />
|
||||||
|
|
||||||
<g className="animate-pulse-red">
|
<g className="animate-pulse-red">
|
||||||
<line x1="35" y1="145" x2="125" y2="45" strokeLinecap="round" opacity="0.12" className="stroke-[#dc2626] dark:stroke-[#ef4444] saber-outer" />
|
<line x1="35" y1="145" x2="125" y2="45" strokeLinecap="round" opacity="0.12" className="stroke-[#C84B4B] dark:stroke-[#ef4444] saber-outer" />
|
||||||
<line x1="35" y1="145" x2="125" y2="45" strokeLinecap="round" opacity="0.25" className="stroke-[#dc2626] dark:stroke-[#ef4444] saber-mid" />
|
<line x1="35" y1="145" x2="125" y2="45" strokeLinecap="round" opacity="0.25" className="stroke-[#C84B4B] dark:stroke-[#ef4444] saber-mid" />
|
||||||
<line x1="35" y1="145" x2="125" y2="45" strokeLinecap="round" className="stroke-[#f87171] dark:stroke-[#fca5a5] saber-core" />
|
<line x1="35" y1="145" x2="125" y2="45" strokeLinecap="round" className="stroke-[#f87171] dark:stroke-[#fca5a5] saber-core" />
|
||||||
</g>
|
</g>
|
||||||
|
|
||||||
<g className="animate-pulse-blue">
|
<g className="animate-pulse-blue">
|
||||||
<line x1="165" y1="145" x2="75" y2="45" strokeLinecap="round" opacity="0.12" className="stroke-[#1d4ed8] dark:stroke-[#3b82f6] saber-blue-outer" />
|
<line x1="165" y1="145" x2="75" y2="45" strokeLinecap="round" opacity="0.12" className="stroke-[#5A8FC4] dark:stroke-[#3b82f6] saber-blue-outer" />
|
||||||
<line x1="165" y1="145" x2="75" y2="45" strokeLinecap="round" opacity="0.25" className="stroke-[#1d4ed8] dark:stroke-[#3b82f6] saber-blue-mid" />
|
<line x1="165" y1="145" x2="75" y2="45" strokeLinecap="round" opacity="0.25" className="stroke-[#5A8FC4] dark:stroke-[#3b82f6] saber-blue-mid" />
|
||||||
<line x1="165" y1="145" x2="75" y2="45" strokeLinecap="round" className="stroke-[#60a5fa] dark:stroke-[#93c5fd] saber-blue-core" />
|
<line x1="165" y1="145" x2="75" y2="45" strokeLinecap="round" className="stroke-[#60a5fa] dark:stroke-[#93c5fd] saber-blue-core" />
|
||||||
</g>
|
</g>
|
||||||
|
|
||||||
|
|||||||
@@ -16,8 +16,8 @@ interface LeadsPerMonthChartProps {
|
|||||||
data: IntervalData[]
|
data: IntervalData[]
|
||||||
}
|
}
|
||||||
|
|
||||||
const NEW_LEADS = "#CC0000"
|
const NEW_LEADS = "#C84B4B"
|
||||||
const CLOSED = "#0033CC"
|
const CLOSED = "#5A8FC4"
|
||||||
|
|
||||||
export function LeadsPerMonthChart({ data: initialData }: LeadsPerMonthChartProps) {
|
export function LeadsPerMonthChart({ data: initialData }: LeadsPerMonthChartProps) {
|
||||||
const [year, setYear] = useState(new Date().getFullYear())
|
const [year, setYear] = useState(new Date().getFullYear())
|
||||||
@@ -153,11 +153,11 @@ export function LeadsPerMonthChart({ data: initialData }: LeadsPerMonthChartProp
|
|||||||
>
|
>
|
||||||
<defs>
|
<defs>
|
||||||
<linearGradient id="newLeadsGrad" x1="0" y1="0" x2="0" y2="1">
|
<linearGradient id="newLeadsGrad" x1="0" y1="0" x2="0" y2="1">
|
||||||
<stop offset="0%" stopColor="#FF1111" />
|
<stop offset="0%" stopColor="#C84B4B" />
|
||||||
<stop offset="100%" stopColor={NEW_LEADS} />
|
<stop offset="100%" stopColor={NEW_LEADS} />
|
||||||
</linearGradient>
|
</linearGradient>
|
||||||
<linearGradient id="closedGrad" x1="0" y1="0" x2="0" y2="1">
|
<linearGradient id="closedGrad" x1="0" y1="0" x2="0" y2="1">
|
||||||
<stop offset="0%" stopColor="#1144FF" />
|
<stop offset="0%" stopColor="#5A8FC4" />
|
||||||
<stop offset="100%" stopColor={CLOSED} />
|
<stop offset="100%" stopColor={CLOSED} />
|
||||||
</linearGradient>
|
</linearGradient>
|
||||||
<filter id="shadowNew">
|
<filter id="shadowNew">
|
||||||
|
|||||||
@@ -23,10 +23,10 @@ export function StatCardSkeleton() {
|
|||||||
|
|
||||||
return (
|
return (
|
||||||
<div className="col-span-full flex flex-col items-center justify-center py-20">
|
<div className="col-span-full flex flex-col items-center justify-center py-20">
|
||||||
<p className="text-[#444444] dark:text-[#AAAAAA] text-sm">
|
<p className="text-[#8A9078] dark:text-[#AAAAAA] text-sm">
|
||||||
Loading your data...
|
Loading your data...
|
||||||
</p>
|
</p>
|
||||||
<div className="w-48 h-1 rounded-full overflow-hidden bg-[#E0E0E0] dark:bg-[#222222] mt-4">
|
<div className="w-48 h-1 rounded-full overflow-hidden bg-[#D4D8CC] dark:bg-[#222222] mt-4">
|
||||||
<div className="w-full h-full rounded-full bg-sidebar animate-[loading_1.5s_ease-in-out_infinite]" />
|
<div className="w-full h-full rounded-full bg-sidebar animate-[loading_1.5s_ease-in-out_infinite]" />
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
@@ -3,6 +3,7 @@
|
|||||||
import { useEffect, useState } from "react"
|
import { useEffect, useState } from "react"
|
||||||
import { motion } from "framer-motion"
|
import { motion } from "framer-motion"
|
||||||
import { cn } from "@/lib/utils"
|
import { cn } from "@/lib/utils"
|
||||||
|
import { useWebsiteTheme } from "@/providers/website-theme-provider"
|
||||||
import { Card, CardContent } from "@/components/ui/card"
|
import { Card, CardContent } from "@/components/ui/card"
|
||||||
import { LucideIcon } from "lucide-react"
|
import { LucideIcon } from "lucide-react"
|
||||||
|
|
||||||
@@ -19,12 +20,12 @@ interface StatCardProps {
|
|||||||
}
|
}
|
||||||
|
|
||||||
const cardColors: Record<string, { bg: string; text: string; glow: string; accent: string }> = {
|
const cardColors: Record<string, { bg: string; text: string; glow: string; accent: string }> = {
|
||||||
"Total Leads": { bg: "bg-[#CC0000]/10", text: "text-[#CC0000] dark:text-[#FF1111]", glow: "via-[rgba(204,0,0,0.25)]", accent: "#CC0000" },
|
"Total Leads": { bg: "bg-[#C84B4B]/10", text: "text-[#C84B4B] dark:text-[#FF1111]", glow: "via-[rgba(200,75,75,0.25)]", accent: "#C84B4B" },
|
||||||
"Open Leads": { bg: "bg-[#0033CC]/10", text: "text-[#0033CC] dark:text-[#1144FF]", glow: "via-[rgba(0,51,204,0.25)]", accent: "#0033CC" },
|
"Open Leads": { bg: "bg-[#5A8FC4]/10", text: "text-[#5A8FC4] dark:text-[#1144FF]", glow: "via-[rgba(90,143,196,0.25)]", accent: "#5A8FC4" },
|
||||||
"Contacted": { bg: "bg-[#CC0000]/10", text: "text-[#CC0000] dark:text-[#FF1111]", glow: "via-[rgba(204,0,0,0.25)]", accent: "#CC0000" },
|
"Contacted": { bg: "bg-[#C84B4B]/10", text: "text-[#C84B4B] dark:text-[#FF1111]", glow: "via-[rgba(200,75,75,0.25)]", accent: "#C84B4B" },
|
||||||
"Pending": { bg: "bg-[#0033CC]/10", text: "text-[#0033CC] dark:text-[#1144FF]", glow: "via-[rgba(0,51,204,0.25)]", accent: "#0033CC" },
|
"Pending": { bg: "bg-[#5A8FC4]/10", text: "text-[#5A8FC4] dark:text-[#1144FF]", glow: "via-[rgba(90,143,196,0.25)]", accent: "#5A8FC4" },
|
||||||
"Closed": { bg: "bg-[#CC0000]/10", text: "text-[#CC0000] dark:text-[#FF1111]", glow: "via-[rgba(204,0,0,0.25)]", accent: "#CC0000" },
|
"Closed": { bg: "bg-[#C84B4B]/10", text: "text-[#C84B4B] dark:text-[#FF1111]", glow: "via-[rgba(200,75,75,0.25)]", accent: "#C84B4B" },
|
||||||
"Conversion Rate": { bg: "bg-[#0033CC]/10", text: "text-[#0033CC] dark:text-[#1144FF]", glow: "via-[rgba(0,51,204,0.25)]", accent: "#0033CC" },
|
"Conversion Rate": { bg: "bg-[#5A8FC4]/10", text: "text-[#5A8FC4] dark:text-[#1144FF]", glow: "via-[rgba(90,143,196,0.25)]", accent: "#5A8FC4" },
|
||||||
}
|
}
|
||||||
|
|
||||||
function computeGoal(max: number): number {
|
function computeGoal(max: number): number {
|
||||||
@@ -50,6 +51,7 @@ function smoothPath(points: { x: number; y: number }[]): string {
|
|||||||
}
|
}
|
||||||
|
|
||||||
export function StatCard({ title, value, icon: Icon, description, index = 0, trend, sparklineField, monthlyBreakdown, conversionRate }: StatCardProps) {
|
export function StatCard({ title, value, icon: Icon, description, index = 0, trend, sparklineField, monthlyBreakdown, conversionRate }: StatCardProps) {
|
||||||
|
const { websiteTheme } = useWebsiteTheme()
|
||||||
const color = cardColors[title] ?? cardColors["Total Leads"]
|
const color = cardColors[title] ?? cardColors["Total Leads"]
|
||||||
|
|
||||||
const isNumeric = typeof value === "number"
|
const isNumeric = typeof value === "number"
|
||||||
@@ -108,8 +110,8 @@ export function StatCard({ title, value, icon: Icon, description, index = 0, tre
|
|||||||
const sparkColor = color.accent
|
const sparkColor = color.accent
|
||||||
|
|
||||||
const isRed = index % 2 === 0
|
const isRed = index % 2 === 0
|
||||||
const stripColor = isRed ? "from-[#CC0000] via-[#FF1111] to-[#CC0000]" : "from-[#0033CC] via-[#1144FF] to-[#0033CC]"
|
const stripColor = isRed ? "from-[#C84B4B] via-[#C84B4B] to-[#C84B4B]" : "from-[#5A8FC4] via-[#5A8FC4] to-[#5A8FC4]"
|
||||||
const stripGlow = isRed ? "shadow-[#CC0000]/20" : "shadow-[#0033CC]/20"
|
const stripGlow = isRed ? "shadow-[#C84B4B]/20" : "shadow-[#5A8FC4]/20"
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<motion.div
|
<motion.div
|
||||||
@@ -119,28 +121,30 @@ export function StatCard({ title, value, icon: Icon, description, index = 0, tre
|
|||||||
className="relative"
|
className="relative"
|
||||||
>
|
>
|
||||||
<Card className="group h-full hover:shadow-xl transition-all duration-200 relative z-10 overflow-hidden">
|
<Card className="group h-full hover:shadow-xl transition-all duration-200 relative z-10 overflow-hidden">
|
||||||
{/* Red/Blue gradient top strip */}
|
{websiteTheme === "spidey" && (
|
||||||
<div className={`h-1 w-full bg-gradient-to-r ${stripColor} ${stripGlow} shadow-sm`} />
|
<div className={`h-1 w-full bg-gradient-to-r ${stripColor} ${stripGlow} shadow-sm`} />
|
||||||
|
)}
|
||||||
|
|
||||||
{/* Action lines on hover */}
|
{websiteTheme === "spidey" && (
|
||||||
<div
|
<div
|
||||||
className="absolute inset-0 pointer-events-none opacity-0 group-hover:opacity-100 transition-opacity duration-300 z-0"
|
className="absolute inset-0 pointer-events-none opacity-0 group-hover:opacity-100 transition-opacity duration-300 z-0"
|
||||||
style={{
|
style={{
|
||||||
backgroundImage: `url("data:image/svg+xml,%3Csvg width='200' height='200' viewBox='0 0 200 200' xmlns='http://www.w3.org/2000/svg'%3E%3Cg stroke='%23CC0000' stroke-width='0.4' opacity='0.15'%3E%3Cline x1='100' y1='100' x2='0' y2='0'/%3E%3Cline x1='100' y1='100' x2='50' y2='0'/%3E%3Cline x1='100' y1='100' x2='100' y2='0'/%3E%3Cline x1='100' y1='100' x2='150' y2='0'/%3E%3Cline x1='100' y1='100' x2='200' y2='0'/%3E%3Cline x1='100' y1='100' x2='200' y2='50'/%3E%3Cline x1='100' y1='100' x2='200' y2='100'/%3E%3Cline x1='100' y1='100' x2='200' y2='150'/%3E%3Cline x1='100' y1='100' x2='200' y2='200'/%3E%3Cline x1='100' y1='100' x2='150' y2='200'/%3E%3Cline x1='100' y1='100' x2='100' y2='200'/%3E%3Cline x1='100' y1='100' x2='50' y2='200'/%3E%3Cline x1='100' y1='100' x2='0' y2='200'/%3E%3Cline x1='100' y1='100' x2='0' y2='150'/%3E%3Cline x1='100' y1='100' x2='0' y2='100'/%3E%3Cline x1='100' y1='100' x2='0' y2='50'/%3E%3C/g%3E%3C/svg%3E")`,
|
backgroundImage: `url("data:image/svg+xml,%3Csvg width='200' height='200' viewBox='0 0 200 200' xmlns='http://www.w3.org/2000/svg'%3E%3Cg stroke='%23CC0000' stroke-width='0.4' opacity='0.15'%3E%3Cline x1='100' y1='100' x2='0' y2='0'/%3E%3Cline x1='100' y1='100' x2='50' y2='0'/%3E%3Cline x1='100' y1='100' x2='100' y2='0'/%3E%3Cline x1='100' y1='100' x2='150' y2='0'/%3E%3Cline x1='100' y1='100' x2='200' y2='0'/%3E%3Cline x1='100' y1='100' x2='200' y2='50'/%3E%3Cline x1='100' y1='100' x2='200' y2='100'/%3E%3Cline x1='100' y1='100' x2='200' y2='150'/%3E%3Cline x1='100' y1='100' x2='200' y2='200'/%3E%3Cline x1='100' y1='100' x2='150' y2='200'/%3E%3Cline x1='100' y1='100' x2='100' y2='200'/%3E%3Cline x1='100' y1='100' x2='50' y2='200'/%3E%3Cline x1='100' y1='100' x2='0' y2='200'/%3E%3Cline x1='100' y1='100' x2='0' y2='150'/%3E%3Cline x1='100' y1='100' x2='0' y2='100'/%3E%3Cline x1='100' y1='100' x2='0' y2='50'/%3E%3C/g%3E%3C/svg%3E")`,
|
||||||
backgroundSize: "cover",
|
backgroundSize: "cover",
|
||||||
backgroundPosition: "center",
|
backgroundPosition: "center",
|
||||||
}}
|
}}
|
||||||
/>
|
/>
|
||||||
|
)}
|
||||||
|
|
||||||
<CardContent className="p-6 flex flex-col relative z-[1]">
|
<CardContent className="p-6 flex flex-col relative z-[1]">
|
||||||
<div className="flex items-center justify-between">
|
<div className="flex items-center justify-between">
|
||||||
<div className="space-y-1">
|
<div className="space-y-1">
|
||||||
<p className="text-sm font-medium text-muted-foreground">{title}</p>
|
<p className="text-sm font-medium text-muted-foreground">{title}</p>
|
||||||
<p className="text-3xl font-bold tracking-tight text-[#111111] dark:text-white">
|
<p className="text-3xl font-bold tracking-tight text-[#2D3020] dark:text-white">
|
||||||
{isNumeric ? display : value}
|
{isNumeric ? display : value}
|
||||||
</p>
|
</p>
|
||||||
{description && (
|
{description && (
|
||||||
<p className="text-xs text-[#888888] dark:text-[#666666]">{description}</p>
|
<p className="text-xs text-[#8A9078] dark:text-[#666666]">{description}</p>
|
||||||
)}
|
)}
|
||||||
{trend && (
|
{trend && (
|
||||||
<span className={cn(
|
<span className={cn(
|
||||||
@@ -196,9 +200,9 @@ export function StatCard({ title, value, icon: Icon, description, index = 0, tre
|
|||||||
{title === "Conversion Rate" && conversionRate !== undefined && (
|
{title === "Conversion Rate" && conversionRate !== undefined && (
|
||||||
<div className="mt-3">
|
<div className="mt-3">
|
||||||
<div className="w-full h-1.5 bg-muted rounded-full overflow-hidden">
|
<div className="w-full h-1.5 bg-muted rounded-full overflow-hidden">
|
||||||
<div className="h-full rounded-full bg-gradient-to-r from-[#CC0000] to-[#0033CC]" style={{ width: `${Math.min(conversionRate, 100)}%` }} />
|
<div className="h-full rounded-full bg-gradient-to-r from-[#C84B4B] to-[#5A8FC4]" style={{ width: `${Math.min(conversionRate, 100)}%` }} />
|
||||||
</div>
|
</div>
|
||||||
<p className="text-xs text-[#888888] dark:text-[#666666] mt-1">{conversionRate}% conversion rate</p>
|
<p className="text-xs text-[#8A9078] dark:text-[#666666] mt-1">{conversionRate}% conversion rate</p>
|
||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
</CardContent>
|
</CardContent>
|
||||||
|
|||||||
@@ -2,6 +2,7 @@
|
|||||||
|
|
||||||
import { useState, useEffect } from "react"
|
import { useState, useEffect } from "react"
|
||||||
import { usePathname } from "next/navigation"
|
import { usePathname } from "next/navigation"
|
||||||
|
import { useWebsiteTheme } from "@/providers/website-theme-provider"
|
||||||
import { motion, AnimatePresence } from "framer-motion"
|
import { motion, AnimatePresence } from "framer-motion"
|
||||||
import { Sidebar } from "./sidebar"
|
import { Sidebar } from "./sidebar"
|
||||||
import { Topbar } from "./topbar"
|
import { Topbar } from "./topbar"
|
||||||
@@ -11,6 +12,7 @@ interface AppShellProps {
|
|||||||
}
|
}
|
||||||
|
|
||||||
export function AppShell({ children }: AppShellProps) {
|
export function AppShell({ children }: AppShellProps) {
|
||||||
|
const { websiteTheme } = useWebsiteTheme()
|
||||||
const [sidebarCollapsed, setSidebarCollapsed] = useState(false)
|
const [sidebarCollapsed, setSidebarCollapsed] = useState(false)
|
||||||
const [mobileOpen, setMobileOpen] = useState(false)
|
const [mobileOpen, setMobileOpen] = useState(false)
|
||||||
const pathname = usePathname()
|
const pathname = usePathname()
|
||||||
@@ -33,51 +35,55 @@ export function AppShell({ children }: AppShellProps) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className="min-h-screen bg-[#F8F8F8] dark:bg-[#0A0A0A] relative overflow-hidden"
|
<div className="min-h-screen bg-background dark:bg-[#0A0A0A] relative overflow-hidden"
|
||||||
style={{
|
style={websiteTheme === "spidey" ? {
|
||||||
backgroundImage: "radial-gradient(circle, #CC000010 1px, transparent 1px), radial-gradient(circle, #0033CC06 1px, transparent 1px)",
|
backgroundImage: "radial-gradient(circle, #CC000010 1px, transparent 1px), radial-gradient(circle, #0033CC06 1px, transparent 1px)",
|
||||||
backgroundSize: "28px 28px, 14px 14px",
|
backgroundSize: "28px 28px, 14px 14px",
|
||||||
backgroundPosition: "0 0, 7px 7px",
|
backgroundPosition: "0 0, 7px 7px",
|
||||||
}}
|
} : undefined}
|
||||||
>
|
>
|
||||||
{/* Spider-Man top gradient bar */}
|
{websiteTheme === "spidey" && (
|
||||||
<div className="fixed top-0 left-0 right-0 h-[3px] w-full bg-gradient-to-r from-[#CC0000] via-[#FFFFFF] to-[#0033CC] dark:from-[#FF1111] dark:via-[#FFFFFF] dark:to-[#1144FF] z-50" />
|
<div className="fixed top-0 left-0 right-0 h-[3px] w-full bg-gradient-to-r from-[#CC0000] via-[#FFFFFF] to-[#0033CC] dark:from-[#FF1111] dark:via-[#FFFFFF] dark:to-[#1144FF] z-50" />
|
||||||
|
)}
|
||||||
|
|
||||||
{/* Corner spider webs */}
|
{websiteTheme === "spidey" && (
|
||||||
<div className="hidden lg:block fixed top-0 left-0 pointer-events-none z-0 opacity-[0.15] dark:opacity-[0.25]">
|
<>
|
||||||
<svg width="180" height="180" viewBox="0 0 180 180" fill="none" xmlns="http://www.w3.org/2000/svg">
|
<div className="hidden lg:block fixed top-0 left-0 pointer-events-none z-0 opacity-[0.15] dark:opacity-[0.25]">
|
||||||
<line x1="0" y1="0" x2="180" y2="0" stroke="#CC0000" strokeWidth="0.8"/>
|
<svg width="180" height="180" viewBox="0 0 180 180" fill="none" xmlns="http://www.w3.org/2000/svg">
|
||||||
<line x1="0" y1="0" x2="180" y2="60" stroke="#CC0000" strokeWidth="0.8"/>
|
<line x1="0" y1="0" x2="180" y2="0" stroke="#CC0000" strokeWidth="0.8"/>
|
||||||
<line x1="0" y1="0" x2="180" y2="120" stroke="#CC0000" strokeWidth="0.8"/>
|
<line x1="0" y1="0" x2="180" y2="60" stroke="#CC0000" strokeWidth="0.8"/>
|
||||||
<line x1="0" y1="0" x2="180" y2="180" stroke="#CC0000" strokeWidth="0.8"/>
|
<line x1="0" y1="0" x2="180" y2="120" stroke="#CC0000" strokeWidth="0.8"/>
|
||||||
<line x1="0" y1="0" x2="120" y2="180" stroke="#CC0000" strokeWidth="0.8"/>
|
<line x1="0" y1="0" x2="180" y2="180" stroke="#CC0000" strokeWidth="0.8"/>
|
||||||
<line x1="0" y1="0" x2="60" y2="180" stroke="#CC0000" strokeWidth="0.8"/>
|
<line x1="0" y1="0" x2="120" y2="180" stroke="#CC0000" strokeWidth="0.8"/>
|
||||||
<line x1="0" y1="0" x2="0" y2="180" stroke="#CC0000" strokeWidth="0.8"/>
|
<line x1="0" y1="0" x2="60" y2="180" stroke="#CC0000" strokeWidth="0.8"/>
|
||||||
<path d="M0,30 Q30,30 30,0" stroke="#CC0000" strokeWidth="0.8" fill="none"/>
|
<line x1="0" y1="0" x2="0" y2="180" stroke="#CC0000" strokeWidth="0.8"/>
|
||||||
<path d="M0,60 Q60,60 60,0" stroke="#CC0000" strokeWidth="0.8" fill="none"/>
|
<path d="M0,30 Q30,30 30,0" stroke="#CC0000" strokeWidth="0.8" fill="none"/>
|
||||||
<path d="M0,90 Q90,90 90,0" stroke="#CC0000" strokeWidth="0.8" fill="none"/>
|
<path d="M0,60 Q60,60 60,0" stroke="#CC0000" strokeWidth="0.8" fill="none"/>
|
||||||
<path d="M0,120 Q120,120 120,0" stroke="#CC0000" strokeWidth="0.8" fill="none"/>
|
<path d="M0,90 Q90,90 90,0" stroke="#CC0000" strokeWidth="0.8" fill="none"/>
|
||||||
<path d="M0,150 Q150,150 150,0" stroke="#CC0000" strokeWidth="0.8" fill="none"/>
|
<path d="M0,120 Q120,120 120,0" stroke="#CC0000" strokeWidth="0.8" fill="none"/>
|
||||||
<path d="M0,180 Q180,180 180,0" stroke="#CC0000" strokeWidth="0.8" fill="none"/>
|
<path d="M0,150 Q150,150 150,0" stroke="#CC0000" strokeWidth="0.8" fill="none"/>
|
||||||
</svg>
|
<path d="M0,180 Q180,180 180,0" stroke="#CC0000" strokeWidth="0.8" fill="none"/>
|
||||||
</div>
|
</svg>
|
||||||
<div className="hidden lg:block fixed top-0 right-0 pointer-events-none z-0 opacity-[0.15] dark:opacity-[0.25]">
|
</div>
|
||||||
<svg width="180" height="180" viewBox="0 0 180 180" fill="none" xmlns="http://www.w3.org/2000/svg" style={{ transform: "scaleX(-1)" }}>
|
<div className="hidden lg:block fixed top-0 right-0 pointer-events-none z-0 opacity-[0.15] dark:opacity-[0.25]">
|
||||||
<line x1="0" y1="0" x2="180" y2="0" stroke="#CC0000" strokeWidth="0.8"/>
|
<svg width="180" height="180" viewBox="0 0 180 180" fill="none" xmlns="http://www.w3.org/2000/svg" style={{ transform: "scaleX(-1)" }}>
|
||||||
<line x1="0" y1="0" x2="180" y2="60" stroke="#CC0000" strokeWidth="0.8"/>
|
<line x1="0" y1="0" x2="180" y2="0" stroke="#CC0000" strokeWidth="0.8"/>
|
||||||
<line x1="0" y1="0" x2="180" y2="120" stroke="#CC0000" strokeWidth="0.8"/>
|
<line x1="0" y1="0" x2="180" y2="60" stroke="#CC0000" strokeWidth="0.8"/>
|
||||||
<line x1="0" y1="0" x2="180" y2="180" stroke="#CC0000" strokeWidth="0.8"/>
|
<line x1="0" y1="0" x2="180" y2="120" stroke="#CC0000" strokeWidth="0.8"/>
|
||||||
<line x1="0" y1="0" x2="120" y2="180" stroke="#CC0000" strokeWidth="0.8"/>
|
<line x1="0" y1="0" x2="180" y2="180" stroke="#CC0000" strokeWidth="0.8"/>
|
||||||
<line x1="0" y1="0" x2="60" y2="180" stroke="#CC0000" strokeWidth="0.8"/>
|
<line x1="0" y1="0" x2="120" y2="180" stroke="#CC0000" strokeWidth="0.8"/>
|
||||||
<line x1="0" y1="0" x2="0" y2="180" stroke="#CC0000" strokeWidth="0.8"/>
|
<line x1="0" y1="0" x2="60" y2="180" stroke="#CC0000" strokeWidth="0.8"/>
|
||||||
<path d="M0,30 Q30,30 30,0" stroke="#CC0000" strokeWidth="0.8" fill="none"/>
|
<line x1="0" y1="0" x2="0" y2="180" stroke="#CC0000" strokeWidth="0.8"/>
|
||||||
<path d="M0,60 Q60,60 60,0" stroke="#CC0000" strokeWidth="0.8" fill="none"/>
|
<path d="M0,30 Q30,30 30,0" stroke="#CC0000" strokeWidth="0.8" fill="none"/>
|
||||||
<path d="M0,90 Q90,90 90,0" stroke="#CC0000" strokeWidth="0.8" fill="none"/>
|
<path d="M0,60 Q60,60 60,0" stroke="#CC0000" strokeWidth="0.8" fill="none"/>
|
||||||
<path d="M0,120 Q120,120 120,0" stroke="#CC0000" strokeWidth="0.8" fill="none"/>
|
<path d="M0,90 Q90,90 90,0" stroke="#CC0000" strokeWidth="0.8" fill="none"/>
|
||||||
<path d="M0,150 Q150,150 150,0" stroke="#CC0000" strokeWidth="0.8" fill="none"/>
|
<path d="M0,120 Q120,120 120,0" stroke="#CC0000" strokeWidth="0.8" fill="none"/>
|
||||||
<path d="M0,180 Q180,180 180,0" stroke="#CC0000" strokeWidth="0.8" fill="none"/>
|
<path d="M0,150 Q150,150 150,0" stroke="#CC0000" strokeWidth="0.8" fill="none"/>
|
||||||
</svg>
|
<path d="M0,180 Q180,180 180,0" stroke="#CC0000" strokeWidth="0.8" fill="none"/>
|
||||||
</div>
|
</svg>
|
||||||
|
</div>
|
||||||
|
</>
|
||||||
|
)}
|
||||||
|
|
||||||
<Sidebar
|
<Sidebar
|
||||||
collapsed={sidebarCollapsed}
|
collapsed={sidebarCollapsed}
|
||||||
|
|||||||
@@ -8,17 +8,17 @@ export function CrmIcon() {
|
|||||||
viewBox="0 0 64 64"
|
viewBox="0 0 64 64"
|
||||||
aria-hidden="true"
|
aria-hidden="true"
|
||||||
>
|
>
|
||||||
<g className="fill-[#333333] dark:fill-white/[0.15] dark:stroke-[#e5e7eb]" style={{strokeWidth: "1.5px"}}>
|
<g className="fill-[#2D3020] dark:fill-white/[0.15] dark:stroke-[#e5e7eb]" style={{strokeWidth: "1.5px"}}>
|
||||||
<path className="dark:[stroke-linejoin:round]" d="m10.35 35.62s-.75.14-1-1.48-.58-8.61-.3-8.87a10 10 0 0 1 2.07-.63s0-7.54 2.67-11.86 5.4-6.09 10.65-7.56a29.38 29.38 0 0 1 15.18.3c4.41 1.18 8.7 4.62 9.89 8.8a49.27 49.27 0 0 1 1.49 9.55 9.53 9.53 0 0 1 2.52.9c.17.33.43 9.12 0 9.38a1.6 1.6 0 0 1 -.87.23s7.86 18 7.59 19-12.93 6.36-30.1 6.23-26.05-5.41-26.36-6.11 6.57-17.88 6.57-17.88z" />
|
<path className="dark:[stroke-linejoin:round]" d="m10.35 35.62s-.75.14-1-1.48-.58-8.61-.3-8.87a10 10 0 0 1 2.07-.63s0-7.54 2.67-11.86 5.4-6.09 10.65-7.56a29.38 29.38 0 0 1 15.18.3c4.41 1.18 8.7 4.62 9.89 8.8a49.27 49.27 0 0 1 1.49 9.55 9.53 9.53 0 0 1 2.52.9c.17.33.43 9.12 0 9.38a1.6 1.6 0 0 1 -.87.23s7.86 18 7.59 19-12.93 6.36-30.1 6.23-26.05-5.41-26.36-6.11 6.57-17.88 6.57-17.88z" />
|
||||||
</g>
|
</g>
|
||||||
<g className="fill-[#777777] dark:fill-white/[0.1] dark:stroke-[#d4d4d4]" style={{strokeWidth: "1px"}}>
|
<g className="fill-[#8A9078] dark:fill-white/[0.1] dark:stroke-[#d4d4d4]" style={{strokeWidth: "1px"}}>
|
||||||
<path d="m13.24 27.92s-.6-7.65 1.3-11.91a19.6 19.6 0 0 1 7.75-8.23 20.47 20.47 0 0 1 6.51-1.45c.05.16.67 10.23.66 11.61a27 27 0 0 1 -.57 4s-4-2.44-8.16-1.38a10.47 10.47 0 0 0 -7.49 7.36z" />
|
<path d="m13.24 27.92s-.6-7.65 1.3-11.91a19.6 19.6 0 0 1 7.75-8.23 20.47 20.47 0 0 1 6.51-1.45c.05.16.67 10.23.66 11.61a27 27 0 0 1 -.57 4s-4-2.44-8.16-1.38a10.47 10.47 0 0 0 -7.49 7.36z" />
|
||||||
<path d="m30.11 22.18s.53-3.69.54-4.89-.8-10.82-.6-10.95a4.11 4.11 0 0 1 1.88 0c.07.16 1.07 11.66 1.28 13.22a9.33 9.33 0 0 1 .27 2.44 2.85 2.85 0 0 1 -3.37.18z" />
|
<path d="m30.11 22.18s.53-3.69.54-4.89-.8-10.82-.6-10.95a4.11 4.11 0 0 1 1.88 0c.07.16 1.07 11.66 1.28 13.22a9.33 9.33 0 0 1 .27 2.44 2.85 2.85 0 0 1 -3.37.18z" />
|
||||||
<path d="m34.8 21.44a76.48 76.48 0 0 1 -1.4-9.35c-.17-3.62-.51-5.65-.06-5.83s6.66.17 10.42 3.45 4.06 5.61 3.81 5.66-2.52-1-2.64-.77-.15.8.06.92 2.69 1.1 2.82 1.26a4.18 4.18 0 0 1 .28.95 16.94 16.94 0 0 0 -3-1.13c-.12.17-.23.92 0 1s3 1.3 3 1.3l.11.79s-2.83-1.18-3-1.14-.36.8-.15.92 3.16 1.26 3.25 1.43.41 1.36.41 1.36l-1.71-.54s.31.87.48 1 1.3.42 1.31.76a31.35 31.35 0 0 1 .2 3.33c-.12 0-2.2-6.45-5.21-7.09s-8.98 1.72-8.98 1.72z" />
|
<path d="m34.8 21.44a76.48 76.48 0 0 1 -1.4-9.35c-.17-3.62-.51-5.65-.06-5.83s6.66.17 10.42 3.45 4.06 5.61 3.81 5.66-2.52-1-2.64-.77-.15.8.06.92 2.69 1.1 2.82 1.26a4.18 4.18 0 0 1 .28.95 16.94 16.94 0 0 0 -3-1.13c-.12.17-.23.92 0 1s3 1.3 3 1.3l.11.79s-2.83-1.18-3-1.14-.36.8-.15.92 3.16 1.26 3.25 1.43.41 1.36.41 1.36l-1.71-.54s.31.87.48 1 1.3.42 1.31.76a31.35 31.35 0 0 1 .2 3.33c-.12 0-2.2-6.45-5.21-7.09s-8.98 1.72-8.98 1.72z" />
|
||||||
<path d="m22.44 22.28c2.21 0 7.19 2.45 9.76 1.84s6.89-2.79 9.51-2.81 4.07 2.48 6.36 7.84 7.14 17.34 7 17.34-8.1-17.89-9.66-20.23-2.66-3-5.86-2.77-6.81 1-6.85 1.41 0 7.17.49 7.75 3.06.71 6.18.51 5-.33 5.77-.64a10.14 10.14 0 0 0 1.69-1l7.79 16.48a30.15 30.15 0 0 0 -2.94-2.56c-.21.05-.31.8-.19.88s3.67 3.33 4 3.7a12.43 12.43 0 0 1 1.61 2.63c-.12.17-.45.55-.66.47s-5-5.71-5.39-5.83-.45.6-.45.6 5.18 5.5 5.06 5.63a8.62 8.62 0 0 1 -1.15.57s-4.34-5-4.5-4.94-.69.6-.4.81a45.71 45.71 0 0 1 4.07 4.36 7 7 0 0 1 -1.2.4 46.49 46.49 0 0 0 -3.58-3.72c-.25 0-.61.39-.49.55s3.34 3.29 3.22 3.46a4 4 0 0 1 -1.16.37s-2.47-2.59-2.65-2.59-.74.31-.53.6 2.39 2.19 2.27 2.28a4.86 4.86 0 0 1 -1 .19c-.13 0-2-2-2.22-2s-.78.19-.65.48 1.83 1.79 1.63 1.92-5.17 1.66-15.76 1.51-14.84-1.85-14.84-1.85a46 46 0 0 0 -4.22-3.69c-.37 0-.78.6-.61.72a29.31 29.31 0 0 1 2.86 2.6c-.16 0-3.27-.92-4.32-1.1a7.6 7.6 0 0 1 -2.26-.61c0-.13 6-14.45 6.27-14.41s4.29 10.07 4.29 10.07a2.77 2.77 0 0 0 1.2 2.89 3.9 3.9 0 0 0 3.49-.25l15.51.08c.12 0 2.65 1.27 3.69-.68s.44-2.67.44-2.67 4-11.27 3.76-11.52a5.63 5.63 0 0 0 -1.76-.35s-2.75 10.32-3 10.33-1.26-.3-1.68-.34a3.18 3.18 0 0 0 -.7 0l-5.48-7.62s1.1-6-3.28-6-3.14 6-3.14 6-1-.06-2 1.51-3.39 6.29-3.39 6.29a3.77 3.77 0 0 0 -1.45.2c-.66.27-1.07.49-1.07.49s-3.54-8.29-3.46-8.46.82-2.15 1.2-2.16 2.93.39 3.93.41 1.16-.11 1.2-.28.27-.8.1-.8-5.11-.57-5.48-.57-1.07 2.14-1.36 2.23-1.32.25-1.2 0 2.16-5.13 2.29-5.1 7.53 1.07 9.73.64 2.76-1.11 3.07-2.16a31.45 31.45 0 0 0 .25-6.59 19.1 19.1 0 0 0 -6.2-1.31c-2.79-.09-4.67-.13-5.87 1.82s-10.79 27.71-10.96 27.76a3.64 3.64 0 0 1 -.92-.29s4.91-14 6.78-19.26 3.47-11.56 9.47-11.46z" />
|
<path d="m22.44 22.28c2.21 0 7.19 2.45 9.76 1.84s6.89-2.79 9.51-2.81 4.07 2.48 6.36 7.84 7.14 17.34 7 17.34-8.1-17.89-9.66-20.23-2.66-3-5.86-2.77-6.81 1-6.85 1.41 0 7.17.49 7.75 3.06.71 6.18.51 5-.33 5.77-.64a10.14 10.14 0 0 0 1.69-1l7.79 16.48a30.15 30.15 0 0 0 -2.94-2.56c-.21.05-.31.8-.19.88s3.67 3.33 4 3.7a12.43 12.43 0 0 1 1.61 2.63c-.12.17-.45.55-.66.47s-5-5.71-5.39-5.83-.45.6-.45.6 5.18 5.5 5.06 5.63a8.62 8.62 0 0 1 -1.15.57s-4.34-5-4.5-4.94-.69.6-.4.81a45.71 45.71 0 0 1 4.07 4.36 7 7 0 0 1 -1.2.4 46.49 46.49 0 0 0 -3.58-3.72c-.25 0-.61.39-.49.55s3.34 3.29 3.22 3.46a4 4 0 0 1 -1.16.37s-2.47-2.59-2.65-2.59-.74.31-.53.6 2.39 2.19 2.27 2.28a4.86 4.86 0 0 1 -1 .19c-.13 0-2-2-2.22-2s-.78.19-.65.48 1.83 1.79 1.63 1.92-5.17 1.66-15.76 1.51-14.84-1.85-14.84-1.85a46 46 0 0 0 -4.22-3.69c-.37 0-.78.6-.61.72a29.31 29.31 0 0 1 2.86 2.6c-.16 0-3.27-.92-4.32-1.1a7.6 7.6 0 0 1 -2.26-.61c0-.13 6-14.45 6.27-14.41s4.29 10.07 4.29 10.07a2.77 2.77 0 0 0 1.2 2.89 3.9 3.9 0 0 0 3.49-.25l15.51.08c.12 0 2.65 1.27 3.69-.68s.44-2.67.44-2.67 4-11.27 3.76-11.52a5.63 5.63 0 0 0 -1.76-.35s-2.75 10.32-3 10.33-1.26-.3-1.68-.34a3.18 3.18 0 0 0 -.7 0l-5.48-7.62s1.1-6-3.28-6-3.14 6-3.14 6-1-.06-2 1.51-3.39 6.29-3.39 6.29a3.77 3.77 0 0 0 -1.45.2c-.66.27-1.07.49-1.07.49s-3.54-8.29-3.46-8.46.82-2.15 1.2-2.16 2.93.39 3.93.41 1.16-.11 1.2-.28.27-.8.1-.8-5.11-.57-5.48-.57-1.07 2.14-1.36 2.23-1.32.25-1.2 0 2.16-5.13 2.29-5.1 7.53 1.07 9.73.64 2.76-1.11 3.07-2.16a31.45 31.45 0 0 0 .25-6.59 19.1 19.1 0 0 0 -6.2-1.31c-2.79-.09-4.67-.13-5.87 1.82s-10.79 27.71-10.96 27.76a3.64 3.64 0 0 1 -.92-.29s4.91-14 6.78-19.26 3.47-11.56 9.47-11.46z" />
|
||||||
<path d="m28.76 40.3a14.69 14.69 0 0 0 2.25 0l2.29-.09s-.39 9.67 0 9.75.67.27.66-.06-.19-7.75.06-7.79.75 1.52.75 1.52-.14 6.42 0 6.46.88.27.84.06 0-4.75 0-4.75l1.43 2.26-.79.06s-.08 2 .17 2a4.75 4.75 0 0 0 .71 0c.12 0 .11 1.16.11 1.16s-7.26-.19-9.8-.13-2.74.11-2.74.11a6.75 6.75 0 0 0 -.55-2l-.62-1.28s4.86-7.15 5.23-7.28z" />
|
<path d="m28.76 40.3a14.69 14.69 0 0 0 2.25 0l2.29-.09s-.39 9.67 0 9.75.67.27.66-.06-.19-7.75.06-7.79.75 1.52.75 1.52-.14 6.42 0 6.46.88.27.84.06 0-4.75 0-4.75l1.43 2.26-.79.06s-.08 2 .17 2a4.75 4.75 0 0 0 .71 0c.12 0 .11 1.16.11 1.16s-7.26-.19-9.8-.13-2.74.11-2.74.11a6.75 6.75 0 0 0 -.55-2l-.62-1.28s4.86-7.15 5.23-7.28z" />
|
||||||
</g>
|
</g>
|
||||||
<g className="fill-[#333333] dark:fill-white/[0.15] dark:stroke-[#d4d4d4]" style={{strokeWidth: "0.7px"}}>
|
<g className="fill-[#2D3020] dark:fill-white/[0.15] dark:stroke-[#d4d4d4]" style={{strokeWidth: "0.7px"}}>
|
||||||
<path d="m30.57 41.13c.28-.1.79-.06.84.1s.41 8.41.17 8.63-.88.14-.88 0-.25-8.69-.13-8.73z" />
|
<path d="m30.57 41.13c.28-.1.79-.06.84.1s.41 8.41.17 8.63-.88.14-.88 0-.25-8.69-.13-8.73z" />
|
||||||
<path d="m28.74 41.26c.21-.09.7-.19.79 0s.55 8.61.38 8.66-.87.18-.87 0-.3-8.66-.3-8.66z" />
|
<path d="m28.74 41.26c.21-.09.7-.19.79 0s.55 8.61.38 8.66-.87.18-.87 0-.3-8.66-.3-8.66z" />
|
||||||
<path d="m27 44.8c.06-.14.87-.4.87-.23a52.12 52.12 0 0 1 .21 5.25 3 3 0 0 1 -.79.06s-.38-4.88-.29-5.08z" />
|
<path d="m27 44.8c.06-.14.87-.4.87-.23a52.12 52.12 0 0 1 .21 5.25 3 3 0 0 1 -.79.06s-.38-4.88-.29-5.08z" />
|
||||||
|
|||||||
@@ -4,7 +4,7 @@ export function SidebarNameLogo() {
|
|||||||
return (
|
return (
|
||||||
<svg
|
<svg
|
||||||
viewBox="0 0 192.756 192.756"
|
viewBox="0 0 192.756 192.756"
|
||||||
className="fill-[#333333] dark:fill-[#e5e7eb] w-full h-auto"
|
className="fill-[#2D3020] dark:fill-[#eef0f7] w-full h-auto"
|
||||||
aria-hidden="true"
|
aria-hidden="true"
|
||||||
>
|
>
|
||||||
<g fill-rule="evenodd" clip-rule="evenodd">
|
<g fill-rule="evenodd" clip-rule="evenodd">
|
||||||
|
|||||||
@@ -73,7 +73,7 @@ export function Topbar({ onMenuClick }: TopbarProps) {
|
|||||||
.toUpperCase();
|
.toUpperCase();
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<header className="sticky top-0 z-20 flex h-16 items-center gap-4 border-b bg-card dark:bg-sidebar px-4 lg:px-6 relative overflow-hidden">
|
<header className="sticky top-0 z-20 flex h-16 items-center gap-4 border-b bg-sidebar dark:bg-sidebar px-4 lg:px-6 relative overflow-hidden">
|
||||||
{/* Logo */}
|
{/* Logo */}
|
||||||
<div className="flex items-center gap-2 mr-2 shrink-0">
|
<div className="flex items-center gap-2 mr-2 shrink-0">
|
||||||
{websiteTheme === "spidey" && <CaptainAmericaShield />}
|
{websiteTheme === "spidey" && <CaptainAmericaShield />}
|
||||||
@@ -94,10 +94,10 @@ export function Topbar({ onMenuClick }: TopbarProps) {
|
|||||||
|
|
||||||
{/* Search */}
|
{/* Search */}
|
||||||
<div className="relative hidden flex-1 sm:block md:w-80">
|
<div className="relative hidden flex-1 sm:block md:w-80">
|
||||||
<Search className="absolute left-3 top-1/2 h-4 w-4 -translate-y-1/2 text-[#888888] dark:text-[#666666]" />
|
<Search className="absolute left-3 top-1/2 h-4 w-4 -translate-y-1/2 text-muted-foreground" />
|
||||||
<Input
|
<Input
|
||||||
placeholder="Search leads, companies..."
|
placeholder="Search leads, companies..."
|
||||||
className="h-9 w-full max-w-sm pl-9 bg-[#F0F0F0] dark:bg-[#1E1E1E] border-[#E0E0E0] dark:border-[#222222] text-[#111111] dark:text-white"
|
className="h-9 w-full max-w-sm pl-9 bg-muted border-border text-foreground"
|
||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
@@ -117,7 +117,7 @@ export function Topbar({ onMenuClick }: TopbarProps) {
|
|||||||
variant="ghost"
|
variant="ghost"
|
||||||
size="icon"
|
size="icon"
|
||||||
onClick={() => setTheme(theme === "dark" ? "light" : "dark")}
|
onClick={() => setTheme(theme === "dark" ? "light" : "dark")}
|
||||||
className="text-muted-foreground"
|
className="text-muted-foreground hover:bg-[#8b949e]/10 hover:text-[#8b949e]"
|
||||||
>
|
>
|
||||||
{mounted ? (
|
{mounted ? (
|
||||||
theme === "dark" ? (
|
theme === "dark" ? (
|
||||||
@@ -135,7 +135,7 @@ export function Topbar({ onMenuClick }: TopbarProps) {
|
|||||||
variant="ghost"
|
variant="ghost"
|
||||||
size="icon"
|
size="icon"
|
||||||
onClick={() => setBugModalOpen(true)}
|
onClick={() => setBugModalOpen(true)}
|
||||||
className="text-muted-foreground"
|
className="text-muted-foreground hover:bg-[#8b949e]/10 hover:text-[#8b949e]"
|
||||||
title="Report a Bug"
|
title="Report a Bug"
|
||||||
>
|
>
|
||||||
<Bug className="h-5 w-5" />
|
<Bug className="h-5 w-5" />
|
||||||
@@ -147,7 +147,7 @@ export function Topbar({ onMenuClick }: TopbarProps) {
|
|||||||
<Button
|
<Button
|
||||||
variant="ghost"
|
variant="ghost"
|
||||||
size="icon"
|
size="icon"
|
||||||
className="relative text-muted-foreground"
|
className="relative text-muted-foreground hover:bg-[#8b949e]/10 hover:text-[#8b949e]"
|
||||||
>
|
>
|
||||||
<Bell className="h-5 w-5" />
|
<Bell className="h-5 w-5" />
|
||||||
<span className="absolute -right-0.5 -top-0.5 flex h-4 w-4 items-center justify-center rounded-full bg-primary text-[10px] font-medium text-primary-foreground">
|
<span className="absolute -right-0.5 -top-0.5 flex h-4 w-4 items-center justify-center rounded-full bg-primary text-[10px] font-medium text-primary-foreground">
|
||||||
@@ -217,7 +217,7 @@ export function Topbar({ onMenuClick }: TopbarProps) {
|
|||||||
{/* User profile */}
|
{/* User profile */}
|
||||||
<DropdownMenu>
|
<DropdownMenu>
|
||||||
<DropdownMenuTrigger asChild>
|
<DropdownMenuTrigger asChild>
|
||||||
<Button variant="ghost" className="relative h-9 gap-2 pl-2 pr-3">
|
<Button variant="ghost" className="relative h-9 gap-2 pl-2 pr-3 hover:bg-[#8b949e]/10 hover:text-[#8b949e]">
|
||||||
<Avatar className="h-7 w-7">
|
<Avatar className="h-7 w-7">
|
||||||
<AvatarImage src={user.avatar} />
|
<AvatarImage src={user.avatar} />
|
||||||
<AvatarFallback>{initials}</AvatarFallback>
|
<AvatarFallback>{initials}</AvatarFallback>
|
||||||
|
|||||||
@@ -6,7 +6,7 @@ import { Card, CardContent, CardDescription, CardHeader, CardTitle } from "@/com
|
|||||||
import { Label } from "@/components/ui/label"
|
import { Label } from "@/components/ui/label"
|
||||||
import { RadioGroup, RadioGroupItem } from "@/components/ui/radio-group"
|
import { RadioGroup, RadioGroupItem } from "@/components/ui/radio-group"
|
||||||
import { cn } from "@/lib/utils"
|
import { cn } from "@/lib/utils"
|
||||||
import { Sun, Moon, Monitor, Shield } from "lucide-react"
|
import { Sun, Moon, Monitor, Palette, Shield } from "lucide-react"
|
||||||
import { useWebsiteTheme } from "@/providers/website-theme-provider"
|
import { useWebsiteTheme } from "@/providers/website-theme-provider"
|
||||||
|
|
||||||
const COLOR_THEME_KEY = "crm-color-theme"
|
const COLOR_THEME_KEY = "crm-color-theme"
|
||||||
@@ -41,12 +41,12 @@ function getStoredColorTheme(): string {
|
|||||||
}
|
}
|
||||||
|
|
||||||
function applyColorTheme(theme: string) {
|
function applyColorTheme(theme: string) {
|
||||||
document.documentElement.className = document.documentElement.className
|
const colorThemes = ["default","ocean","forest","sunset","midnight","rose","amber","violet","slate","ruby"]
|
||||||
.replace(/\b(default|ocean|forest|sunset|midnight|rose|amber|violet|slate|ruby)\b/g, "")
|
const classes = document.documentElement.className.split(" ").filter(c => !colorThemes.includes(c))
|
||||||
.trim()
|
|
||||||
if (theme !== "default") {
|
if (theme !== "default") {
|
||||||
document.documentElement.classList.add(theme)
|
classes.push(theme)
|
||||||
}
|
}
|
||||||
|
document.documentElement.className = classes.join(" ").trim()
|
||||||
localStorage.setItem(COLOR_THEME_KEY, theme)
|
localStorage.setItem(COLOR_THEME_KEY, theme)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -75,7 +75,7 @@ export function BugReportModal({ open, onClose }: BugReportModalProps) {
|
|||||||
>
|
>
|
||||||
<button
|
<button
|
||||||
onClick={handleClose}
|
onClick={handleClose}
|
||||||
className="absolute right-4 top-4 text-[#888888] hover:text-[#111111] dark:hover:text-white transition-colors"
|
className="absolute right-4 top-4 text-[#8A9078] hover:text-[#2D3020] dark:hover:text-white transition-colors"
|
||||||
>
|
>
|
||||||
<X className="h-5 w-5" />
|
<X className="h-5 w-5" />
|
||||||
</button>
|
</button>
|
||||||
@@ -83,15 +83,15 @@ export function BugReportModal({ open, onClose }: BugReportModalProps) {
|
|||||||
{submitted ? (
|
{submitted ? (
|
||||||
<div className="flex flex-col items-center py-8 text-center">
|
<div className="flex flex-col items-center py-8 text-center">
|
||||||
<CheckCircle className="h-12 w-12 text-emerald-500 mb-4" />
|
<CheckCircle className="h-12 w-12 text-emerald-500 mb-4" />
|
||||||
<h3 className="text-lg font-semibold text-[#111111] dark:text-white mb-2">
|
<h3 className="text-lg font-semibold text-[#2D3020] dark:text-white mb-2">
|
||||||
Bug Report Submitted
|
Bug Report Submitted
|
||||||
</h3>
|
</h3>
|
||||||
<p className="text-sm text-[#666666] dark:text-[#AAAAAA]">
|
<p className="text-sm text-[#8A9078] dark:text-[#AAAAAA]">
|
||||||
Thank you for your report. The development team will investigate.
|
Thank you for your report. The development team will investigate.
|
||||||
</p>
|
</p>
|
||||||
<button
|
<button
|
||||||
onClick={handleClose}
|
onClick={handleClose}
|
||||||
className="mt-6 rounded-lg bg-[#CC0000] px-6 py-2 text-sm font-medium text-white hover:bg-[#AA0000] transition-colors"
|
className="mt-6 rounded-lg bg-[#C84B4B] px-6 py-2 text-sm font-medium text-white hover:bg-[#C84B4B]/80 transition-colors"
|
||||||
>
|
>
|
||||||
Done
|
Done
|
||||||
</button>
|
</button>
|
||||||
@@ -99,14 +99,14 @@ export function BugReportModal({ open, onClose }: BugReportModalProps) {
|
|||||||
) : (
|
) : (
|
||||||
<>
|
<>
|
||||||
<div className="flex items-center gap-3 mb-6">
|
<div className="flex items-center gap-3 mb-6">
|
||||||
<div className="flex h-10 w-10 items-center justify-center rounded-lg bg-red-100 dark:bg-red-900/30">
|
<div className="flex h-10 w-10 items-center justify-center rounded-lg bg-[#FAEAEA] dark:bg-red-900/30">
|
||||||
<Bug className="h-5 w-5 text-[#CC0000]" />
|
<Bug className="h-5 w-5 text-[#C84B4B]" />
|
||||||
</div>
|
</div>
|
||||||
<div>
|
<div>
|
||||||
<h3 className="text-lg font-semibold text-[#111111] dark:text-white">
|
<h3 className="text-lg font-semibold text-[#2D3020] dark:text-white">
|
||||||
Report a Bug
|
Report a Bug
|
||||||
</h3>
|
</h3>
|
||||||
<p className="text-xs text-[#666666] dark:text-[#AAAAAA]">
|
<p className="text-xs text-[#8A9078] dark:text-[#AAAAAA]">
|
||||||
Help us improve the system
|
Help us improve the system
|
||||||
</p>
|
</p>
|
||||||
</div>
|
</div>
|
||||||
@@ -120,8 +120,8 @@ export function BugReportModal({ open, onClose }: BugReportModalProps) {
|
|||||||
|
|
||||||
<form onSubmit={handleSubmit} className="space-y-4">
|
<form onSubmit={handleSubmit} className="space-y-4">
|
||||||
<div>
|
<div>
|
||||||
<label className="mb-1.5 block text-sm font-medium text-[#444444] dark:text-[#CCCCCC]">
|
<label className="mb-1.5 block text-sm font-medium text-[#8A9078] dark:text-[#CCCCCC]">
|
||||||
Title <span className="text-[#CC0000]">*</span>
|
Title <span className="text-[#C84B4B]">*</span>
|
||||||
</label>
|
</label>
|
||||||
<input
|
<input
|
||||||
type="text"
|
type="text"
|
||||||
@@ -129,13 +129,13 @@ export function BugReportModal({ open, onClose }: BugReportModalProps) {
|
|||||||
onChange={(e) => setTitle(e.target.value)}
|
onChange={(e) => setTitle(e.target.value)}
|
||||||
placeholder="Brief description of the issue"
|
placeholder="Brief description of the issue"
|
||||||
required
|
required
|
||||||
className="w-full rounded-lg border border-[#E0E0E0] dark:border-[#333333] bg-white dark:bg-[#1E1E1E] px-3 py-2 text-sm text-[#111111] dark:text-white placeholder-[#999999] focus:border-[#CC0000] focus:outline-none focus:ring-1 focus:ring-[#CC0000]"
|
className="w-full rounded-lg border border-[#D4D8CC] dark:border-[#333333] bg-white dark:bg-[#1E1E1E] px-3 py-2 text-sm text-[#2D3020] dark:text-white placeholder-[#8A9078] focus:border-[#C84B4B] focus:outline-none focus:ring-1 focus:ring-[#C84B4B]"
|
||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div>
|
<div>
|
||||||
<label className="mb-1.5 block text-sm font-medium text-[#444444] dark:text-[#CCCCCC]">
|
<label className="mb-1.5 block text-sm font-medium text-[#8A9078] dark:text-[#CCCCCC]">
|
||||||
Description <span className="text-[#CC0000]">*</span>
|
Description <span className="text-[#C84B4B]">*</span>
|
||||||
</label>
|
</label>
|
||||||
<textarea
|
<textarea
|
||||||
value={description}
|
value={description}
|
||||||
@@ -143,18 +143,18 @@ export function BugReportModal({ open, onClose }: BugReportModalProps) {
|
|||||||
placeholder="What happened? What did you expect?"
|
placeholder="What happened? What did you expect?"
|
||||||
rows={4}
|
rows={4}
|
||||||
required
|
required
|
||||||
className="w-full resize-none rounded-lg border border-[#E0E0E0] dark:border-[#333333] bg-white dark:bg-[#1E1E1E] px-3 py-2 text-sm text-[#111111] dark:text-white placeholder-[#999999] focus:border-[#CC0000] focus:outline-none focus:ring-1 focus:ring-[#CC0000]"
|
className="w-full resize-none rounded-lg border border-[#D4D8CC] dark:border-[#333333] bg-white dark:bg-[#1E1E1E] px-3 py-2 text-sm text-[#2D3020] dark:text-white placeholder-[#8A9078] focus:border-[#C84B4B] focus:outline-none focus:ring-1 focus:ring-[#C84B4B]"
|
||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div>
|
<div>
|
||||||
<label className="mb-1.5 block text-sm font-medium text-[#444444] dark:text-[#CCCCCC]">
|
<label className="mb-1.5 block text-sm font-medium text-[#8A9078] dark:text-[#CCCCCC]">
|
||||||
Severity
|
Severity
|
||||||
</label>
|
</label>
|
||||||
<select
|
<select
|
||||||
value={severity}
|
value={severity}
|
||||||
onChange={(e) => setSeverity(e.target.value)}
|
onChange={(e) => setSeverity(e.target.value)}
|
||||||
className="w-full rounded-lg border border-[#E0E0E0] dark:border-[#333333] bg-white dark:bg-[#1E1E1E] px-3 py-2 text-sm text-[#111111] dark:text-white focus:border-[#CC0000] focus:outline-none focus:ring-1 focus:ring-[#CC0000]"
|
className="w-full rounded-lg border border-[#D4D8CC] dark:border-[#333333] bg-white dark:bg-[#1E1E1E] px-3 py-2 text-sm text-[#2D3020] dark:text-white focus:border-[#C84B4B] focus:outline-none focus:ring-1 focus:ring-[#C84B4B]"
|
||||||
>
|
>
|
||||||
<option value="low">Low — Minor cosmetic issue</option>
|
<option value="low">Low — Minor cosmetic issue</option>
|
||||||
<option value="medium">Medium — Affects functionality</option>
|
<option value="medium">Medium — Affects functionality</option>
|
||||||
@@ -163,8 +163,8 @@ export function BugReportModal({ open, onClose }: BugReportModalProps) {
|
|||||||
</select>
|
</select>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div className="rounded-lg bg-[#F5F5F5] dark:bg-[#1A1A1A] px-3 py-2">
|
<div className="rounded-lg bg-[#FAFAF6] dark:bg-[#1A1A1A] px-3 py-2">
|
||||||
<p className="text-xs text-[#888888]">
|
<p className="text-xs text-[#8A9078]">
|
||||||
<span className="font-medium">Page:</span> {typeof window !== "undefined" ? window.location.href : ""}
|
<span className="font-medium">Page:</span> {typeof window !== "undefined" ? window.location.href : ""}
|
||||||
</p>
|
</p>
|
||||||
</div>
|
</div>
|
||||||
@@ -172,7 +172,7 @@ export function BugReportModal({ open, onClose }: BugReportModalProps) {
|
|||||||
<button
|
<button
|
||||||
type="submit"
|
type="submit"
|
||||||
disabled={loading}
|
disabled={loading}
|
||||||
className="flex w-full items-center justify-center gap-2 rounded-lg bg-[#CC0000] px-4 py-2.5 text-sm font-medium text-white hover:bg-[#AA0000] disabled:opacity-50 transition-colors"
|
className="flex w-full items-center justify-center gap-2 rounded-lg bg-[#C84B4B] px-4 py-2.5 text-sm font-medium text-white hover:bg-[#C84B4B]/80 disabled:opacity-50 transition-colors"
|
||||||
>
|
>
|
||||||
{loading && <Loader2 className="h-4 w-4 animate-spin" />}
|
{loading && <Loader2 className="h-4 w-4 animate-spin" />}
|
||||||
{loading ? "Submitting..." : "Submit Bug Report"}
|
{loading ? "Submitting..." : "Submit Bug Report"}
|
||||||
|
|||||||
@@ -19,8 +19,8 @@ export function PageHeader({ title, description, children, className }: PageHead
|
|||||||
className={cn("flex items-center justify-between pb-6", className)}
|
className={cn("flex items-center justify-between pb-6", className)}
|
||||||
>
|
>
|
||||||
<div>
|
<div>
|
||||||
<h1 className="text-2xl font-bold tracking-tight border-l-4 border-[#CC0000] dark:border-[#FF1111] pl-4 text-[#111111] dark:text-white">{title}</h1>
|
<h1 className="text-2xl font-bold tracking-tight border-l-4 border-[#C84B4B] dark:border-primary pl-4 text-[#2D3020] dark:text-white">{title}</h1>
|
||||||
{description && <p className="text-sm text-[#444444] dark:text-[#AAAAAA] mt-1">{description}</p>}
|
{description && <p className="text-sm text-[#8A9078] dark:text-[#AAAAAA] mt-1">{description}</p>}
|
||||||
</div>
|
</div>
|
||||||
{children && <div className="flex items-center gap-3">{children}</div>}
|
{children && <div className="flex items-center gap-3">{children}</div>}
|
||||||
</motion.div>
|
</motion.div>
|
||||||
|
|||||||
@@ -30,11 +30,11 @@ export function getDashboardStats(period: string): DashboardStats {
|
|||||||
})
|
})
|
||||||
|
|
||||||
return [
|
return [
|
||||||
{ name: "Open", value: statusCounts.open, color: "#CC0000" },
|
{ name: "Open", value: statusCounts.open, color: "#C84B4B" },
|
||||||
{ name: "Contacted", value: statusCounts.contacted, color: "#0033CC" },
|
{ name: "Contacted", value: statusCounts.contacted, color: "#5A8FC4" },
|
||||||
{ name: "Pending", value: statusCounts.pending, color: "#FFFFFF" },
|
{ name: "Pending", value: statusCounts.pending, color: "#FFFFFF" },
|
||||||
{ name: "Closed", value: statusCounts.closed, color: "#0033CC" },
|
{ name: "Closed", value: statusCounts.closed, color: "#5A8FC4" },
|
||||||
{ name: "Ignored", value: statusCounts.ignored, color: "#CC0000" },
|
{ name: "Ignored", value: statusCounts.ignored, color: "#C84B4B" },
|
||||||
]
|
]
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
+1
-1
@@ -74,7 +74,7 @@ function buildEmail(event: EventConfirmationInput, variant: "scheduled" | "resch
|
|||||||
<html><head><meta charset="utf-8"></head>
|
<html><head><meta charset="utf-8"></head>
|
||||||
<body style="font-family:Arial,Helvetica,sans-serif;background:#f4f4f5;padding:40px 20px">
|
<body style="font-family:Arial,Helvetica,sans-serif;background:#f4f4f5;padding:40px 20px">
|
||||||
<div style="max-width:520px;margin:0 auto;background:#fff;border-radius:12px;overflow:hidden;box-shadow:0 2px 12px rgba(0,0,0,0.08)">
|
<div style="max-width:520px;margin:0 auto;background:#fff;border-radius:12px;overflow:hidden;box-shadow:0 2px 12px rgba(0,0,0,0.08)">
|
||||||
<div style="background:linear-gradient(135deg,#CC0000,#0033CC);padding:24px 32px">
|
<div style="background:linear-gradient(135deg,#C84B4B,#5A8FC4);padding:24px 32px">
|
||||||
<h1 style="color:#fff;margin:0;font-size:20px">${heading}</h1>
|
<h1 style="color:#fff;margin:0;font-size:20px">${heading}</h1>
|
||||||
</div>
|
</div>
|
||||||
<div style="padding:32px">
|
<div style="padding:32px">
|
||||||
|
|||||||
@@ -5,7 +5,6 @@ import { createContext, useContext, useState, useEffect, ReactNode, useCallback
|
|||||||
const WEBSITE_THEME_KEY = "crm-website-theme"
|
const WEBSITE_THEME_KEY = "crm-website-theme"
|
||||||
|
|
||||||
const themeClasses: Record<string, string> = {
|
const themeClasses: Record<string, string> = {
|
||||||
default: "theme-default",
|
|
||||||
spidey: "theme-spidey",
|
spidey: "theme-spidey",
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -43,13 +42,6 @@ export function WebsiteThemeProvider({ children }: { children: ReactNode }) {
|
|||||||
const [loading, setLoading] = useState(true)
|
const [loading, setLoading] = useState(true)
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
const stored = getStoredTheme()
|
|
||||||
if (stored) {
|
|
||||||
setWebsiteThemeState(stored)
|
|
||||||
applyWebsiteTheme(stored)
|
|
||||||
setLoading(false)
|
|
||||||
return
|
|
||||||
}
|
|
||||||
fetch("/api/settings/website-theme")
|
fetch("/api/settings/website-theme")
|
||||||
.then((res) => (res.ok ? res.json() : null))
|
.then((res) => (res.ok ? res.json() : null))
|
||||||
.then((data) => {
|
.then((data) => {
|
||||||
@@ -59,7 +51,10 @@ export function WebsiteThemeProvider({ children }: { children: ReactNode }) {
|
|||||||
applyWebsiteTheme(theme)
|
applyWebsiteTheme(theme)
|
||||||
})
|
})
|
||||||
.catch(() => {
|
.catch(() => {
|
||||||
applyWebsiteTheme("default")
|
const stored = getStoredTheme()
|
||||||
|
const theme = stored || "default"
|
||||||
|
setWebsiteThemeState(theme)
|
||||||
|
applyWebsiteTheme(theme)
|
||||||
})
|
})
|
||||||
.finally(() => setLoading(false))
|
.finally(() => setLoading(false))
|
||||||
}, [])
|
}, [])
|
||||||
|
|||||||
@@ -53,6 +53,12 @@ const config: Config = {
|
|||||||
border: "hsl(var(--sidebar-border))",
|
border: "hsl(var(--sidebar-border))",
|
||||||
ring: "hsl(var(--sidebar-ring))",
|
ring: "hsl(var(--sidebar-ring))",
|
||||||
},
|
},
|
||||||
|
blue: "hsl(var(--color-blue))",
|
||||||
|
teal: "hsl(var(--color-teal))",
|
||||||
|
"red-tint": "hsl(var(--color-red-tint))",
|
||||||
|
"blue-tint": "hsl(var(--color-blue-tint))",
|
||||||
|
"teal-tint": "hsl(var(--color-teal-tint))",
|
||||||
|
avatar: "hsl(var(--color-avatar))",
|
||||||
},
|
},
|
||||||
borderRadius: {
|
borderRadius: {
|
||||||
lg: "var(--radius)",
|
lg: "var(--radius)",
|
||||||
|
|||||||
Reference in New Issue
Block a user