Getting the 10 syncs and adding my AI that works but has issues

This commit is contained in:
Ace
2026-07-01 12:16:22 +02:00
33 changed files with 838 additions and 481 deletions
+25 -6
View File
@@ -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 {
+31 -31
View File
@@ -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>
+11 -8
View File
@@ -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>
) )
} }
+57
View File
@@ -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 })
}
}
+6
View File
@@ -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)
+3 -3
View File
@@ -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
View File
@@ -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 })
} }
} }
-3
View File
@@ -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
+40 -40
View File
@@ -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
View File
@@ -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 {
+12 -12
View File
@@ -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&apos;re Invited! You&apos;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
View File
@@ -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 />
+5
View File
@@ -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
View File
@@ -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>
+193
View File
@@ -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>
)
}
+32 -46
View File
@@ -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} &mdash; {job.description}</div> <div className="text-xs text-[#4b5563] mt-0.5">{job.industry} &mdash; {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>
) )
+18 -2
View File
@@ -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">
+34 -34
View File
@@ -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>
+27 -23
View File
@@ -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>
+46 -40
View File
@@ -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}
+3 -3
View File
@@ -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" />
+1 -1
View File
@@ -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">
+7 -7
View File
@@ -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>
+5 -5
View File
@@ -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)
} }
+19 -19
View File
@@ -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"}
+2 -2
View File
@@ -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>
+4 -4
View File
@@ -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
View File
@@ -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">
+4 -9
View File
@@ -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))
}, []) }, [])
+6
View File
@@ -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)",