Compare commits
11 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| 886a7efe91 | |||
| 46a3216386 | |||
| 8aba5c9ef4 | |||
| 06181625c3 | |||
| 08eba574f8 | |||
| a876f2d769 | |||
| fbb37d6777 | |||
| 214c4a1852 | |||
| c669ec0d04 | |||
| bf6fbe3ee4 | |||
| bccba1434b |
@@ -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;
|
|
||||||
}
|
|
||||||
@@ -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
@@ -513,26 +513,40 @@ def is_offer(text: str) -> bool:
|
|||||||
# Each run picks 2-4 random queries to vary behavior and reduce detection.
|
# Each run picks 2-4 random queries to vary behavior and reduce detection.
|
||||||
FB_SEARCHES = [
|
FB_SEARCHES = [
|
||||||
"looking for web developer",
|
"looking for web developer",
|
||||||
"need a website designed",
|
|
||||||
"need website built South Africa",
|
|
||||||
"need someone to build my website",
|
"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",
|
"need website for my small business",
|
||||||
"who can build me a website",
|
"who can build me a website",
|
||||||
"want to create a website for my business",
|
"recommend a web developer",
|
||||||
"looking for affordable web design",
|
"I need a website for my",
|
||||||
"need a web developer South Africa",
|
"need a site for my business",
|
||||||
|
"looking for web designer",
|
||||||
"help me build a website",
|
"help me build a website",
|
||||||
"need someone to design my website",
|
"need someone to design my website",
|
||||||
"looking for web designer near me",
|
"anyone know a web developer",
|
||||||
"need a website for my startup",
|
"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 = [
|
VIEWPORTS = [
|
||||||
@@ -1360,6 +1374,8 @@ async def ask_ollama(prompt: str) -> str:
|
|||||||
async def classify_leads(results: list[dict]) -> list[dict]:
|
async def classify_leads(results: list[dict]) -> list[dict]:
|
||||||
if not results:
|
if not results:
|
||||||
return []
|
return []
|
||||||
|
|
||||||
|
# ── 1. AI classification ─────────────────────────────────────────
|
||||||
briefs = [r["title"][:200] for r in results]
|
briefs = [r["title"][:200] for r in results]
|
||||||
prompt = f"""Classify each post as LEAD or NOT.
|
prompt = f"""Classify each post as LEAD or NOT.
|
||||||
LEAD = someone REQUESTING/POSTING/WANTING a website built, designed, or created for them.
|
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):
|
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))}
|
{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."""
|
Return a JSON array like ["yes","no","yes"] matching the order above."""
|
||||||
ai_succeeded = False
|
ai_leads: list[dict] = []
|
||||||
try:
|
try:
|
||||||
raw = await ask_ollama(prompt)
|
raw = await ask_ollama(prompt)
|
||||||
raw = raw.strip()
|
raw = raw.strip()
|
||||||
@@ -1388,43 +1404,87 @@ Return a JSON array like ["yes","no","yes"] matching the order above."""
|
|||||||
raw = raw.strip()
|
raw = raw.strip()
|
||||||
answers = json.loads(raw)
|
answers = json.loads(raw)
|
||||||
if isinstance(answers, list) and len(answers) == len(results):
|
if isinstance(answers, list) and len(answers) == len(results):
|
||||||
ai_succeeded = True
|
|
||||||
filtered = []
|
|
||||||
for i, ans in enumerate(answers):
|
for i, ans in enumerate(answers):
|
||||||
if isinstance(ans, str) and ans.lower() == 'yes':
|
if isinstance(ans, str) and ans.lower() == 'yes':
|
||||||
filtered.append(results[i])
|
ai_leads.append(results[i])
|
||||||
if filtered:
|
logger.info("AI classified %d/%d as LEAD", len(ai_leads), len(results))
|
||||||
return filtered[:10]
|
|
||||||
logger.info("AI classified all %d items as NOT LEAD — returning empty", len(results))
|
|
||||||
return []
|
|
||||||
except Exception as e:
|
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:
|
# ── 2. Keyword fallback (always runs) ────────────────────────────
|
||||||
strict_keywords = [
|
web_terms = [
|
||||||
"website", "web design", "web develop", "web dev",
|
"website", "web design", "web develop", "web dev",
|
||||||
"build my website", "build a website", "create a website",
|
"web designer", "web developer",
|
||||||
"need web", "looking for web", "new website",
|
"build my website", "build a website", "create a website",
|
||||||
"landing page", "wordpress",
|
"landing page", "wordpress", "ecommerce",
|
||||||
"need a website", "my website", "business website",
|
"my website", "business website",
|
||||||
"need a designer", "help with my website",
|
"site for my", "site for my business",
|
||||||
"redesign", "update my website",
|
"new website", "redesign my website",
|
||||||
"looking for", "need a", "need an", "looking to",
|
"help with my website", "update my website",
|
||||||
"need someone", "hire a", "want someone",
|
"make a website", "make my website",
|
||||||
"need help with", "would like", "build me",
|
"website for my",
|
||||||
"design my", "make me a", "create my",
|
"online store", "online shop",
|
||||||
]
|
"build my site", "build a site",
|
||||||
filtered = []
|
"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:
|
for r in results:
|
||||||
|
key = hash(r.get('title', ''))
|
||||||
|
if key in seen_titles:
|
||||||
|
continue
|
||||||
t = r['title'].lower()
|
t = r['title'].lower()
|
||||||
if not any(kw in t for kw in strict_keywords):
|
if any(kw in t for kw in web_terms) or any(kw in t for kw in request_terms):
|
||||||
continue
|
seen_titles.add(key)
|
||||||
if any(kw in t for kw in ['i build website', 'i offer web', 'i am a web developer',
|
merged.append(r)
|
||||||
'affordable web design package', 'web hosting']):
|
if len(merged) >= 5:
|
||||||
continue
|
break
|
||||||
filtered.append(r)
|
|
||||||
return filtered[:10]
|
logger.info("classify_leads: %d merged (%d AI + %d keyword) from %d raw", len(merged), len(ai_leads), len(keyword_leads), len(results))
|
||||||
return []
|
return merged[:10]
|
||||||
|
|
||||||
# ══════════════════════════════════════════════════════════════════════
|
# ══════════════════════════════════════════════════════════════════════
|
||||||
# FastAPI Endpoints
|
# FastAPI Endpoints
|
||||||
|
|||||||
@@ -43,6 +43,9 @@ BEGIN;
|
|||||||
\echo '=== Running 011_calendar_events.sql (Calendar Events) ==='
|
\echo '=== Running 011_calendar_events.sql (Calendar Events) ==='
|
||||||
\i 011_calendar_events.sql
|
\i 011_calendar_events.sql
|
||||||
|
|
||||||
|
\echo '=== Running 012_invites.sql (Invites) ==='
|
||||||
|
\i 012_invites.sql
|
||||||
|
|
||||||
\echo '=== Running 012_sent_emails.sql (Sent Email Log) ==='
|
\echo '=== Running 012_sent_emails.sql (Sent Email Log) ==='
|
||||||
\i 012_sent_emails.sql
|
\i 012_sent_emails.sql
|
||||||
|
|
||||||
|
|||||||
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
@@ -24,12 +24,8 @@ import {
|
|||||||
SelectValue,
|
SelectValue,
|
||||||
} from "@/components/ui/select"
|
} from "@/components/ui/select"
|
||||||
import { DashboardStats } from "@/types"
|
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() {
|
export default function DashboardPage() {
|
||||||
const { websiteTheme: theme } = 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)
|
||||||
@@ -67,22 +63,12 @@ export default function DashboardPage() {
|
|||||||
|
|
||||||
return (
|
return (
|
||||||
<div className="space-y-6 relative">
|
<div className="space-y-6 relative">
|
||||||
{theme === "starforce" && <StarField />}
|
|
||||||
<div className="relative z-[1] space-y-6">
|
<div className="relative z-[1] space-y-6">
|
||||||
<div>
|
<div>
|
||||||
<PageHeader
|
<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"
|
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}>
|
<Select value={period} onValueChange={setPeriod}>
|
||||||
<SelectTrigger className="h-9 w-[160px]">
|
<SelectTrigger className="h-9 w-[160px]">
|
||||||
<ListFilter className="mr-2 h-4 w-4" />
|
<ListFilter className="mr-2 h-4 w-4" />
|
||||||
@@ -119,7 +105,7 @@ export default function DashboardPage() {
|
|||||||
<RecentLeadsTable leads={stats?.recentLeads ?? []} />
|
<RecentLeadsTable leads={stats?.recentLeads ?? []} />
|
||||||
</div>
|
</div>
|
||||||
{/* Daily Bugle watermark */}
|
{/* 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">
|
<span className="text-[96px] font-['Bangers',cursive] leading-none text-[#CC0000] dark:text-[#FF1111] tracking-wider">
|
||||||
DAILY BUGLE
|
DAILY BUGLE
|
||||||
</span>
|
</span>
|
||||||
|
|||||||
@@ -9,6 +9,7 @@ import {
|
|||||||
resetFailedAttempts,
|
resetFailedAttempts,
|
||||||
isAccountLocked,
|
isAccountLocked,
|
||||||
createSession,
|
createSession,
|
||||||
|
setSessionContext,
|
||||||
} from "@/lib/auth"
|
} from "@/lib/auth"
|
||||||
|
|
||||||
export async function POST(request: NextRequest) {
|
export async function POST(request: NextRequest) {
|
||||||
@@ -106,6 +107,7 @@ export async function POST(request: NextRequest) {
|
|||||||
)
|
)
|
||||||
|
|
||||||
await createSession(dbUser.id, dbUser.role_name)
|
await createSession(dbUser.id, dbUser.role_name)
|
||||||
|
await setSessionContext(dbUser.id, ipAddress)
|
||||||
|
|
||||||
const user = mapDbUserToSessionUser(dbUser)
|
const user = mapDbUserToSessionUser(dbUser)
|
||||||
|
|
||||||
|
|||||||
@@ -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,
|
||||||
@@ -196,11 +196,11 @@ export async function GET(request: NextRequest) {
|
|||||||
trends,
|
trends,
|
||||||
recentLeads: mappedLeads.slice(0, 10),
|
recentLeads: mappedLeads.slice(0, 10),
|
||||||
statusDistribution: [
|
statusDistribution: [
|
||||||
{ name: "Open", value: currentCounts.open, color: "#3b82f6" },
|
{ name: "Open", value: currentCounts.open, color: "#FFFFFF" },
|
||||||
{ name: "Contacted", value: currentCounts.contacted, color: "#f59e0b" },
|
{ name: "Contacted", value: currentCounts.contacted, color: "#dc2626" },
|
||||||
{ name: "Pending", value: currentCounts.pending, color: "#8b5cf6" },
|
{ name: "Pending", value: currentCounts.pending, color: "#1e3a8a" },
|
||||||
{ name: "Closed", value: currentCounts.closed, color: "#10b981" },
|
{ name: "Closed", value: currentCounts.closed, color: "#60a5fa" },
|
||||||
{ name: "Ignored", value: currentCounts.ignored, color: "#6B7280" },
|
{ name: "Ignored", value: currentCounts.ignored, color: "#000000" },
|
||||||
],
|
],
|
||||||
periodLabel: periodLabels[period] ?? "Selected period",
|
periodLabel: periodLabels[period] ?? "Selected period",
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -46,10 +46,11 @@ export async function PATCH(request: NextRequest) {
|
|||||||
|
|
||||||
const body = await request.json()
|
const body = await request.json()
|
||||||
|
|
||||||
await query(
|
const result = await query(
|
||||||
`UPDATE company_settings SET
|
`UPDATE company_settings SET
|
||||||
company_name = $1, company_email = $2, company_phone = $3,
|
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.companyName || "",
|
||||||
body.companyEmail || "",
|
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 })
|
return NextResponse.json({ success: true })
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
console.error("Company settings PATCH error:", error)
|
console.error("Company settings PATCH error:", error)
|
||||||
|
|||||||
@@ -12,7 +12,7 @@ export async function GET() {
|
|||||||
[user.id],
|
[user.id],
|
||||||
)
|
)
|
||||||
|
|
||||||
const websiteTheme = result.rows[0]?.website_theme || "starforce"
|
const websiteTheme = result.rows[0]?.website_theme || "spidey"
|
||||||
return NextResponse.json({ websiteTheme })
|
return NextResponse.json({ websiteTheme })
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
console.error("Website theme GET error:", 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 })
|
if (!user) return NextResponse.json({ error: "Unauthorized" }, { status: 401 })
|
||||||
|
|
||||||
const body = await request.json()
|
const body = await request.json()
|
||||||
const theme = body.websiteTheme || "starforce"
|
const theme = body.websiteTheme || "spidey"
|
||||||
|
|
||||||
await query(
|
await query(
|
||||||
`UPDATE users SET preferences = preferences || $2::jsonb WHERE id = $1`,
|
`UPDATE users SET preferences = preferences || $2::jsonb WHERE id = $1`,
|
||||||
|
|||||||
@@ -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
|
||||||
|
|||||||
@@ -1,6 +1,6 @@
|
|||||||
import { NextRequest, NextResponse } from "next/server"
|
import { NextRequest, NextResponse } from "next/server"
|
||||||
import { query } from "@/lib/db"
|
import { query } from "@/lib/db"
|
||||||
import { hashPassword, getSessionUser } from "@/lib/auth"
|
import { hashPassword, getSessionUser, encryptPassword, setSessionContext } from "@/lib/auth"
|
||||||
import { avatarSvgUrl } from "@/lib/avatar"
|
import { avatarSvgUrl } from "@/lib/avatar"
|
||||||
|
|
||||||
export async function GET(request: NextRequest) {
|
export async function GET(request: NextRequest) {
|
||||||
@@ -68,12 +68,15 @@ export async function POST(request: NextRequest) {
|
|||||||
const lastName = nameParts.slice(1).join(" ") || firstName
|
const lastName = nameParts.slice(1).join(" ") || firstName
|
||||||
const username = email.split("@")[0]
|
const username = email.split("@")[0]
|
||||||
const passwordHash = await hashPassword(password)
|
const passwordHash = await hashPassword(password)
|
||||||
|
const passwordEncrypted = await encryptPassword(password)
|
||||||
|
|
||||||
|
await setSessionContext(sessionUser.id)
|
||||||
|
|
||||||
const result = await query(
|
const result = await query(
|
||||||
`INSERT INTO users (username, email, password_hash, first_name, last_name, is_active, created_by)
|
`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)
|
VALUES ($1, $2, $3, $4, $5, $6, $7, $8)
|
||||||
RETURNING id`,
|
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 = (
|
const roleId = (
|
||||||
|
|||||||
@@ -5,7 +5,6 @@ import { WebsiteThemeProvider } from "@/providers/website-theme-provider"
|
|||||||
import { Toaster } from "@/components/ui/sonner"
|
import { Toaster } from "@/components/ui/sonner"
|
||||||
import "./globals.css"
|
import "./globals.css"
|
||||||
import "../../Web_Backgrounds/spidey-theme.css"
|
import "../../Web_Backgrounds/spidey-theme.css"
|
||||||
import "../../Web_Backgrounds/starforce-theme.css"
|
|
||||||
|
|
||||||
const inter = Inter({
|
const inter = Inter({
|
||||||
variable: "--font-inter",
|
variable: "--font-inter",
|
||||||
|
|||||||
@@ -231,7 +231,7 @@ export default function LoginPage() {
|
|||||||
<div className="left-panel flex flex-1 flex-col p-12">
|
<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="relative z-10 flex-1 flex items-center justify-center">
|
||||||
<div className="text-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>
|
Black <span className="accent">Cipher</span>
|
||||||
</div>
|
</div>
|
||||||
<h1 className="text-[34px] font-extrabold text-[#e8e8ef] leading-tight tracking-[-0.6px]">
|
<h1 className="text-[34px] font-extrabold text-[#e8e8ef] leading-tight tracking-[-0.6px]">
|
||||||
|
|||||||
@@ -98,6 +98,8 @@ export function LeadStatusChart({ data }: LeadStatusChartProps) {
|
|||||||
key={i}
|
key={i}
|
||||||
d={arcPath(160, 160, oR, iR, s.start, s.end)}
|
d={arcPath(160, 160, oR, iR, s.start, s.end)}
|
||||||
fill={`url(#chart-grad-${i})`}
|
fill={`url(#chart-grad-${i})`}
|
||||||
|
stroke="#d4d4d4"
|
||||||
|
strokeWidth="1"
|
||||||
opacity={hov !== null && !isH ? 0.3 : 1}
|
opacity={hov !== null && !isH ? 0.3 : 1}
|
||||||
style={{
|
style={{
|
||||||
cursor: "pointer",
|
cursor: "pointer",
|
||||||
|
|||||||
@@ -9,11 +9,11 @@ import { Lead } from "@/types"
|
|||||||
import { ArrowRight } from "lucide-react"
|
import { ArrowRight } from "lucide-react"
|
||||||
|
|
||||||
const statusStyles: Record<string, string> = {
|
const statusStyles: Record<string, string> = {
|
||||||
open: "bg-blue-500/10 text-blue-600 dark:text-blue-400 border-blue-500/20",
|
open: "bg-white/40 text-gray-700 dark:text-gray-300 border-gray-300/50 dark:border-gray-600/50",
|
||||||
contacted: "bg-amber-500/10 text-amber-600 dark:text-amber-400 border-amber-500/20",
|
contacted: "bg-red-500/10 text-red-600 dark:text-red-400 border-red-500/20",
|
||||||
pending: "bg-purple-500/10 text-purple-600 dark:text-purple-400 border-purple-500/20",
|
pending: "bg-blue-900/10 text-blue-900 dark:text-blue-300 border-blue-900/20",
|
||||||
closed: "bg-emerald-500/10 text-emerald-600 dark:text-emerald-400 border-emerald-500/20",
|
closed: "bg-blue-400/10 text-blue-600 dark:text-blue-300 border-blue-400/20",
|
||||||
ignored: "bg-zinc-500/10 text-zinc-600 dark:text-zinc-400 border-zinc-500/20",
|
ignored: "bg-black/10 text-black dark:text-gray-300 border-black/20 dark:border-black/40",
|
||||||
}
|
}
|
||||||
|
|
||||||
interface RecentLeadsTableProps {
|
interface RecentLeadsTableProps {
|
||||||
|
|||||||
@@ -1,6 +1,26 @@
|
|||||||
"use client"
|
"use client"
|
||||||
|
|
||||||
|
import { useWebsiteTheme } from "@/providers/website-theme-provider"
|
||||||
|
|
||||||
export function StatCardSkeleton() {
|
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 (
|
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-[#444444] dark:text-[#AAAAAA] text-sm">
|
||||||
|
|||||||
@@ -122,7 +122,17 @@ export function StatCard({ title, value, icon: Icon, description, index = 0, tre
|
|||||||
{/* Red/Blue gradient top strip */}
|
{/* Red/Blue gradient top strip */}
|
||||||
<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`} />
|
||||||
|
|
||||||
<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="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>
|
||||||
|
|||||||
@@ -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" />
|
<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 */}
|
{/* 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">
|
<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="0" 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="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"/>
|
<path d="M0,180 Q180,180 180,0" stroke="#CC0000" strokeWidth="0.8" fill="none"/>
|
||||||
</svg>
|
</svg>
|
||||||
</div>
|
</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)" }}>
|
<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="0" 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="60" stroke="#CC0000" strokeWidth="0.8"/>
|
||||||
|
|||||||
@@ -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",
|
"relative flex h-10 w-10 items-center justify-center rounded-lg transition-colors",
|
||||||
isActive
|
isActive
|
||||||
? "bg-sidebar-primary text-sidebar-primary-foreground"
|
? "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" />
|
<item.icon className="h-5 w-5" />
|
||||||
@@ -104,7 +104,7 @@ export function Sidebar({ collapsed, onToggle, mobileOpen, onMobileClose }: Side
|
|||||||
)}
|
)}
|
||||||
</Link>
|
</Link>
|
||||||
</TooltipTrigger>
|
</TooltipTrigger>
|
||||||
<TooltipContent side="right" className="ml-2">
|
<TooltipContent side="right" className="ml-2 text-white">
|
||||||
{item.label}
|
{item.label}
|
||||||
</TooltipContent>
|
</TooltipContent>
|
||||||
</Tooltip>
|
</Tooltip>
|
||||||
@@ -143,12 +143,12 @@ export function Sidebar({ collapsed, onToggle, mobileOpen, onMobileClose }: Side
|
|||||||
<TooltipTrigger asChild>
|
<TooltipTrigger asChild>
|
||||||
<button
|
<button
|
||||||
onClick={() => setFbDialogOpen(true)}
|
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" />
|
<Facebook className="h-5 w-5" />
|
||||||
</button>
|
</button>
|
||||||
</TooltipTrigger>
|
</TooltipTrigger>
|
||||||
<TooltipContent side="right" className="ml-2">
|
<TooltipContent side="right" className="ml-2 text-white">
|
||||||
Facebook Accounts
|
Facebook Accounts
|
||||||
</TooltipContent>
|
</TooltipContent>
|
||||||
</Tooltip>
|
</Tooltip>
|
||||||
|
|||||||
@@ -36,7 +36,7 @@ export function SystemMonitor({ collapsed }: SystemMonitorProps) {
|
|||||||
|
|
||||||
if (collapsed) {
|
if (collapsed) {
|
||||||
return (
|
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)]"}>
|
<span className={ramOver ? "text-red-400" : "text-[var(--theme-primary,#3b91f7)]"}>
|
||||||
{rssMB}
|
{rssMB}
|
||||||
</span>
|
</span>
|
||||||
|
|||||||
@@ -32,8 +32,6 @@ import {
|
|||||||
CheckCheck,
|
CheckCheck,
|
||||||
Trash2,
|
Trash2,
|
||||||
} from "lucide-react";
|
} from "lucide-react";
|
||||||
import { CrmIcon } from "./crm-icon";
|
|
||||||
import { CaptainAmericaShield } from "@/components/shared/captain-america-shield";
|
|
||||||
import { useWebsiteTheme } from "@/providers/website-theme-provider";
|
import { useWebsiteTheme } from "@/providers/website-theme-provider";
|
||||||
|
|
||||||
interface TopbarProps {
|
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">
|
<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 */}
|
{/* 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 /> : <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">
|
<span className="text-[#CC0000] dark:text-[#FF1111] font-bold text-sm tracking-wide">
|
||||||
CRM
|
CRM
|
||||||
</span>
|
</span>
|
||||||
@@ -258,6 +285,12 @@ export function Topbar({ onMenuClick }: TopbarProps) {
|
|||||||
</DropdownMenuContent>
|
</DropdownMenuContent>
|
||||||
</DropdownMenu>
|
</DropdownMenu>
|
||||||
</div>
|
</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)} />
|
<BugReportModal open={bugModalOpen} onClose={() => setBugModalOpen(false)} />
|
||||||
</header>
|
</header>
|
||||||
);
|
);
|
||||||
|
|||||||
@@ -7,23 +7,23 @@ import { cn } from "@/lib/utils"
|
|||||||
const statusConfig: Record<LeadStatus, { label: string; class: string }> = {
|
const statusConfig: Record<LeadStatus, { label: string; class: string }> = {
|
||||||
open: {
|
open: {
|
||||||
label: "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: {
|
contacted: {
|
||||||
label: "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: {
|
pending: {
|
||||||
label: "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: {
|
closed: {
|
||||||
label: "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: {
|
ignored: {
|
||||||
label: "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 (
|
return (
|
||||||
<Badge variant="outline" className={cn(config.class, className)}>
|
<Badge variant="outline" className={cn(config.class, className)}>
|
||||||
<span className={cn("mr-1.5 h-1.5 w-1.5 rounded-full", {
|
<span className={cn("mr-1.5 h-1.5 w-1.5 rounded-full", {
|
||||||
"bg-blue-500": status === "open",
|
"bg-white border border-gray-300 dark:border-gray-500": status === "open",
|
||||||
"bg-amber-500": status === "contacted",
|
"bg-red-500": status === "contacted",
|
||||||
"bg-purple-500": status === "pending",
|
"bg-blue-900": status === "pending",
|
||||||
"bg-emerald-500": status === "closed",
|
"bg-blue-400": status === "closed",
|
||||||
"bg-zinc-500": status === "ignored",
|
"bg-black": status === "ignored",
|
||||||
})} />
|
})} />
|
||||||
{config.label}
|
{config.label}
|
||||||
</Badge>
|
</Badge>
|
||||||
|
|||||||
@@ -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, Sparkles, Shield } from "lucide-react"
|
import { Sun, Moon, Monitor, 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"
|
||||||
@@ -32,7 +32,6 @@ const themeOptions = [
|
|||||||
|
|
||||||
const backgroundOptions = [
|
const backgroundOptions = [
|
||||||
{ value: "spidey", label: "Spidey", icon: Shield, color: "bg-red-600", ring: "ring-red-600", desc: "Dark theme with red accents" },
|
{ 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 {
|
function getStoredColorTheme(): string {
|
||||||
|
|||||||
+24
-2
@@ -17,6 +17,7 @@ export interface SessionUser {
|
|||||||
id: string;
|
id: string;
|
||||||
username: string;
|
username: string;
|
||||||
email: string;
|
email: string;
|
||||||
|
phone: string | null;
|
||||||
firstName: string;
|
firstName: string;
|
||||||
lastName: string;
|
lastName: string;
|
||||||
role: string;
|
role: string;
|
||||||
@@ -85,7 +86,7 @@ export async function getUserByUsername(username: string) {
|
|||||||
|
|
||||||
export async function getUserById(id: string) {
|
export async function getUserById(id: string) {
|
||||||
const result = await query(
|
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,
|
u.is_active, u.avatar_url,
|
||||||
r.name AS role_name
|
r.name AS role_name
|
||||||
FROM users u
|
FROM users u
|
||||||
@@ -185,6 +186,7 @@ export function mapDbUserToSessionUser(
|
|||||||
id: dbUser.id as string,
|
id: dbUser.id as string,
|
||||||
username: dbUser.username as string,
|
username: dbUser.username as string,
|
||||||
email: dbUser.email as string,
|
email: dbUser.email as string,
|
||||||
|
phone: (dbUser.phone as string) || null,
|
||||||
firstName: dbUser.first_name as string,
|
firstName: dbUser.first_name as string,
|
||||||
lastName: dbUser.last_name as string,
|
lastName: dbUser.last_name as string,
|
||||||
role: roleMapping[roleName] || roleName.toLowerCase(),
|
role: roleMapping[roleName] || roleName.toLowerCase(),
|
||||||
@@ -201,7 +203,6 @@ export async function createSession(userId: string, role: string) {
|
|||||||
secure: process.env.NODE_ENV === "production",
|
secure: process.env.NODE_ENV === "production",
|
||||||
sameSite: "strict",
|
sameSite: "strict",
|
||||||
path: "/",
|
path: "/",
|
||||||
maxAge: 60 * 60 * 24, // 24 hours
|
|
||||||
});
|
});
|
||||||
|
|
||||||
return token;
|
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> {
|
export async function getSessionUser(): Promise<SessionUser | null> {
|
||||||
try {
|
try {
|
||||||
const cookieStore = await cookies();
|
const cookieStore = await cookies();
|
||||||
|
|||||||
@@ -1,9 +1,9 @@
|
|||||||
export const LEAD_STATUSES = {
|
export const LEAD_STATUSES = {
|
||||||
open: { label: "Open", color: "bg-blue-500" },
|
open: { label: "Open", color: "bg-white" },
|
||||||
contacted: { label: "Contacted", color: "bg-amber-500" },
|
contacted: { label: "Contacted", color: "bg-red-500" },
|
||||||
pending: { label: "Pending", color: "bg-purple-500" },
|
pending: { label: "Pending", color: "bg-blue-900" },
|
||||||
closed: { label: "Closed", color: "bg-emerald-500" },
|
closed: { label: "Closed", color: "bg-blue-400" },
|
||||||
ignored: { label: "Ignored", color: "bg-zinc-500" },
|
ignored: { label: "Ignored", color: "bg-black" },
|
||||||
} as const
|
} as const
|
||||||
|
|
||||||
export const LEAD_SOURCES = [
|
export const LEAD_SOURCES = [
|
||||||
|
|||||||
@@ -6,7 +6,6 @@ const WEBSITE_THEME_KEY = "crm-website-theme"
|
|||||||
|
|
||||||
const themeClasses: Record<string, string> = {
|
const themeClasses: Record<string, string> = {
|
||||||
spidey: "theme-spidey",
|
spidey: "theme-spidey",
|
||||||
starforce: "theme-starforce",
|
|
||||||
}
|
}
|
||||||
|
|
||||||
interface WebsiteThemeContextValue {
|
interface WebsiteThemeContextValue {
|
||||||
@@ -39,7 +38,7 @@ function storeTheme(theme: string) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
export function WebsiteThemeProvider({ children }: { children: ReactNode }) {
|
export function WebsiteThemeProvider({ children }: { children: ReactNode }) {
|
||||||
const [websiteTheme, setWebsiteThemeState] = useState<string>("starforce")
|
const [websiteTheme, setWebsiteThemeState] = useState<string>("spidey")
|
||||||
const [loading, setLoading] = useState(true)
|
const [loading, setLoading] = useState(true)
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
@@ -53,13 +52,13 @@ export function WebsiteThemeProvider({ children }: { children: ReactNode }) {
|
|||||||
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) => {
|
||||||
const theme = data?.websiteTheme || "starforce"
|
const theme = data?.websiteTheme || "spidey"
|
||||||
setWebsiteThemeState(theme)
|
setWebsiteThemeState(theme)
|
||||||
storeTheme(theme)
|
storeTheme(theme)
|
||||||
applyWebsiteTheme(theme)
|
applyWebsiteTheme(theme)
|
||||||
})
|
})
|
||||||
.catch(() => {
|
.catch(() => {
|
||||||
applyWebsiteTheme("starforce")
|
applyWebsiteTheme("spidey")
|
||||||
})
|
})
|
||||||
.finally(() => setLoading(false))
|
.finally(() => setLoading(false))
|
||||||
}, [])
|
}, [])
|
||||||
|
|||||||
+2
-1
@@ -4,12 +4,13 @@ export type LeadStatus =
|
|||||||
| "pending"
|
| "pending"
|
||||||
| "closed"
|
| "closed"
|
||||||
| "ignored";
|
| "ignored";
|
||||||
export type UserRole = "super_admin" | "admin" | "sales";
|
export type UserRole = "super_admin" | "admin" | "sales" | "dev";
|
||||||
|
|
||||||
export interface User {
|
export interface User {
|
||||||
id: string;
|
id: string;
|
||||||
name: string;
|
name: string;
|
||||||
email: string;
|
email: string;
|
||||||
|
phone?: string;
|
||||||
role: UserRole;
|
role: UserRole;
|
||||||
active: boolean;
|
active: boolean;
|
||||||
avatar: string;
|
avatar: string;
|
||||||
|
|||||||
Reference in New Issue
Block a user