Compare commits

...

11 Commits

31 changed files with 276 additions and 247 deletions
-64
View File
@@ -1,64 +0,0 @@
/* ============================================================================
Starforce Theme — Dark cosmic with crimson accents and neon green glow
Captures the current starfield + drift + glow effects used live.
============================================================================ */
.theme-starforce {
--background: 0 0% 10%;
--foreground: 210 40% 98%;
--card: 0 0% 9%;
--card-foreground: 210 40% 98%;
--popover: 222.2 84% 11%;
--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-foreground: 210 40% 98%;
--muted: 217.2 32.6% 17.5%;
--muted-foreground: 215 20.2% 65.1%;
--accent: 217.2 32.6% 17.5%;
--accent-foreground: 210 40% 98%;
--destructive: 0 62.8% 30.6%;
--destructive-foreground: 210 40% 98%;
--border: 217.2 32.6% 17.5%;
--input: 217.2 32.6% 17.5%;
--ring: 0 100% 53%;
--radius: 0.5rem;
--sidebar: 0 0% 11%;
--sidebar-foreground: 210 40% 98%;
--sidebar-primary: 145 65% 50%;
--sidebar-primary-foreground: 0 0% 100%;
--sidebar-accent: 217.2 32.6% 17.5%;
--sidebar-accent-foreground: 210 40% 98%;
--sidebar-border: 217.2 32.6% 17.5%;
--sidebar-ring: 0 100% 53%;
}
.theme-starforce body::before {
content: "";
position: fixed;
inset: 0;
z-index: -1;
pointer-events: none;
background-image:
radial-gradient(1px 1px at 10% 20%, rgba(100,100,120,0.4) 0%, transparent 100%),
radial-gradient(1px 1px at 20% 40%, rgba(100,100,120,0.3) 0%, transparent 100%),
radial-gradient(1.5px 1.5px at 30% 10%, rgba(80,80,100,0.5) 0%, transparent 100%),
radial-gradient(1px 1px at 40% 60%, rgba(100,100,120,0.3) 0%, transparent 100%),
radial-gradient(1px 1px at 50% 80%, rgba(90,90,110,0.4) 0%, transparent 100%),
radial-gradient(1.5px 1.5px at 60% 30%, rgba(80,80,100,0.5) 0%, transparent 100%),
radial-gradient(1px 1px at 70% 50%, rgba(100,100,120,0.3) 0%, transparent 100%),
radial-gradient(1px 1px at 80% 90%, rgba(90,90,110,0.4) 0%, transparent 100%),
radial-gradient(1.5px 1.5px at 90% 15%, rgba(80,80,100,0.5) 0%, transparent 100%),
radial-gradient(1px 1px at 15% 70%, rgba(100,100,120,0.3) 0%, transparent 100%),
radial-gradient(1px 1px at 25% 90%, rgba(90,90,110,0.4) 0%, transparent 100%),
radial-gradient(1.5px 1.5px at 35% 35%, rgba(80,80,100,0.5) 0%, transparent 100%),
radial-gradient(1px 1px at 45% 15%, rgba(100,100,120,0.3) 0%, transparent 100%),
radial-gradient(1px 1px at 55% 75%, rgba(90,90,110,0.4) 0%, transparent 100%),
radial-gradient(1.5px 1.5px at 65% 55%, rgba(80,80,100,0.5) 0%, transparent 100%),
radial-gradient(1px 1px at 75% 25%, rgba(100,100,120,0.3) 0%, transparent 100%),
radial-gradient(1px 1px at 85% 65%, rgba(90,90,110,0.4) 0%, transparent 100%),
radial-gradient(1.5px 1.5px at 95% 45%, rgba(80,80,100,0.5) 0%, transparent 100%);
background-size: 200% 200%;
animation: drift 60s linear infinite;
}
-59
View File
@@ -1,59 +0,0 @@
{
"theme": {
"id": "starforce",
"name": "Starforce",
"description": "Dark cosmic theme with crimson accents, starfield background, and neon green glow effects",
"type": "dark",
"default": true
},
"colors": {
"background": "hsl(0, 0%, 10%)",
"foreground": "hsl(210, 40%, 98%)",
"card": "hsl(0, 0%, 9%)",
"cardForeground": "hsl(210, 40%, 98%)",
"popover": "hsl(222.2, 84%, 11%)",
"popoverForeground": "hsl(210, 40%, 98%)",
"primary": "hsl(0, 100%, 53%)",
"primaryForeground": "hsl(222.2, 47.4%, 11.2%)",
"secondary": "hsl(217.2, 32.6%, 17.5%)",
"secondaryForeground": "hsl(210, 40%, 98%)",
"muted": "hsl(217.2, 32.6%, 17.5%)",
"mutedForeground": "hsl(215, 20.2%, 65.1%)",
"accent": "hsl(217.2, 32.6%, 17.5%)",
"accentForeground": "hsl(210, 40%, 98%)",
"destructive": "hsl(0, 62.8%, 30.6%)",
"destructiveForeground": "hsl(210, 40%, 98%)",
"border": "hsl(217.2, 32.6%, 17.5%)",
"input": "hsl(217.2, 32.6%, 17.5%)",
"ring": "hsl(0, 100%, 53%)",
"sidebar": "hsl(0, 0%, 11%)",
"sidebarForeground": "hsl(210, 40%, 98%)",
"sidebarPrimary": "hsl(145, 65%, 50%)",
"sidebarPrimaryForeground": "hsl(0, 0%, 100%)",
"sidebarAccent": "hsl(217.2, 32.6%, 17.5%)",
"sidebarAccentForeground": "hsl(210, 40%, 98%)",
"sidebarBorder": "hsl(217.2, 32.6%, 17.5%)",
"sidebarRing": "hsl(0, 100%, 53%)"
},
"borderRadius": "0.5rem",
"typography": {
"fontFamily": "Inter, sans-serif",
"headingFont": "Inter, sans-serif"
},
"keyColors": {
"primary": "#FF0000",
"background": "#1a1a1a",
"card": "#171717",
"sidebar": "#1c1c1c",
"border": "#2a2a35",
"text": "#e8e8ef",
"mutedText": "#888888",
"glow": "#39ff14"
},
"effects": {
"starfield": true,
"starTwinkle": true,
"starDrift": true,
"textGlow": "#39ff14"
}
}
+107 -47
View File
@@ -513,26 +513,40 @@ def is_offer(text: str) -> bool:
# Each run picks 2-4 random queries to vary behavior and reduce detection.
FB_SEARCHES = [
"looking for web developer",
"need a website designed",
"need website built South Africa",
"need someone to build my website",
"need web designer",
"looking for someone to create website",
"need ecommerce website built",
"want to hire web developer",
"need wordpress website",
"I need a website for my business",
"need a site for my business",
"looking for website designer South Africa",
"need website for my small business",
"who can build me a website",
"want to create a website for my business",
"looking for affordable web design",
"need a web developer South Africa",
"recommend a web developer",
"I need a website for my",
"need a site for my business",
"looking for web designer",
"help me build a website",
"need someone to design my website",
"looking for web designer near me",
"need a website for my startup",
"anyone know a web developer",
"need a web developer for my project",
"need website for my startup",
"want a website for my company",
"need a new website for my",
"looking for web developer for my business",
"need ecommerce website for my",
"looking for affordable web design",
"need wordpress website",
"who can design a website for my",
"looking for someone to create a website",
"recommendations for a web designer",
"I need a website",
"I need a website built",
"need wordpress site",
"need a custom website",
"I need a new website",
"need an online store",
"need an online shop",
"need website help",
"who can help me with my website",
"looking for a website designer",
"need a shopify website",
"looking for website design",
"want to build a website for my",
]
VIEWPORTS = [
@@ -1360,6 +1374,8 @@ async def ask_ollama(prompt: str) -> str:
async def classify_leads(results: list[dict]) -> list[dict]:
if not results:
return []
# ── 1. AI classification ─────────────────────────────────────────
briefs = [r["title"][:200] for r in results]
prompt = f"""Classify each post as LEAD or NOT.
LEAD = someone REQUESTING/POSTING/WANTING a website built, designed, or created for them.
@@ -1375,7 +1391,7 @@ NOT LEAD:
For each numbered post, answer ONLY "yes" (LEAD) or "no" (NOT LEAD):
{chr(10).join(f'{i+1}. {t}' for i, t in enumerate(briefs))}
Return a JSON array like ["yes","no","yes"] matching the order above."""
ai_succeeded = False
ai_leads: list[dict] = []
try:
raw = await ask_ollama(prompt)
raw = raw.strip()
@@ -1388,43 +1404,87 @@ Return a JSON array like ["yes","no","yes"] matching the order above."""
raw = raw.strip()
answers = json.loads(raw)
if isinstance(answers, list) and len(answers) == len(results):
ai_succeeded = True
filtered = []
for i, ans in enumerate(answers):
if isinstance(ans, str) and ans.lower() == 'yes':
filtered.append(results[i])
if filtered:
return filtered[:10]
logger.info("AI classified all %d items as NOT LEAD — returning empty", len(results))
return []
ai_leads.append(results[i])
logger.info("AI classified %d/%d as LEAD", len(ai_leads), len(results))
except Exception as e:
logger.warning("AI classification failed, falling back to keyword filter: %s", e)
logger.warning("AI classification failed: %s", e)
if not ai_succeeded:
strict_keywords = [
"website", "web design", "web develop", "web dev",
"build my website", "build a website", "create a website",
"need web", "looking for web", "new website",
"landing page", "wordpress",
"need a website", "my website", "business website",
"need a designer", "help with my website",
"redesign", "update my website",
"looking for", "need a", "need an", "looking to",
"need someone", "hire a", "want someone",
"need help with", "would like", "build me",
"design my", "make me a", "create my",
]
filtered = []
# ── 2. Keyword fallback (always runs) ────────────────────────────
web_terms = [
"website", "web design", "web develop", "web dev",
"web designer", "web developer",
"build my website", "build a website", "create a website",
"landing page", "wordpress", "ecommerce",
"my website", "business website",
"site for my", "site for my business",
"new website", "redesign my website",
"help with my website", "update my website",
"make a website", "make my website",
"website for my",
"online store", "online shop",
"build my site", "build a site",
"set up a website", "set up my website",
"custom website",
"shopify",
"my site",
"webpage", "web page",
]
request_terms = [
"looking for", "need a", "need an", "looking to",
"need someone", "hire a", "want someone",
"need help with", "would like", "build me",
"design my", "make me a", "create my",
"looking", "need", "want", "help",
"who can", "i need",
"recommend", "anyone know", "anyone recommend",
"know a", "know any", "recommendation",
"suggest", "looking for recommendations",
"can anyone", "does anyone",
"dm me", "pm me", "message me",
"quote for",
"can you help",
"how much",
"price for",
]
keyword_leads: list[dict] = []
for r in results:
t = r['title'].lower()
has_web = any(kw in t for kw in web_terms)
has_request = any(kw in t for kw in request_terms)
if not has_web or not has_request:
continue
if any(kw in t for kw in ['i build website', 'i offer web', 'i am a web developer',
'affordable web design package', 'web hosting',
'i do web design', 'i develop websites']):
continue
keyword_leads.append(r)
# ── 3. Merge: prefer AI leads, supplement with keywords to reach 5 ──
seen_titles: set[int] = set()
merged: list[dict] = []
for r in ai_leads + keyword_leads:
key = hash(r.get('title', ''))
if key not in seen_titles:
seen_titles.add(key)
merged.append(r)
# Fill to 5 with loose keyword matches (at least web OR request term)
if len(merged) < 5:
for r in results:
key = hash(r.get('title', ''))
if key in seen_titles:
continue
t = r['title'].lower()
if not any(kw in t for kw in strict_keywords):
continue
if any(kw in t for kw in ['i build website', 'i offer web', 'i am a web developer',
'affordable web design package', 'web hosting']):
continue
filtered.append(r)
return filtered[:10]
return []
if any(kw in t for kw in web_terms) or any(kw in t for kw in request_terms):
seen_titles.add(key)
merged.append(r)
if len(merged) >= 5:
break
logger.info("classify_leads: %d merged (%d AI + %d keyword) from %d raw", len(merged), len(ai_leads), len(keyword_leads), len(results))
return merged[:10]
# ══════════════════════════════════════════════════════════════════════
# FastAPI Endpoints
+3
View File
@@ -43,6 +43,9 @@ BEGIN;
\echo '=== Running 011_calendar_events.sql (Calendar Events) ==='
\i 011_calendar_events.sql
\echo '=== Running 012_invites.sql (Invites) ==='
\i 012_invites.sql
\echo '=== Running 012_sent_emails.sql (Sent Email Log) ==='
\i 012_sent_emails.sql
+2 -16
View File
@@ -24,12 +24,8 @@ import {
SelectValue,
} from "@/components/ui/select"
import { DashboardStats } from "@/types"
import { CrossedLightsabers } from "@/components/dashboard/crossed-lightsabers"
import { StarField } from "@/components/dashboard/star-field"
import { useWebsiteTheme } from "@/providers/website-theme-provider"
export default function DashboardPage() {
const { websiteTheme: theme } = useWebsiteTheme()
const [period, setPeriod] = useState("6months")
const [stats, setStats] = useState<DashboardStats | null>(null)
const pollingRef = useRef<NodeJS.Timeout | null>(null)
@@ -67,22 +63,12 @@ export default function DashboardPage() {
return (
<div className="space-y-6 relative">
{theme === "starforce" && <StarField />}
<div className="relative z-[1] space-y-6">
<div>
<PageHeader
title={<span style={{fontFamily:"'Audiowide',sans-serif"}}>Dashboard</span>}
title={<span style={{fontFamily:"'Bangers',cursive",letterSpacing:"0.05em"}}>Dashboard</span>}
description="Overview of your sales pipeline"
>
{theme === "starforce" && (
<span
className="pointer-events-none select-none text-5xl text-gray-600 dark:text-[#b0f0c0] tracking-wider font-bold text-glow whitespace-nowrap"
style={{ fontFamily: "'Cormorant Garamond', serif" }}
>
Trust the Force, Find your Path
</span>
)}
{theme === "starforce" && <CrossedLightsabers />}
<Select value={period} onValueChange={setPeriod}>
<SelectTrigger className="h-9 w-[160px]">
<ListFilter className="mr-2 h-4 w-4" />
@@ -119,7 +105,7 @@ export default function DashboardPage() {
<RecentLeadsTable leads={stats?.recentLeads ?? []} />
</div>
{/* Daily Bugle watermark */}
<div className="absolute top-12 right-4 pointer-events-none select-none z-0 opacity-[0.04] dark:opacity-[0.06]">
<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">
DAILY BUGLE
</span>
+2
View File
@@ -9,6 +9,7 @@ import {
resetFailedAttempts,
isAccountLocked,
createSession,
setSessionContext,
} from "@/lib/auth"
export async function POST(request: NextRequest) {
@@ -106,6 +107,7 @@ export async function POST(request: NextRequest) {
)
await createSession(dbUser.id, dbUser.role_name)
await setSessionContext(dbUser.id, ipAddress)
const user = mapDbUserToSessionUser(dbUser)
+8 -8
View File
@@ -80,8 +80,8 @@ function countStatuses(leads: any[]) {
return counts
}
function buildMonthlyBreakdown(leads: any[], period: string) {
const { start, end } = getPeriodDateRange(period)
function buildMonthlyBreakdown(leads: any[], period: string, rangeOverride?: { start: Date; end: Date }) {
const { start, end } = rangeOverride || getPeriodDateRange(period)
const result: { label: string; total: number; open: number; contacted: number; pending: number; closed: number; ignored: number }[] = []
const current = new Date(start)
const isMonthly = period === "6months" || period === "12months"
@@ -170,7 +170,7 @@ export async function GET(request: NextRequest) {
updatedAt: r.updated_at,
}))
const monthlyBreakdown = buildMonthlyBreakdown(currentLeads, period)
const monthlyBreakdown = buildMonthlyBreakdown(currentLeads, period, yearParam ? { start, end } : undefined)
const trends = {
totalLeads: computeTrend(currentCounts.open + currentCounts.contacted + currentCounts.pending + currentCounts.closed + currentCounts.ignored,
@@ -196,11 +196,11 @@ export async function GET(request: NextRequest) {
trends,
recentLeads: mappedLeads.slice(0, 10),
statusDistribution: [
{ name: "Open", value: currentCounts.open, color: "#3b82f6" },
{ name: "Contacted", value: currentCounts.contacted, color: "#f59e0b" },
{ name: "Pending", value: currentCounts.pending, color: "#8b5cf6" },
{ name: "Closed", value: currentCounts.closed, color: "#10b981" },
{ name: "Ignored", value: currentCounts.ignored, color: "#6B7280" },
{ name: "Open", value: currentCounts.open, color: "#FFFFFF" },
{ name: "Contacted", value: currentCounts.contacted, color: "#dc2626" },
{ name: "Pending", value: currentCounts.pending, color: "#1e3a8a" },
{ name: "Closed", value: currentCounts.closed, color: "#60a5fa" },
{ name: "Ignored", value: currentCounts.ignored, color: "#000000" },
],
periodLabel: periodLabels[period] ?? "Selected period",
}
+18 -2
View File
@@ -46,10 +46,11 @@ export async function PATCH(request: NextRequest) {
const body = await request.json()
await query(
const result = await query(
`UPDATE company_settings SET
company_name = $1, company_email = $2, company_phone = $3,
company_website = $4, company_address = $5, updated_by = $6, updated_at = NOW()`,
company_website = $4, company_address = $5, updated_by = $6, updated_at = NOW()
WHERE id = (SELECT id FROM company_settings ORDER BY updated_at DESC LIMIT 1)`,
[
body.companyName || "",
body.companyEmail || "",
@@ -60,6 +61,21 @@ export async function PATCH(request: NextRequest) {
],
)
if (result.rowCount === 0) {
await query(
`INSERT INTO company_settings (company_name, company_email, company_phone, company_website, company_address, updated_by)
VALUES ($1, $2, $3, $4, $5, $6)`,
[
body.companyName || "",
body.companyEmail || "",
body.companyPhone || "",
body.companyWebsite || "",
body.companyAddress || "",
user.id,
],
)
}
return NextResponse.json({ success: true })
} catch (error) {
console.error("Company settings PATCH error:", error)
+2 -2
View File
@@ -12,7 +12,7 @@ export async function GET() {
[user.id],
)
const websiteTheme = result.rows[0]?.website_theme || "starforce"
const websiteTheme = result.rows[0]?.website_theme || "spidey"
return NextResponse.json({ websiteTheme })
} catch (error) {
console.error("Website theme GET error:", error)
@@ -26,7 +26,7 @@ export async function PUT(request: NextRequest) {
if (!user) return NextResponse.json({ error: "Unauthorized" }, { status: 401 })
const body = await request.json()
const theme = body.websiteTheme || "starforce"
const theme = body.websiteTheme || "spidey"
await query(
`UPDATE users SET preferences = preferences || $2::jsonb WHERE id = $1`,
-3
View File
@@ -8,9 +8,6 @@ let prevTime = Date.now()
export async function GET() {
const sessionUser = await getSessionUser()
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 elapsed = now - prevTime
+7 -4
View File
@@ -1,6 +1,6 @@
import { NextRequest, NextResponse } from "next/server"
import { query } from "@/lib/db"
import { hashPassword, getSessionUser } from "@/lib/auth"
import { hashPassword, getSessionUser, encryptPassword, setSessionContext } from "@/lib/auth"
import { avatarSvgUrl } from "@/lib/avatar"
export async function GET(request: NextRequest) {
@@ -68,12 +68,15 @@ export async function POST(request: NextRequest) {
const lastName = nameParts.slice(1).join(" ") || firstName
const username = email.split("@")[0]
const passwordHash = await hashPassword(password)
const passwordEncrypted = await encryptPassword(password)
await setSessionContext(sessionUser.id)
const result = await query(
`INSERT INTO users (username, email, password_hash, first_name, last_name, is_active, created_by)
VALUES ($1, $2, $3, $4, $5, $6, $7)
`INSERT INTO users (username, email, password_hash, password_encrypted, first_name, last_name, is_active, created_by)
VALUES ($1, $2, $3, $4, $5, $6, $7, $8)
RETURNING id`,
[username.toLowerCase(), email.toLowerCase(), passwordHash, firstName, lastName, active ?? true, sessionUser.id]
[username.toLowerCase(), email.toLowerCase(), passwordHash, passwordEncrypted, firstName, lastName, active ?? true, sessionUser.id]
)
const roleId = (
-1
View File
@@ -5,7 +5,6 @@ import { WebsiteThemeProvider } from "@/providers/website-theme-provider"
import { Toaster } from "@/components/ui/sonner"
import "./globals.css"
import "../../Web_Backgrounds/spidey-theme.css"
import "../../Web_Backgrounds/starforce-theme.css"
const inter = Inter({
variable: "--font-inter",
+1 -1
View File
@@ -231,7 +231,7 @@ export default function LoginPage() {
<div className="left-panel flex flex-1 flex-col p-12">
<div className="relative z-10 flex-1 flex items-center justify-center">
<div className="text-center">
<div className="bc-logo mb-8" style={{"--theme-primary":"#1BB0CE"}}>
<div className="bc-logo mb-8" style={{"--theme-primary":"#1BB0CE"} as React.CSSProperties}>
Black <span className="accent">Cipher</span>
</div>
<h1 className="text-[34px] font-extrabold text-[#e8e8ef] leading-tight tracking-[-0.6px]">
@@ -98,6 +98,8 @@ export function LeadStatusChart({ data }: LeadStatusChartProps) {
key={i}
d={arcPath(160, 160, oR, iR, s.start, s.end)}
fill={`url(#chart-grad-${i})`}
stroke="#d4d4d4"
strokeWidth="1"
opacity={hov !== null && !isH ? 0.3 : 1}
style={{
cursor: "pointer",
@@ -9,11 +9,11 @@ import { Lead } from "@/types"
import { ArrowRight } from "lucide-react"
const statusStyles: Record<string, string> = {
open: "bg-blue-500/10 text-blue-600 dark:text-blue-400 border-blue-500/20",
contacted: "bg-amber-500/10 text-amber-600 dark:text-amber-400 border-amber-500/20",
pending: "bg-purple-500/10 text-purple-600 dark:text-purple-400 border-purple-500/20",
closed: "bg-emerald-500/10 text-emerald-600 dark:text-emerald-400 border-emerald-500/20",
ignored: "bg-zinc-500/10 text-zinc-600 dark:text-zinc-400 border-zinc-500/20",
open: "bg-white/40 text-gray-700 dark:text-gray-300 border-gray-300/50 dark:border-gray-600/50",
contacted: "bg-red-500/10 text-red-600 dark:text-red-400 border-red-500/20",
pending: "bg-blue-900/10 text-blue-900 dark:text-blue-300 border-blue-900/20",
closed: "bg-blue-400/10 text-blue-600 dark:text-blue-300 border-blue-400/20",
ignored: "bg-black/10 text-black dark:text-gray-300 border-black/20 dark:border-black/40",
}
interface RecentLeadsTableProps {
@@ -1,6 +1,26 @@
"use client"
import { useWebsiteTheme } from "@/providers/website-theme-provider"
export function StatCardSkeleton() {
const { websiteTheme } = useWebsiteTheme()
if (websiteTheme === "spidey") {
return (
<div className="col-span-full flex flex-col items-center justify-center py-20">
<p className="text-[#CC0000] dark:text-[#FF1111] text-5xl font-['Bangers',cursive] animate-pulse">
THWIP!
</p>
<p className="text-[#444444] dark:text-[#AAAAAA] text-sm mt-2">
Loading your data...
</p>
<div className="w-48 h-1 rounded-full overflow-hidden bg-[#E0E0E0] dark:bg-[#222222] mt-4">
<div className="w-full h-full rounded-full bg-gradient-to-r from-[#CC0000] via-[#FFFFFF] to-[#0033CC] animate-[loading_1.5s_ease-in-out_infinite]" />
</div>
</div>
)
}
return (
<div className="col-span-full flex flex-col items-center justify-center py-20">
<p className="text-[#444444] dark:text-[#AAAAAA] text-sm">
+11 -1
View File
@@ -122,7 +122,17 @@ export function StatCard({ title, value, icon: Icon, description, index = 0, tre
{/* Red/Blue gradient top strip */}
<div className={`h-1 w-full bg-gradient-to-r ${stripColor} ${stripGlow} shadow-sm`} />
<CardContent className="p-6 flex flex-col">
{/* Action lines on hover */}
<div
className="absolute inset-0 pointer-events-none opacity-0 group-hover:opacity-100 transition-opacity duration-300 z-0"
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")`,
backgroundSize: "cover",
backgroundPosition: "center",
}}
/>
<CardContent className="p-6 flex flex-col relative z-[1]">
<div className="flex items-center justify-between">
<div className="space-y-1">
<p className="text-sm font-medium text-muted-foreground">{title}</p>
+2 -2
View File
@@ -44,7 +44,7 @@ export function AppShell({ children }: AppShellProps) {
<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 */}
<div className="hidden lg:block fixed top-0 left-0 pointer-events-none z-0 opacity-[0.07] dark:opacity-[0.12]">
<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">
<line x1="0" y1="0" x2="180" y2="0" stroke="#CC0000" strokeWidth="0.8"/>
<line x1="0" y1="0" x2="180" y2="60" stroke="#CC0000" strokeWidth="0.8"/>
@@ -61,7 +61,7 @@ export function AppShell({ children }: AppShellProps) {
<path d="M0,180 Q180,180 180,0" stroke="#CC0000" strokeWidth="0.8" fill="none"/>
</svg>
</div>
<div className="hidden lg:block fixed top-0 right-0 pointer-events-none z-0 opacity-[0.07] dark:opacity-[0.12]">
<div className="hidden lg:block fixed top-0 right-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" style={{ transform: "scaleX(-1)" }}>
<line x1="0" y1="0" x2="180" y2="0" stroke="#CC0000" strokeWidth="0.8"/>
<line x1="0" y1="0" x2="180" y2="60" stroke="#CC0000" strokeWidth="0.8"/>
+4 -4
View File
@@ -95,7 +95,7 @@ export function Sidebar({ collapsed, onToggle, mobileOpen, onMobileClose }: Side
"relative flex h-10 w-10 items-center justify-center rounded-lg transition-colors",
isActive
? "bg-sidebar-primary text-sidebar-primary-foreground"
: "text-sidebar-foreground/60 hover:bg-sidebar-accent hover:text-sidebar-accent-foreground"
: "text-sidebar-foreground/60 hover:bg-sidebar-accent hover:text-white"
)}
>
<item.icon className="h-5 w-5" />
@@ -104,7 +104,7 @@ export function Sidebar({ collapsed, onToggle, mobileOpen, onMobileClose }: Side
)}
</Link>
</TooltipTrigger>
<TooltipContent side="right" className="ml-2">
<TooltipContent side="right" className="ml-2 text-white">
{item.label}
</TooltipContent>
</Tooltip>
@@ -143,12 +143,12 @@ export function Sidebar({ collapsed, onToggle, mobileOpen, onMobileClose }: Side
<TooltipTrigger asChild>
<button
onClick={() => setFbDialogOpen(true)}
className="relative flex h-10 w-10 items-center justify-center rounded-lg transition-colors text-sidebar-foreground/60 hover:bg-sidebar-accent hover:text-sidebar-accent-foreground"
className="relative flex h-10 w-10 items-center justify-center rounded-lg transition-colors text-sidebar-foreground/60 hover:bg-sidebar-accent hover:text-white"
>
<Facebook className="h-5 w-5" />
</button>
</TooltipTrigger>
<TooltipContent side="right" className="ml-2">
<TooltipContent side="right" className="ml-2 text-white">
Facebook Accounts
</TooltipContent>
</Tooltip>
+1 -1
View File
@@ -36,7 +36,7 @@ export function SystemMonitor({ collapsed }: SystemMonitorProps) {
if (collapsed) {
return (
<div className="border-t border-sidebar-border px-3 py-2 flex justify-center gap-1.5">
<div className="border-t border-sidebar-border px-1 py-1.5 flex flex-col items-center text-[10px] leading-tight">
<span className={ramOver ? "text-red-400" : "text-[var(--theme-primary,#3b91f7)]"}>
{rssMB}
</span>
+36 -3
View File
@@ -32,8 +32,6 @@ import {
CheckCheck,
Trash2,
} from "lucide-react";
import { CrmIcon } from "./crm-icon";
import { CaptainAmericaShield } from "@/components/shared/captain-america-shield";
import { useWebsiteTheme } from "@/providers/website-theme-provider";
interface TopbarProps {
@@ -77,7 +75,36 @@ export function Topbar({ onMenuClick }: TopbarProps) {
<header className="sticky top-0 z-20 flex h-16 items-center gap-4 border-b bg-card dark:bg-[#141414] px-4 lg:px-6 relative overflow-hidden">
{/* Logo */}
<div className="flex items-center gap-2 mr-2 shrink-0">
{websiteTheme === "spidey" ? <CaptainAmericaShield /> : <CrmIcon />}
{websiteTheme === "spidey" && (
<>
<svg width="28" height="28" viewBox="0 0 28 28" fill="none" className="dark:hidden shrink-0">
<circle cx="14" cy="14" r="14" fill="white"/>
<ellipse cx="14" cy="14" rx="4" ry="5" fill="#CC0000"/>
<ellipse cx="14" cy="14" rx="2" ry="2.5" fill="#CC0000"/>
<line x1="14" y1="9" x2="6" y2="5" stroke="#CC0000" strokeWidth="1.2"/>
<line x1="14" y1="9" x2="22" y2="5" stroke="#CC0000" strokeWidth="1.2"/>
<line x1="14" y1="12" x2="5" y2="11" stroke="#CC0000" strokeWidth="1.2"/>
<line x1="14" y1="12" x2="23" y2="11" stroke="#CC0000" strokeWidth="1.2"/>
<line x1="14" y1="19" x2="6" y2="23" stroke="#CC0000" strokeWidth="1.2"/>
<line x1="14" y1="19" x2="22" y2="23" stroke="#CC0000" strokeWidth="1.2"/>
<line x1="14" y1="16" x2="5" y2="17" stroke="#CC0000" strokeWidth="1.2"/>
<line x1="14" y1="16" x2="23" y2="17" stroke="#CC0000" strokeWidth="1.2"/>
</svg>
<svg width="28" height="28" viewBox="0 0 28 28" fill="none" className="hidden dark:block shrink-0">
<circle cx="14" cy="14" r="14" fill="#1A1A1A"/>
<ellipse cx="14" cy="14" rx="4" ry="5" fill="#FF1111"/>
<ellipse cx="14" cy="14" rx="2" ry="2.5" fill="#FF1111"/>
<line x1="14" y1="9" x2="6" y2="5" stroke="#FF1111" strokeWidth="1.2"/>
<line x1="14" y1="9" x2="22" y2="5" stroke="#FF1111" strokeWidth="1.2"/>
<line x1="14" y1="12" x2="5" y2="11" stroke="#FF1111" strokeWidth="1.2"/>
<line x1="14" y1="12" x2="23" y2="11" stroke="#FF1111" strokeWidth="1.2"/>
<line x1="14" y1="19" x2="6" y2="23" stroke="#FF1111" strokeWidth="1.2"/>
<line x1="14" y1="19" x2="22" y2="23" stroke="#FF1111" strokeWidth="1.2"/>
<line x1="14" y1="16" x2="5" y2="17" stroke="#FF1111" strokeWidth="1.2"/>
<line x1="14" y1="16" x2="23" y2="17" stroke="#FF1111" strokeWidth="1.2"/>
</svg>
</>
)}
<span className="text-[#CC0000] dark:text-[#FF1111] font-bold text-sm tracking-wide">
CRM
</span>
@@ -258,6 +285,12 @@ export function Topbar({ onMenuClick }: TopbarProps) {
</DropdownMenuContent>
</DropdownMenu>
</div>
{websiteTheme === "spidey" && (
<div className="absolute right-0 top-0 bottom-0 w-[6px] pointer-events-none opacity-80 rounded-r-[16px] overflow-hidden">
<div className="h-1/2 bg-[#CC0000] dark:bg-[#FF1111]" />
<div className="h-1/2 bg-[#0033CC] dark:bg-[#1144FF]" />
</div>
)}
<BugReportModal open={bugModalOpen} onClose={() => setBugModalOpen(false)} />
</header>
);
+10 -10
View File
@@ -7,23 +7,23 @@ import { cn } from "@/lib/utils"
const statusConfig: Record<LeadStatus, { label: string; class: string }> = {
open: {
label: "Open",
class: "bg-blue-500/10 text-blue-600 dark:text-blue-400 border-blue-500/20",
class: "bg-white/40 text-gray-700 dark:text-gray-300 border-gray-300/50 dark:border-gray-600/50",
},
contacted: {
label: "Contacted",
class: "bg-amber-500/10 text-amber-600 dark:text-amber-400 border-amber-500/20",
class: "bg-red-500/10 text-red-600 dark:text-red-400 border-red-500/20",
},
pending: {
label: "Pending",
class: "bg-purple-500/10 text-purple-600 dark:text-purple-400 border-purple-500/20",
class: "bg-blue-900/10 text-blue-900 dark:text-blue-300 border-blue-900/20",
},
closed: {
label: "Closed",
class: "bg-emerald-500/10 text-emerald-600 dark:text-emerald-400 border-emerald-500/20",
class: "bg-blue-400/10 text-blue-600 dark:text-blue-300 border-blue-400/20",
},
ignored: {
label: "Ignored",
class: "bg-zinc-500/10 text-zinc-600 dark:text-zinc-400 border-zinc-500/20",
class: "bg-black/10 text-black dark:text-gray-300 border-black/20 dark:border-black/40",
},
}
@@ -37,11 +37,11 @@ export function LeadStatusBadge({ status, className }: LeadStatusBadgeProps) {
return (
<Badge variant="outline" className={cn(config.class, className)}>
<span className={cn("mr-1.5 h-1.5 w-1.5 rounded-full", {
"bg-blue-500": status === "open",
"bg-amber-500": status === "contacted",
"bg-purple-500": status === "pending",
"bg-emerald-500": status === "closed",
"bg-zinc-500": status === "ignored",
"bg-white border border-gray-300 dark:border-gray-500": status === "open",
"bg-red-500": status === "contacted",
"bg-blue-900": status === "pending",
"bg-blue-400": status === "closed",
"bg-black": status === "ignored",
})} />
{config.label}
</Badge>
+1 -2
View File
@@ -6,7 +6,7 @@ import { Card, CardContent, CardDescription, CardHeader, CardTitle } from "@/com
import { Label } from "@/components/ui/label"
import { RadioGroup, RadioGroupItem } from "@/components/ui/radio-group"
import { cn } from "@/lib/utils"
import { Sun, Moon, Monitor, Sparkles, Shield } from "lucide-react"
import { Sun, Moon, Monitor, Shield } from "lucide-react"
import { useWebsiteTheme } from "@/providers/website-theme-provider"
const COLOR_THEME_KEY = "crm-color-theme"
@@ -32,7 +32,6 @@ const themeOptions = [
const backgroundOptions = [
{ value: "spidey", label: "Spidey", icon: Shield, color: "bg-red-600", ring: "ring-red-600", desc: "Dark theme with red accents" },
{ value: "starforce", label: "StarForce", icon: Sparkles, color: "bg-red-700", ring: "ring-red-700", desc: "Cosmic dark with starfield & green glow" },
]
function getStoredColorTheme(): string {
+24 -2
View File
@@ -17,6 +17,7 @@ export interface SessionUser {
id: string;
username: string;
email: string;
phone: string | null;
firstName: string;
lastName: string;
role: string;
@@ -85,7 +86,7 @@ export async function getUserByUsername(username: string) {
export async function getUserById(id: string) {
const result = await query(
` SELECT u.id, u.username, u.email, u.first_name, u.last_name,
` SELECT u.id, u.username, u.email, u.phone, u.first_name, u.last_name,
u.is_active, u.avatar_url,
r.name AS role_name
FROM users u
@@ -185,6 +186,7 @@ export function mapDbUserToSessionUser(
id: dbUser.id as string,
username: dbUser.username as string,
email: dbUser.email as string,
phone: (dbUser.phone as string) || null,
firstName: dbUser.first_name as string,
lastName: dbUser.last_name as string,
role: roleMapping[roleName] || roleName.toLowerCase(),
@@ -201,7 +203,6 @@ export async function createSession(userId: string, role: string) {
secure: process.env.NODE_ENV === "production",
sameSite: "strict",
path: "/",
maxAge: 60 * 60 * 24, // 24 hours
});
return token;
@@ -218,6 +219,27 @@ export async function destroySession() {
});
}
export async function encryptPassword(password: string): Promise<string> {
const result = await query("SELECT encrypt_password($1) AS encrypted", [password]);
return result.rows[0]?.encrypted;
}
export async function decryptPassword(encrypted: string): Promise<string | null> {
try {
const result = await query("SELECT decrypt_password($1) AS decrypted", [encrypted]);
return result.rows[0]?.decrypted || null;
} catch {
return null;
}
}
export async function setSessionContext(userId: string, ip?: string) {
await query("SELECT set_config('app.current_user_id', $1, true)", [userId]);
if (ip) {
await query("SELECT set_config('app.current_ip', $1, true)", [ip]);
}
}
export async function getSessionUser(): Promise<SessionUser | null> {
try {
const cookieStore = await cookies();
+5 -5
View File
@@ -1,9 +1,9 @@
export const LEAD_STATUSES = {
open: { label: "Open", color: "bg-blue-500" },
contacted: { label: "Contacted", color: "bg-amber-500" },
pending: { label: "Pending", color: "bg-purple-500" },
closed: { label: "Closed", color: "bg-emerald-500" },
ignored: { label: "Ignored", color: "bg-zinc-500" },
open: { label: "Open", color: "bg-white" },
contacted: { label: "Contacted", color: "bg-red-500" },
pending: { label: "Pending", color: "bg-blue-900" },
closed: { label: "Closed", color: "bg-blue-400" },
ignored: { label: "Ignored", color: "bg-black" },
} as const
export const LEAD_SOURCES = [
+3 -4
View File
@@ -6,7 +6,6 @@ const WEBSITE_THEME_KEY = "crm-website-theme"
const themeClasses: Record<string, string> = {
spidey: "theme-spidey",
starforce: "theme-starforce",
}
interface WebsiteThemeContextValue {
@@ -39,7 +38,7 @@ function storeTheme(theme: string) {
}
export function WebsiteThemeProvider({ children }: { children: ReactNode }) {
const [websiteTheme, setWebsiteThemeState] = useState<string>("starforce")
const [websiteTheme, setWebsiteThemeState] = useState<string>("spidey")
const [loading, setLoading] = useState(true)
useEffect(() => {
@@ -53,13 +52,13 @@ export function WebsiteThemeProvider({ children }: { children: ReactNode }) {
fetch("/api/settings/website-theme")
.then((res) => (res.ok ? res.json() : null))
.then((data) => {
const theme = data?.websiteTheme || "starforce"
const theme = data?.websiteTheme || "spidey"
setWebsiteThemeState(theme)
storeTheme(theme)
applyWebsiteTheme(theme)
})
.catch(() => {
applyWebsiteTheme("starforce")
applyWebsiteTheme("spidey")
})
.finally(() => setLoading(false))
}, [])
+2 -1
View File
@@ -4,12 +4,13 @@ export type LeadStatus =
| "pending"
| "closed"
| "ignored";
export type UserRole = "super_admin" | "admin" | "sales";
export type UserRole = "super_admin" | "admin" | "sales" | "dev";
export interface User {
id: string;
name: string;
email: string;
phone?: string;
role: UserRole;
active: boolean;
avatar: string;