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"
}
}
+97 -37
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. # 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",
"web designer", "web developer",
"build my website", "build a website", "create a website", "build my website", "build a website", "create a website",
"need web", "looking for web", "new website", "landing page", "wordpress", "ecommerce",
"landing page", "wordpress", "my website", "business website",
"need a website", "my website", "business website", "site for my", "site for my business",
"need a designer", "help with my website", "new website", "redesign my website",
"redesign", "update 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", "looking for", "need a", "need an", "looking to",
"need someone", "hire a", "want someone", "need someone", "hire a", "want someone",
"need help with", "would like", "build me", "need help with", "would like", "build me",
"design my", "make me a", "create my", "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",
] ]
filtered = [] keyword_leads: list[dict] = []
for r in results: for r in results:
t = r['title'].lower() t = r['title'].lower()
if not any(kw in t for kw in strict_keywords): 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 continue
if any(kw in t for kw in ['i build website', 'i offer web', 'i am a web developer', 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']): 'affordable web design package', 'web hosting',
'i do web design', 'i develop websites']):
continue continue
filtered.append(r) keyword_leads.append(r)
return filtered[:10]
return [] # ── 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 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 # FastAPI Endpoints
+3
View File
@@ -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
+2 -16
View File
@@ -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>
+2
View File
@@ -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)
+8 -8
View File
@@ -80,8 +80,8 @@ function countStatuses(leads: any[]) {
return counts return counts
} }
function buildMonthlyBreakdown(leads: any[], period: string) { function buildMonthlyBreakdown(leads: any[], period: string, rangeOverride?: { start: Date; end: Date }) {
const { start, end } = getPeriodDateRange(period) const { start, end } = rangeOverride || getPeriodDateRange(period)
const result: { label: string; total: number; open: number; contacted: number; pending: number; closed: number; ignored: number }[] = [] const result: { label: string; total: number; open: number; contacted: number; pending: number; closed: number; ignored: number }[] = []
const current = new Date(start) const current = new Date(start)
const isMonthly = period === "6months" || period === "12months" const isMonthly = period === "6months" || period === "12months"
@@ -170,7 +170,7 @@ export async function GET(request: NextRequest) {
updatedAt: r.updated_at, updatedAt: r.updated_at,
})) }))
const monthlyBreakdown = buildMonthlyBreakdown(currentLeads, period) const monthlyBreakdown = buildMonthlyBreakdown(currentLeads, period, yearParam ? { start, end } : undefined)
const trends = { const trends = {
totalLeads: computeTrend(currentCounts.open + currentCounts.contacted + currentCounts.pending + currentCounts.closed + currentCounts.ignored, totalLeads: computeTrend(currentCounts.open + currentCounts.contacted + currentCounts.pending + currentCounts.closed + currentCounts.ignored,
@@ -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",
} }
+18 -2
View File
@@ -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)
+2 -2
View File
@@ -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`,
-3
View File
@@ -8,9 +8,6 @@ let prevTime = Date.now()
export async function GET() { export async function GET() {
const sessionUser = await getSessionUser() const sessionUser = await getSessionUser()
if (!sessionUser) return NextResponse.json({ error: "Unauthorized" }, { status: 401 }) if (!sessionUser) return NextResponse.json({ error: "Unauthorized" }, { status: 401 })
if (sessionUser.role !== "admin" && sessionUser.role !== "super_admin") {
return NextResponse.json({ error: "Forbidden" }, { status: 403 })
}
const now = Date.now() const now = Date.now()
const elapsed = now - prevTime const elapsed = now - prevTime
+7 -4
View File
@@ -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 = (
-1
View File
@@ -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",
+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="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">
+11 -1
View File
@@ -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>
+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" /> <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"/>
+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", "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>
+1 -1
View File
@@ -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>
+36 -3
View File
@@ -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>
); );
+10 -10
View File
@@ -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>
+1 -2
View File
@@ -6,7 +6,7 @@ import { Card, CardContent, CardDescription, CardHeader, CardTitle } from "@/com
import { Label } from "@/components/ui/label" import { Label } from "@/components/ui/label"
import { RadioGroup, RadioGroupItem } from "@/components/ui/radio-group" import { RadioGroup, RadioGroupItem } from "@/components/ui/radio-group"
import { cn } from "@/lib/utils" import { cn } from "@/lib/utils"
import { Sun, Moon, Monitor, 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
View File
@@ -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();
+5 -5
View File
@@ -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 = [
+3 -4
View File
@@ -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
View File
@@ -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;