Merge remote-tracking branch 'origin/main' into master

This commit is contained in:
2026-06-26 16:47:37 +02:00
82 changed files with 7954 additions and 1077 deletions
+9 -3
View File
@@ -47,9 +47,6 @@ error-log
# vercel
.vercel
# rust
rust-ai/target/
# python
browser-use-service/venv/
browser-use-service/__pycache__/
@@ -58,3 +55,12 @@ browser-use-service/.env
# typescript
*.tsbuildinfo
next-env.d.ts
.git
.git_bak
node_modules
target
# rust build artifacts
rust-ai/target/
# browser-use-service generated files
browser-use-service/*.txt
+69
View File
@@ -0,0 +1,69 @@
/* ============================================================================
Spidey Theme — Baseline Design Tokens
This is the current website appearance captured as a reusable CSS theme.
============================================================================ */
.theme-spidey {
--background: 222.2 84% 4.9%;
--foreground: 210 40% 98%;
--card: 222.2 84% 4.9%;
--card-foreground: 210 40% 98%;
--popover: 222.2 84% 4.9%;
--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: 222.2 84% 4.9%;
--sidebar-foreground: 210 40% 98%;
--sidebar-primary: 0 100% 53%;
--sidebar-primary-foreground: 0 0% 100%;
--sidebar-accent: 217.2 32.6% 17.5%;
--sidebar-accent-foreground: 210 40% 98%;
--sidebar-border: 217.2 32.6% 17.5%;
--sidebar-ring: 0 100% 53%;
}
.theme-spidey body::before {
content: "";
position: fixed;
inset: 0;
z-index: -1;
pointer-events: none;
background-image:
repeating-conic-gradient(
from 10deg,
transparent 0deg 10deg,
rgba(220, 38, 38, 0.04) 10deg 12deg
),
repeating-radial-gradient(
circle at 50% 50%,
transparent 0,
transparent 30px,
rgba(37, 99, 235, 0.03) 30px,
transparent 32px
),
radial-gradient(2px 2px at 10% 20%, rgba(220,38,38,0.35) 0%, transparent 100%),
radial-gradient(2px 2px at 30% 80%, rgba(37,99,235,0.35) 0%, transparent 100%),
radial-gradient(2px 2px at 50% 30%, rgba(220,38,38,0.3) 0%, transparent 100%),
radial-gradient(2px 2px at 70% 60%, rgba(37,99,235,0.3) 0%, transparent 100%),
radial-gradient(2px 2px at 85% 15%, rgba(220,38,38,0.35) 0%, transparent 100%),
radial-gradient(2px 2px at 20% 50%, rgba(37,99,235,0.25) 0%, transparent 100%),
radial-gradient(2px 2px at 65% 85%, rgba(220,38,38,0.25) 0%, transparent 100%),
radial-gradient(2px 2px at 40% 10%, rgba(37,99,235,0.3) 0%, transparent 100%),
radial-gradient(2px 2px at 90% 75%, rgba(220,38,38,0.25) 0%, transparent 100%),
radial-gradient(2px 2px at 5% 60%, rgba(37,99,235,0.3) 0%, transparent 100%),
radial-gradient(2px 2px at 55% 55%, rgba(220,38,38,0.2) 0%, transparent 100%);
background-size: 200% 200%;
animation: drift 60s linear infinite;
}
+52
View File
@@ -0,0 +1,52 @@
{
"theme": {
"id": "spidey",
"name": "Spidey",
"description": "Current website appearance — dark theme with red accents",
"type": "dark",
"default": true
},
"colors": {
"background": "hsl(222.2, 84%, 4.9%)",
"foreground": "hsl(210, 40%, 98%)",
"card": "hsl(222.2, 84%, 4.9%)",
"cardForeground": "hsl(210, 40%, 98%)",
"popover": "hsl(222.2, 84%, 4.9%)",
"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(222.2, 84%, 4.9%)",
"sidebarForeground": "hsl(210, 40%, 98%)",
"sidebarPrimary": "hsl(0, 100%, 53%)",
"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": "#0a0a0f",
"card": "#141414",
"sidebar": "#0a0a0f",
"border": "#1a1a24",
"text": "#e8e8ef",
"mutedText": "#888888"
}
}
+64
View File
@@ -0,0 +1,64 @@
/* ============================================================================
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
@@ -0,0 +1,59 @@
{
"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"
}
}
+29
View File
@@ -0,0 +1,29 @@
import { NextResponse } from "next/server"
import { getSessionUser } from "@/lib/auth"
import { query } from "@/lib/db"
export async function GET() {
try {
const user = await getSessionUser()
if (!user) return NextResponse.json({ error: "Unauthorized" }, { status: 401 })
const result = await query(
`SELECT u.id, u.first_name, u.last_name, u.email
FROM users u
WHERE u.deleted_at IS NULL AND u.id != $1
ORDER BY u.first_name ASC`,
[user.id],
)
const users = result.rows.map((r: any) => ({
id: r.id,
name: `${r.first_name} ${r.last_name}`,
email: r.email,
}))
return NextResponse.json({ users })
} catch (error) {
console.error("Event users error:", error)
return NextResponse.json({ error: "Failed to load users" }, { status: 500 })
}
}
+54
View File
@@ -0,0 +1,54 @@
import { NextRequest, NextResponse } from "next/server"
import { getSessionUser } from "@/lib/auth"
import { query } from "@/lib/db"
import { buildIcs } from "@/lib/ics"
export async function GET(_request: NextRequest, { params }: { params: Promise<{ id: string }> }) {
try {
const user = await getSessionUser()
if (!user) return NextResponse.json({ error: "Unauthorized" }, { status: 401 })
const { id } = await params
const result = await query(
`SELECT e.id, e.user_id, e.participant_id, e.title, e.description, e.event_type,
e.start_time, e.end_time, e.duration_minutes, e.status,
u.email AS user_email, u.first_name AS user_first, u.last_name AS user_last,
p.email AS p_email, p.first_name AS p_first, p.last_name AS p_last
FROM scheduled_events e
JOIN users u ON u.id = e.user_id
LEFT JOIN users p ON p.id = e.participant_id
WHERE e.id = $1 AND e.user_id = $2`,
[id, user.id],
)
if (result.rows.length === 0) {
return NextResponse.json({ error: "Event not found" }, { status: 404 })
}
const r = result.rows[0]
const icsContent = buildIcs({
uid: r.id,
title: r.title,
description: r.description || undefined,
startTime: new Date(r.start_time),
endTime: r.end_time ? new Date(r.end_time) : undefined,
durationMinutes: r.duration_minutes || undefined,
organizerName: `${r.user_first} ${r.user_last}`,
organizerEmail: r.user_email,
attendeeName: r.p_first ? `${r.p_first} ${r.p_last}` : undefined,
attendeeEmail: r.p_email || undefined,
})
return new NextResponse(icsContent, {
headers: {
"Content-Type": "text/calendar; charset=utf-8",
"Content-Disposition": `attachment; filename="event-${r.id}.ics"`,
},
})
} catch (error) {
console.error("ICS GET error:", error)
return NextResponse.json({ error: "Failed to generate calendar file" }, { status: 500 })
}
}
+167
View File
@@ -0,0 +1,167 @@
import { NextRequest, NextResponse } from "next/server"
import { getSessionUser } from "@/lib/auth"
import { query } from "@/lib/db"
import { sendEventRescheduled } from "@/lib/email"
export async function PATCH(request: NextRequest, { params }: { params: Promise<{ id: string }> }) {
try {
const user = await getSessionUser()
if (!user) return NextResponse.json({ error: "Unauthorized" }, { status: 401 })
const { id } = await params
const { title, description, eventType, startTime, endTime, durationMinutes, status, participantId, participantNotes } = await request.json()
const existing = await query(
`SELECT e.user_id, e.participant_id, e.title, e.description, e.participant_notes, e.event_type,
e.start_time, e.end_time, e.duration_minutes, e.status,
u.email AS u_email, u.first_name AS u_first, u.last_name AS u_last,
p.email AS p_email, p.first_name AS p_first, p.last_name AS p_last
FROM scheduled_events e
JOIN users u ON u.id = e.user_id
LEFT JOIN users p ON p.id = e.participant_id
WHERE e.id = $1`,
[id],
user.id,
)
if (existing.rows.length === 0) {
return NextResponse.json({ error: "Event not found" }, { status: 404 })
}
const old = existing.rows[0]
const isCreator = old.user_id === user.id
const isParticipant = old.participant_id === user.id
if (!isCreator && !isParticipant) {
return NextResponse.json({ error: "Forbidden" }, { status: 403 })
}
if (!isCreator && (
(title !== undefined && title !== old.title) ||
(eventType !== undefined && eventType !== old.event_type) ||
(startTime !== undefined && startTime !== old.start_time) ||
(endTime !== undefined && endTime !== old.end_time) ||
(durationMinutes !== undefined && durationMinutes !== old.duration_minutes) ||
(participantId !== undefined && participantId !== old.participant_id)
)) {
return NextResponse.json({ error: "Only the creator can edit event details" }, { status: 403 })
}
const result = await query(
`UPDATE scheduled_events SET
title = COALESCE($1, title),
description = COALESCE($2, description),
participant_notes = COALESCE($3, participant_notes),
event_type = COALESCE($4, event_type),
start_time = COALESCE($5, start_time),
end_time = COALESCE($6, end_time),
duration_minutes = COALESCE($7, duration_minutes),
status = COALESCE($8, status),
participant_id = COALESCE($9, participant_id),
updated_at = NOW()
WHERE id = $10
RETURNING id, user_id, participant_id, lead_id, conversation_id, title, description, participant_notes, event_type, start_time, end_time, duration_minutes, status, created_at`,
[
title || null,
description ?? null,
participantNotes ?? null,
eventType || null,
startTime || null,
endTime ?? null,
durationMinutes ?? null,
status || null,
participantId ?? null,
id,
],
user.id,
)
const r = result.rows[0]
const isChanged = r.start_time !== old.start_time || r.end_time !== old.end_time ||
r.title !== old.title || r.event_type !== old.event_type ||
r.status !== old.status
if (isChanged && r.participant_id && r.participant_id !== user.id) {
sendEventRescheduled({
creatorName: `${user.firstName} ${user.lastName}`,
creatorEmail: user.email,
participantName: old.p_first ? `${old.p_first} ${old.p_last}` : null,
participantEmail: old.p_email || null,
title: r.title,
description: r.description || undefined,
eventType: r.event_type,
startTime: r.start_time,
endTime: r.end_time || undefined,
durationMinutes: r.duration_minutes || undefined,
}).catch((err) => console.error("Reschedule email error:", err))
}
const creatorResult = await query(
`SELECT u.id, u.first_name, u.last_name, u.email, u.avatar_url,
ur.role_id, r.name AS role_name, r.display_name AS role_display
FROM users u
LEFT JOIN user_roles ur ON ur.user_id = u.id
LEFT JOIN roles r ON r.id = ur.role_id
WHERE u.id = $1`,
[old.user_id],
)
const creatorInfo = creatorResult.rows[0]
const participantInfo = old.participant_id ? { id: old.participant_id, name: `${old.p_first} ${old.p_last}` || old.participant_id, email: old.p_email || null } : null
return NextResponse.json({
event: {
id: r.id,
userId: r.user_id,
participantId: r.participant_id,
leadId: r.lead_id,
conversationId: r.conversation_id,
title: r.title,
description: r.description,
participantNotes: r.participant_notes,
eventType: r.event_type,
startTime: r.start_time,
endTime: r.end_time,
durationMinutes: r.duration_minutes,
status: r.status,
creator: creatorInfo ? { id: creatorInfo.id, name: `${creatorInfo.first_name} ${creatorInfo.last_name}`, email: creatorInfo.email, role: creatorInfo.role_display || creatorInfo.role_name, avatar: creatorInfo.avatar_url } : { id: old.user_id, name: "Unknown" },
participant: participantInfo,
lead: null,
createdAt: r.created_at,
},
})
} catch (error) {
console.error("Events PATCH error:", error)
return NextResponse.json({ error: "Failed to update event" }, { status: 500 })
}
}
export async function DELETE(request: NextRequest, { params }: { params: Promise<{ id: string }> }) {
try {
const user = await getSessionUser()
if (!user) return NextResponse.json({ error: "Unauthorized" }, { status: 401 })
const { id } = await params
const existing = await query(
`SELECT user_id FROM scheduled_events WHERE id = $1`,
[id],
user.id,
)
if (existing.rows.length === 0) {
return NextResponse.json({ error: "Event not found" }, { status: 404 })
}
if (existing.rows[0].user_id !== user.id) {
return NextResponse.json({ error: "Forbidden" }, { status: 403 })
}
await query(`DELETE FROM scheduled_events WHERE id = $1`, [id], user.id)
return NextResponse.json({ success: true })
} catch (error) {
console.error("Events DELETE error:", error)
return NextResponse.json({ error: "Failed to delete event" }, { status: 500 })
}
}
+188
View File
@@ -0,0 +1,188 @@
import { NextRequest, NextResponse } from "next/server"
import { getSessionUser } from "@/lib/auth"
import { query } from "@/lib/db"
import { sendEventConfirmation } from "@/lib/email"
export async function GET(request: NextRequest) {
try {
const user = await getSessionUser()
if (!user) return NextResponse.json({ error: "Unauthorized" }, { status: 401 })
const { searchParams } = new URL(request.url)
const start = searchParams.get("start")
const end = searchParams.get("end")
const status = searchParams.get("status")
let sql = `SELECT e.id, e.user_id, e.participant_id, e.lead_id, e.conversation_id,
e.title, e.description, e.participant_notes, e.event_type,
e.start_time, e.end_time, e.duration_minutes, e.status, e.created_at,
u.id AS u_id, u.first_name AS u_first, u.last_name AS u_last, u.email AS u_email, u.avatar_url AS u_avatar,
ur.role_id AS u_role_id, r.name AS u_role_name, r.display_name AS u_role_display,
p.id AS p_id, p.first_name AS p_first, p.last_name AS p_last, p.email AS p_email, p.avatar_url AS p_avatar,
pr.role_id AS p_role_id, pr2.name AS p_role_name, pr2.display_name AS p_role_display,
l.id AS l_id, l.company_name, l.contact_name
FROM scheduled_events e
JOIN users u ON u.id = e.user_id
LEFT JOIN users p ON p.id = e.participant_id
LEFT JOIN leads l ON l.id = e.lead_id
LEFT JOIN user_roles ur ON ur.user_id = e.user_id
LEFT JOIN roles r ON r.id = ur.role_id
LEFT JOIN user_roles pr ON pr.user_id = e.participant_id
LEFT JOIN roles pr2 ON pr2.id = pr.role_id`
const params: unknown[] = []
let idx = 1
if (user.role !== "super_admin") {
sql += ` WHERE e.user_id = $${idx} OR e.participant_id = $${idx}`
params.push(user.id)
idx++
} else {
sql += ` WHERE 1=1`
}
if (start) {
sql += ` AND e.start_time >= $${idx}`
params.push(start)
idx++
}
if (end) {
sql += ` AND e.start_time <= $${idx}`
params.push(end)
idx++
}
if (status) {
sql += ` AND e.status = $${idx}`
params.push(status)
idx++
}
sql += " ORDER BY e.start_time ASC"
const result = await query(sql, params, user.id)
const events = result.rows.map((r: any) => ({
id: r.id,
userId: r.user_id,
participantId: r.participant_id,
leadId: r.lead_id,
conversationId: r.conversation_id,
title: r.title,
description: r.description,
participantNotes: r.participant_notes,
eventType: r.event_type,
startTime: r.start_time,
endTime: r.end_time,
durationMinutes: r.duration_minutes,
status: r.status,
creator: { id: r.u_id, name: `${r.u_first} ${r.u_last}`, email: r.u_email, role: r.u_role_display || r.u_role_name, avatar: r.u_avatar },
participant: r.p_id ? { id: r.p_id, name: `${r.p_first} ${r.p_last}`, email: r.p_email, role: r.p_role_display || r.p_role_name, avatar: r.p_avatar } : null,
lead: r.l_id ? { id: r.l_id, companyName: r.company_name, contactName: r.contact_name } : null,
createdAt: r.created_at,
}))
return NextResponse.json({ events })
} catch (error) {
console.error("Events GET error:", error)
return NextResponse.json({ error: "Failed to load events" }, { status: 500 })
}
}
export async function POST(request: NextRequest) {
try {
const user = await getSessionUser()
if (!user) return NextResponse.json({ error: "Unauthorized" }, { status: 401 })
const {
participantId, leadId, conversationId,
title, description, eventType,
startTime, endTime, durationMinutes,
} = await request.json()
if (!title || !startTime) {
return NextResponse.json({ error: "Title and start time are required" }, { status: 400 })
}
const result = await query(
`INSERT INTO scheduled_events (user_id, participant_id, lead_id, conversation_id, title, description, event_type, start_time, end_time, duration_minutes)
VALUES ($1, $2, $3, $4, $5, $6, $7, $8, $9, $10)
RETURNING id, user_id, participant_id, lead_id, conversation_id, title, description, participant_notes, event_type, start_time, end_time, duration_minutes, status, created_at`,
[
user.id,
participantId || null,
leadId || null,
conversationId || null,
title,
description || null,
eventType || "meeting",
startTime,
endTime || null,
durationMinutes || null,
],
user.id,
)
const r = result.rows[0]
if (participantId && participantId !== user.id) {
await query(
`INSERT INTO notifications (user_id, type, title, description, link, context_id, context_type)
VALUES ($1, $2, $3, $4, $5, $6, $7)`,
[
participantId,
"event_scheduled",
`Meeting scheduled: ${title}`,
`${user.firstName} ${user.lastName} scheduled a ${eventType || "meeting"} with you`,
`/calendar`,
r.id,
"scheduled_event",
],
)
}
const participantResult = participantId ? await query(
`SELECT email, first_name, last_name FROM users WHERE id = $1`,
[participantId],
) : null
const participant = participantResult?.rows[0] || null
sendEventConfirmation({
creatorName: `${user.firstName} ${user.lastName}`,
creatorEmail: user.email,
participantName: participant ? `${participant.first_name} ${participant.last_name}` : null,
participantEmail: participant?.email || null,
title,
description: description || undefined,
eventType: eventType || "meeting",
startTime,
endTime: endTime || undefined,
durationMinutes: durationMinutes || undefined,
}).catch((err) => console.error("Email error:", err))
return NextResponse.json({
event: {
id: r.id,
userId: r.user_id,
participantId: r.participant_id,
leadId: r.lead_id,
conversationId: r.conversation_id,
title: r.title,
description: r.description,
participantNotes: r.participant_notes,
eventType: r.event_type,
startTime: r.start_time,
endTime: r.end_time,
durationMinutes: r.duration_minutes,
status: r.status,
creator: { id: user.id, name: `${user.firstName} ${user.lastName}`, email: user.email, role: user.role },
participant: participantId ? { id: participantId, name: participant ? `${participant.first_name} ${participant.last_name}` : participantId, email: participant?.email || null } : null,
lead: leadId ? { id: leadId, companyName: "", contactName: "" } : null,
createdAt: r.created_at,
},
}, { status: 201 })
} catch (error) {
console.error("Events POST error:", error)
return NextResponse.json({ error: "Failed to create event" }, { status: 500 })
}
}
+861
View File
@@ -0,0 +1,861 @@
"use client"
import { useState, useEffect, useCallback, Component, type ReactNode } from "react"
import { useRouter } from "next/navigation"
import { Button } from "@/components/ui/button"
import { Card } from "@/components/ui/card"
import { Badge } from "@/components/ui/badge"
import { CalendarEvent, EventType } from "@/types"
import {
Dialog, DialogContent, DialogHeader, DialogTitle, DialogFooter,
} from "@/components/ui/dialog"
import { Input } from "@/components/ui/input"
import { Label } from "@/components/ui/label"
import {
Select, SelectContent, SelectItem, SelectTrigger, SelectValue,
} from "@/components/ui/select"
import { Textarea } from "@/components/ui/textarea"
import { cn } from "@/lib/utils"
import { useUser } from "@/providers/user-provider"
import { useNotifications } from "@/providers/notification-provider"
import { toast } from "sonner"
import {
ChevronLeft, ChevronRight, Plus, Calendar, Clock, Phone,
Video, Users, CheckCircle2, XCircle, RotateCcw, Loader2,
ArrowUpRight, Zap, StickyNote,
} from "lucide-react"
const EVENT_ICONS: Record<EventType, React.ElementType> = {
meeting: Users,
call: Phone,
chat: Video,
follow_up: Clock,
demo: Calendar,
other: Calendar,
}
function eventVars(type: EventType) {
return `--event-${type}`
}
function eventBgStyle(type: EventType): React.CSSProperties {
const v = eventVars(type)
return {
backgroundColor: `hsl(var(${v}) / 0.1)`,
color: `hsl(var(${v}))`,
borderColor: `hsl(var(${v}) / 0.25)`,
}
}
function eventDotStyle(type: EventType): React.CSSProperties {
return { backgroundColor: `hsl(var(--event-${type}))` }
}
function getDaysInMonth(year: number, month: number) {
return new Date(year, month + 1, 0).getDate()
}
function getFirstDayOfMonth(year: number, month: number) {
return new Date(year, month, 1).getDay()
}
const MONTHS = ["January", "February", "March", "April", "May", "June", "July", "August", "September", "October", "November", "December"]
const DAYS = ["Sun", "Mon", "Tue", "Wed", "Thu", "Fri", "Sat"]
function formatTime(iso: string) {
return new Date(iso).toLocaleTimeString("en-US", { hour: "numeric", minute: "2-digit", hour12: true })
}
function formatDate(iso: string) {
return new Date(iso).toLocaleDateString("en-US", { month: "short", day: "numeric", year: "numeric" })
}
export default function CalendarPage() {
const router = useRouter()
const { user } = useUser()
const { addNotification } = useNotifications()
const [events, setEvents] = useState<CalendarEvent[]>([])
const [upcomingEvents, setUpcomingEvents] = useState<CalendarEvent[]>([])
const [loading, setLoading] = useState(true)
const [today] = useState(new Date())
const [currentMonth, setCurrentMonth] = useState(today.getMonth())
const [currentYear, setCurrentYear] = useState(today.getFullYear())
const [selectedDate, setSelectedDate] = useState<Date | null>(null)
const [dialogOpen, setDialogOpen] = useState(false)
const [editingEvent, setEditingEvent] = useState<CalendarEvent | null>(null)
const [detailEvent, setDetailEvent] = useState<CalendarEvent | null>(null)
const [users, setUsers] = useState<{ id: string; name: string; email: string }[]>([])
const [formTitle, setFormTitle] = useState("")
const [formDescription, setFormDescription] = useState("")
const [formType, setFormType] = useState<EventType>("meeting")
const [formDate, setFormDate] = useState("")
const [formTime, setFormTime] = useState("")
const [formDuration, setFormDuration] = useState("30")
const [formParticipantId, setFormParticipantId] = useState("")
const [formSaving, setFormSaving] = useState(false)
const [editNotes, setEditNotes] = useState(false)
const [notesText, setNotesText] = useState("")
const [notesSaving, setNotesSaving] = useState(false)
const fetchMonthEvents = useCallback(async () => {
const start = new Date(currentYear, currentMonth, 1).toISOString()
const end = new Date(currentYear, currentMonth + 1, 0, 23, 59, 59).toISOString()
const res = await fetch(`/api/events?start=${encodeURIComponent(start)}&end=${encodeURIComponent(end)}`)
if (res.ok) {
const data = await res.json()
setEvents(data.events || [])
}
}, [currentYear, currentMonth])
const fetchUpcomingEvents = useCallback(async () => {
const start = new Date().toISOString()
const end = new Date(Date.now() + 90 * 24 * 60 * 60 * 1000).toISOString()
const res = await fetch(`/api/events?start=${encodeURIComponent(start)}&end=${encodeURIComponent(end)}&status=scheduled`)
if (res.ok) {
const data = await res.json()
setUpcomingEvents((data.events || []).slice(0, 10))
}
}, [])
useEffect(() => {
if (!user) { router.push("/login"); return }
fetch("/api/event-users")
.then((r) => r.json())
.then((data) => setUsers(data.users || []))
.catch(() => {})
}, [user, router])
useEffect(() => {
setLoading(true)
fetchMonthEvents().catch(() => toast.error("Failed to load events")).finally(() => setLoading(false))
}, [fetchMonthEvents])
useEffect(() => {
fetchUpcomingEvents().catch(() => {})
}, [fetchUpcomingEvents])
useEffect(() => {
const interval = setInterval(() => {
fetchMonthEvents().catch(() => {})
fetchUpcomingEvents().catch(() => {})
}, 15000)
return () => clearInterval(interval)
}, [fetchMonthEvents, fetchUpcomingEvents])
const daysInMonth = getDaysInMonth(currentYear, currentMonth)
const firstDay = getFirstDayOfMonth(currentYear, currentMonth)
const prevMonth = () => {
if (currentMonth === 0) { setCurrentMonth(11); setCurrentYear(currentYear - 1) }
else setCurrentMonth(currentMonth - 1)
}
const nextMonth = () => {
if (currentMonth === 11) { setCurrentMonth(0); setCurrentYear(currentYear + 1) }
else setCurrentMonth(currentMonth + 1)
}
const getEventsForDay = useCallback((day: number) => {
const dateStr = `${currentYear}-${String(currentMonth + 1).padStart(2, "0")}-${String(day).padStart(2, "0")}`
return events.filter((e) => e.startTime.startsWith(dateStr))
}, [events, currentYear, currentMonth])
const isToday = (day: number) => {
return today.getDate() === day && today.getMonth() === currentMonth && today.getFullYear() === currentYear
}
const openCreateDialog = (day?: number) => {
setEditingEvent(null)
setFormTitle("")
setFormDescription("")
setFormType("meeting")
setFormDuration("30")
setFormParticipantId("")
const d = day ? new Date(currentYear, currentMonth, day) : new Date()
setFormDate(d.toISOString().split("T")[0])
setFormTime("10:00")
setDialogOpen(true)
}
const openEditDialog = (event: CalendarEvent) => {
setEditingEvent(event)
setFormTitle(event.title)
setFormDescription(event.description || "")
setFormType(event.eventType)
setFormDuration(String(event.durationMinutes || 30))
setFormDate(new Date(event.startTime).toISOString().split("T")[0])
setFormTime(new Date(event.startTime).toLocaleTimeString("en-US", { hour: "2-digit", minute: "2-digit", hour12: false }))
setFormParticipantId(event.participantId || "")
setDialogOpen(true)
}
const handleSave = async () => {
if (!formTitle.trim() || !formDate || !formTime) {
toast.error("Title, date, and time are required")
return
}
setFormSaving(true)
try {
const startTime = new Date(`${formDate}T${formTime}:00`).toISOString()
let endTime = null
if (formDuration) {
const end = new Date(startTime)
end.setMinutes(end.getMinutes() + parseInt(formDuration))
endTime = end.toISOString()
}
const body: Record<string, unknown> = {
title: formTitle.trim(),
description: formDescription.trim() || null,
eventType: formType,
startTime,
endTime,
durationMinutes: formDuration ? parseInt(formDuration) : null,
}
if (formParticipantId) body.participantId = formParticipantId
let res
if (editingEvent) {
res = await fetch(`/api/events/${editingEvent.id}`, {
method: "PATCH",
headers: { "Content-Type": "application/json" },
body: JSON.stringify(body),
})
} else {
res = await fetch("/api/events", {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify(body),
})
}
if (!res.ok) throw new Error("Failed to save")
const data = await res.json()
if (editingEvent) {
setEvents((prev) => prev.map((e) => e.id === editingEvent.id ? { ...data.event, participant: e.participant, lead: e.lead } : e))
toast.success("Event updated")
} else {
setEvents((prev) => [...prev, data.event])
addNotification("event_scheduled", `Event created: ${formTitle}`, `Scheduled for ${formatDate(startTime)} at ${formatTime(startTime)}`, "/calendar")
toast.success("Event created")
}
setDialogOpen(false)
} catch {
toast.error("Failed to save event")
} finally {
setFormSaving(false)
}
}
const updateEventStatus = async (event: CalendarEvent, newStatus: string) => {
try {
const res = await fetch(`/api/events/${event.id}`, {
method: "PATCH",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({ status: newStatus }),
})
if (!res.ok) throw new Error("Failed to update")
const data = await res.json()
setEvents((prev) => prev.map((e) => e.id === event.id ? { ...data.event, participant: e.participant, lead: e.lead } : e))
toast.success(`Event ${newStatus}`)
setDetailEvent(null)
} catch {
toast.error("Failed to update event")
}
}
const saveNotes = async (event: CalendarEvent) => {
if (notesText === (event.participantNotes || "")) { setEditNotes(false); return }
setNotesSaving(true)
try {
const res = await fetch(`/api/events/${event.id}`, {
method: "PATCH",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({ participantNotes: notesText || null }),
})
if (!res.ok) throw new Error("Failed to save")
const data = await res.json()
setEvents((prev) => prev.map((e) => e.id === event.id ? { ...data.event, participant: e.participant, lead: e.lead } : e))
if (detailEvent && detailEvent.id === event.id) {
setDetailEvent({ ...detailEvent, participantNotes: notesText || null })
}
toast.success("Notes saved")
setEditNotes(false)
} catch {
toast.error("Failed to save notes")
} finally {
setNotesSaving(false)
}
}
const deleteEvent = async (event: CalendarEvent) => {
try {
const res = await fetch(`/api/events/${event.id}`, { method: "DELETE" })
if (!res.ok) throw new Error("Failed to delete")
setEvents((prev) => prev.filter((e) => e.id !== event.id))
toast.success("Event deleted")
setDetailEvent(null)
setDialogOpen(false)
} catch {
toast.error("Failed to delete event")
}
}
const calendarDays: (number | null)[] = []
for (let i = 0; i < firstDay; i++) calendarDays.push(null)
for (let d = 1; d <= daysInMonth; d++) calendarDays.push(d)
if (!user) return null
return (
<div className="flex flex-col h-[calc(100vh-4rem)] bg-gradient-to-b from-background via-background to-muted/20">
{/* Header */}
<div className="flex items-center justify-between px-6 py-3 border-b shrink-0 bg-gradient-to-r from-background via-accent/5 to-background backdrop-blur-sm shadow-[0_1px_3px_-1px_hsl(var(--border))]">
<div className="flex items-center gap-4">
<div className="flex items-center gap-2.5">
<div className="p-2 rounded-lg bg-gradient-to-br from-primary/20 to-primary/5 text-primary shadow-sm ring-1 ring-primary/15">
<Calendar className="h-4 w-4" />
</div>
<h1 className="text-lg font-bold tracking-tight">Calendar</h1>
</div>
<div className="flex items-center gap-1.5 p-0.5 rounded-lg border bg-card/60 shadow-sm">
<Button variant="ghost" size="icon" className="h-7 w-7 rounded-md hover:bg-accent/60 hover:scale-105 active:scale-95 transition-all" onClick={prevMonth}>
<ChevronLeft className="h-3.5 w-3.5" />
</Button>
<span className="text-sm font-bold min-w-[130px] text-center select-none tracking-tight px-1">
{MONTHS[currentMonth]} {currentYear}
</span>
<Button variant="ghost" size="icon" className="h-7 w-7 rounded-md hover:bg-accent/60 hover:scale-105 active:scale-95 transition-all" onClick={nextMonth}>
<ChevronRight className="h-3.5 w-3.5" />
</Button>
</div>
<Button variant="ghost" size="sm" className="text-xs h-7 px-2 hover:bg-accent/40 hover:scale-105 active:scale-95 transition-all" onClick={() => { setCurrentMonth(today.getMonth()); setCurrentYear(today.getFullYear()) }}>
Today
</Button>
</div>
<Button onClick={() => openCreateDialog()} size="sm" className="shadow-sm hover:shadow-md hover:scale-[1.02] active:scale-[0.98] transition-all bg-gradient-to-r from-primary to-primary/90 h-9">
<Plus className="h-4 w-4 mr-1.5" /> New Event
</Button>
</div>
{/* Body - flex row with independent scroll */}
<div className="flex-1 flex overflow-hidden p-4 lg:p-6 gap-4 lg:gap-6">
{loading ? (
<div className="flex-1 flex items-center justify-center">
<div className="flex flex-col items-center gap-3">
<div className="relative">
<Loader2 className="h-8 w-8 animate-spin text-primary/60" />
<div className="absolute inset-0 animate-pulse rounded-full bg-primary/5" />
</div>
<p className="text-sm text-muted-foreground animate-pulse">Loading calendar...</p>
</div>
</div>
) : (
<>
{/* Left: Calendar Grid */}
<div className="flex-1 flex flex-col gap-4 min-w-0 overflow-hidden">
{/* Stats bar */}
<div className="flex items-center gap-3 shrink-0">
<div className="flex items-center gap-2 px-3.5 py-2 rounded-lg bg-primary/5 border border-primary/10 shadow-sm">
<div className="h-2 w-2 rounded-full bg-primary" />
<span className="text-sm font-bold text-primary">{events.filter(e => e.status === "scheduled").length}</span>
<span className="text-[11px] text-primary/60 font-medium">scheduled</span>
</div>
<div className="flex items-center gap-2 px-3.5 py-2 rounded-lg bg-emerald-500/5 border border-emerald-500/10 shadow-sm">
<div className="h-2 w-2 rounded-full bg-emerald-500" />
<span className="text-sm font-bold text-emerald-600 dark:text-emerald-400">{events.filter(e => e.status === "completed").length}</span>
<span className="text-[11px] text-emerald-600/60 dark:text-emerald-400/60 font-medium">completed</span>
</div>
<div className="flex items-center gap-2 px-3.5 py-2 rounded-lg bg-muted/30 border shadow-sm">
<div className="h-2 w-2 rounded-full bg-muted-foreground/30" />
<span className="text-sm font-bold text-foreground/70">{events.length}</span>
<span className="text-[11px] text-muted-foreground/50 font-medium">total</span>
</div>
</div>
{/* Calendar grid wrapper - scrollable */}
<div className="flex-1 overflow-auto rounded-xl border bg-card shadow-sm">
<div className="min-w-[600px]">
{/* Day headers */}
<div className="grid grid-cols-7 sticky top-0 z-10 bg-gradient-to-r from-muted/30 via-background to-muted/30 border-b border-border/40">
{DAYS.map((d, i) => (
<div key={d} className={cn(
"px-3 py-2.5 text-[11px] font-semibold text-muted-foreground/50 text-center tracking-widest uppercase",
i === 0 && "text-red-500/40 dark:text-red-400/40",
i === 6 && "text-red-500/40 dark:text-red-400/40",
)}>
{d}
</div>
))}
</div>
{/* Day cells */}
<div className="grid grid-cols-7">
{calendarDays.map((day, i) => {
if (day === null) return <div key={`empty-${i}`} className="min-h-[110px] border-r border-b border-border/40 bg-gradient-to-b from-muted/[0.01] to-transparent" />
const dayEvents = getEventsForDay(day)
const todayMark = isToday(day)
const hasEvents = dayEvents.length > 0
return (
<div
key={day}
className={cn(
"min-h-[110px] p-1.5 border-r border-b border-border/40 cursor-pointer transition-all duration-150 relative group",
"hover:bg-accent/20 hover:shadow-[inset_0_0_0_1px_hsl(var(--border))]",
todayMark && "bg-gradient-to-b from-primary/[0.04] to-transparent",
)}
onClick={() => { setSelectedDate(new Date(currentYear, currentMonth, day)); openCreateDialog(day) }}
>
<div className="flex items-center justify-between mb-1 px-0.5">
<span
className={cn(
"inline-flex items-center justify-center h-6 w-6 text-xs font-medium rounded-full transition-all duration-200",
todayMark && "bg-primary text-primary-foreground text-[11px] font-bold shadow-md ring-2 ring-primary/30 scale-110",
!todayMark && "text-muted-foreground/70 group-hover:text-foreground",
)}
>
{day}
</span>
{hasEvents && (
<span className="text-[10px] font-semibold text-muted-foreground/30 group-hover:text-muted-foreground/60 transition-all">
{dayEvents.length}
</span>
)}
</div>
<div className="space-y-0.5">
{dayEvents.slice(0, 3).map((event) => {
const Icon = EVENT_ICONS[event.eventType]
return (
<button
key={event.id}
onClick={(e) => { e.stopPropagation(); setDetailEvent(event) }}
className={cn(
"w-full text-left text-[10px] leading-tight flex items-center gap-1 rounded transition-all duration-150",
"hover:shadow-sm hover:scale-[1.02] active:scale-[0.97] group/pill",
event.status !== "scheduled" && "opacity-40",
)}
>
<div className="w-0.5 h-5 rounded-full shrink-0" style={eventDotStyle(event.eventType)} />
<div style={eventBgStyle(event.eventType)}
className="flex-1 flex items-center gap-1 px-1.5 py-0.5 rounded-r min-w-0"
>
<Icon className="h-2 w-2 shrink-0 opacity-50" />
<span className="truncate font-medium">{event.title}</span>
</div>
</button>
)
})}
{dayEvents.length > 3 && (
<button
onClick={(e) => { e.stopPropagation() }}
className="text-[10px] text-muted-foreground/40 hover:text-muted-foreground pl-0.5 font-semibold transition-colors"
>
+{dayEvents.length - 3} more
</button>
)}
</div>
</div>
)
})}
</div>
</div>
</div>
</div>
{/* Right: Upcoming sidebar */}
<div className="w-72 lg:w-80 shrink-0 flex flex-col gap-4 overflow-y-auto">
<div className="flex items-center justify-between shrink-0">
<h2 className="text-sm font-bold flex items-center gap-2 tracking-tight">
<ArrowUpRight className="h-4 w-4 text-primary/70" />
Upcoming
</h2>
{upcomingEvents.length > 0 && (
<Badge variant="secondary" className="text-[10px] font-semibold px-2 py-0.5 ring-1 ring-border/50">
{upcomingEvents.length}
</Badge>
)}
</div>
{upcomingEvents.length === 0 ? (
<Card className="p-6 border-dashed bg-card/50">
<div className="flex flex-col items-center gap-2.5 text-center">
<div className="p-3 rounded-xl bg-gradient-to-br from-muted/50 to-muted/20 ring-1 ring-border/20">
<Calendar className="h-6 w-6 text-muted-foreground/30" />
</div>
<div>
<p className="text-sm font-medium text-muted-foreground">No upcoming events</p>
<p className="text-[11px] text-muted-foreground/40 mt-0.5">Schedule one to get started</p>
</div>
<Button variant="outline" size="sm" onClick={() => openCreateDialog()} className="hover:scale-105 active:scale-95 transition-all shadow-sm">
<Plus className="h-3 w-3 mr-1.5" /> Schedule event
</Button>
</div>
</Card>
) : (
<div className="space-y-2">
{upcomingEvents.map((event) => {
const Icon = EVENT_ICONS[event.eventType]
return (
<button
key={event.id}
onClick={() => setDetailEvent(event)}
className={cn(
"w-full text-left rounded-lg border bg-card/80 hover:bg-card transition-all duration-150 overflow-hidden",
"hover:shadow-md hover:border-accent-foreground/10 active:scale-[0.98]",
)}
>
<div className="flex">
<div className="w-0.5 shrink-0" style={eventDotStyle(event.eventType)} />
<div className="flex-1 p-3">
<div className="flex items-start gap-2.5">
<div className={cn(
"p-1.5 rounded shrink-0 ring-1 ring-border/20",
event.userId === user?.id ? "bg-primary/5" : "bg-muted",
)}>
<Icon className={cn(
"h-3.5 w-3.5",
event.userId === user?.id ? "text-primary/60" : "text-muted-foreground/40",
)} />
</div>
<div className="flex-1 min-w-0">
<div className="flex items-center justify-between gap-1.5">
<p className="text-sm font-semibold truncate">{event.title}</p>
<span className={cn(
"text-[9px] font-semibold px-1.5 py-0.5 rounded capitalize shrink-0",
event.status === "scheduled" && "bg-primary/10 text-primary",
event.status === "completed" && "bg-emerald-500/10 text-emerald-600 dark:text-emerald-400",
event.status === "cancelled" && "bg-red-500/10 text-red-600 dark:text-red-400",
)}>
{event.status}
</span>
</div>
<div className="flex items-center gap-1.5 mt-1 text-[10px] text-muted-foreground/60 font-medium">
<Clock className="h-3 w-3" />
{formatDate(event.startTime)}
<span className="text-muted-foreground/20">·</span>
{formatTime(event.startTime)}
</div>
{event.durationMinutes && (
<div className="flex items-center gap-1 mt-0.5 text-[10px] text-muted-foreground/40 font-medium">
<Zap className="h-2.5 w-2.5" />
{event.durationMinutes} min
</div>
)}
<div className="flex items-center gap-1 mt-1">
<span className={cn(
"h-3.5 w-3.5 rounded-full text-[7px] flex items-center justify-center font-bold ring-1",
event.userId === user?.id
? "bg-primary/10 text-primary ring-primary/20"
: "bg-muted text-muted-foreground/60 ring-border",
)}>
{event.userId === user?.id
? (event.participant?.name.charAt(0) || "?")
: (event.creator?.name.charAt(0) || "?")}
</span>
<span className="text-[10px] text-muted-foreground/60 font-medium">
{event.userId === user?.id
? (event.participant?.name || "No participant")
: (event.creator?.name || "Unknown")}
</span>
</div>
</div>
</div>
</div>
</div>
</button>
)
})}
</div>
)}
</div>
</>
)}
</div>
{/* Create/Edit Dialog */}
<Dialog open={dialogOpen} onOpenChange={setDialogOpen}>
<DialogContent className="sm:max-w-[520px]">
<DialogHeader>
<div className="flex items-center gap-3">
<div className={cn(
"p-3 rounded-xl border shadow-sm",
editingEvent
? "bg-gradient-to-br from-muted to-muted/50 text-muted-foreground border-border"
: "bg-gradient-to-br from-primary/15 to-primary/5 text-primary border-primary/20",
)}>
{editingEvent ? <Clock className="h-5 w-5" /> : <Plus className="h-5 w-5" />}
</div>
<div>
<DialogTitle className="text-lg font-bold">{editingEvent ? "Edit Event" : "Create Event"}</DialogTitle>
<p className="text-xs text-muted-foreground/60 mt-0.5">
{editingEvent ? "Update the event details below" : "Fill in the details for your new event"}
</p>
</div>
</div>
</DialogHeader>
<div className="space-y-5 py-4">
<div className="space-y-2">
<Label htmlFor="title" className="text-xs font-semibold">Title</Label>
<Input id="title" value={formTitle} onChange={(e) => setFormTitle(e.target.value)} placeholder="e.g. Product demo with client" className="h-10" />
</div>
<div className="grid grid-cols-2 gap-4">
<div className="space-y-2">
<Label htmlFor="date" className="text-xs font-semibold">Date</Label>
<Input id="date" type="date" value={formDate} onChange={(e) => setFormDate(e.target.value)} className="h-10" />
</div>
<div className="space-y-2">
<Label htmlFor="time" className="text-xs font-semibold">Time</Label>
<Input id="time" type="time" value={formTime} onChange={(e) => setFormTime(e.target.value)} className="h-10" />
</div>
</div>
<div className="grid grid-cols-2 gap-4">
<div className="space-y-2">
<Label htmlFor="type" className="text-xs font-semibold">Type</Label>
<Select value={formType} onValueChange={(v: EventType) => setFormType(v)}>
<SelectTrigger id="type" className="h-10">
<SelectValue />
</SelectTrigger>
<SelectContent>
<SelectItem value="meeting">Meeting</SelectItem>
<SelectItem value="call">Call</SelectItem>
<SelectItem value="chat">Video Chat</SelectItem>
<SelectItem value="follow_up">Follow Up</SelectItem>
<SelectItem value="demo">Demo</SelectItem>
<SelectItem value="other">Other</SelectItem>
</SelectContent>
</Select>
</div>
<div className="space-y-2">
<Label htmlFor="duration" className="text-xs font-semibold">Duration</Label>
<Select value={formDuration} onValueChange={setFormDuration}>
<SelectTrigger id="duration" className="h-10">
<SelectValue />
</SelectTrigger>
<SelectContent>
<SelectItem value="15">15 min</SelectItem>
<SelectItem value="30">30 min</SelectItem>
<SelectItem value="45">45 min</SelectItem>
<SelectItem value="60">1 hour</SelectItem>
<SelectItem value="90">1.5 hours</SelectItem>
<SelectItem value="120">2 hours</SelectItem>
</SelectContent>
</Select>
</div>
</div>
<div className="space-y-2">
<Label htmlFor="participant" className="text-xs font-semibold">Participant</Label>
<Select value={formParticipantId || "__none__"} onValueChange={(v) => setFormParticipantId(v === "__none__" ? "" : v)}>
<SelectTrigger id="participant" className="h-10">
<SelectValue placeholder="Select a participant…" />
</SelectTrigger>
<SelectContent>
<SelectItem value="__none__">None</SelectItem>
{users.map((u) => (
<SelectItem key={u.id} value={u.id}>
{u.name} ({u.email})
</SelectItem>
))}
</SelectContent>
</Select>
</div>
<div className="space-y-2">
<Label htmlFor="description" className="text-xs font-semibold">Description</Label>
<Textarea id="description" value={formDescription} onChange={(e) => setFormDescription(e.target.value)} placeholder="Add any notes or agenda items..." rows={3} className="resize-none" />
</div>
</div>
<DialogFooter className="flex items-center justify-between border-t pt-4">
<div>
{editingEvent && (
<Button variant="destructive" size="sm" onClick={() => deleteEvent(editingEvent)} className="shadow-sm">
Delete event
</Button>
)}
</div>
<div className="flex gap-2">
<Button variant="outline" onClick={() => setDialogOpen(false)}>Cancel</Button>
<Button onClick={handleSave} disabled={formSaving} className="shadow-sm">
{formSaving && <Loader2 className="h-4 w-4 animate-spin mr-2" />}
{editingEvent ? "Update Event" : "Create Event"}
</Button>
</div>
</DialogFooter>
</DialogContent>
</Dialog>
{/* Event Detail Dialog */}
<Dialog open={!!detailEvent} onOpenChange={(open) => { if (!open) setDetailEvent(null) }}>
{detailEvent && (
<DialogContent className="sm:max-w-[460px]">
<DialogHeader>
<div className="flex items-center gap-3">
<div className="p-3 rounded-xl border shadow-sm" style={eventBgStyle(detailEvent.eventType)}>
{(() => { const Icon = EVENT_ICONS[detailEvent.eventType]; return Icon ? <Icon className="h-5 w-5" /> : <Calendar className="h-5 w-5" /> })()}
</div>
<div className="flex-1 min-w-0">
<DialogTitle className="text-lg font-bold truncate">{detailEvent.title}</DialogTitle>
<span className={cn(
"inline-flex text-[10px] font-semibold px-2 py-0.5 rounded-full capitalize mt-0.5 ring-1",
detailEvent.status === "scheduled" && "bg-primary/10 text-primary ring-primary/20",
detailEvent.status === "completed" && "bg-emerald-500/10 text-emerald-600 dark:text-emerald-400 ring-emerald-500/20",
detailEvent.status === "cancelled" && "bg-red-500/10 text-red-600 dark:text-red-400 ring-red-500/20",
)}>
{detailEvent.status}
</span>
</div>
</div>
</DialogHeader>
<div className="space-y-4">
{/* Date / Time */}
<div className="grid grid-cols-2 gap-3">
<div className="p-3.5 rounded-xl bg-gradient-to-br from-muted/50 to-muted/20 border shadow-sm">
<div className="flex items-center gap-2 text-[10px] font-semibold text-muted-foreground/50 uppercase tracking-wider mb-1.5">
<Calendar className="h-3.5 w-3.5" />
Date
</div>
<p className="text-sm font-bold">{formatDate(detailEvent.startTime)}</p>
</div>
<div className="p-3.5 rounded-xl bg-gradient-to-br from-muted/50 to-muted/20 border shadow-sm">
<div className="flex items-center gap-2 text-[10px] font-semibold text-muted-foreground/50 uppercase tracking-wider mb-1.5">
<Clock className="h-3.5 w-3.5" />
Time
</div>
<p className="text-sm font-bold">{formatTime(detailEvent.startTime)}</p>
</div>
</div>
{/* Duration + Type */}
<div className="grid grid-cols-2 gap-3">
{detailEvent.durationMinutes && (
<div className="p-3.5 rounded-xl bg-gradient-to-br from-muted/50 to-muted/20 border shadow-sm">
<div className="flex items-center gap-2 text-[10px] font-semibold text-muted-foreground/50 uppercase tracking-wider mb-1.5">
<Zap className="h-3.5 w-3.5" />
Duration
</div>
<p className="text-sm font-bold">{detailEvent.durationMinutes} min</p>
</div>
)}
<div className="p-3.5 rounded-xl bg-gradient-to-br from-muted/50 to-muted/20 border shadow-sm">
<div className="flex items-center gap-2 text-[10px] font-semibold text-muted-foreground/50 uppercase tracking-wider mb-1.5 capitalize">
{(() => { const Icon = EVENT_ICONS[detailEvent.eventType]; return Icon ? <Icon className="h-3.5 w-3.5" /> : <Calendar className="h-3.5 w-3.5" /> })()}
Type
</div>
<p className="text-sm font-bold capitalize">{detailEvent.eventType.replace("_", " ")}</p>
</div>
</div>
{/* Description */}
{detailEvent.description && (
<div className="p-3.5 rounded-xl bg-gradient-to-br from-muted/50 to-muted/20 border shadow-sm">
<p className="text-[10px] font-semibold text-muted-foreground/50 uppercase tracking-wider mb-1.5">Description</p>
<p className="text-sm whitespace-pre-wrap leading-relaxed">{detailEvent.description}</p>
</div>
)}
{/* Linked lead */}
{detailEvent.lead && (
<div className="p-3.5 rounded-xl bg-gradient-to-br from-muted/50 to-muted/20 border shadow-sm">
<p className="text-[10px] font-semibold text-muted-foreground/50 uppercase tracking-wider mb-1.5">Linked Lead</p>
<p className="text-sm font-bold">{detailEvent.lead.contactName} {detailEvent.lead.companyName}</p>
</div>
)}
{/* Organizer & Participant - cross-linked */}
<div className="grid grid-cols-2 gap-3">
<div className="p-3.5 rounded-xl bg-gradient-to-br from-muted/50 to-muted/20 border shadow-sm">
<p className="text-[10px] font-semibold text-muted-foreground/50 uppercase tracking-wider mb-1.5 flex items-center gap-1.5">
<Users className="h-3 w-3" />
{detailEvent.userId === user?.id ? "You (Organizer)" : "Organizer"}
</p>
<p className="text-sm font-bold flex items-center gap-2">
<span className="h-6 w-6 rounded-full bg-muted text-muted-foreground text-[10px] flex items-center justify-center font-bold ring-1 ring-border">
{detailEvent.creator?.name.charAt(0).toUpperCase() || "?"}
</span>
<span className="truncate">{detailEvent.creator?.name || "Unknown"}</span>
{detailEvent.creator?.role && (
<span className="text-[10px] font-medium text-muted-foreground/50 capitalize shrink-0">({detailEvent.creator.role})</span>
)}
</p>
</div>
<div className="p-3.5 rounded-xl bg-gradient-to-br from-primary/5 to-primary/[0.02] border shadow-sm">
<p className="text-[10px] font-semibold text-muted-foreground/50 uppercase tracking-wider mb-1.5 flex items-center gap-1.5">
<Users className="h-3 w-3" />
{detailEvent.participantId === user?.id ? "You (Participant)" : "Participant"}
</p>
{detailEvent.participant ? (
<p className="text-sm font-bold flex items-center gap-2">
<span className="h-6 w-6 rounded-full bg-primary/10 text-primary text-[10px] flex items-center justify-center font-bold ring-1 ring-primary/20">
{detailEvent.participant.name.charAt(0).toUpperCase()}
</span>
<span className="truncate">{detailEvent.participant.name}</span>
{detailEvent.participant.role && (
<span className="text-[10px] font-medium text-muted-foreground/50 capitalize shrink-0">({detailEvent.participant.role})</span>
)}
</p>
) : (
<p className="text-sm text-muted-foreground/50 italic">No participant</p>
)}
</div>
</div>
{/* Participant Notes (editable by both parties) */}
<div className="p-3.5 rounded-xl bg-gradient-to-br from-muted/50 to-muted/20 border shadow-sm">
<div className="flex items-center justify-between mb-1.5">
<p className="text-[10px] font-semibold text-muted-foreground/50 uppercase tracking-wider flex items-center gap-1.5">
<StickyNote className="h-3 w-3" />
Notes
</p>
{!editNotes && (
<Button variant="ghost" size="sm" className="h-7 text-[11px] font-semibold hover:bg-accent/40" onClick={() => { setNotesText(detailEvent.participantNotes || ""); setEditNotes(true) }}>
{detailEvent.participantNotes ? "Edit" : "Add note"}
</Button>
)}
</div>
{editNotes ? (
<div className="space-y-2.5">
<Textarea value={notesText} onChange={(e) => setNotesText(e.target.value)} rows={3} className="resize-none text-sm" placeholder="Add your notes about this event…" />
<div className="flex gap-2 justify-end">
<Button variant="ghost" size="sm" onClick={() => setEditNotes(false)} disabled={notesSaving}>Cancel</Button>
<Button size="sm" onClick={() => saveNotes(detailEvent)} disabled={notesSaving} className="shadow-sm">
{notesSaving && <Loader2 className="h-3 w-3 animate-spin mr-1" />}
Save
</Button>
</div>
</div>
) : detailEvent.participantNotes ? (
<p className="text-sm whitespace-pre-wrap leading-relaxed font-medium">{detailEvent.participantNotes}</p>
) : (
<p className="text-sm text-muted-foreground/40 italic font-medium">No notes yet</p>
)}
</div>
</div>
<DialogFooter className="flex items-center gap-2 border-t pt-4">
{detailEvent.status === "scheduled" && (
<>
<Button variant="default" size="sm" onClick={() => updateEventStatus(detailEvent, "completed")} className="shadow-sm">
<CheckCircle2 className="h-4 w-4 mr-1.5" /> Complete
</Button>
<Button variant="outline" size="sm" onClick={() => updateEventStatus(detailEvent, "cancelled")}>
<XCircle className="h-4 w-4 mr-1.5" /> Cancel
</Button>
<Button variant="outline" size="sm" onClick={() => { setDetailEvent(null); openEditDialog(detailEvent) }}>
Edit
</Button>
</>
)}
{detailEvent.status === "completed" && (
<Button variant="outline" size="sm" onClick={() => updateEventStatus(detailEvent, "scheduled")}>
<RotateCcw className="h-4 w-4 mr-1.5" /> Reopen
</Button>
)}
</DialogFooter>
</DialogContent>
)}
</Dialog>
</div>
)
}
@@ -0,0 +1,24 @@
CREATE TABLE IF NOT EXISTS scheduled_events (
id UUID PRIMARY KEY DEFAULT uuid_generate_v4(),
user_id UUID NOT NULL REFERENCES users(id) ON DELETE CASCADE,
participant_id UUID REFERENCES users(id) ON DELETE SET NULL,
lead_id UUID REFERENCES leads(id) ON DELETE SET NULL,
conversation_id UUID REFERENCES conversations(id) ON DELETE SET NULL,
title VARCHAR(255) NOT NULL,
description TEXT,
event_type VARCHAR(20) NOT NULL DEFAULT 'meeting',
start_time TIMESTAMPTZ NOT NULL,
end_time TIMESTAMPTZ,
duration_minutes INT,
status VARCHAR(20) NOT NULL DEFAULT 'scheduled',
created_at TIMESTAMPTZ NOT NULL DEFAULT NOW(),
updated_at TIMESTAMPTZ NOT NULL DEFAULT NOW(),
CONSTRAINT chk_event_type CHECK (event_type IN ('meeting','call','chat','follow_up','demo','other')),
CONSTRAINT chk_event_status CHECK (status IN ('scheduled','completed','cancelled','rescheduled'))
);
CREATE INDEX IF NOT EXISTS idx_scheduled_events_user ON scheduled_events(user_id, start_time);
CREATE INDEX IF NOT EXISTS idx_scheduled_events_participant ON scheduled_events(participant_id);
CREATE INDEX IF NOT EXISTS idx_scheduled_events_lead ON scheduled_events(lead_id);
CREATE INDEX IF NOT EXISTS idx_scheduled_events_date ON scheduled_events(start_time);
CREATE INDEX IF NOT EXISTS idx_scheduled_events_status ON scheduled_events(user_id, status);
@@ -0,0 +1,22 @@
-- ============================================================================
-- Migration 013: Row-Level Security for scheduled_events
-- ============================================================================
-- Enables RLS on the scheduled_events table so that users can only see
-- their own events at the database level. This is defense-in-depth:
-- the API already filters by user_id, but RLS ensures data isolation
-- even if there's a bug in the application layer.
--
-- The policy allows all operations when app.current_user_id is NULL
-- (backward-compatible fallback for queries that don't set the variable).
-- When the variable IS set, only rows matching the user's ID are visible.
-- ============================================================================
ALTER TABLE scheduled_events ENABLE ROW LEVEL SECURITY;
DROP POLICY IF EXISTS scheduled_events_user_policy ON scheduled_events;
CREATE POLICY scheduled_events_user_policy ON scheduled_events
FOR ALL
USING (
user_id = current_setting('app.current_user_id', true)::uuid
OR current_setting('app.current_user_id', true) IS NULL
);
@@ -0,0 +1,19 @@
-- ============================================================================
-- Migration 015: Fix RLS for scheduled_events to include participant_id
-- ============================================================================
-- Previously, the RLS policy only checked user_id, which meant participants
-- could not see events at the database level (the app-layer WHERE clause
-- did the filtering, but RLS was defense-in-depth that missed this case).
--
-- This policy also allows app.current_user_id IS NULL for backward compat
-- with queries that don't set the session variable.
-- ============================================================================
DROP POLICY IF EXISTS scheduled_events_user_policy ON scheduled_events;
CREATE POLICY scheduled_events_user_policy ON scheduled_events
FOR ALL
USING (
user_id = current_setting('app.current_user_id', true)::uuid
OR participant_id = current_setting('app.current_user_id', true)::uuid
OR current_setting('app.current_user_id', true) IS NULL
);
@@ -0,0 +1 @@
ALTER TABLE scheduled_events ADD COLUMN IF NOT EXISTS participant_notes TEXT;
+22
View File
@@ -0,0 +1,22 @@
export type EventType = "meeting" | "call" | "chat" | "follow_up" | "demo" | "other"
export type EventStatus = "scheduled" | "completed" | "cancelled" | "rescheduled"
export interface CalendarEvent {
id: string
userId: string
participantId: string | null
leadId: string | null
conversationId: string | null
title: string
description: string | null
participantNotes: string | null
eventType: EventType
startTime: string
endTime: string | null
durationMinutes: number | null
status: EventStatus
creator: { id: string; name: string; email?: string; role?: string; avatar?: string } | null
participant: { id: string; name: string; email?: string; role?: string; avatar?: string } | null
lead: { id: string; companyName: string; contactName: string } | null
createdAt: string
}
+224 -15
View File
@@ -1,12 +1,26 @@
// ── CRM AI Server ──────────────────────────────────────────────────
// Provides:
// - Chat API (POST /ai/chat) — routes user messages to Ollama for sales coaching
// - Setup wizard endpoints (GET /setup/status, POST /setup/profile, etc.)
// - Combined /status endpoint for splash page health polling
// - Configuration routes (GET/POST /ai/instructions, GET /ai/jobs)
// - Model pull support (POST /setup/ollama/pull)
//
// This is a zero-dependency Node.js HTTP server (no Express needed).
import http from "node:http"
import fs from "node:fs"
import path from "node:path"
import { spawn } from "node:child_process"
import { fileURLToPath } from "node:url"
const __dirname = path.dirname(fileURLToPath(import.meta.url))
const ROOT = path.resolve(__dirname, "..")
// ── Load .env.local ──────────────────────────────────────────────
// Reads key=value pairs and sets them as process.env so they're
// available throughout the server. Ignores comments and blank lines.
// Values with matching quotes are unquoted.
try {
const envPath = path.join(ROOT, ".env.local")
const envContent = fs.readFileSync(envPath, "utf-8")
@@ -22,7 +36,7 @@ try {
}
console.log("Loaded .env.local")
} catch {
// .env.local may not exist, ignore
// .env.local may not exist (first run), which is fine
}
// ── Config from env ─────────────────────────────────────────────
@@ -34,7 +48,15 @@ const DATABASE_URL = process.env.DATABASE_URL
const JOBS_PATH = process.env.JOBS_PATH || path.join(ROOT, "data", "ai", "jobs.jsonl")
const AI_MD_PATH = process.env.AI_MD_PATH || path.join(ROOT, "data", "ai", "ai.md")
// ── Setup state ──────────────────────────────────────────────────
// Tracks the Ollama model pull process so the setup wizard can
// poll for download progress.
let pullProcess = null
let pullProgress = { status: "idle", progress: 0, message: "" }
// ── Job loading ─────────────────────────────────────────────────
// Loads job categories from a JSONL file (one JSON object per line).
// Used as context for the AI sales coach chat responses.
function loadJobs() {
try {
const content = fs.readFileSync(JOBS_PATH, "utf-8")
@@ -59,6 +81,8 @@ function loadJobs() {
}
// ── ai.md management ────────────────────────────────────────────
// ai.md is a Markdown file containing system instructions for the AI.
// It can be read, written, or appended to via the API.
function readInstructions() {
try {
return fs.readFileSync(AI_MD_PATH, "utf-8")
@@ -73,6 +97,7 @@ function writeInstructions(content) {
}
function appendToImprovementLog(entry) {
// Adds a timestamped entry to the ## Improvement Log section of ai.md
const current = readInstructions()
const timestamp = new Date().toISOString().replace("T", " ").substring(0, 16)
const logEntry = `\n- ${timestamp}${entry}`
@@ -96,10 +121,13 @@ function appendToImprovementLog(entry) {
}
// ── Chat handler ────────────────────────────────────────────────
// scrapeFacebook() calls the scraper service (port 3008) to get leads.
// handleChat() processes user messages — triggers lead scraping when
// the user asks for "leads" or "listings", otherwise routes to Ollama
// for AI-powered sales coaching.
async function scrapeFacebook() {
const profilePath = process.env.FX_PROFILE || ""
const urlPath = `/scrape/facebook?force=true${profilePath ? `&profile_path=${encodeURIComponent(profilePath)}` : ""}`
const logPath = "C:\\Users\\USER-PC\\AppData\\Local\\Temp\\opencode\\ai-scrape-debug.log"
try {
const body = await new Promise((resolve, reject) => {
const req = http.request({ hostname: "127.0.0.1", port: 3008, path: urlPath, method: "POST", timeout: 360000 }, (res) => {
@@ -134,6 +162,7 @@ function formatLeads(leads) {
}
async function handleChat(userMessage, userId, userRole) {
// If the user asks for leads, trigger the scraper
const lowerMsg = userMessage.toLowerCase()
const triggerWords = ["lists", "listings", "leads", "recent leads", "pull leads", "show me leads", "show listings"]
@@ -145,6 +174,7 @@ async function handleChat(userMessage, userId, userRole) {
return "Scraper returned no results or encountered an error. Try again later."
}
// Otherwise, build a system prompt with job context and send to Ollama
const jobs = loadedJobs
const instructions = readInstructions()
@@ -184,7 +214,7 @@ Provide concise, actionable sales advice. When asked about a specific job catego
const data = await ollamaRes.json()
const responseText = data.message?.content || ""
// Try to persist to DB (best-effort)
// Persist conversation to PostgreSQL (best-effort — table may not exist yet)
try {
if (pgPool && userId) {
await pgPool.query(
@@ -200,6 +230,8 @@ Provide concise, actionable sales advice. When asked about a specific job catego
}
// ── PG pool (lazy init) ────────────────────────────────────────
// PostgreSQL connection pool for storing conversation history.
// Lazy-initialized so the server starts even without a DB.
let pgPool = null
async function initPg() {
if (!DATABASE_URL) return
@@ -241,8 +273,9 @@ function parseURL(req) {
return { pathname: url.pathname, searchParams: url.searchParams }
}
// ── HTTP Server ─────────────────────────────────────────────────
const server = http.createServer(async (req, res) => {
// CORS
// CORS headers — allow the Next.js frontend (port 3006) to call us
res.setHeader("Access-Control-Allow-Origin", "*")
res.setHeader("Access-Control-Allow-Methods", "GET, POST, OPTIONS")
res.setHeader("Access-Control-Allow-Headers", "Content-Type")
@@ -275,10 +308,12 @@ const server = http.createServer(async (req, res) => {
}
// GET /status — combined health of all services
// Used by the splash page to check if AI, Scraper, and Frontend are ready.
// Polls each service internally to avoid cross-origin CORS issues.
if (req.method === "GET" && pathname === "/status") {
const { default: http } = await import("http")
const results = { ai: true }
// Check scraper
// Check scraper (port 3008)
try {
await new Promise((resolve, reject) => {
const r = http.get("http://127.0.0.1:3008/health", { timeout: 3000 }, (res) => { res.resume(); resolve() })
@@ -286,7 +321,7 @@ const server = http.createServer(async (req, res) => {
})
results.scraper = true
} catch { results.scraper = false }
// Check frontend
// Check frontend (port 3006)
try {
await new Promise((resolve, reject) => {
const r = http.get("http://127.0.0.1:3006", { timeout: 3000 }, (res) => { res.resume(); resolve() })
@@ -297,18 +332,193 @@ const server = http.createServer(async (req, res) => {
return sendJSON(res, 200, results)
}
// GET /ai/jobs
// ── Setup endpoints ─────────────────────────────────────────
// GET /setup/status — check environment
// Called by the splash page on boot. Returns info about:
// - Ollama availability
// - Model presence
// - Detected browsers with login status
// - Whether this is a first run (wizard needed)
if (req.method === "GET" && pathname === "/setup/status") {
const envExists = fs.existsSync(path.join(ROOT, ".env.local"))
// Ollama check
let ollamaRunning = false
try {
await fetch(`${OLLAMA_URL}/api/tags`, { signal: AbortSignal.timeout(3000) })
ollamaRunning = true
} catch {}
// Model check
let modelAvailable = false
if (ollamaRunning) {
try {
const r = await fetch(`${OLLAMA_URL}/api/show`, {
method: "POST", body: JSON.stringify({ name: MODEL }),
signal: AbortSignal.timeout(5000),
})
modelAvailable = r.ok
} catch {}
}
// Detect all browsers via scraper
let browsers = { firefox: { path: null }, opera: { path: null }, chrome: { path: null }, edge: { path: null } }
let facebookLoggedIn = false
let selectedBrowser = process.env.SELECTED_BROWSER || ""
try {
await fetch("http://127.0.0.1:3008/health", { signal: AbortSignal.timeout(2000) })
const profiles = await (await fetch("http://127.0.0.1:3008/setup/profile", { signal: AbortSignal.timeout(5000) })).json()
for (const [b, p] of Object.entries(profiles)) {
if (p) browsers[b] = { path: p }
}
// Check login for the selected browser first, then try all
const detectedList = Object.entries(browsers).filter(([, v]) => v.path)
for (const [b, v] of detectedList) {
try {
const r = await fetch("http://127.0.0.1:3008/setup/check-login", {
method: "POST", headers: { "Content-Type": "application/json" },
body: JSON.stringify({ browser: b, profile_path: v.path }),
signal: AbortSignal.timeout(20000),
})
if (r.ok) {
const d = await r.json()
browsers[b].logged_in = d.logged_in === true
if (d.logged_in && !facebookLoggedIn) {
facebookLoggedIn = true
if (!selectedBrowser) selectedBrowser = b
}
}
} catch {}
}
} catch {}
const anyDetected = Object.values(browsers).some(v => v.path)
// first_run = any setup step is incomplete
const firstRun = !envExists || !ollamaRunning || !anyDetected || !facebookLoggedIn || !modelAvailable
return sendJSON(res, 200, {
first_run: firstRun,
env_exists: envExists,
ollama_running: ollamaRunning,
model_available: modelAvailable,
model_name: MODEL,
selected_browser: selectedBrowser,
browsers,
facebook_logged_in: facebookLoggedIn,
})
}
// POST /setup/profile — save selected browser + path to .env.local
// Called by the setup wizard when the user confirms their browser choice.
// Writes SELECTED_BROWSER and the matching profile env var to .env.local.
if (req.method === "POST" && pathname === "/setup/profile") {
const body = await parseBody(req)
const browserName = (body.browser || "").trim().toLowerCase()
const profilePath = (body.path || "").trim()
if (!browserName || !["firefox", "opera", "chrome", "edge"].includes(browserName))
return sendJSON(res, 400, { error: "Valid browser required (firefox/opera/chrome/edge)" })
if (!profilePath)
return sendJSON(res, 400, { error: "Path required" })
const envKey = browserName === "firefox" ? "FX_PROFILE"
: browserName === "opera" ? "OPERA_PROFILE"
: browserName === "edge" ? "EDGE_PROFILE"
: "CHROME_PROFILE"
const envPath = path.join(ROOT, ".env.local")
let content = ""
try { content = fs.readFileSync(envPath, "utf-8") } catch {}
let lines = content.split("\n")
// Update or add SELECTED_BROWSER
let foundSel = false
for (let i = 0; i < lines.length; i++) {
if (lines[i].trim().startsWith("SELECTED_BROWSER=")) {
lines[i] = `SELECTED_BROWSER=${browserName}`
foundSel = true
break
}
}
if (!foundSel) lines.push(`SELECTED_BROWSER=${browserName}`)
// Update or add browser profile
let foundProf = false
for (let i = 0; i < lines.length; i++) {
if (lines[i].trim().startsWith(`${envKey}=`)) {
lines[i] = `${envKey}=${profilePath}`
foundProf = true
break
}
}
if (!foundProf) lines.push(`${envKey}=${profilePath}`)
fs.writeFileSync(envPath, lines.join("\n"), "utf-8")
process.env.SELECTED_BROWSER = browserName
process.env[envKey] = profilePath
return sendJSON(res, 200, { success: true, browser: browserName, path: profilePath })
}
// POST /setup/check-login — proxy to scraper, accepts browser + profile_path
// The splash page calls this (via the AI server) to verify Facebook login status.
if (req.method === "POST" && pathname === "/setup/check-login") {
const body = await parseBody(req)
const browserName = (body.browser || "").trim().toLowerCase() || process.env.SELECTED_BROWSER || ""
const profilePath = (body.profile_path || "").trim()
if (!profilePath) return sendJSON(res, 200, { logged_in: false, reason: "no_profile" })
try {
const r = await fetch("http://127.0.0.1:3008/setup/check-login", {
method: "POST", headers: { "Content-Type": "application/json" },
body: JSON.stringify({ browser: browserName, profile_path: profilePath }),
signal: AbortSignal.timeout(20000),
})
if (r.ok) { const d = await r.json(); return sendJSON(res, 200, d) }
} catch {}
return sendJSON(res, 200, { logged_in: false, reason: "scraper_unavailable" })
}
// POST /setup/ollama/pull — start pulling the model
// Spawns "ollama pull" as a child process. The setup wizard polls
// the progress endpoint to show a download progress bar.
if (req.method === "POST" && pathname === "/setup/ollama/pull") {
if (pullProcess) return sendJSON(res, 200, { status: "already_running" })
pullProgress = { status: "downloading", progress: 0, message: "Starting..." }
const isWin = process.platform === "win32"
const cmd = isWin ? "ollama.exe" : "ollama"
pullProcess = spawn(cmd, ["pull", MODEL], { stdio: ["ignore", "pipe", "pipe"] })
pullProcess.stdout.on("data", (data) => {
const text = data.toString()
pullProgress.message = text.trim()
// Extract percentage from patterns like "pulling xxxx... 45%"
const m = text.match(/(\d+)%/)
if (m) pullProgress.progress = parseInt(m[1], 10)
})
pullProcess.on("close", (code) => {
pullProcess = null
pullProgress.status = code === 0 ? "done" : "failed"
if (code === 0) pullProgress.progress = 100
})
return sendJSON(res, 200, { status: "started" })
}
// GET /setup/ollama/pull/progress
// Returns current download progress for the setup wizard.
if (req.method === "GET" && pathname === "/setup/ollama/pull/progress") {
return sendJSON(res, 200, pullProgress)
}
// GET /ai/jobs — return loaded job categories
if (req.method === "GET" && pathname === "/ai/jobs") {
return sendJSON(res, 200, { jobs: loadedJobs })
}
// GET /ai/instructions
// GET /ai/instructions — return current ai.md content
if (req.method === "GET" && pathname === "/ai/instructions") {
const instructions = readInstructions()
return sendJSON(res, 200, { success: true, instructions })
}
// POST /ai/instructions
// POST /ai/instructions — update ai.md or append improvement log entry
if (req.method === "POST" && pathname === "/ai/instructions") {
const body = await parseBody(req)
if (body.content) {
@@ -322,18 +532,17 @@ const server = http.createServer(async (req, res) => {
})
}
// POST /ai/chat
// POST /ai/chat — main AI chat endpoint
// Accepts { message, user_id?, user_role? } and returns AI response.
// user_role must be "sales", "admin", or "super_admin" if provided.
if (req.method === "POST" && pathname === "/ai/chat") {
const startTime = Date.now()
const chunks = []
req.on("data", c => chunks.push(c))
req.on("end", () => {
const rawBody = Buffer.concat(chunks).toString()
try { fs.appendFileSync("C:\\Users\\USER-PC\\AppData\\Local\\Temp\\opencode\\ai-req-log.txt",
`${new Date().toISOString()} headers=${JSON.stringify(req.headers)} body=${rawBody}\n`) } catch {}
try {
const body = JSON.parse(rawBody)
// Continue processing
processRequest(req, res, body, startTime)
} catch {
sendJSON(res, 400, { error: "Invalid JSON" })
@@ -342,7 +551,7 @@ const server = http.createServer(async (req, res) => {
return
}
// Separate handler
// Separate handler for /ai/chat (defined here due to hoisting within the IIFE)
async function processRequest(req, res, body, startTime) {
const { message, user_id, user_role } = body
@@ -359,7 +568,7 @@ async function processRequest(req, res, body, startTime) {
return sendJSON(res, 200, { response })
}
// 404
// 404 fallback
sendJSON(res, 404, { error: "Not found" })
} catch (err) {
console.error("Request error:", err)
+507 -31
View File
@@ -1,15 +1,15 @@
# ── Imports ──────────────────────────────────────────────────────────
import os, json, asyncio, re, shutil, sqlite3, urllib.parse, random, logging, tempfile
from datetime import datetime, timedelta
from fastapi import FastAPI, Query, Body
from fastapi.middleware.cors import CORSMiddleware
import uvicorn
from playwright.async_api import async_playwright
from browser_use import Agent, Browser
from langchain_ollama import ChatOllama
def make_ollama(model: str | None = None, **kwargs) -> ChatOllama:
"""Create ChatOllama with required attrs for browser-use Agent compatibility."""
# ── Lazy Imports for browser-use (not always available) ──────────────
def _make_ollama(model: str | None = None, **kwargs):
from langchain_ollama import ChatOllama
llm = ChatOllama(model=model or CLASSIFY_MODEL, **kwargs)
object.__setattr__(llm, 'provider', 'ollama')
object.__setattr__(llm, 'name', 'ChatOllama')
@@ -17,6 +17,17 @@ def make_ollama(model: str | None = None, **kwargs) -> ChatOllama:
return llm
def _make_browser(**kwargs):
from browser_use import Browser
return Browser(**kwargs)
def _make_agent(task: str, llm, browser, **kwargs):
from browser_use import Agent
return Agent(task=task, llm=llm, browser=browser, **kwargs)
# ── Logging & App Setup ──────────────────────────────────────────────
logging.basicConfig(level=logging.INFO, format='%(asctime)s [%(levelname)s] %(message)s')
logger = logging.getLogger(__name__)
@@ -29,13 +40,24 @@ app.add_middleware(
)
PORT = int(os.getenv("PORT", "3008"))
# ── AI / Ollama Config ───────────────────────────────────────────────
OLLAMA_URL = os.getenv("OLLAMA_URL", "http://localhost:11434")
CLASSIFY_MODEL = os.getenv("CLASSIFY_MODEL", "dolphin-llama3:8b")
# ── Env Vars (set via .env.local) ──────────────────────────────────────
# These store detected browser profile paths from the setup wizard.
FX_PROFILE = os.getenv('FX_PROFILE', '')
CHROME_PROFILE = os.getenv('CHROME_PROFILE', '')
OPERA_PROFILE = os.getenv('OPERA_PROFILE', '')
EDGE_PROFILE = os.getenv('EDGE_PROFILE', '')
# SELECTED_BROWSER determines which browser the scrape dispatcher tries first.
# Values: "firefox", "chrome", "opera", "edge", or "" (auto-detect all).
SELECTED_BROWSER = os.getenv('SELECTED_BROWSER', '')
# ── Chromium Launch Args ──────────────────────────────────────────────
# These flags reduce detectable automation signals in Chrome/Edge/Opera.
# They disable background services, sync, updates, throttling, popups, etc.
CHROME_LAUNCH_ARGS = [
'--headless=new',
'--disable-blink-features=AutomationControlled',
@@ -60,20 +82,130 @@ CHROME_LAUNCH_ARGS = [
]
# ── Profile Detection ────────────────────────────────────────────────
# All find_*_profile() functions return the parent directory (User Data or equivalent),
# NOT the "Default" subdirectory. This is important because copy_chrome_profile()
# appends "Default" internally, so returning the full Default path would cause
# duplicated path segments (e.g. ".../User Data/Default/Default").
def detect_browser_from_profile(profile_path: str) -> str | None:
"""Detect browser type from profile path. Returns 'firefox', 'chromium', or None."""
"""
Infer browser type from the profile path string.
Checks for keywords like "firefox", "opera", "edge", "chrome" in the path.
Returns "firefox", "chrome", "opera", "edge", or None.
"""
if not profile_path:
return None
p = profile_path.lower().replace('\\', '/')
if 'firefox' in p or '.default' in p or '.dev-edition' in p:
return 'firefox'
if 'chrome' in p or 'chromium' in p or 'edge' in p:
return 'chromium'
if 'opera' in p:
return 'opera'
if 'edge' in p:
return 'edge'
if 'chrome' in p or 'chromium' in p:
return 'chrome'
return None
def find_firefox_profile() -> str | None:
"""Auto-detect Firefox profile directory cross-platform.
Scans the Profiles dir and returns the first .default or .dev-edition folder found,
preferring "default-release" profiles."""
import sys
home = os.path.expanduser("~")
candidates = []
if sys.platform == "win32":
appdata = os.environ.get("APPDATA", "")
if appdata:
profiles_dir = os.path.join(appdata, "Mozilla", "Firefox", "Profiles")
else:
profiles_dir = os.path.join(home, "AppData", "Roaming", "Mozilla", "Firefox", "Profiles")
elif sys.platform == "darwin":
profiles_dir = os.path.join(home, "Library", "Application Support", "Firefox", "Profiles")
else:
profiles_dir = os.path.join(home, ".mozilla", "firefox")
if os.path.isdir(profiles_dir):
for entry in os.listdir(profiles_dir):
full = os.path.join(profiles_dir, entry)
if os.path.isdir(full) and (".default" in entry.lower() or ".dev-edition" in entry.lower()):
candidates.append(full)
candidates.sort(key=lambda p: "default-release" in p.lower(), reverse=True)
if candidates:
logger.info("Auto-detected Firefox profile: %s", candidates[0])
return candidates[0]
return None
def find_chrome_profile() -> str | None:
"""Auto-detect Chrome profile (User Data dir) cross-platform.
Returns the User Data parent dir (NOT the Default subdirectory)."""
import sys
home = os.path.expanduser("~")
if sys.platform == "win32":
base = os.path.join(os.environ.get("LOCALAPPDATA", ""), "Google", "Chrome", "User Data")
elif sys.platform == "darwin":
base = os.path.join(home, "Library", "Application Support", "Google", "Chrome")
else:
base = os.path.join(home, ".config", "google-chrome")
if os.path.isdir(os.path.join(base, "Default")):
logger.info("Auto-detected Chrome profile: %s", base)
return base
return None
def find_opera_profile() -> str | None:
"""Auto-detect Opera profile directory cross-platform.
Opera stores its profile at the "Opera Stable" level (no "User Data/Default"
nesting like Chrome/Edge), so this returns the profile root directly."""
import sys
home = os.path.expanduser("~")
if sys.platform == "win32":
base = os.path.join(os.environ.get("APPDATA", ""), "Opera Software", "Opera Stable")
elif sys.platform == "darwin":
base = os.path.join(home, "Library", "Application Support", "com.operasoftware.Opera")
else:
base = os.path.join(home, ".config", "opera")
if os.path.isdir(base) and os.listdir(base):
logger.info("Auto-detected Opera profile: %s", base)
return base
return None
def find_edge_profile() -> str | None:
"""Auto-detect Edge profile (User Data dir) cross-platform.
Returns the User Data parent dir (NOT the Default subdirectory)."""
import sys
home = os.path.expanduser("~")
if sys.platform == "win32":
base = os.path.join(os.environ.get("LOCALAPPDATA", ""), "Microsoft", "Edge", "User Data")
elif sys.platform == "darwin":
base = os.path.join(home, "Library", "Application Support", "Microsoft Edge")
else:
base = os.path.join(home, ".config", "microsoft-edge")
if os.path.isdir(os.path.join(base, "Default")):
logger.info("Auto-detected Edge profile: %s", base)
return base
return None
# ── Profile Copying ─────────────────────────────────────────────────
# Both functions copy essential cookies/login/storage files from the user's
# real browser profile to a temp directory. Playwright then uses these temp
# profiles via launch_persistent_context(), preserving Facebook login state
# without modifying the user's actual profile.
def copy_firefox_profile(src_path: str) -> str:
"""Copy essential Firefox profile files to a temp dir for Playwright."""
"""Copy essential Firefox profile files (cookies, localStorage, permissions) to a temp dir for Playwright.
Also creates a minimal profiles.ini so Firefox treats it as a valid profile."""
essential = ['cookies.sqlite', 'webappsstore.sqlite', 'permissions.sqlite']
dst = tempfile.mkdtemp(prefix='fb_fx_profile_')
for f_name in essential:
@@ -87,7 +219,12 @@ def copy_firefox_profile(src_path: str) -> str:
def copy_chrome_profile(user_data_dir: str, profile_dir: str = 'Default') -> str:
"""Copy Chrome profile to temp dir for launch_persistent_context."""
"""Copy Chromium-based profile (Chrome/Edge/Opera) to a temp dir.
Copies Cookies, Login Data, Bookmarks, Web Data, Local Storage, Session Storage, and Local State.
Args:
user_data_dir: Parent directory (e.g. ".../Chrome/User Data")
profile_dir: Subdirectory name, typically "Default" (default)
"""
essential = ['Cookies', 'Login Data', 'Bookmarks', 'Web Data']
dst = tempfile.mkdtemp(prefix='fb_chrome_udir_')
src_profile = os.path.join(user_data_dir, profile_dir)
@@ -111,7 +248,8 @@ def copy_chrome_profile(user_data_dir: str, profile_dir: str = 'Default') -> str
def ensure_ublock_extension() -> str | None:
"""Download and extract uBlock Origin extension for ad blocking."""
"""Download and extract uBlock Origin extension for ad blocking via Chromium.
Cached after first download to avoid re-downloading on every scrape."""
ext_dir = os.path.join(tempfile.gettempdir(), 'fb_ublock_origin')
manifest = os.path.join(ext_dir, 'manifest.json')
if os.path.exists(manifest):
@@ -134,10 +272,15 @@ def ensure_ublock_extension() -> str | None:
return None
# ── Agent Output Parsing ──────────────────────────────────────────────
# When the browser-use Agent fallback is used, it returns natural language
# output containing post listings. This function tries to extract structured
# data from that output, first attempting JSON parsing, then falling back to
# a line-by-line heuristic (numbered or bulleted items).
def extract_agent_posts(agent_result: str, page_text: str) -> list[dict]:
"""Parse posts from agent output or page text fallback."""
posts = []
# Try JSON from agent output
json_match = re.search(r'\[.*?\]', agent_result, re.DOTALL)
if json_match:
try:
@@ -173,6 +316,12 @@ def extract_agent_posts(agent_result: str, page_text: str) -> list[dict]:
return posts
# ── Keyword Filters for Lead Detection ──────────────────────────────
# BROAD_KEYWORDS — catch any post that might relate to web development needs.
# OFFER_PATTERNS — regex patterns matching service offers (which we ignore).
# REQUEST_PATTERNS — regex patterns matching people asking for help/services.
# Together these form a two-pass filter + AI classification pipeline.
BROAD_KEYWORDS = [
"website", "web design", "web develop", "web dev",
"build my website", "build a website", "create a website",
@@ -242,6 +391,13 @@ REQUEST_PATTERNS = [
r"\bquote\s+(please|for|to)\b",
]
# ── Anti-Detection Scripts ────────────────────────────────────────────
# These JS scripts are injected into every page via add_init_script().
# They override navigator properties (webdriver, userAgent, plugins, etc.)
# and WebGL/canvas/audio fingerprints to make Playwright harder to detect.
# Firefox and Chromium variants differ slightly (e.g. Chrome needs the
# "window.chrome" object; Firefox must NOT have it).
def firefox_init_script() -> str:
return r"""// Firefox Anti-Detection
Object.defineProperty(navigator, 'webdriver', { get: () => undefined });
@@ -336,18 +492,25 @@ Object.defineProperty(navigator, 'plugins', { get: () => [1,2,3,4,5].map(i => ({
Object.defineProperty(navigator, 'mimeTypes', { get: () => [{type:'application/pdf',suffixes:'pdf',description:'Portable Document Format',enabledPlugin:navigator.plugins[0]},{type:'text/pdf',suffixes:'pdf',description:'Portable Document Format',enabledPlugin:navigator.plugins[0]}] });"""
# ── Text Classification Utilities ────────────────────────────────────
def kw_match(text: str) -> bool:
t = text.lower()
return any(kw in t for kw in BROAD_KEYWORDS)
def is_request(text: str) -> bool:
"""Detect if text contains a request for services (e.g. "I need", "looking for")."""
t = text.lower()
return any(re.search(p, t) for p in REQUEST_PATTERNS)
def is_offer(text: str) -> bool:
"""Detect if text is an offer/advertisement (we skip these, not leads)."""
t = text.lower()
return any(re.search(p, t) for p in OFFER_PATTERNS)
# ── Facebook Search Queries ───────────────────────────────────────────
# These are the search terms the scraper will use on Facebook.
# They target people looking for web development / website services.
# Each run picks 2-4 random queries to vary behavior and reduce detection.
FB_SEARCHES = [
"looking for web developer",
"need a website designed",
@@ -380,6 +543,12 @@ VIEWPORTS = [
{'width': 1920, 'height': 1080},
]
# ── Cookie Extraction ────────────────────────────────────────────────
# Reads Facebook cookies from the browser profile's SQLite database.
# Firefox stores cookies in cookies.sqlite; Chromium stores them in Cookies
# (a SQLite DB as well). This function handles Firefox's format.
# The cookies are copied to a temp file first to avoid locking issues.
async def get_fb_cookies(profile_path: str | None = None):
cookie_db_path = profile_path or FX_PROFILE
if not cookie_db_path:
@@ -424,6 +593,9 @@ async def get_fb_cookies(profile_path: str | None = None):
pass
return []
# ── Date Parsing ─────────────────────────────────────────────────────
# Facebook displays dates in relative format ("2h ago", "Yesterday", "Monday")
# or various absolute formats. These functions normalize them to YYYY-MM-DD.
WEEKDAY_ORDER = ['monday', 'tuesday', 'wednesday', 'thursday', 'friday', 'saturday', 'sunday']
def _parse_fb_date(block: list[str]) -> str:
@@ -477,6 +649,12 @@ def _clean_fb_text(text: str) -> str:
cleaned_lines.append(stripped)
return '\n'.join(cleaned_lines)
# ── Post Extraction ──────────────────────────────────────────────────
# Two strategies for extracting posts from page content:
# 1. _extract_posts_from_elements — uses structured DOM data from _get_article_elements()
# 2. _extract_posts_from_text — fallback that parses raw page text line by line
# Both apply dedup (seen_texts), offer filtering, and request scoring.
def _extract_posts_from_elements(elements: list[dict], base_url: str) -> list[dict]:
posts = []
seen_texts = set()
@@ -569,6 +747,11 @@ def _extract_posts_from_text(raw: str, url: str) -> list[dict]:
cur.pop(0)
return posts
# ── Human-like Behavior Simulation ──────────────────────────────────
# These functions add random delays, mouse movements, and scroll patterns
# to make automated browsing look more like a real human user.
# This is critical for avoiding Facebook's bot detection.
async def human_scroll(page, steps: int = None, total_delay: float = None):
steps = steps or random.randint(2, 5)
total_delay = total_delay or random.uniform(6, 18)
@@ -604,6 +787,12 @@ async def random_idle(page):
except Exception:
pass
# ── Facebook DOM Parsing ─────────────────────────────────────────────
# This JS function runs inside the page context to extract structured post
# data (text, author, URL, date) from Facebook's complex DOM structure.
# It tries multiple selectors (article, feed, etc.) and uses the first
# one that returns results, since Facebook's DOM changes frequently.
async def _get_article_elements(page) -> list[dict]:
return await page.evaluate('''() => {
const results = [];
@@ -665,6 +854,8 @@ async def _get_article_elements(page) -> list[dict]:
}''')
async def _ensure_page(page, context):
"""Check if the current page is still alive. If closed (e.g. by a popup),
create a fresh page and navigate to Facebook."""
try:
await page.evaluate('1')
return page
@@ -680,6 +871,12 @@ async def _ensure_page(page, context):
await page.wait_for_timeout(random.randint(3000, 8000))
return page
# ── Facebook Search & Scrape ─────────────────────────────────────────
# Performs a Facebook search for a given query, scrolls through results,
# extracts posts, and returns them. Includes randomization of scroll behavior,
# mouse movements, and idle actions to mimic human browsing patterns.
async def search_facebook(page, context, query: str):
page = await _ensure_page(page, context)
url = f'https://www.facebook.com/search/posts/?q={urllib.parse.quote(query)}'
@@ -716,6 +913,10 @@ async def search_facebook(page, context, query: str):
return page, []
return page, posts
# ── Detection Signals ────────────────────────────────────────────────
# Keywords/phrases in page URL or body text that indicate Facebook's
# security/bot detection systems have flagged the session.
DETECTION_SIGNALS = [
'/checkpoint/', '/login.php?', 'action=security_check',
'unusual activity', 'suspicious login', 'suspicious activity',
@@ -736,39 +937,90 @@ def check_detection_signals(page_url: str, page_text: str = '') -> str | None:
def cleanup_chrome():
"""Kill orphaned Chrome headless shell processes. These sometimes linger
after failed scrapes and interfere with subsequent runs."""
import subprocess, signal
try:
subprocess.run(["taskkill", "/F", "/IM", "chrome-headless-shell.exe"], capture_output=True, timeout=5)
except Exception:
pass
# ── Main Scrape Dispatcher ────────────────────────────────────────────
# scrape_facebook() is the main entry point. It:
# 1. Resolves the browser profile path (from SELECTED_BROWSER env var or auto-detect)
# 2. Dispatches to the correct browser-specific scraper
# 3. If the browser scrape is flagged by Facebook, falls through to the Agent fallback
#
# Priority order for browser selection:
# - SELECTED_BROWSER env var (set by setup wizard) → that browser's scraper
# - Auto-detect: Firefox → Opera → Chrome → Edge (first found wins)
# - If no profile found → Agent (browser-use + ChatOllama) fallback
async def scrape_facebook(profile_path: str | None = None, force: bool = False) -> dict:
"""Dispatcher — Firefox primary, browser-use Agent fallback."""
effective_path = profile_path or FX_PROFILE
browser_type = detect_browser_from_profile(effective_path)
"""Dispatcher — respect SELECTED_BROWSER, fall through on flag, then Agent."""
effective_path = profile_path or ""
browser_type = ""
if not browser_type and CHROME_PROFILE:
browser_type = detect_browser_from_profile(CHROME_PROFILE)
effective_path = CHROME_PROFILE
# Resolve profile and browser type
if effective_path:
browser_type = detect_browser_from_profile(effective_path) or ""
elif SELECTED_BROWSER == "firefox":
effective_path = FX_PROFILE or find_firefox_profile() or ""
browser_type = "firefox"
elif SELECTED_BROWSER == "opera":
effective_path = OPERA_PROFILE or find_opera_profile() or ""
browser_type = "opera"
elif SELECTED_BROWSER == "edge":
effective_path = EDGE_PROFILE or find_edge_profile() or ""
browser_type = "edge"
elif SELECTED_BROWSER == "chrome":
effective_path = CHROME_PROFILE or find_chrome_profile() or ""
browser_type = "chrome"
else:
# Auto-detect — try all in priority order
for name, env_var, find_fn in [
("firefox", "FX_PROFILE", find_firefox_profile),
("opera", "OPERA_PROFILE", find_opera_profile),
("chrome", "CHROME_PROFILE", find_chrome_profile),
("edge", "EDGE_PROFILE", find_edge_profile),
]:
p = os.getenv(env_var, "") or find_fn() or ""
if p:
effective_path = p
browser_type = name
break
logger.info("Detected browser: %s (profile: %s)", browser_type or "none", effective_path)
if not browser_type or not effective_path:
logger.info("No profile found — falling back to Agent")
return await _scrape_with_agent(force)
# Firefox primary (raw Playwright, stealth)
logger.info("Selected browser: %s (profile: %s)", browser_type, effective_path)
# Firefox path
if browser_type == "firefox":
result = await _scrape_with_firefox(effective_path, force)
if result.get("success") or not result.get("flagged"):
return result
logger.warning("Firefox path failed (%s), falling back to Agent", result.get("flag_reason", "unknown"))
logger.warning("Firefox flagged (%s), trying Agent", result.get("flag_reason", "unknown"))
return await _scrape_with_agent(force)
# CHROME_PROFILE set or no profile → Agent
if browser_type == "chromium":
return await _scrape_with_agent(force)
# No profile at all → try Agent (fresh Chromium)
# Chromium-based (chrome / opera / edge)
result = await _scrape_with_chromium(effective_path, browser_type, force)
if result.get("success") or not result.get("flagged"):
return result
logger.warning("%s flagged (%s), trying Agent", browser_type, result.get("flag_reason", "unknown"))
return await _scrape_with_agent(force)
# ── Firefox Scraper ──────────────────────────────────────────────────
# Launches a headless Firefox with the user's real profile cookies (copied
# to a temp dir). Uses firefox_user_prefs to disable automation flags and
# tracking protection. Injects anti-detection script via add_init_script().
# If force=False, randomly skips 30% of runs as a decoy (looks like random
# human browsing). If flagged by Facebook, returns flagged=True for the
# dispatcher to fall through to the Agent.
async def _scrape_with_firefox(profile_path: str, force: bool) -> dict:
"""Scrape Facebook using Firefox + persistent real profile (no cookie injection)."""
if not profile_path:
@@ -876,6 +1128,136 @@ async def _scrape_with_firefox(profile_path: str, force: bool) -> dict:
pass
# ── Chromium Scraper ─────────────────────────────────────────────────
# Generic scraper for Chrome/Edge/Opera. Uses the same structure as the
# Firefox scraper but with Chromium-specific launch config:
# - Chrome: channel="chrome"
# - Edge: channel="msedge"
# - Opera: executable_path from shutil.which("opera")
# Cookies come from copy_chrome_profile (temp copy of user's profile data).
# Anti-detection script differs slightly from Firefox variant.
# Same decoy-skip and flagged-fallback behavior as Firefox.
async def _scrape_with_chromium(profile_path: str, browser: str, force: bool = False) -> dict:
"""Scrape Facebook using a Chromium-based browser profile (Chrome/Edge/Opera)."""
if not profile_path:
return {"success": False, "leads": [], "flagged": False, "flag_reason": None, "error": "No profile path"}
channel = None
executable_path = None
if browser == "chrome":
channel = "chrome"
elif browser == "edge":
channel = "msedge"
elif browser == "opera":
executable_path = shutil.which("opera") or shutil.which("Opera")
profile_dir = None
try:
profile_dir = copy_chrome_profile(profile_path)
async with async_playwright() as pw:
launch_kwargs = dict(
user_data_dir=profile_dir,
headless=True,
args=CHROME_LAUNCH_ARGS,
)
if channel:
launch_kwargs["channel"] = channel
if executable_path:
launch_kwargs["executable_path"] = executable_path
context = await pw.chromium.launch_persistent_context(**launch_kwargs)
pages = context.pages
page = pages[0] if pages else await context.new_page()
await context.add_init_script(chromium_init_script())
try:
await page.goto('https://www.google.com/', wait_until='domcontentloaded', timeout=15000)
await page.wait_for_timeout(random.randint(1000, 3000))
except Exception:
logger.warning("Google navigation failed, trying Facebook directly")
await page.goto('https://www.facebook.com/', wait_until='domcontentloaded', timeout=30000)
await page.wait_for_timeout(random.randint(3000, 8000))
url = page.url
page_text = await page.evaluate('document.body.innerText') if '/login' in url.lower() else ''
det = check_detection_signals(url, page_text)
if det or '/login' in url.lower():
logger.warning("Facebook login page detected — flag: %s", det or "login_page")
await context.close()
return {"success": False, "leads": [], "flagged": True, "flag_reason": det or "login_page", "error": "Facebook login page detected"}
await human_scroll(page, steps=random.randint(2, 4), total_delay=random.uniform(8, 20))
if random.random() < 0.25:
await page.evaluate("window.scrollTo(0, 0)")
await page.wait_for_timeout(random.randint(2000, 5000))
await human_scroll(page, steps=random.randint(1, 2))
if not force and random.random() < 0.3:
await page.wait_for_timeout(random.randint(8000, 20000))
await context.close()
return {"success": True, "leads": [], "flagged": False, "flag_reason": None, "error": None}
all_posts = []
searches = random.sample(FB_SEARCHES, k=random.randint(2, 4))
for i, query in enumerate(searches):
page, posts = await search_facebook(page, context, query)
all_posts.extend(posts)
if not posts:
continue
if random.random() < 0.4:
await page.evaluate(f"window.scrollBy(0, {random.randint(-300, 300)})")
delay = random.uniform(8, 25)
await page.wait_for_timeout(int(delay * 1000))
if i == random.randint(0, 1) and random.random() < 0.15:
new_page = await context.new_page()
try:
await new_page.goto('https://www.facebook.com/groups/', wait_until='domcontentloaded', timeout=15000)
await new_page.wait_for_timeout(random.randint(3000, 8000))
except Exception:
pass
await new_page.close()
page = await _ensure_page(page, context)
if random.random() < 0.5:
await page.wait_for_timeout(random.randint(3000, 10000))
await context.close()
seen = set()
deduped = []
for p in all_posts:
key = p.get('content', '')[:100]
if key not in seen:
seen.add(key)
deduped.append(p)
deduped = [p for p in deduped if _is_within_days(p.get('date', ''), 3)]
leads = deduped[:20]
if leads:
leads = await classify_leads(leads)
return {"success": True, "leads": leads[:15], "flagged": False, "flag_reason": None, "error": None}
except Exception as e:
logger.error("%s scrape failed: %s", browser, e)
return {"success": False, "leads": [], "flagged": False, "flag_reason": None, "error": str(e)}
finally:
if profile_dir and os.path.exists(profile_dir):
try:
shutil.rmtree(profile_dir, ignore_errors=True)
except Exception:
pass
# ── Agent Fallback ──────────────────────────────────────────────────
# When all browser-based scrapers fail/are flagged, this fallback uses
# browser-use Agent with ChatOllama (local, free) to navigate Facebook
# autonomously via GPT-style prompting. No API keys needed.
# Uses Chromium headless with the same launch args as _scrape_with_chromium.
# The Agent is prompted to extract structured post data and return JSON.
async def _scrape_with_agent(force: bool = False) -> dict:
"""Fallback scraper — browser-use Agent + ChatOllama (free/local, Chromium)."""
cleanup_chrome()
@@ -886,7 +1268,7 @@ async def _scrape_with_agent(force: bool = False) -> dict:
profile_dir = copy_chrome_profile(CHROME_PROFILE)
user_data_dir = profile_dir
browser = Browser(
browser = _make_browser(
headless=True,
args=CHROME_LAUNCH_ARGS,
user_data_dir=user_data_dir,
@@ -896,7 +1278,7 @@ async def _scrape_with_agent(force: bool = False) -> dict:
all_posts = []
for query in random.sample(FB_SEARCHES, k=random.randint(2, 4)):
agent = Agent(
agent = _make_agent(
task=f"""You are logged into Facebook. Do the following:
1. Navigate to facebook.com and make sure you are on the homepage
2. Use the Facebook search to find: {query}
@@ -910,7 +1292,7 @@ async def _scrape_with_agent(force: bool = False) -> dict:
6. Collect as many posts as you can (aim for 5-10 per search)
When done, return the data as a JSON list with keys: content, author, url, date.""",
llm=make_ollama(num_ctx=32000, temperature=0.3),
llm=_make_ollama(num_ctx=32000, temperature=0.3),
browser=browser,
max_actions_per_step=3,
use_vision=False,
@@ -953,6 +1335,12 @@ When done, return the data as a JSON list with keys: content, author, url, date.
except Exception as e:
logger.warning("Failed to clean up Chrome profile %s: %s", profile_dir, e)
# ── AI Classification ────────────────────────────────────────────────
# Uses the local Ollama model to classify scraped posts as LEAD or NOT LEAD.
# Falls back to keyword-based filtering if the AI is unavailable.
# ask_ollama() sends a raw prompt to Ollama's /api/chat endpoint.
# classify_leads() builds a classification prompt and parses the JSON response.
async def ask_ollama(prompt: str) -> str:
import httpx
async with httpx.AsyncClient(timeout=120) as c:
@@ -1038,19 +1426,107 @@ Return a JSON array like ["yes","no","yes"] matching the order above."""
return filtered[:10]
return []
# ══════════════════════════════════════════════════════════════════════
# FastAPI Endpoints
# ══════════════════════════════════════════════════════════════════════
@app.get("/health")
async def health():
"""Simple health check for the splash page status polling."""
return {"status": "ok"}
def _detect_all_profiles() -> dict:
"""Return all detected browser profiles."""
return {
"firefox": FX_PROFILE or find_firefox_profile() or "",
"opera": OPERA_PROFILE or find_opera_profile() or "",
"chrome": CHROME_PROFILE or find_chrome_profile() or "",
"edge": EDGE_PROFILE or find_edge_profile() or "",
}
@app.get("/setup/profile")
async def setup_profile():
"""Auto-detect all browser profiles."""
return _detect_all_profiles()
async def _check_browser_login(profile_path: str, browser: str) -> dict:
"""Check Facebook login for a specific browser type."""
profile_dir = None
try:
if browser == "firefox":
profile_dir = copy_firefox_profile(profile_path)
async with async_playwright() as pw:
context = await pw.firefox.launch_persistent_context(
user_data_dir=profile_dir, headless=True,
firefox_user_prefs={"dom.webdriver.enabled": False},
)
page = context.pages[0] if context.pages else await context.new_page()
await page.goto("https://www.facebook.com/", wait_until="domcontentloaded", timeout=30000)
await page.wait_for_timeout(3000)
logged_in = "/login" not in page.url.lower()
await context.close()
shutil.rmtree(profile_dir, ignore_errors=True)
return {"logged_in": logged_in, "browser": browser, "path": profile_path}
else:
channel = None
exe = None
if browser == "chrome":
channel = "chrome"
elif browser == "edge":
channel = "msedge"
elif browser == "opera":
exe = shutil.which("opera") or shutil.which("Opera")
profile_dir = copy_chrome_profile(profile_path)
async with async_playwright() as pw:
kw = dict(user_data_dir=profile_dir, headless=True, args=CHROME_LAUNCH_ARGS)
if channel:
kw["channel"] = channel
if exe:
kw["executable_path"] = exe
context = await pw.chromium.launch_persistent_context(**kw)
page = context.pages[0] if context.pages else await context.new_page()
await page.goto("https://www.facebook.com/", wait_until="domcontentloaded", timeout=30000)
await page.wait_for_timeout(3000)
logged_in = "/login" not in page.url.lower()
await context.close()
shutil.rmtree(profile_dir, ignore_errors=True)
return {"logged_in": logged_in, "browser": browser, "path": profile_path}
except Exception as e:
if profile_dir:
try: shutil.rmtree(profile_dir, ignore_errors=True)
except: pass
return {"logged_in": False, "browser": browser, "path": profile_path, "error": str(e)}
@app.post("/setup/check-login")
async def setup_check_login(body: dict):
"""Check if Facebook is logged in. Accepts optional 'browser' param, tries all if not given."""
browser = (body.get("browser") or "").strip().lower()
profile_path = (body.get("profile_path") or "").strip()
if browser and profile_path:
return await _check_browser_login(profile_path, browser)
# No specific browser — try all detected
profiles = _detect_all_profiles()
priority = ["firefox", "opera", "chrome", "edge"]
for b in priority:
p = profiles.get(b, "")
if p:
result = await _check_browser_login(p, b)
if result.get("logged_in"):
return result
logger.info("%s not logged in, trying next", b)
return {"logged_in": False, "reason": "none_logged_in", "message": "No browser with Facebook login found"}
@app.post("/agent/run")
async def agent_run(task: str = Body(..., embed=True)):
"""Run a browser-use Agent with ChatOllama (free/local)."""
try:
browser = Browser(headless=True, args=CHROME_LAUNCH_ARGS)
browser = _make_browser(headless=True, args=CHROME_LAUNCH_ARGS)
await browser.start()
agent = Agent(
agent = _make_agent(
task=task,
llm=make_ollama(num_ctx=32000, temperature=0.3),
llm=_make_ollama(num_ctx=32000, temperature=0.3),
browser=browser,
use_vision=False,
max_actions_per_step=5,
+18
View File
@@ -0,0 +1,18 @@
# ── Python Dependencies for the Facebook Scraper (FastAPI) ───────
# Install via: pip install -r requirements.txt
# Web framework for the REST API (health, setup, scrape endpoints)
fastapi>=0.115.0
# ASGI server for running the FastAPI app
uvicorn>=0.34.0
# Browser automation (launches Firefox, Chrome, Edge, Opera via Playwright)
playwright>=1.49.0
# AI-powered browser agent (fallback when direct browser scraping is flagged)
# Uses ChatOllama locally — no API keys needed
browser-use>=0.1.0
# LangChain integration for ChatOllama (provides the LLM for browser-use Agent)
langchain-ollama>=0.2.0
@@ -0,0 +1,24 @@
CREATE TABLE IF NOT EXISTS scheduled_events (
id UUID PRIMARY KEY DEFAULT uuid_generate_v4(),
user_id UUID NOT NULL REFERENCES users(id) ON DELETE CASCADE,
participant_id UUID REFERENCES users(id) ON DELETE SET NULL,
lead_id UUID REFERENCES leads(id) ON DELETE SET NULL,
conversation_id UUID REFERENCES conversations(id) ON DELETE SET NULL,
title VARCHAR(255) NOT NULL,
description TEXT,
event_type VARCHAR(20) NOT NULL DEFAULT 'meeting',
start_time TIMESTAMPTZ NOT NULL,
end_time TIMESTAMPTZ,
duration_minutes INT,
status VARCHAR(20) NOT NULL DEFAULT 'scheduled',
created_at TIMESTAMPTZ NOT NULL DEFAULT NOW(),
updated_at TIMESTAMPTZ NOT NULL DEFAULT NOW(),
CONSTRAINT chk_event_type CHECK (event_type IN ('meeting','call','chat','follow_up','demo','other')),
CONSTRAINT chk_event_status CHECK (status IN ('scheduled','completed','cancelled','rescheduled'))
);
CREATE INDEX IF NOT EXISTS idx_scheduled_events_user ON scheduled_events(user_id, start_time);
CREATE INDEX IF NOT EXISTS idx_scheduled_events_participant ON scheduled_events(participant_id);
CREATE INDEX IF NOT EXISTS idx_scheduled_events_lead ON scheduled_events(lead_id);
CREATE INDEX IF NOT EXISTS idx_scheduled_events_date ON scheduled_events(start_time);
CREATE INDEX IF NOT EXISTS idx_scheduled_events_status ON scheduled_events(user_id, status);
+12
View File
@@ -0,0 +1,12 @@
CREATE TABLE IF NOT EXISTS sent_emails (
id UUID PRIMARY KEY DEFAULT uuid_generate_v4(),
recipient VARCHAR(255) NOT NULL,
subject VARCHAR(255) NOT NULL,
body_html TEXT,
body_text TEXT,
event_id UUID,
created_at TIMESTAMPTZ NOT NULL DEFAULT NOW()
);
CREATE INDEX IF NOT EXISTS idx_sent_emails_recipient ON sent_emails(recipient);
CREATE INDEX IF NOT EXISTS idx_sent_emails_created ON sent_emails(created_at DESC);
+22
View File
@@ -0,0 +1,22 @@
-- ============================================================================
-- Migration 013: Row-Level Security for scheduled_events
-- ============================================================================
-- Enables RLS on the scheduled_events table so that users can only see
-- their own events at the database level. This is defense-in-depth:
-- the API already filters by user_id, but RLS ensures data isolation
-- even if there's a bug in the application layer.
--
-- The policy allows all operations when app.current_user_id is NULL
-- (backward-compatible fallback for queries that don't set the variable).
-- When the variable IS set, only rows matching the user's ID are visible.
-- ============================================================================
ALTER TABLE scheduled_events ENABLE ROW LEVEL SECURITY;
DROP POLICY IF EXISTS scheduled_events_user_policy ON scheduled_events;
CREATE POLICY scheduled_events_user_policy ON scheduled_events
FOR ALL
USING (
user_id = current_setting('app.current_user_id', true)::uuid
OR current_setting('app.current_user_id', true) IS NULL
);
@@ -0,0 +1,5 @@
ALTER TABLE notifications
ADD COLUMN IF NOT EXISTS context_id UUID,
ADD COLUMN IF NOT EXISTS context_type VARCHAR(50);
CREATE INDEX IF NOT EXISTS idx_notifications_context ON notifications(context_type, context_id);
@@ -0,0 +1,19 @@
-- ============================================================================
-- Migration 015: Fix RLS for scheduled_events to include participant_id
-- ============================================================================
-- Previously, the RLS policy only checked user_id, which meant participants
-- could not see events at the database level (the app-layer WHERE clause
-- did the filtering, but RLS was defense-in-depth that missed this case).
--
-- This policy also allows app.current_user_id IS NULL for backward compat
-- with queries that don't set the session variable.
-- ============================================================================
DROP POLICY IF EXISTS scheduled_events_user_policy ON scheduled_events;
CREATE POLICY scheduled_events_user_policy ON scheduled_events
FOR ALL
USING (
user_id = current_setting('app.current_user_id', true)::uuid
OR participant_id = current_setting('app.current_user_id', true)::uuid
OR current_setting('app.current_user_id', true) IS NULL
);
@@ -0,0 +1 @@
ALTER TABLE scheduled_events ADD COLUMN IF NOT EXISTS participant_notes TEXT;
+24 -1
View File
@@ -34,11 +34,34 @@ BEGIN;
\echo '=== Running 009_settings.sql (Company Settings + User Preferences) ==='
\i 009_settings.sql
\echo '=== Running 013_security_upgrade.sql (Security Architecture: RLS, Encryption, Master Keys, Backups) ==='
\echo '=== Running 010_chat_notifications.sql (Chat Notifications) ==='
\i 010_chat_notifications.sql
\echo '=== Running 010_facebook_accounts.sql (Facebook Accounts) ==='
\i 010_facebook_accounts.sql
\echo '=== Running 011_calendar_events.sql (Calendar Events) ==='
\i 011_calendar_events.sql
\echo '=== Running 012_sent_emails.sql (Sent Email Log) ==='
\i 012_sent_emails.sql
\echo '=== Running 013_security_upgrade.sql (Security Architecture) ==='
\i 013_security_upgrade.sql
\echo '=== Running 013_rls_calendar.sql (Calendar RLS) ==='
\i 013_rls_calendar.sql
\echo '=== Running 014_bug_reports.sql (Bug Reporting System) ==='
\i 014_bug_reports.sql
\echo '=== Running 014_notifications_context.sql (Notifications Context) ==='
\i 014_notifications_context.sql
\echo '=== Running 015_rls_calendar_participant.sql (Calendar RLS Participant Fix) ==='
\i 015_rls_calendar_participant.sql
\echo '=== Running 016_participant_notes.sql (Participant Notes Column) ==='
\i 016_participant_notes.sql
\echo '=== Migration Complete ==='
COMMIT;
-9
View File
@@ -1,12 +1,4 @@
import type { NextConfig } from "next"
import crypto from "crypto"
// In development, generate a random JWT secret on every server start.
// This invalidates all previously issued JWT tokens, ensuring the user
// must re-authenticate after each dev server restart.
if (process.env.NODE_ENV === "development") {
process.env.JWT_SECRET = crypto.randomBytes(32).toString("hex")
}
const nextConfig: NextConfig = {
eslint: {
@@ -20,7 +12,6 @@ const nextConfig: NextConfig = {
},
],
},
}
export default nextConfig
+1358 -142
View File
File diff suppressed because it is too large Load Diff
+10 -4
View File
@@ -5,13 +5,14 @@
"scripts": {
"dev": "npm run dev:precheck & npm run dev:ollama & npm run dev:start",
"dev:signaling": "node signaling-server.mjs",
"dev:open": "powershell -NoProfile -Command \"Start-Sleep 8; Start-Process 'http://localhost:3001/splash'\"",
"dev:open": "node scripts/open-browser.mjs",
"dev:start": "concurrently -n AI,BROWSE,SIGNAL,NEXT,OPEN -c cyan,magenta,yellow,green,white \"npm run dev:rust\" \"npm run dev:browser-use\" \"npm run dev:signaling\" \"npm run dev:next\" \"npm run dev:open\"",
"dev:next": "next dev -p 3006",
"dev:precheck": "powershell -NoProfile -Command \"Get-NetTCPConnection -State Listen | Where-Object { $_.LocalPort -in 3001,3006,3007,3008 } | ForEach-Object { Stop-Process -Id $_.OwningProcess -Force -ErrorAction SilentlyContinue; Write-Host ('Freed port '+$_.LocalPort) }\"",
"dev:ollama": "powershell -NoProfile -Command \"$ollama = if (Get-Command ollama -ErrorAction SilentlyContinue) { 'ollama' } else { Join-Path $env:LOCALAPPDATA 'Programs\\Ollama\\ollama.exe' }; if (-not (Get-Process ollama -ErrorAction SilentlyContinue)) { Start-Process $ollama -ArgumentList 'serve' -WindowStyle Hidden; Start-Sleep 3 }; exit 0\"",
"dev:precheck": "node scripts/precheck.mjs",
"dev:ollama": "node scripts/ensure-ollama.mjs",
"dev:rust": "node ai-server/index.mjs",
"dev:browser-use": "set FX_PROFILE=C:\\Users\\USER-PC\\AppData\\Roaming\\Mozilla\\Firefox\\Profiles\\h8p11vlj.default-release && cd browser-use-service && python main.py",
"dev:browser-use": "cd browser-use-service && node ../scripts/run-python.mjs main.py",
"setup": "node scripts/setup.mjs",
"build": "next build",
"start": "next start -p 3006",
"lint": "eslint"
@@ -41,11 +42,14 @@
"bcryptjs": "^3.0.3",
"class-variance-authority": "^0.7.1",
"clsx": "^2.1.1",
"devenv": "^1.0.1",
"dotenv": "^17.4.2",
"framer-motion": "^11.15.0",
"jose": "^6.2.3",
"lucide-react": "^0.468.0",
"next": "15.0.4",
"next-themes": "^0.4.4",
"nodemailer": "^9.0.1",
"pg": "^8.21.0",
"react": "^18.3.1",
"react-dom": "^18.3.1",
@@ -60,12 +64,14 @@
},
"devDependencies": {
"@types/node": "^20",
"@types/nodemailer": "^8.0.1",
"@types/pg": "^8.20.0",
"@types/react": "^18",
"@types/react-dom": "^18",
"concurrently": "^10.0.3",
"eslint": "^9",
"eslint-config-next": "15.0.4",
"maildev": "^2.2.1",
"postcss": "^8.4.49",
"tailwindcss": "^3.4.17",
"typescript": "^5"
+1
View File
@@ -0,0 +1 @@
+1 -1
View File
@@ -446,7 +446,7 @@ async fn main() {
let database_url = std::env::var("DATABASE_URL").expect("DATABASE_URL must be set");
let jwt_secret = std::env::var("JWT_SECRET").expect("JWT_SECRET must be set");
let ollama_url = std::env::var("OLLAMA_BASE_URL").unwrap_or_else(|_| "http://localhost:11434".to_string());
let model = std::env::var("AI_MODEL").unwrap_or_else(|_| "llama3.2:3b".to_string());
let model = std::env::var("AI_MODEL").unwrap_or_else(|_| "dolphin-phi".to_string());
let host = std::env::var("AI_HOST").unwrap_or_else(|_| "127.0.0.1".to_string());
let port: u16 = std::env::var("AI_PORT").unwrap_or_else(|_| "3001".to_string()).parse().expect("AI_PORT must be a number");
+56
View File
@@ -0,0 +1,56 @@
// ── Ollama Launcher ────────────────────────────────────────────────
// Checks if Ollama is already running. If not, finds the binary and
// starts it as a detached background process.
// Cross-platform: uses tasklist (Windows) or pgrep (Linux/Mac) to
// detect running process; uses where/which to find the binary.
import { execSync, spawn } from "node:child_process"
import { platform } from "node:os"
function isRunning() {
try {
if (platform() === "win32") {
execSync('tasklist /FI "IMAGENAME eq ollama.exe" 2>nul | findstr ollama', { stdio: "pipe", timeout: 3000 })
} else {
execSync('pgrep -x ollama', { stdio: "pipe", timeout: 3000 })
}
return true
} catch {
return false
}
}
function findOllama() {
if (platform() === "win32") {
// Try PATH first, then common install locations
try {
return execSync("where ollama", { encoding: "utf8", timeout: 3000 }).trim().split("\n")[0]
} catch {}
const local = `${process.env.LOCALAPPDATA}\\Programs\\Ollama\\ollama.exe`
const programFiles = `${process.env.PROGRAMFILES}\\Ollama\\ollama.exe`
if (local) try { execSync(`"${local}" --version`, { stdio: "ignore", timeout: 2000 }); return local } catch {}
if (programFiles) try { execSync(`"${programFiles}" --version`, { stdio: "ignore", timeout: 2000 }); return programFiles } catch {}
} else {
try { return execSync("which ollama", { encoding: "utf8", timeout: 3000 }).trim() } catch {}
}
return null
}
if (!isRunning()) {
const bin = findOllama()
if (!bin) {
console.error("Ollama not found. Install from https://ollama.com")
process.exit(1)
}
console.log("Starting Ollama...")
// Spawn detached so it outlives this script and the npm process
if (platform() === "win32") {
spawn(bin, ["serve"], { stdio: "ignore", detached: true, windowsHide: true }).unref()
} else {
spawn(bin, ["serve"], { stdio: "ignore", detached: true }).unref()
}
// Give it a moment to start listening
execSync("sleep 3", { stdio: "ignore", timeout: 5000 })
} else {
console.log("Ollama already running")
}
+29
View File
@@ -0,0 +1,29 @@
// ── Browser Opener ─────────────────────────────────────────────────
// Opens the splash page in the user's default browser after an 8-second
// delay. The delay gives all services time to start before the user
// sees the loading screen.
// Cross-platform: uses start (Windows), open (Mac), or xdg-open (Linux).
import { execSync } from "node:child_process"
import { platform } from "node:os"
const url = process.argv[2] || "http://localhost:3001/splash"
const sleep = (ms) => new Promise((r) => setTimeout(r, ms))
async function main() {
await sleep(8000)
try {
if (platform() === "win32") {
execSync(`start "" "${url}"`, { stdio: "ignore", timeout: 5000 })
} else if (platform() === "darwin") {
execSync(`open "${url}"`, { stdio: "ignore", timeout: 5000 })
} else {
execSync(`xdg-open "${url}"`, { stdio: "ignore", timeout: 5000 })
}
} catch (e) {
console.error("Failed to open browser:", e.message)
}
}
main()
+38
View File
@@ -0,0 +1,38 @@
// ── Port Precheck ──────────────────────────────────────────────────
// Kills any existing processes on ports 3001, 3006, 3007, 3008.
// These are the AI server, Next.js frontend, Signaling server, and
// Python scraper respectively.
// Runs before anything else starts to avoid EADDRINUSE errors.
import { execSync } from "node:child_process"
import { platform } from "node:os"
const PORTS = [3001, 3006, 3007, 3008]
if (platform() === "win32") {
// Windows: use netstat + findstr to find listening PIDs, then taskkill
for (const port of PORTS) {
try {
const out = execSync(`netstat -ano | findstr "LISTENING" | findstr ":${port} "`, { encoding: "utf8", timeout: 5000 })
const lines = out.trim().split("\n").filter(Boolean)
for (const line of lines) {
const parts = line.trim().split(/\s+/)
const pid = parts[parts.length - 1]
if (pid) {
try { execSync(`taskkill /F /PID ${pid}`, { stdio: "ignore", timeout: 3000 }); console.log(`Freed port ${port} (PID ${pid})`) } catch {}
}
}
} catch {}
}
} else {
// Linux/Mac: use lsof -ti to find PIDs, then kill -9
for (const port of PORTS) {
try {
const pid = execSync(`lsof -ti:${port} 2>/dev/null`, { encoding: "utf8", timeout: 5000 }).trim()
if (pid) {
execSync(`kill -9 ${pid}`, { stdio: "ignore", timeout: 3000 })
console.log(`Freed port ${port} (PID ${pid})`)
}
} catch {}
}
}
+47
View File
@@ -0,0 +1,47 @@
// ── Python Runner ──────────────────────────────────────────────────
// Detects the system's Python executable (python vs python3) and runs
// a given script with arguments. Used by the dev:browser-use npm script.
// Avoids shell:true — spawns Python directly with its full path.
// Cross-platform: uses where (Windows) or which (Linux/Mac).
import { execSync, spawn } from "node:child_process"
import { platform } from "node:os"
import { statSync } from "node:fs"
function detectPython() {
const commonPaths = [
`${process.env.LOCALAPPDATA}\\Programs\\Python\\Python313\\python.exe`,
`${process.env.LOCALAPPDATA}\\Programs\\Python\\Python312\\python.exe`,
`${process.env.ProgramFiles}\\Python313\\python.exe`,
`${process.env.ProgramFiles}\\Python312\\python.exe`,
]
for (const p of commonPaths) {
try {
statSync(p)
return p
} catch {}
}
const candidates = platform() === "win32" ? ["python", "python3"] : ["python3", "python"]
for (const cmd of candidates) {
try {
const out = execSync(platform() === "win32" ? `where ${cmd}` : `which ${cmd}`, { encoding: "utf8", timeout: 5000 })
const path = out.trim().split("\n")[0].replace(/\r$/, "")
if (path) return path
} catch {}
}
console.error("Python not found. Install Python 3 from https://python.org")
process.exit(1)
}
const PYTHON = detectPython()
const script = process.argv[2]
const args = process.argv.slice(3)
if (!script) {
console.error("Usage: node run-python.mjs <script> [args...]")
process.exit(1)
}
// Spawn Python with inherited stdio so the script's output is visible
const proc = spawn(PYTHON, [script, ...args], { stdio: "inherit" })
proc.on("exit", (code) => process.exit(code ?? 1))
+82
View File
@@ -0,0 +1,82 @@
// ── One-command Setup ──────────────────────────────────────────────
// Run via: npm run setup
// Does the following in order:
// 1. npm install (Node.js dependencies)
// 2. pip install -r requirements.txt (Python dependencies)
// 3. playwright install firefox chromium (Playwright browsers)
// 4. Copies .env.example to .env.local if not exists
//
// All steps are cross-platform (Windows, Mac, Linux).
// Uses execSync for simplicity since each step blocks the next.
import { execSync } from "node:child_process"
import { existsSync, copyFileSync } from "node:fs"
import { platform } from "node:os"
const SEP = platform() === "win32" ? "&" : ";"
// Auto-detect Python executable (python vs python3)
function detectPython() {
const candidates = platform() === "win32" ? ["python", "python3"] : ["python3", "python"]
for (const cmd of candidates) {
try {
execSync(`${cmd} --version`, { stdio: "pipe", timeout: 5000 })
return cmd
} catch {}
}
console.error("Python not found. Install Python 3 from https://python.org")
process.exit(1)
}
// Auto-detect pip (pip vs pip3), fall back to python -m pip
function detectPip(python) {
const candidates = platform() === "win32" ? ["pip", "pip3"] : ["pip3", "pip"]
for (const cmd of candidates) {
try {
execSync(`${cmd} --version`, { stdio: "pipe", timeout: 5000 })
return cmd
} catch {}
}
return `${python} -m pip`
}
const PY = detectPython()
const PIP = detectPip(PY)
function run(cmd, label) {
console.log(`\n── ${label} ──`)
try {
execSync(cmd, { stdio: "inherit", timeout: 120000 })
} catch (e) {
console.error(` ✗ Failed: ${e.message}`)
process.exit(1)
}
}
console.log("=== CoastIT CRM Setup ===\n")
// 1. Node dependencies
run("npm install", "Installing Node.js dependencies")
// 2. Python dependencies (run from browser-use-service directory)
run(`cd browser-use-service ${SEP} ${PIP} install -r requirements.txt`, "Installing Python dependencies")
// 3. Playwright browsers (Firefox for primary scraping, Chromium for Chrome/Edge/Opera + Agent fallback)
run(`${PY} -m playwright install firefox chromium`, "Installing Playwright browsers")
// 4. .env file — create from template if it doesn't exist
if (!existsSync(".env.local")) {
console.log("\n── Creating .env.local ──")
copyFileSync(".env.example", ".env.local")
console.log(" ✓ Created .env.local from .env.example — edit it with your settings")
} else {
console.log("\n── .env.local already exists, skipping ──")
}
// 5. Remaining manual steps
console.log("\n── Next steps ──")
console.log(" 1. Make sure PostgreSQL is running with database 'crm'")
console.log(" 2. Pull the Ollama model: ollama pull dolphin-llama3:8b")
console.log(" 3. Edit .env.local with your settings")
console.log(" 4. Run: npm run dev")
console.log("\n=== Setup complete! ===")
+19
View File
@@ -143,6 +143,25 @@ io.on("connection", (socket) => {
io.to(targetSocketId).emit("call:busy", { from: userId })
}
})
socket.on("message:deleted", async ({ conversationId, messageId, senderId }) => {
try {
const result = await pool.query(
`SELECT cp.user_id FROM conversation_participants cp
WHERE cp.conversation_id = $1 AND cp.user_id != $2`,
[conversationId, senderId || userId],
)
for (const row of result.rows) {
const targetSocketId = onlineUsers.get(row.user_id)
if (targetSocketId) {
io.to(targetSocketId).emit("message:deleted", { conversationId, messageId })
}
}
console.log(`[message:deleted] broadcast msg=${messageId} conv=${conversationId} by=${senderId || userId}`)
} catch {
console.warn(`[message:deleted] failed to broadcast msg=${messageId}`)
}
})
}
socket.on("room:join", ({ roomId, displayName }) => {
+451 -69
View File
@@ -5,6 +5,7 @@
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<title>Loading CoastIT CRM</title>
<style>
/* ── Global Reset ──────────────────────────────────────────────── */
* { margin: 0; padding: 0; box-sizing: border-box; }
body {
background: #0a0a1a;
@@ -18,7 +19,8 @@
overflow: hidden;
}
/* Robot */
/* ── Robot Animation ────────────────────────────────────────────── */
/* Animated robot mascot that bobs, swings arms, and blinks while loading */
.robot {
position: relative;
width: 120px;
@@ -128,7 +130,9 @@
100% { transform: translateY(-4px) rotate(8deg); }
}
/* Status indicators */
/* ── Status Indicators ────────────────────────────────────────── */
/* Shows AI Server, Scraper, and Frontend status dots side by side.
Each transitions from dim → cyan (ready) or red (failed). */
.services {
display: flex;
flex-direction: column;
@@ -207,7 +211,7 @@
color: #ef4444;
}
/* Loading text */
/* ── Loading Text ──────────────────────────────────────────────── */
.loading-text {
font-size: 16px;
font-weight: 600;
@@ -222,7 +226,9 @@
50% { opacity: 1; }
}
/* Launch gear (inline, below everything) */
/* ── Launch Gear ────────────────────────────────────────────────── */
/* Appears below the loading text after all services are ready.
Shows a spinning gear + "Launching gear!" text, then redirects to /login. */
.launch-overlay {
display: none;
flex-direction: column;
@@ -255,7 +261,9 @@
100% { transform: scale(1); opacity: 1; }
}
/* Error state */
/* ── Error State ────────────────────────────────────────────────── */
/* When a service fails to start within the timeout (60s), show
red text, an error message, and a retry button. */
.loading-text.error {
background: linear-gradient(135deg, #ef4444, #f97316);
-webkit-background-clip: text;
@@ -293,11 +301,135 @@
.retry-btn.active {
display: inline-block;
}
/* ── Setup Wizard ──────────────────────────────────────────────── */
/* Full-screen 4-step wizard for first-time setup:
1. Environment check (Ollama, model, browser, Facebook login)
2. Browser auto-detection & selection
3. Ollama model pull with progress bar
4. Done — close wizard, start normal loading flow */
.setup-wizard {
display: none;
position: fixed;
inset: 0;
background: #0a0a1a;
z-index: 200;
flex-direction: column;
align-items: center;
justify-content: center;
padding: 40px 20px;
overflow-y: auto;
}
.setup-wizard.active { display: flex; }
.setup-step {
display: none;
flex-direction: column;
align-items: center;
max-width: 520px;
width: 100%;
animation: fadeIn 0.3s ease-out;
}
.setup-step.active { display: flex; }
@keyframes fadeIn { from { opacity: 0; transform: translateY(12px); } to { opacity: 1; transform: translateY(0); } }
.setup-steps {
display: flex;
gap: 8px;
margin-bottom: 32px;
}
.setup-dot {
width: 10px; height: 10px;
border-radius: 50%;
background: rgba(255,255,255,0.15);
transition: all 0.3s;
}
.setup-dot.done { background: #22d3ee; box-shadow: 0 0 6px rgba(34,211,238,0.5); }
.setup-dot.current { background: #6366f1; box-shadow: 0 0 6px rgba(99,102,241,0.5); }
.setup-title { font-size: 24px; font-weight: 700; margin-bottom: 8px; }
.setup-subtitle { font-size: 14px; opacity: 0.5; margin-bottom: 28px; text-align: center; }
.setup-card {
background: rgba(255,255,255,0.04);
border: 1px solid rgba(255,255,255,0.08);
border-radius: 12px;
padding: 20px;
width: 100%;
margin-bottom: 20px;
}
.check-row {
display: flex;
align-items: center;
gap: 12px;
padding: 10px 0;
border-bottom: 1px solid rgba(255,255,255,0.04);
}
.check-row:last-child { border-bottom: none; }
.check-icon { font-size: 18px; min-width: 24px; text-align: center; }
.check-label { flex: 1; font-size: 14px; }
.check-value { font-size: 12px; opacity: 0.5; max-width: 200px; overflow: hidden; text-overflow: ellipsis; white-space: nowrap; }
.setup-input {
width: 100%;
padding: 10px 14px;
background: rgba(255,255,255,0.06);
border: 1px solid rgba(255,255,255,0.12);
border-radius: 8px;
color: #fff;
font-size: 14px;
font-family: inherit;
outline: none;
transition: border-color 0.2s;
}
.setup-input:focus { border-color: #6366f1; }
.setup-btn {
padding: 10px 28px;
background: linear-gradient(135deg, #6366f1, #8b5cf6);
border: none;
border-radius: 8px;
color: #fff;
font-size: 14px;
font-weight: 600;
cursor: pointer;
transition: opacity 0.2s;
}
.setup-btn:hover { opacity: 0.85; }
.setup-btn:disabled { opacity: 0.4; cursor: not-allowed; }
.setup-btn.outline {
background: transparent;
border: 1px solid rgba(255,255,255,0.15);
}
.setup-btn.outline:hover { background: rgba(255,255,255,0.06); }
.setup-btns { display: flex; gap: 12px; margin-top: 8px; }
.progress-bar {
width: 100%;
height: 8px;
background: rgba(255,255,255,0.08);
border-radius: 4px;
overflow: hidden;
margin: 12px 0;
}
.progress-fill {
height: 100%;
background: linear-gradient(90deg, #6366f1, #22d3ee);
border-radius: 4px;
transition: width 0.5s ease;
width: 0%;
}
.status-ok { color: #22d3ee; }
.status-err { color: #ef4444; }
.status-warn { color: #f59e0b; }
</style>
</head>
<body>
<!-- Robot -->
<!-- ── Robot Mascot ────────────────────────────────────────────── -->
<div class="robot">
<div class="robot-antenna"></div>
<div class="robot-head">
@@ -312,7 +444,7 @@
</div>
</div>
<!-- Services -->
<!-- ── Service Status Indicators ───────────────────────────────── -->
<div class="services">
<div class="service" id="svc-ai">
<span class="service-name">AI Server</span>
@@ -345,109 +477,359 @@
<div class="loading-text" id="loading-text">Loading...</div>
<!-- Launch gear (inline, below everything) -->
<!-- ── Launch Gear ──────────────────────────────────────────── -->
<div class="launch-overlay" id="launch-overlay">
<div class="launch-gear"></div>
<div class="launch-text">🚀 Launching gear!</div>
<div class="launch-text">Launching gear!</div>
</div>
<div class="error-msg" id="error-msg"></div>
<button class="retry-btn" id="retry-btn" onclick="location.reload()">Try Again</button>
<script>
const MAX_ATTEMPTS = 30;
let attempts = 0;
<!-- ══════════════════════════════════════════════════════════════
Setup Wizard (4 steps, shown only on first run)
══════════════════════════════════════════════════════════════ -->
<div class="setup-wizard" id="setup-wizard">
<div class="setup-steps">
<div class="setup-dot current" id="sdot-1"></div>
<div class="setup-dot" id="sdot-2"></div>
<div class="setup-dot" id="sdot-3"></div>
<div class="setup-dot" id="sdot-4"></div>
</div>
<!-- Step 1: Environment check -->
<div class="setup-step active" id="sstep-1">
<div class="robot" style="margin-bottom:20px">
<div class="robot-antenna"></div>
<div class="robot-head"><div class="robot-eye left"></div><div class="robot-eye right"></div></div>
<div class="robot-body">
<div class="robot-arm left"></div><div class="robot-arm right"></div>
<div class="robot-leg left"></div><div class="robot-leg right"></div>
</div>
</div>
<div class="setup-title">Welcome to CoastIT CRM</div>
<div class="setup-subtitle">Let's check your environment before we start</div>
<div class="setup-card" id="env-checks">
<div class="check-row"><span class="check-icon" id="env-ollama"></span><span class="check-label">Ollama</span><span class="check-value" id="env-ollama-val">checking...</span></div>
<div class="check-row"><span class="check-icon" id="env-model"></span><span class="check-label">AI Model</span><span class="check-value" id="env-model-val">checking...</span></div>
<div class="check-row"><span class="check-icon" id="env-profile"></span><span class="check-label">Browser Profile</span><span class="check-value" id="env-profile-val">checking...</span></div>
<div class="check-row"><span class="check-icon" id="env-fb"></span><span class="check-label">Facebook Login</span><span class="check-value" id="env-fb-val">checking...</span></div>
</div>
<button class="setup-btn" id="welcome-next" onclick="goStep(2)">Next →</button>
</div>
<!-- Step 2: Browser auto-detection & selection -->
<div class="setup-step" id="sstep-2">
<div class="setup-title">Detect your browser</div>
<div class="setup-subtitle">We found these browsers with Facebook login. Click one to select it.</div>
<div class="setup-card" id="browser-list">
<div id="browser-scanning" style="text-align:center;padding:16px;opacity:0.6">🔍 Scanning for browsers...</div>
<div id="browser-results" style="display:none"></div>
</div>
<div class="setup-btns">
<button class="setup-btn outline" onclick="goStep(1)">← Back</button>
<button class="setup-btn" id="browser-confirm" onclick="confirmBrowser()" disabled>Confirm →</button>
</div>
</div>
<!-- Step 3: Ollama Model Pull -->
<div class="setup-step" id="sstep-3">
<div class="setup-title">AI Model</div>
<div class="setup-subtitle">We need to pull the AI model for the scraper to work</div>
<div class="setup-card" style="text-align:center">
<div style="font-size:14px;margin-bottom:8px;font-weight:600" id="model-name">dolphin-llama3:8b</div>
<div class="progress-bar"><div class="progress-fill" id="model-progress-fill"></div></div>
<div style="font-size:12px;opacity:0.5;margin-top:6px" id="model-status">Not downloaded yet</div>
</div>
<div class="setup-btns">
<button class="setup-btn outline" onclick="goStep(3)">← Back</button>
<button class="setup-btn" id="model-pull-btn" onclick="pullModel()">Pull Model</button>
</div>
</div>
<!-- Step 4: Done → Launch -->
<div class="setup-step" id="sstep-4">
<div style="font-size:64px;margin-bottom:16px">🎉</div>
<div class="setup-title">All set!</div>
<div class="setup-subtitle">Your environment is configured. We'll now start all services.</div>
<div class="setup-card" id="final-checks" style="text-align:center">
<div style="font-size:13px;opacity:0.7" id="final-summary">All checks passed</div>
</div>
<button class="setup-btn" onclick="finishSetup()" style="margin-top:8px">Launch 🚀</button>
</div>
</div>
<script>
// ══════════════════════════════════════════════════════════════════
// Client-side JavaScript
// ══════════════════════════════════════════════════════════════════
// ── State ──
let setupData = null;
let currentStep = 1;
const TOTAL_STEPS = 4;
let selectedBrowser = "";
// ── Normal loading state ──
const MAX_ATTEMPTS = 30; // 30 attempts × 2s = 60s timeout
let attempts = 0;
const CHECKS = [
{ id: 'ai', key: 'ai', ready: false, failed: false },
{ id: 'scraper', key: 'scraper', ready: false, failed: false },
{ id: 'frontend',key: 'frontend', ready: false, failed: false },
];
const MSGS = { ai: 'AI Ready', scraper: 'Scraper Ready', frontend: 'Frontend Ready' };
const NAMES = { ai: 'AI Server', scraper: 'Scraper', frontend: 'Frontend' };
const BROWSE_ICONS = { firefox: '🦊', opera: '🔵', chrome: '🌐', edge: '🔷' };
// ── Step navigation ──
function goStep(n) {
currentStep = n;
document.querySelectorAll('.setup-step').forEach((el, i) => {
el.classList.toggle('active', i + 1 === n);
});
document.querySelectorAll('.setup-dot').forEach((el, i) => {
el.classList.toggle('current', i + 1 === n);
el.classList.toggle('done', i + 1 < n);
});
if (n === 2) renderBrowserCards();
}
// ── Step 1: Welcome + Environment check ──
// Fetches /setup/status from the AI server, displays checkmarks/crosses
// for each environment requirement. If first_run is false, skips the
// wizard entirely and goes straight to the normal loading flow.
async function checkEnv() {
try {
const res = await fetch('/setup/status');
setupData = await res.json();
} catch {
setupData = { first_run: true, ollama_running: false, model_available: false, facebook_logged_in: false, browsers: {} };
}
setEnvStatus('ollama', setupData.ollama_running, setupData.ollama_running ? 'Running' : 'Not running');
setEnvStatus('model', setupData.model_available, setupData.model_available ? `${setupData.model_name} ✓` : 'Not pulled');
// Browser summary
const detected = Object.entries(setupData.browsers || {}).filter(([, v]) => v.path)
const loggedIn = detected.filter(([, v]) => v.logged_in)
const browserSummary = loggedIn.length
? loggedIn.map(([b]) => `${b.charAt(0).toUpperCase()+b.slice(1)} ✓`).join(', ')
: detected.length ? 'Found but not logged into Facebook' : 'None detected'
setEnvStatus('profile', detected.length > 0, browserSummary)
setEnvStatus('fb', setupData.facebook_logged_in, setupData.facebook_logged_in ? 'Logged in' : loggedIn.length ? '' : 'Not checked')
if (!setupData.first_run) {
document.getElementById('setup-wizard').classList.remove('active');
startNormalFlow();
return;
}
document.getElementById('setup-wizard').classList.add('active');
}
function setEnvStatus(id, ok, label) {
document.getElementById('env-' + id).textContent = ok ? '✅' : '❌';
document.getElementById('env-' + id + '-val').textContent = label;
}
// ── Step 2: Browser selection ──
// Renders clickable cards for each detected browser.
// Auto-selects the first one that has Facebook login.
// No manual path input needed — all detection is automatic.
function renderBrowserCards() {
const scanning = document.getElementById('browser-scanning');
const results = document.getElementById('browser-results');
const browsers = setupData.browsers || {};
const detected = Object.entries(browsers).filter(([, v]) => v.path)
if (detected.length === 0) {
scanning.textContent = '❌ No browsers with Facebook detected. Log in on your browser and click Retry.';
const retryBtn = document.createElement('button');
retryBtn.className = 'setup-btn';
retryBtn.textContent = 'Retry Detection';
retryBtn.onclick = () => location.reload();
scanning.after(retryBtn);
return;
}
scanning.style.display = 'none';
results.style.display = 'block';
// Auto-select first logged-in browser, or first detected
const firstLoggedIn = detected.find(([, v]) => v.logged_in)
selectedBrowser = firstLoggedIn ? firstLoggedIn[0] : detected[0][0]
results.innerHTML = detected.map(([name, info]) => {
const isSel = name === selectedBrowser
const icon = BROWSE_ICONS[name] || '🌐'
const status = info.logged_in ? '✅ Logged in' : info.path ? '⚠️ Not logged in' : '❌ Not found'
const pathShort = info.path ? info.path.length > 45 ? '...' + info.path.slice(-42) : info.path : ''
return `<div class="browser-card ${isSel ? 'selected' : ''}" data-browser="${name}" onclick="pickBrowser('${name}')" style="display:flex;align-items:center;gap:12px;padding:12px;border:2px solid ${isSel ? '#6366f1' : 'rgba(255,255,255,0.08)'};border-radius:10px;margin-bottom:8px;cursor:pointer;transition:all 0.2s">
<span style="font-size:24px">${icon}</span>
<div style="flex:1"><strong style="text-transform:capitalize">${name}</strong><div style="font-size:12px;opacity:0.5;overflow:hidden;text-overflow:ellipsis;white-space:nowrap">${pathShort}</div></div>
<span style="font-size:13px">${status}</span>
</div>`
}).join('')
document.getElementById('browser-confirm').disabled = false
}
function pickBrowser(name) {
selectedBrowser = name
document.querySelectorAll('.browser-card').forEach(el => {
el.style.borderColor = el.dataset.browser === name ? '#6366f1' : 'rgba(255,255,255,0.08)'
})
}
// Saves the selected browser to .env.local via the AI server's /setup/profile endpoint
async function confirmBrowser() {
if (!selectedBrowser) return
const btn = document.getElementById('browser-confirm')
btn.textContent = 'Saving...'
btn.disabled = true
const path = setupData.browsers[selectedBrowser].path
try {
const res = await fetch('/setup/profile', {
method: 'POST', headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ browser: selectedBrowser, path })
})
const data = await res.json()
if (data.success) {
setupData.selected_browser = selectedBrowser
goStep(3)
} else {
alert('Failed to save: ' + (data.error || 'unknown'))
btn.disabled = false
}
} catch {
alert('Could not save profile')
btn.disabled = false
}
btn.textContent = 'Confirm →'
}
// ── Step 3: Ollama Model Pull ──
// Kicks off an "ollama pull" via the AI server and polls for progress.
let pullPolling = false;
async function pullModel() {
if (pullPolling) return;
const btn = document.getElementById('model-pull-btn');
const fill = document.getElementById('model-progress-fill');
const status = document.getElementById('model-status');
btn.disabled = true;
btn.textContent = 'Starting...';
try {
const res = await fetch('/setup/ollama/pull', { method: 'POST' });
const data = await res.json();
if (data.status === 'already_running') { btn.textContent = 'Already running'; return }
btn.textContent = 'Downloading...';
pullPolling = true;
pollPullProgress();
} catch {
status.textContent = 'Failed to start download';
status.className = 'status-err';
btn.disabled = false;
btn.textContent = 'Retry';
}
}
async function pollPullProgress() {
const fill = document.getElementById('model-progress-fill');
const status = document.getElementById('model-status');
const btn = document.getElementById('model-pull-btn');
try {
const res = await fetch('/setup/ollama/pull/progress');
const data = await res.json();
fill.style.width = data.progress + '%';
if (data.status === 'done') {
status.textContent = '✅ Model downloaded!';
status.className = 'status-ok';
btn.textContent = 'Done';
setupData.model_available = true;
pullPolling = false;
setTimeout(() => goStep(4), 1500);
return;
}
if (data.status === 'failed') {
status.textContent = '❌ Download failed: ' + data.message;
status.className = 'status-err';
btn.textContent = 'Retry';
btn.disabled = false;
pullPolling = false;
return;
}
status.textContent = data.message || `Downloading... ${data.progress}%`;
setTimeout(pollPullProgress, 1000);
} catch {
status.textContent = 'Checking...';
setTimeout(pollPullProgress, 2000);
}
}
// ── Step 4: Finish Setup ──
// Closes the wizard and starts the normal loading flow.
function finishSetup() {
document.getElementById('setup-wizard').classList.remove('active');
startNormalFlow();
}
// ── Normal Loading Flow ──
// Polls /status every 2 seconds for up to 60 seconds.
// Tracks each service's state (ready/waiting/failed).
// All ready → show launch gear, redirect to /login after 5s.
// Any failed → show error message + retry button.
function startNormalFlow() { poll() }
async function checkAllServices() {
try {
const res = await fetch('/status', { cache: 'no-store' });
const data = await res.json();
for (const svc of CHECKS) {
svc.ready = data[svc.key] === true;
}
} catch {
for (const svc of CHECKS) svc.ready = false;
}
for (const svc of CHECKS) svc.ready = data[svc.key] === true;
} catch { for (const svc of CHECKS) svc.ready = false }
}
function updateUI() {
let allReady = true;
let anyFailed = false;
let allReady = true, anyFailed = false;
const failedNames = [];
for (const svc of CHECKS) {
const el = document.getElementById('svc-' + svc.id);
const statusText = document.getElementById('status-' + svc.id);
const st = document.getElementById('status-' + svc.id);
el.classList.remove('ready', 'failed');
if (svc.ready) {
el.classList.add('ready');
statusText.textContent = MSGS[svc.id];
} else if (svc.failed) {
el.classList.add('failed');
statusText.textContent = 'Failed';
anyFailed = true;
allReady = false;
failedNames.push(NAMES[svc.id]);
} else {
statusText.textContent = 'waiting...';
allReady = false;
}
if (svc.ready) { el.classList.add('ready'); st.textContent = MSGS[svc.id] }
else if (svc.failed) { el.classList.add('failed'); st.textContent = 'Failed'; anyFailed = true; allReady = false; failedNames.push(NAMES[svc.id]) }
else { st.textContent = 'waiting...'; allReady = false }
}
const loadingText = document.getElementById('loading-text');
const errorMsg = document.getElementById('error-msg');
const retryBtn = document.getElementById('retry-btn');
const lt = document.getElementById('loading-text'), em = document.getElementById('error-msg'), rb = document.getElementById('retry-btn'), lo = document.getElementById('launch-overlay');
if (anyFailed) {
loadingText.className = 'loading-text error';
loadingText.textContent = 'Something went wrong';
errorMsg.textContent = failedNames.join(', ') + ' failed to start. Check your terminal for details.';
errorMsg.classList.add('active');
retryBtn.classList.add('active');
document.getElementById('launch-overlay').classList.remove('active');
lt.className = 'loading-text error'; lt.textContent = 'Something went wrong';
em.textContent = failedNames.join(', ') + ' failed to start. Check your terminal.';
em.classList.add('active'); rb.classList.add('active'); lo.classList.remove('active');
} else if (allReady) {
loadingText.className = 'loading-text';
loadingText.textContent = 'All systems ready!';
errorMsg.classList.remove('active');
retryBtn.classList.remove('active');
document.getElementById('launch-overlay').classList.add('active');
lt.className = 'loading-text'; lt.textContent = 'All systems ready!';
em.classList.remove('active'); rb.classList.remove('active');
lo.classList.add('active');
setTimeout(() => { window.location.href = 'http://localhost:3006/login'; }, 5000);
} else {
loadingText.className = 'loading-text';
const ready = CHECKS.filter(s => s.ready).length;
loadingText.textContent = `Loading... (${ready}/${CHECKS.length})`;
lt.className = 'loading-text';
lt.textContent = `Loading... (${CHECKS.filter(s => s.ready).length}/${CHECKS.length})`;
}
}
async function poll() {
attempts++;
if (attempts > MAX_ATTEMPTS) {
for (const svc of CHECKS) {
if (!svc.ready) svc.failed = true;
}
updateUI();
return;
for (const svc of CHECKS) { if (!svc.ready) svc.failed = true }
updateUI(); return;
}
await checkAllServices();
updateUI();
if (!CHECKS.every(s => s.ready) && !CHECKS.some(s => s.failed)) {
await checkAllServices(); updateUI();
if (!CHECKS.every(s => s.ready) && !CHECKS.some(s => s.failed))
setTimeout(poll, 2000);
}
}
poll();
// ── Boot ──
// Entry point: check environment → shows wizard or starts normal flow
checkEnv();
</script>
</body>
</html>
</html>
+861
View File
@@ -0,0 +1,861 @@
"use client"
import { useState, useEffect, useCallback, Component, type ReactNode } from "react"
import { useRouter } from "next/navigation"
import { Button } from "@/components/ui/button"
import { Card } from "@/components/ui/card"
import { Badge } from "@/components/ui/badge"
import { CalendarEvent, EventType } from "@/types"
import {
Dialog, DialogContent, DialogHeader, DialogTitle, DialogFooter,
} from "@/components/ui/dialog"
import { Input } from "@/components/ui/input"
import { Label } from "@/components/ui/label"
import {
Select, SelectContent, SelectItem, SelectTrigger, SelectValue,
} from "@/components/ui/select"
import { Textarea } from "@/components/ui/textarea"
import { cn } from "@/lib/utils"
import { useUser } from "@/providers/user-provider"
import { useNotifications } from "@/providers/notification-provider"
import { toast } from "sonner"
import {
ChevronLeft, ChevronRight, Plus, Calendar, Clock, Phone,
Video, Users, CheckCircle2, XCircle, RotateCcw, Loader2,
ArrowUpRight, Zap, StickyNote,
} from "lucide-react"
const EVENT_ICONS: Record<EventType, React.ElementType> = {
meeting: Users,
call: Phone,
chat: Video,
follow_up: Clock,
demo: Calendar,
other: Calendar,
}
function eventVars(type: EventType) {
return `--event-${type}`
}
function eventBgStyle(type: EventType): React.CSSProperties {
const v = eventVars(type)
return {
backgroundColor: `hsl(var(${v}) / 0.1)`,
color: `hsl(var(${v}))`,
borderColor: `hsl(var(${v}) / 0.25)`,
}
}
function eventDotStyle(type: EventType): React.CSSProperties {
return { backgroundColor: `hsl(var(--event-${type}))` }
}
function getDaysInMonth(year: number, month: number) {
return new Date(year, month + 1, 0).getDate()
}
function getFirstDayOfMonth(year: number, month: number) {
return new Date(year, month, 1).getDay()
}
const MONTHS = ["January", "February", "March", "April", "May", "June", "July", "August", "September", "October", "November", "December"]
const DAYS = ["Sun", "Mon", "Tue", "Wed", "Thu", "Fri", "Sat"]
function formatTime(iso: string) {
return new Date(iso).toLocaleTimeString("en-US", { hour: "numeric", minute: "2-digit", hour12: true })
}
function formatDate(iso: string) {
return new Date(iso).toLocaleDateString("en-US", { month: "short", day: "numeric", year: "numeric" })
}
export default function CalendarPage() {
const router = useRouter()
const { user } = useUser()
const { addNotification } = useNotifications()
const [events, setEvents] = useState<CalendarEvent[]>([])
const [upcomingEvents, setUpcomingEvents] = useState<CalendarEvent[]>([])
const [loading, setLoading] = useState(true)
const [today] = useState(new Date())
const [currentMonth, setCurrentMonth] = useState(today.getMonth())
const [currentYear, setCurrentYear] = useState(today.getFullYear())
const [selectedDate, setSelectedDate] = useState<Date | null>(null)
const [dialogOpen, setDialogOpen] = useState(false)
const [editingEvent, setEditingEvent] = useState<CalendarEvent | null>(null)
const [detailEvent, setDetailEvent] = useState<CalendarEvent | null>(null)
const [users, setUsers] = useState<{ id: string; name: string; email: string }[]>([])
const [formTitle, setFormTitle] = useState("")
const [formDescription, setFormDescription] = useState("")
const [formType, setFormType] = useState<EventType>("meeting")
const [formDate, setFormDate] = useState("")
const [formTime, setFormTime] = useState("")
const [formDuration, setFormDuration] = useState("30")
const [formParticipantId, setFormParticipantId] = useState("")
const [formSaving, setFormSaving] = useState(false)
const [editNotes, setEditNotes] = useState(false)
const [notesText, setNotesText] = useState("")
const [notesSaving, setNotesSaving] = useState(false)
const fetchMonthEvents = useCallback(async () => {
const start = new Date(currentYear, currentMonth, 1).toISOString()
const end = new Date(currentYear, currentMonth + 1, 0, 23, 59, 59).toISOString()
const res = await fetch(`/api/events?start=${encodeURIComponent(start)}&end=${encodeURIComponent(end)}`)
if (res.ok) {
const data = await res.json()
setEvents(data.events || [])
}
}, [currentYear, currentMonth])
const fetchUpcomingEvents = useCallback(async () => {
const start = new Date().toISOString()
const end = new Date(Date.now() + 90 * 24 * 60 * 60 * 1000).toISOString()
const res = await fetch(`/api/events?start=${encodeURIComponent(start)}&end=${encodeURIComponent(end)}&status=scheduled`)
if (res.ok) {
const data = await res.json()
setUpcomingEvents((data.events || []).slice(0, 10))
}
}, [])
useEffect(() => {
if (!user) { router.push("/login"); return }
fetch("/api/event-users")
.then((r) => r.json())
.then((data) => setUsers(data.users || []))
.catch(() => {})
}, [user, router])
useEffect(() => {
setLoading(true)
fetchMonthEvents().catch(() => toast.error("Failed to load events")).finally(() => setLoading(false))
}, [fetchMonthEvents])
useEffect(() => {
fetchUpcomingEvents().catch(() => {})
}, [fetchUpcomingEvents])
useEffect(() => {
const interval = setInterval(() => {
fetchMonthEvents().catch(() => {})
fetchUpcomingEvents().catch(() => {})
}, 15000)
return () => clearInterval(interval)
}, [fetchMonthEvents, fetchUpcomingEvents])
const daysInMonth = getDaysInMonth(currentYear, currentMonth)
const firstDay = getFirstDayOfMonth(currentYear, currentMonth)
const prevMonth = () => {
if (currentMonth === 0) { setCurrentMonth(11); setCurrentYear(currentYear - 1) }
else setCurrentMonth(currentMonth - 1)
}
const nextMonth = () => {
if (currentMonth === 11) { setCurrentMonth(0); setCurrentYear(currentYear + 1) }
else setCurrentMonth(currentMonth + 1)
}
const getEventsForDay = useCallback((day: number) => {
const dateStr = `${currentYear}-${String(currentMonth + 1).padStart(2, "0")}-${String(day).padStart(2, "0")}`
return events.filter((e) => e.startTime.startsWith(dateStr))
}, [events, currentYear, currentMonth])
const isToday = (day: number) => {
return today.getDate() === day && today.getMonth() === currentMonth && today.getFullYear() === currentYear
}
const openCreateDialog = (day?: number) => {
setEditingEvent(null)
setFormTitle("")
setFormDescription("")
setFormType("meeting")
setFormDuration("30")
setFormParticipantId("")
const d = day ? new Date(currentYear, currentMonth, day) : new Date()
setFormDate(d.toISOString().split("T")[0])
setFormTime("10:00")
setDialogOpen(true)
}
const openEditDialog = (event: CalendarEvent) => {
setEditingEvent(event)
setFormTitle(event.title)
setFormDescription(event.description || "")
setFormType(event.eventType)
setFormDuration(String(event.durationMinutes || 30))
setFormDate(new Date(event.startTime).toISOString().split("T")[0])
setFormTime(new Date(event.startTime).toLocaleTimeString("en-US", { hour: "2-digit", minute: "2-digit", hour12: false }))
setFormParticipantId(event.participantId || "")
setDialogOpen(true)
}
const handleSave = async () => {
if (!formTitle.trim() || !formDate || !formTime) {
toast.error("Title, date, and time are required")
return
}
setFormSaving(true)
try {
const startTime = new Date(`${formDate}T${formTime}:00`).toISOString()
let endTime = null
if (formDuration) {
const end = new Date(startTime)
end.setMinutes(end.getMinutes() + parseInt(formDuration))
endTime = end.toISOString()
}
const body: Record<string, unknown> = {
title: formTitle.trim(),
description: formDescription.trim() || null,
eventType: formType,
startTime,
endTime,
durationMinutes: formDuration ? parseInt(formDuration) : null,
}
if (formParticipantId) body.participantId = formParticipantId
let res
if (editingEvent) {
res = await fetch(`/api/events/${editingEvent.id}`, {
method: "PATCH",
headers: { "Content-Type": "application/json" },
body: JSON.stringify(body),
})
} else {
res = await fetch("/api/events", {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify(body),
})
}
if (!res.ok) throw new Error("Failed to save")
const data = await res.json()
if (editingEvent) {
setEvents((prev) => prev.map((e) => e.id === editingEvent.id ? { ...data.event, participant: e.participant, lead: e.lead } : e))
toast.success("Event updated")
} else {
setEvents((prev) => [...prev, data.event])
addNotification("event_scheduled", `Event created: ${formTitle}`, `Scheduled for ${formatDate(startTime)} at ${formatTime(startTime)}`, "/calendar")
toast.success("Event created")
}
setDialogOpen(false)
} catch {
toast.error("Failed to save event")
} finally {
setFormSaving(false)
}
}
const updateEventStatus = async (event: CalendarEvent, newStatus: string) => {
try {
const res = await fetch(`/api/events/${event.id}`, {
method: "PATCH",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({ status: newStatus }),
})
if (!res.ok) throw new Error("Failed to update")
const data = await res.json()
setEvents((prev) => prev.map((e) => e.id === event.id ? { ...data.event, participant: e.participant, lead: e.lead } : e))
toast.success(`Event ${newStatus}`)
setDetailEvent(null)
} catch {
toast.error("Failed to update event")
}
}
const saveNotes = async (event: CalendarEvent) => {
if (notesText === (event.participantNotes || "")) { setEditNotes(false); return }
setNotesSaving(true)
try {
const res = await fetch(`/api/events/${event.id}`, {
method: "PATCH",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({ participantNotes: notesText || null }),
})
if (!res.ok) throw new Error("Failed to save")
const data = await res.json()
setEvents((prev) => prev.map((e) => e.id === event.id ? { ...data.event, participant: e.participant, lead: e.lead } : e))
if (detailEvent && detailEvent.id === event.id) {
setDetailEvent({ ...detailEvent, participantNotes: notesText || null })
}
toast.success("Notes saved")
setEditNotes(false)
} catch {
toast.error("Failed to save notes")
} finally {
setNotesSaving(false)
}
}
const deleteEvent = async (event: CalendarEvent) => {
try {
const res = await fetch(`/api/events/${event.id}`, { method: "DELETE" })
if (!res.ok) throw new Error("Failed to delete")
setEvents((prev) => prev.filter((e) => e.id !== event.id))
toast.success("Event deleted")
setDetailEvent(null)
setDialogOpen(false)
} catch {
toast.error("Failed to delete event")
}
}
const calendarDays: (number | null)[] = []
for (let i = 0; i < firstDay; i++) calendarDays.push(null)
for (let d = 1; d <= daysInMonth; d++) calendarDays.push(d)
if (!user) return null
return (
<div className="flex flex-col h-[calc(100vh-4rem)] bg-gradient-to-b from-background via-background to-muted/20">
{/* Header */}
<div className="flex items-center justify-between px-6 py-3 border-b shrink-0 bg-gradient-to-r from-background via-accent/5 to-background backdrop-blur-sm shadow-[0_1px_3px_-1px_hsl(var(--border))]">
<div className="flex items-center gap-4">
<div className="flex items-center gap-2.5">
<div className="p-2 rounded-lg bg-gradient-to-br from-primary/20 to-primary/5 text-primary shadow-sm ring-1 ring-primary/15">
<Calendar className="h-4 w-4" />
</div>
<h1 className="text-lg font-bold tracking-tight">Calendar</h1>
</div>
<div className="flex items-center gap-1.5 p-0.5 rounded-lg border bg-card/60 shadow-sm">
<Button variant="ghost" size="icon" className="h-7 w-7 rounded-md hover:bg-accent/60 hover:scale-105 active:scale-95 transition-all" onClick={prevMonth}>
<ChevronLeft className="h-3.5 w-3.5" />
</Button>
<span className="text-sm font-bold min-w-[130px] text-center select-none tracking-tight px-1">
{MONTHS[currentMonth]} {currentYear}
</span>
<Button variant="ghost" size="icon" className="h-7 w-7 rounded-md hover:bg-accent/60 hover:scale-105 active:scale-95 transition-all" onClick={nextMonth}>
<ChevronRight className="h-3.5 w-3.5" />
</Button>
</div>
<Button variant="ghost" size="sm" className="text-xs h-7 px-2 hover:bg-accent/40 hover:scale-105 active:scale-95 transition-all" onClick={() => { setCurrentMonth(today.getMonth()); setCurrentYear(today.getFullYear()) }}>
Today
</Button>
</div>
<Button onClick={() => openCreateDialog()} size="sm" className="shadow-sm hover:shadow-md hover:scale-[1.02] active:scale-[0.98] transition-all bg-gradient-to-r from-primary to-primary/90 h-9">
<Plus className="h-4 w-4 mr-1.5" /> New Event
</Button>
</div>
{/* Body - flex row with independent scroll */}
<div className="flex-1 flex overflow-hidden p-4 lg:p-6 gap-4 lg:gap-6">
{loading ? (
<div className="flex-1 flex items-center justify-center">
<div className="flex flex-col items-center gap-3">
<div className="relative">
<Loader2 className="h-8 w-8 animate-spin text-primary/60" />
<div className="absolute inset-0 animate-pulse rounded-full bg-primary/5" />
</div>
<p className="text-sm text-muted-foreground animate-pulse">Loading calendar...</p>
</div>
</div>
) : (
<>
{/* Left: Calendar Grid */}
<div className="flex-1 flex flex-col gap-4 min-w-0 overflow-hidden">
{/* Stats bar */}
<div className="flex items-center gap-3 shrink-0">
<div className="flex items-center gap-2 px-3.5 py-2 rounded-lg bg-primary/5 border border-primary/10 shadow-sm">
<div className="h-2 w-2 rounded-full bg-primary" />
<span className="text-sm font-bold text-primary">{events.filter(e => e.status === "scheduled").length}</span>
<span className="text-[11px] text-primary/60 font-medium">scheduled</span>
</div>
<div className="flex items-center gap-2 px-3.5 py-2 rounded-lg bg-emerald-500/5 border border-emerald-500/10 shadow-sm">
<div className="h-2 w-2 rounded-full bg-emerald-500" />
<span className="text-sm font-bold text-emerald-600 dark:text-emerald-400">{events.filter(e => e.status === "completed").length}</span>
<span className="text-[11px] text-emerald-600/60 dark:text-emerald-400/60 font-medium">completed</span>
</div>
<div className="flex items-center gap-2 px-3.5 py-2 rounded-lg bg-muted/30 border shadow-sm">
<div className="h-2 w-2 rounded-full bg-muted-foreground/30" />
<span className="text-sm font-bold text-foreground/70">{events.length}</span>
<span className="text-[11px] text-muted-foreground/50 font-medium">total</span>
</div>
</div>
{/* Calendar grid wrapper - scrollable */}
<div className="flex-1 overflow-auto rounded-xl border bg-card shadow-sm">
<div className="min-w-[600px]">
{/* Day headers */}
<div className="grid grid-cols-7 sticky top-0 z-10 bg-gradient-to-r from-muted/30 via-background to-muted/30 border-b border-border/40">
{DAYS.map((d, i) => (
<div key={d} className={cn(
"px-3 py-2.5 text-[11px] font-semibold text-muted-foreground/50 text-center tracking-widest uppercase",
i === 0 && "text-red-500/40 dark:text-red-400/40",
i === 6 && "text-red-500/40 dark:text-red-400/40",
)}>
{d}
</div>
))}
</div>
{/* Day cells */}
<div className="grid grid-cols-7">
{calendarDays.map((day, i) => {
if (day === null) return <div key={`empty-${i}`} className="min-h-[110px] border-r border-b border-border/40 bg-gradient-to-b from-muted/[0.01] to-transparent" />
const dayEvents = getEventsForDay(day)
const todayMark = isToday(day)
const hasEvents = dayEvents.length > 0
return (
<div
key={day}
className={cn(
"min-h-[110px] p-1.5 border-r border-b border-border/40 cursor-pointer transition-all duration-150 relative group",
"hover:bg-accent/20 hover:shadow-[inset_0_0_0_1px_hsl(var(--border))]",
todayMark && "bg-gradient-to-b from-primary/[0.04] to-transparent",
)}
onClick={() => { setSelectedDate(new Date(currentYear, currentMonth, day)); openCreateDialog(day) }}
>
<div className="flex items-center justify-between mb-1 px-0.5">
<span
className={cn(
"inline-flex items-center justify-center h-6 w-6 text-xs font-medium rounded-full transition-all duration-200",
todayMark && "bg-primary text-primary-foreground text-[11px] font-bold shadow-md ring-2 ring-primary/30 scale-110",
!todayMark && "text-muted-foreground/70 group-hover:text-foreground",
)}
>
{day}
</span>
{hasEvents && (
<span className="text-[10px] font-semibold text-muted-foreground/30 group-hover:text-muted-foreground/60 transition-all">
{dayEvents.length}
</span>
)}
</div>
<div className="space-y-0.5">
{dayEvents.slice(0, 3).map((event) => {
const Icon = EVENT_ICONS[event.eventType]
return (
<button
key={event.id}
onClick={(e) => { e.stopPropagation(); setDetailEvent(event) }}
className={cn(
"w-full text-left text-[10px] leading-tight flex items-center gap-1 rounded transition-all duration-150",
"hover:shadow-sm hover:scale-[1.02] active:scale-[0.97] group/pill",
event.status !== "scheduled" && "opacity-40",
)}
>
<div className="w-0.5 h-5 rounded-full shrink-0" style={eventDotStyle(event.eventType)} />
<div style={eventBgStyle(event.eventType)}
className="flex-1 flex items-center gap-1 px-1.5 py-0.5 rounded-r min-w-0"
>
<Icon className="h-2 w-2 shrink-0 opacity-50" />
<span className="truncate font-medium">{event.title}</span>
</div>
</button>
)
})}
{dayEvents.length > 3 && (
<button
onClick={(e) => { e.stopPropagation() }}
className="text-[10px] text-muted-foreground/40 hover:text-muted-foreground pl-0.5 font-semibold transition-colors"
>
+{dayEvents.length - 3} more
</button>
)}
</div>
</div>
)
})}
</div>
</div>
</div>
</div>
{/* Right: Upcoming sidebar */}
<div className="w-72 lg:w-80 shrink-0 flex flex-col gap-4 overflow-y-auto">
<div className="flex items-center justify-between shrink-0">
<h2 className="text-sm font-bold flex items-center gap-2 tracking-tight">
<ArrowUpRight className="h-4 w-4 text-primary/70" />
Upcoming
</h2>
{upcomingEvents.length > 0 && (
<Badge variant="secondary" className="text-[10px] font-semibold px-2 py-0.5 ring-1 ring-border/50">
{upcomingEvents.length}
</Badge>
)}
</div>
{upcomingEvents.length === 0 ? (
<Card className="p-6 border-dashed bg-card/50">
<div className="flex flex-col items-center gap-2.5 text-center">
<div className="p-3 rounded-xl bg-gradient-to-br from-muted/50 to-muted/20 ring-1 ring-border/20">
<Calendar className="h-6 w-6 text-muted-foreground/30" />
</div>
<div>
<p className="text-sm font-medium text-muted-foreground">No upcoming events</p>
<p className="text-[11px] text-muted-foreground/40 mt-0.5">Schedule one to get started</p>
</div>
<Button variant="outline" size="sm" onClick={() => openCreateDialog()} className="hover:scale-105 active:scale-95 transition-all shadow-sm">
<Plus className="h-3 w-3 mr-1.5" /> Schedule event
</Button>
</div>
</Card>
) : (
<div className="space-y-2">
{upcomingEvents.map((event) => {
const Icon = EVENT_ICONS[event.eventType]
return (
<button
key={event.id}
onClick={() => setDetailEvent(event)}
className={cn(
"w-full text-left rounded-lg border bg-card/80 hover:bg-card transition-all duration-150 overflow-hidden",
"hover:shadow-md hover:border-accent-foreground/10 active:scale-[0.98]",
)}
>
<div className="flex">
<div className="w-0.5 shrink-0" style={eventDotStyle(event.eventType)} />
<div className="flex-1 p-3">
<div className="flex items-start gap-2.5">
<div className={cn(
"p-1.5 rounded shrink-0 ring-1 ring-border/20",
event.userId === user?.id ? "bg-primary/5" : "bg-muted",
)}>
<Icon className={cn(
"h-3.5 w-3.5",
event.userId === user?.id ? "text-primary/60" : "text-muted-foreground/40",
)} />
</div>
<div className="flex-1 min-w-0">
<div className="flex items-center justify-between gap-1.5">
<p className="text-sm font-semibold truncate">{event.title}</p>
<span className={cn(
"text-[9px] font-semibold px-1.5 py-0.5 rounded capitalize shrink-0",
event.status === "scheduled" && "bg-primary/10 text-primary",
event.status === "completed" && "bg-emerald-500/10 text-emerald-600 dark:text-emerald-400",
event.status === "cancelled" && "bg-red-500/10 text-red-600 dark:text-red-400",
)}>
{event.status}
</span>
</div>
<div className="flex items-center gap-1.5 mt-1 text-[10px] text-muted-foreground/60 font-medium">
<Clock className="h-3 w-3" />
{formatDate(event.startTime)}
<span className="text-muted-foreground/20">·</span>
{formatTime(event.startTime)}
</div>
{event.durationMinutes && (
<div className="flex items-center gap-1 mt-0.5 text-[10px] text-muted-foreground/40 font-medium">
<Zap className="h-2.5 w-2.5" />
{event.durationMinutes} min
</div>
)}
<div className="flex items-center gap-1 mt-1">
<span className={cn(
"h-3.5 w-3.5 rounded-full text-[7px] flex items-center justify-center font-bold ring-1",
event.userId === user?.id
? "bg-primary/10 text-primary ring-primary/20"
: "bg-muted text-muted-foreground/60 ring-border",
)}>
{event.userId === user?.id
? (event.participant?.name.charAt(0) || "?")
: (event.creator?.name.charAt(0) || "?")}
</span>
<span className="text-[10px] text-muted-foreground/60 font-medium">
{event.userId === user?.id
? (event.participant?.name || "No participant")
: (event.creator?.name || "Unknown")}
</span>
</div>
</div>
</div>
</div>
</div>
</button>
)
})}
</div>
)}
</div>
</>
)}
</div>
{/* Create/Edit Dialog */}
<Dialog open={dialogOpen} onOpenChange={setDialogOpen}>
<DialogContent className="sm:max-w-[520px]">
<DialogHeader>
<div className="flex items-center gap-3">
<div className={cn(
"p-3 rounded-xl border shadow-sm",
editingEvent
? "bg-gradient-to-br from-muted to-muted/50 text-muted-foreground border-border"
: "bg-gradient-to-br from-primary/15 to-primary/5 text-primary border-primary/20",
)}>
{editingEvent ? <Clock className="h-5 w-5" /> : <Plus className="h-5 w-5" />}
</div>
<div>
<DialogTitle className="text-lg font-bold">{editingEvent ? "Edit Event" : "Create Event"}</DialogTitle>
<p className="text-xs text-muted-foreground/60 mt-0.5">
{editingEvent ? "Update the event details below" : "Fill in the details for your new event"}
</p>
</div>
</div>
</DialogHeader>
<div className="space-y-5 py-4">
<div className="space-y-2">
<Label htmlFor="title" className="text-xs font-semibold">Title</Label>
<Input id="title" value={formTitle} onChange={(e) => setFormTitle(e.target.value)} placeholder="e.g. Product demo with client" className="h-10" />
</div>
<div className="grid grid-cols-2 gap-4">
<div className="space-y-2">
<Label htmlFor="date" className="text-xs font-semibold">Date</Label>
<Input id="date" type="date" value={formDate} onChange={(e) => setFormDate(e.target.value)} className="h-10" />
</div>
<div className="space-y-2">
<Label htmlFor="time" className="text-xs font-semibold">Time</Label>
<Input id="time" type="time" value={formTime} onChange={(e) => setFormTime(e.target.value)} className="h-10" />
</div>
</div>
<div className="grid grid-cols-2 gap-4">
<div className="space-y-2">
<Label htmlFor="type" className="text-xs font-semibold">Type</Label>
<Select value={formType} onValueChange={(v: EventType) => setFormType(v)}>
<SelectTrigger id="type" className="h-10">
<SelectValue />
</SelectTrigger>
<SelectContent>
<SelectItem value="meeting">Meeting</SelectItem>
<SelectItem value="call">Call</SelectItem>
<SelectItem value="chat">Video Chat</SelectItem>
<SelectItem value="follow_up">Follow Up</SelectItem>
<SelectItem value="demo">Demo</SelectItem>
<SelectItem value="other">Other</SelectItem>
</SelectContent>
</Select>
</div>
<div className="space-y-2">
<Label htmlFor="duration" className="text-xs font-semibold">Duration</Label>
<Select value={formDuration} onValueChange={setFormDuration}>
<SelectTrigger id="duration" className="h-10">
<SelectValue />
</SelectTrigger>
<SelectContent>
<SelectItem value="15">15 min</SelectItem>
<SelectItem value="30">30 min</SelectItem>
<SelectItem value="45">45 min</SelectItem>
<SelectItem value="60">1 hour</SelectItem>
<SelectItem value="90">1.5 hours</SelectItem>
<SelectItem value="120">2 hours</SelectItem>
</SelectContent>
</Select>
</div>
</div>
<div className="space-y-2">
<Label htmlFor="participant" className="text-xs font-semibold">Participant</Label>
<Select value={formParticipantId || "__none__"} onValueChange={(v) => setFormParticipantId(v === "__none__" ? "" : v)}>
<SelectTrigger id="participant" className="h-10">
<SelectValue placeholder="Select a participant…" />
</SelectTrigger>
<SelectContent>
<SelectItem value="__none__">None</SelectItem>
{users.map((u) => (
<SelectItem key={u.id} value={u.id}>
{u.name} ({u.email})
</SelectItem>
))}
</SelectContent>
</Select>
</div>
<div className="space-y-2">
<Label htmlFor="description" className="text-xs font-semibold">Description</Label>
<Textarea id="description" value={formDescription} onChange={(e) => setFormDescription(e.target.value)} placeholder="Add any notes or agenda items..." rows={3} className="resize-none" />
</div>
</div>
<DialogFooter className="flex items-center justify-between border-t pt-4">
<div>
{editingEvent && (
<Button variant="destructive" size="sm" onClick={() => deleteEvent(editingEvent)} className="shadow-sm">
Delete event
</Button>
)}
</div>
<div className="flex gap-2">
<Button variant="outline" onClick={() => setDialogOpen(false)}>Cancel</Button>
<Button onClick={handleSave} disabled={formSaving} className="shadow-sm">
{formSaving && <Loader2 className="h-4 w-4 animate-spin mr-2" />}
{editingEvent ? "Update Event" : "Create Event"}
</Button>
</div>
</DialogFooter>
</DialogContent>
</Dialog>
{/* Event Detail Dialog */}
<Dialog open={!!detailEvent} onOpenChange={(open) => { if (!open) setDetailEvent(null) }}>
{detailEvent && (
<DialogContent className="sm:max-w-[460px]">
<DialogHeader>
<div className="flex items-center gap-3">
<div className="p-3 rounded-xl border shadow-sm" style={eventBgStyle(detailEvent.eventType)}>
{(() => { const Icon = EVENT_ICONS[detailEvent.eventType]; return Icon ? <Icon className="h-5 w-5" /> : <Calendar className="h-5 w-5" /> })()}
</div>
<div className="flex-1 min-w-0">
<DialogTitle className="text-lg font-bold truncate">{detailEvent.title}</DialogTitle>
<span className={cn(
"inline-flex text-[10px] font-semibold px-2 py-0.5 rounded-full capitalize mt-0.5 ring-1",
detailEvent.status === "scheduled" && "bg-primary/10 text-primary ring-primary/20",
detailEvent.status === "completed" && "bg-emerald-500/10 text-emerald-600 dark:text-emerald-400 ring-emerald-500/20",
detailEvent.status === "cancelled" && "bg-red-500/10 text-red-600 dark:text-red-400 ring-red-500/20",
)}>
{detailEvent.status}
</span>
</div>
</div>
</DialogHeader>
<div className="space-y-4">
{/* Date / Time */}
<div className="grid grid-cols-2 gap-3">
<div className="p-3.5 rounded-xl bg-gradient-to-br from-muted/50 to-muted/20 border shadow-sm">
<div className="flex items-center gap-2 text-[10px] font-semibold text-muted-foreground/50 uppercase tracking-wider mb-1.5">
<Calendar className="h-3.5 w-3.5" />
Date
</div>
<p className="text-sm font-bold">{formatDate(detailEvent.startTime)}</p>
</div>
<div className="p-3.5 rounded-xl bg-gradient-to-br from-muted/50 to-muted/20 border shadow-sm">
<div className="flex items-center gap-2 text-[10px] font-semibold text-muted-foreground/50 uppercase tracking-wider mb-1.5">
<Clock className="h-3.5 w-3.5" />
Time
</div>
<p className="text-sm font-bold">{formatTime(detailEvent.startTime)}</p>
</div>
</div>
{/* Duration + Type */}
<div className="grid grid-cols-2 gap-3">
{detailEvent.durationMinutes && (
<div className="p-3.5 rounded-xl bg-gradient-to-br from-muted/50 to-muted/20 border shadow-sm">
<div className="flex items-center gap-2 text-[10px] font-semibold text-muted-foreground/50 uppercase tracking-wider mb-1.5">
<Zap className="h-3.5 w-3.5" />
Duration
</div>
<p className="text-sm font-bold">{detailEvent.durationMinutes} min</p>
</div>
)}
<div className="p-3.5 rounded-xl bg-gradient-to-br from-muted/50 to-muted/20 border shadow-sm">
<div className="flex items-center gap-2 text-[10px] font-semibold text-muted-foreground/50 uppercase tracking-wider mb-1.5 capitalize">
{(() => { const Icon = EVENT_ICONS[detailEvent.eventType]; return Icon ? <Icon className="h-3.5 w-3.5" /> : <Calendar className="h-3.5 w-3.5" /> })()}
Type
</div>
<p className="text-sm font-bold capitalize">{detailEvent.eventType.replace("_", " ")}</p>
</div>
</div>
{/* Description */}
{detailEvent.description && (
<div className="p-3.5 rounded-xl bg-gradient-to-br from-muted/50 to-muted/20 border shadow-sm">
<p className="text-[10px] font-semibold text-muted-foreground/50 uppercase tracking-wider mb-1.5">Description</p>
<p className="text-sm whitespace-pre-wrap leading-relaxed">{detailEvent.description}</p>
</div>
)}
{/* Linked lead */}
{detailEvent.lead && (
<div className="p-3.5 rounded-xl bg-gradient-to-br from-muted/50 to-muted/20 border shadow-sm">
<p className="text-[10px] font-semibold text-muted-foreground/50 uppercase tracking-wider mb-1.5">Linked Lead</p>
<p className="text-sm font-bold">{detailEvent.lead.contactName} {detailEvent.lead.companyName}</p>
</div>
)}
{/* Organizer & Participant - cross-linked */}
<div className="grid grid-cols-2 gap-3">
<div className="p-3.5 rounded-xl bg-gradient-to-br from-muted/50 to-muted/20 border shadow-sm">
<p className="text-[10px] font-semibold text-muted-foreground/50 uppercase tracking-wider mb-1.5 flex items-center gap-1.5">
<Users className="h-3 w-3" />
{detailEvent.userId === user?.id ? "You (Organizer)" : "Organizer"}
</p>
<p className="text-sm font-bold flex items-center gap-2">
<span className="h-6 w-6 rounded-full bg-muted text-muted-foreground text-[10px] flex items-center justify-center font-bold ring-1 ring-border">
{detailEvent.creator?.name.charAt(0).toUpperCase() || "?"}
</span>
<span className="truncate">{detailEvent.creator?.name || "Unknown"}</span>
{detailEvent.creator?.role && (
<span className="text-[10px] font-medium text-muted-foreground/50 capitalize shrink-0">({detailEvent.creator.role})</span>
)}
</p>
</div>
<div className="p-3.5 rounded-xl bg-gradient-to-br from-primary/5 to-primary/[0.02] border shadow-sm">
<p className="text-[10px] font-semibold text-muted-foreground/50 uppercase tracking-wider mb-1.5 flex items-center gap-1.5">
<Users className="h-3 w-3" />
{detailEvent.participantId === user?.id ? "You (Participant)" : "Participant"}
</p>
{detailEvent.participant ? (
<p className="text-sm font-bold flex items-center gap-2">
<span className="h-6 w-6 rounded-full bg-primary/10 text-primary text-[10px] flex items-center justify-center font-bold ring-1 ring-primary/20">
{detailEvent.participant.name.charAt(0).toUpperCase()}
</span>
<span className="truncate">{detailEvent.participant.name}</span>
{detailEvent.participant.role && (
<span className="text-[10px] font-medium text-muted-foreground/50 capitalize shrink-0">({detailEvent.participant.role})</span>
)}
</p>
) : (
<p className="text-sm text-muted-foreground/50 italic">No participant</p>
)}
</div>
</div>
{/* Participant Notes (editable by both parties) */}
<div className="p-3.5 rounded-xl bg-gradient-to-br from-muted/50 to-muted/20 border shadow-sm">
<div className="flex items-center justify-between mb-1.5">
<p className="text-[10px] font-semibold text-muted-foreground/50 uppercase tracking-wider flex items-center gap-1.5">
<StickyNote className="h-3 w-3" />
Notes
</p>
{!editNotes && (
<Button variant="ghost" size="sm" className="h-7 text-[11px] font-semibold hover:bg-accent/40" onClick={() => { setNotesText(detailEvent.participantNotes || ""); setEditNotes(true) }}>
{detailEvent.participantNotes ? "Edit" : "Add note"}
</Button>
)}
</div>
{editNotes ? (
<div className="space-y-2.5">
<Textarea value={notesText} onChange={(e) => setNotesText(e.target.value)} rows={3} className="resize-none text-sm" placeholder="Add your notes about this event…" />
<div className="flex gap-2 justify-end">
<Button variant="ghost" size="sm" onClick={() => setEditNotes(false)} disabled={notesSaving}>Cancel</Button>
<Button size="sm" onClick={() => saveNotes(detailEvent)} disabled={notesSaving} className="shadow-sm">
{notesSaving && <Loader2 className="h-3 w-3 animate-spin mr-1" />}
Save
</Button>
</div>
</div>
) : detailEvent.participantNotes ? (
<p className="text-sm whitespace-pre-wrap leading-relaxed font-medium">{detailEvent.participantNotes}</p>
) : (
<p className="text-sm text-muted-foreground/40 italic font-medium">No notes yet</p>
)}
</div>
</div>
<DialogFooter className="flex items-center gap-2 border-t pt-4">
{detailEvent.status === "scheduled" && (
<>
<Button variant="default" size="sm" onClick={() => updateEventStatus(detailEvent, "completed")} className="shadow-sm">
<CheckCircle2 className="h-4 w-4 mr-1.5" /> Complete
</Button>
<Button variant="outline" size="sm" onClick={() => updateEventStatus(detailEvent, "cancelled")}>
<XCircle className="h-4 w-4 mr-1.5" /> Cancel
</Button>
<Button variant="outline" size="sm" onClick={() => { setDetailEvent(null); openEditDialog(detailEvent) }}>
Edit
</Button>
</>
)}
{detailEvent.status === "completed" && (
<Button variant="outline" size="sm" onClick={() => updateEventStatus(detailEvent, "scheduled")}>
<RotateCcw className="h-4 w-4 mr-1.5" /> Reopen
</Button>
)}
</DialogFooter>
</DialogContent>
)}
</Dialog>
</div>
)
}
+559 -108
View File
@@ -15,51 +15,52 @@ import {
DropdownMenuTrigger,
} from "@/components/ui/dropdown-menu"
import {
Search, Send, Phone, MoreHorizontal, Paperclip,
Search, Send, Phone, Video, MoreHorizontal, Paperclip,
Smile, Flag, Ban, Trash2, Image, FileIcon, X, Mic, Square, Play, Pause, Check, CheckCheck,
CornerDownRight, Forward, Pencil, Download,
CornerDownRight, Forward, Pencil, Download, Undo2, CalendarDays, Loader2, FolderOpen, Mail,
} from "lucide-react"
import { hasBlockedCodeExtension, filterBlockedFiles } from "@/lib/blocked-extensions"
import {
Dialog, DialogContent, DialogHeader, DialogTitle, DialogDescription,
DialogFooter, DialogClose,
} from "@/components/ui/dialog"
import { Label } from "@/components/ui/label"
import { Textarea } from "@/components/ui/textarea"
import {
Select, SelectContent, SelectItem, SelectTrigger, SelectValue,
} from "@/components/ui/select"
import { useTheme } from "next-themes"
import { useUser } from "@/providers/user-provider"
import { toast } from "sonner"
import { io, Socket } from "socket.io-client"
import VoiceCallModal from "@/components/chats/voice-call-modal"
import data from "@emoji-mart/data"
import Picker from "@emoji-mart/react"
import MediaPicker from "@/components/chats/media-picker"
function VoiceMessagePlayer({ src, initialDuration }: { src: string; initialDuration: number }) {
const activeAudioRef: { current: HTMLAudioElement | null } = { current: null }
const previewAudioRef: { current: HTMLAudioElement | null } = { current: null }
const previewAnimRef: { current: number } = { current: 0 }
function VoiceMessagePlayer({ src, initialDuration, isOwn }: { src: string; initialDuration: number; isOwn?: boolean }) {
const [playing, setPlaying] = useState(false)
const [current, setCurrent] = useState(0)
const [duration, setDuration] = useState(initialDuration)
const audioRef = useRef<HTMLAudioElement>(null)
const animRef = useRef<number>(0)
const progRef = useRef<HTMLInputElement>(null)
useEffect(() => {
const audio = audioRef.current
if (!audio) return
const onLoaded = () => {
if (isFinite(audio.duration)) setDuration(audio.duration)
}
const onLoaded = () => { if (isFinite(audio.duration)) setDuration(audio.duration) }
const onEnded = () => { setPlaying(false); setCurrent(0) }
audio.addEventListener("loadedmetadata", onLoaded)
audio.addEventListener("ended", onEnded)
return () => {
audio.removeEventListener("loadedmetadata", onLoaded)
audio.removeEventListener("ended", onEnded)
}
return () => { audio.removeEventListener("loadedmetadata", onLoaded); audio.removeEventListener("ended", onEnded) }
}, [src])
useEffect(() => {
if (!playing) { cancelAnimationFrame(animRef.current); return }
const tick = () => {
if (audioRef.current) setCurrent(audioRef.current.currentTime)
animRef.current = requestAnimationFrame(tick)
}
const tick = () => { if (audioRef.current) setCurrent(audioRef.current.currentTime); animRef.current = requestAnimationFrame(tick) }
animRef.current = requestAnimationFrame(tick)
return () => cancelAnimationFrame(animRef.current)
}, [playing])
@@ -67,26 +68,55 @@ function VoiceMessagePlayer({ src, initialDuration }: { src: string; initialDura
const toggle = () => {
if (!audioRef.current) return
if (playing) { audioRef.current.pause(); setPlaying(false) }
else { audioRef.current.play(); setPlaying(true) }
else {
if (activeAudioRef.current && activeAudioRef.current !== audioRef.current) { activeAudioRef.current.pause() }
activeAudioRef.current = audioRef.current
audioRef.current.play(); setPlaying(true)
}
}
const seek = (e: React.ChangeEvent<HTMLInputElement>) => {
const val = parseFloat(e.target.value)
if (audioRef.current) { audioRef.current.currentTime = val; setCurrent(val) }
}
const replay = () => {
if (!audioRef.current) return
audioRef.current.currentTime = 0; setCurrent(0)
if (!playing) { audioRef.current.play(); setPlaying(true) }
}
const displayDuration = isFinite(duration) && duration > 0 ? duration : initialDuration
const pct = displayDuration > 0 ? (current / displayDuration) * 100 : 0
const fmt = (s: number) => {
const secs = isFinite(s) ? Math.max(0, Math.floor(s)) : 0
return `${Math.floor(secs / 60)}:${(secs % 60).toString().padStart(2, "0")}`
}
const fmt = (s: number) => { const secs = isFinite(s) ? Math.max(0, Math.floor(s)) : 0; return `${Math.floor(secs / 60)}:${(secs % 60).toString().padStart(2, "0")}` }
const barCount = 28
const waveform = Array.from({ length: barCount }, (_, i) => {
const peak = 0.15 + Math.sin(i * 1.1) * 0.35 + Math.sin(i * 2.3) * 0.2 + Math.sin(i * 0.7) * 0.3
return Math.max(0.15, Math.min(1, peak))
})
return (
<div className="flex items-center gap-2 min-w-[180px]">
<div className="flex items-center gap-2 min-w-[200px] select-none">
<audio ref={audioRef} src={src} preload="metadata" />
<button type="button" onClick={toggle} className="h-8 w-8 shrink-0 rounded-full flex items-center justify-center hover:bg-black/10 transition-colors">
{playing ? <Pause className="h-4 w-4 fill-current" /> : <Play className="h-4 w-4 ml-0.5 fill-current" />}
</button>
<div className="flex-1 h-1.5 rounded-full bg-white/30 relative overflow-hidden">
<div className="h-full rounded-full bg-white transition-all duration-100" style={{ width: `${pct}%` }} />
<div className="flex-1 flex flex-col gap-0.5 min-w-0">
<div className="flex items-center gap-1 h-8">
{waveform.map((h, i) => {
const filled = (i / barCount) <= (pct / 100)
return <div key={i} className="flex-1 rounded-full transition-colors duration-75" style={{ height: `${h * 100}%`, minHeight: 3, background: filled ? "currentColor" : "currentColor", opacity: filled ? 1 : 0.35 }} />
})}
</div>
<input type="range" ref={progRef} min={0} max={displayDuration || 1} step={0.1} value={Math.min(current, displayDuration)} onChange={seek} className="w-full h-1 cursor-pointer appearance-none bg-transparent [&::-webkit-slider-runnable-track]:h-1 [&::-webkit-slider-runnable-track]:rounded-full [&::-webkit-slider-runnable-track]:bg-white/20 [&::-webkit-slider-thumb]:appearance-none [&::-webkit-slider-thumb]:h-3 [&::-webkit-slider-thumb]:w-3 [&::-webkit-slider-thumb]:rounded-full [&::-webkit-slider-thumb]:bg-white [&::-webkit-slider-thumb]:border-0" />
</div>
<div className="flex flex-col items-end gap-0.5 shrink-0">
<span className="text-[11px] tabular-nums opacity-70">{fmt(current)}</span>
<button type="button" onClick={replay} className="opacity-50 hover:opacity-100 transition-opacity">
<Undo2 className="h-2.5 w-2.5" />
</button>
</div>
<span className="text-[11px] tabular-nums opacity-70 w-8 text-right">{fmt(displayDuration)}</span>
</div>
)
}
@@ -100,6 +130,7 @@ export default function ChatsPage() {
const [activeChat, setActiveChat] = useState<string | null>(null)
const [messageInput, setMessageInput] = useState("")
const [showEmojiPicker, setShowEmojiPicker] = useState(false)
const [showAttachMenu, setShowAttachMenu] = useState(false)
const [attachments, setAttachments] = useState<File[]>([])
const [panelWidth, setPanelWidth] = useState(320)
const [isResizing, setIsResizing] = useState(false)
@@ -113,6 +144,11 @@ export default function ChatsPage() {
const [isPaused, setIsPaused] = useState(false)
const [recordingDuration, setRecordingDuration] = useState(0)
const recordingDurationRef = useRef(0)
const [previewBlob, setPreviewBlob] = useState<Blob | null>(null)
const [previewUrl, setPreviewUrl] = useState<string | null>(null)
const [previewDuration, setPreviewDuration] = useState(0)
const [isPreviewPlaying, setIsPreviewPlaying] = useState(false)
const [previewCurrent, setPreviewCurrent] = useState(0)
const [voiceMessages, setVoiceMessages] = useState<Map<string, { url: string; duration: number }>>(new Map())
const [messageAttachments, setMessageAttachments] = useState<Map<string, { name: string; type: string; url: string }[]>>(new Map())
const [replyingTo, setReplyingTo] = useState<any>(null)
@@ -125,20 +161,46 @@ export default function ChatsPage() {
const [searchResults, setSearchResults] = useState<any[]>([])
const [searchingUsers, setSearchingUsers] = useState(false)
const [unreadMap, setUnreadMap] = useState<Map<string, number>>(new Map())
const [isCallModalOpen, setIsCallModalOpen] = useState(false)
const [previewAvatarUrl, setPreviewAvatarUrl] = useState<string | null>(null)
<<<<<<< HEAD
const [scheduleDialogOpen, setScheduleDialogOpen] = useState(false)
const [scheduleTitle, setScheduleTitle] = useState("")
const [scheduleDate, setScheduleDate] = useState("")
const [scheduleTime, setScheduleTime] = useState("")
const [scheduleDuration, setScheduleDuration] = useState("30")
const [scheduleType, setScheduleType] = useState<string>("meeting")
const [scheduleSaving, setScheduleSaving] = useState(false)
=======
const [previewImageUrl, setPreviewImageUrl] = useState<string | null>(null)
>>>>>>> 21868fe336a205bcd2ef4a16283753f8dd3052bc
const fileInputRef = useRef<HTMLInputElement>(null)
const textareaRef = useRef<HTMLTextAreaElement>(null)
const emojiPickerRef = useRef<HTMLDivElement>(null)
const isSendingRef = useRef(false)
const attachMenuRef = useRef<HTMLDivElement>(null)
const messagesEndRef = useRef<HTMLDivElement>(null)
const mediaRecorderRef = useRef<MediaRecorder | null>(null)
const recordingChunksRef = useRef<Blob[]>([])
const resizeStartRef = useRef({ x: 0, width: 0 })
const blobUrlsRef = useRef<string[]>([])
const conversationOrderRef = useRef<string[]>([])
const pollTimerRef = useRef<ReturnType<typeof setInterval> | null>(null)
const socketRef = useRef<Socket | null>(null)
const isDeletingRef = useRef(false)
const isOnlyEmoji = (text: string) => /^(\p{Extended_Pictographic}\s*)+$/u.test(text.trim())
const formatPreviewContent = (content: string) => {
if (!content?.startsWith("{")) return content
try {
const p = JSON.parse(content)
if (p.gif) return "📷 GIF"
if (p.v) return "🎤 Voice message"
if (p.sticker) return "💮 Sticker"
} catch {}
return content
}
const autoResizeTextarea = useCallback(() => {
const ta = textareaRef.current
if (!ta) return
@@ -208,6 +270,17 @@ export default function ChatsPage() {
setMessageAttachments((a) => {
const n = new Map(a)
for (const k of n.keys()) if (!validMsgIds.has(k)) n.delete(k)
// Restore persisted file attachments from JSON content
for (const msgs of next.values()) {
for (const m of msgs) {
try {
const parsed = JSON.parse(m.content)
if (parsed.fileAttachments && Array.isArray(parsed.fileAttachments)) {
n.set(m.id, parsed.fileAttachments)
}
} catch {}
}
}
return n
})
setReplyMap((r) => {
@@ -257,6 +330,26 @@ export default function ChatsPage() {
return () => document.removeEventListener("mousedown", handleClickOutside)
}, [])
// Strip blocked files from attachments on every change
useEffect(() => {
if (attachments.length === 0) return
const { allowed, rejected } = filterBlockedFiles(attachments)
if (rejected.length > 0) {
toast.error(`Blocked file type: ${rejected[0].name}`)
setAttachments(allowed)
}
}, [attachments])
useEffect(() => {
const handleClickOutside = (e: MouseEvent) => {
if (attachMenuRef.current && !attachMenuRef.current.contains(e.target as Node)) {
setShowAttachMenu(false)
}
}
document.addEventListener("mousedown", handleClickOutside)
return () => document.removeEventListener("mousedown", handleClickOutside)
}, [])
// revoke blob URLs on unmount
useEffect(() => {
return () => {
@@ -265,6 +358,65 @@ export default function ChatsPage() {
}
}, [])
// Poll for new messages every 4 seconds for real-time sync
useEffect(() => {
if (!activeChat) return
pollTimerRef.current = setInterval(async () => {
try {
const res = await fetch(`/api/conversations/${activeChat}/messages?limit=50`)
if (!res.ok) return
const data = await res.json()
const serverMsgs = data.messages || []
setConversationMessages((prev) => {
const existing = prev.get(activeChat) || []
if (serverMsgs.length === existing.length) return prev
const next = new Map(prev)
next.set(activeChat, serverMsgs)
return next
})
setConversations((prev) => {
const updated = [...prev]
const convIdx = updated.findIndex((c) => c.id === activeChat)
if (convIdx >= 0 && serverMsgs.length > 0) {
const last = serverMsgs[serverMsgs.length - 1]
updated[convIdx] = { ...updated[convIdx], lastMessage: last.content, lastMessageTime: last.timestamp }
}
return updated
})
} catch { /* polling silently */ }
}, 4000)
return () => { if (pollTimerRef.current) clearInterval(pollTimerRef.current) }
}, [activeChat])
// Connect to signaling server for real-time message deletion events
useEffect(() => {
let socket: Socket | null = null
let cancelled = false
;(async () => {
try {
const res = await fetch("/api/auth/jwt")
if (!res.ok) return
const { token } = await res.json()
if (cancelled) return
socket = io(process.env.NEXT_PUBLIC_SIGNALING_URL || "http://localhost:3007", {
auth: { token },
transports: ["websocket", "polling"],
})
socketRef.current = socket
socket.on("message:deleted", ({ conversationId, messageId }: { conversationId: string; messageId: string }) => {
setConversationMessages((prev) => {
const existing = prev.get(conversationId)
if (!existing) return prev
const next = new Map(prev)
next.set(conversationId, existing.filter((m) => m.id !== messageId))
return next
})
})
} catch { /* socket connection failed silently */ }
})()
return () => { cancelled = true; socket?.disconnect(); socketRef.current = null }
}, [])
const handleResizeStart = useCallback((e: React.MouseEvent) => {
e.preventDefault()
setIsResizing(true)
@@ -291,23 +443,32 @@ export default function ChatsPage() {
messagesEndRef.current?.scrollIntoView({ behavior: "smooth" })
}, [conversations])
const handleEmojiSelect = (emoji: { native: string }) => {
setMessageInput((prev) => prev + emoji.native)
const handleEmojiSelect = (emoji: string) => {
setMessageInput((prev) => prev + emoji)
setShowEmojiPicker(false)
setTimeout(() => autoResizeTextarea(), 0)
}
const handleMediaSelect = (content: string) => {
addMessageToChat(content)
setShowEmojiPicker(false)
}
const MAX_FILE_SIZE = 10 * 1024 * 1024 // 10MB
const handleFileSelect = (e: React.ChangeEvent<HTMLInputElement>) => {
const files = Array.from(e.target.files ?? [])
const oversized = files.filter((f) => f.size > MAX_FILE_SIZE)
const raw = Array.from(e.target.files ?? [])
const oversized = raw.filter((f) => f.size > MAX_FILE_SIZE)
if (oversized.length > 0) {
toast.error(`${oversized[0].name} exceeds the 10MB file size limit`)
if (e.target) e.target.value = ""
return
}
setAttachments((prev) => [...prev, ...files])
const { allowed, rejected } = filterBlockedFiles(raw)
if (rejected.length > 0) {
toast.error(`Blocked file type: ${rejected[0].name}`)
}
setAttachments((prev) => [...prev, ...allowed])
if (e.target) e.target.value = ""
}
@@ -317,7 +478,11 @@ export default function ChatsPage() {
const addMessageToChat = async (content: string, voice?: { url: string; duration: number }, replyTo?: { senderName: string; content: string }, fileAttachments?: { name: string; type: string; url: string }[]) => {
if (!activeChat || !user) return
const payload = voice ? "[Voice message]" : content
const payload = voice
? "[Voice message]"
: fileAttachments && fileAttachments.length > 0
? JSON.stringify({ text: content, fileAttachments })
: content
try {
const res = await fetch(`/api/conversations/${activeChat}/messages`, {
method: "POST",
@@ -336,7 +501,7 @@ export default function ChatsPage() {
setConversations((prev) => {
const updated = prev.map((conv) =>
conv.id === activeChat
? { ...conv, lastMessage: content, lastMessageTime: "Just now" }
? { ...conv, lastMessage: getDisplayContent(content), lastMessageTime: "Just now" }
: conv
)
const idx = updated.findIndex((c) => c.id === activeChat)
@@ -378,73 +543,114 @@ export default function ChatsPage() {
const handleSend = async (e: React.FormEvent) => {
e.preventDefault()
if (!messageInput.trim() && attachments.length === 0) return
if (editingMessage) {
if (isSendingRef.current) return
// Check blocked files before sending
if (attachments.some((f) => hasBlockedCodeExtension(f.name))) {
toast.error("Remove blocked file types before sending")
return
}
isSendingRef.current = true
try {
if (editingMessage) {
setConversationMessages((prev) => {
const next = new Map(prev)
const msgs = (next.get(activeChat || "") || []).map((m) =>
m.id === editingMessage.id ? { ...m, content: messageInput.trim() } : m
)
next.set(activeChat || "", msgs)
return next
})
setEditingMessage(null)
setMessageInput("")
setTimeout(() => autoResizeTextarea(), 0)
return
}
const fileAttachments = attachments.length > 0
? await Promise.all(attachments.map(async (f) => {
const url = await fileToDataURL(f)
return { name: f.name, type: f.type, url }
}))
: undefined
if (replyingTo) {
const replySender = replyingTo.senderId === user.id ? user.name : otherParticipant(conversation!).name
await addMessageToChat(messageInput.trim(), undefined, { senderName: replySender, content: replyingTo.content }, fileAttachments)
setReplyingTo(null)
} else {
await addMessageToChat(messageInput.trim(), undefined, undefined, fileAttachments)
}
setMessageInput("")
setTimeout(() => autoResizeTextarea(), 0)
setAttachments([])
} finally {
isSendingRef.current = false
}
}
const handleDeleteMessage = async (messageId: string) => {
if (!activeChat || !user) return
if (isDeletingRef.current) return
isDeletingRef.current = true
const prevMessages = conversationMessages.get(activeChat) || []
try {
const res = await fetch(`/api/conversations/${activeChat}/messages/${messageId}`, { method: "DELETE" })
if (!res.ok) {
const err = await res.json().catch(() => ({}))
throw new Error(err.error || "Delete failed")
}
// Broadcast deletion to other participants via Socket.io
const socket = socketRef.current
if (socket?.connected) {
socket.emit("message:deleted", { conversationId: activeChat, messageId, senderId: user.id })
}
// Revoke blob URLs for attachments and voice
const files = messageAttachments.get(messageId)
if (files) files.forEach((f) => { URL.revokeObjectURL(f.url); blobUrlsRef.current = blobUrlsRef.current.filter((u) => u !== f.url) })
const voice = voiceMessages.get(messageId)
if (voice) { URL.revokeObjectURL(voice.url); blobUrlsRef.current = blobUrlsRef.current.filter((u) => u !== voice.url) }
// Remove from local state
setConversationMessages((prev) => {
const next = new Map(prev)
const msgs = (next.get(activeChat || "") || []).map((m) =>
m.id === editingMessage.id ? { ...m, content: messageInput.trim() } : m
)
const msgs = (next.get(activeChat || "") || []).filter((m) => m.id !== messageId)
next.set(activeChat || "", msgs)
return next
})
setEditingMessage(null)
setMessageInput("")
setTimeout(() => autoResizeTextarea(), 0)
return
}
const fileAttachments = attachments.length > 0
? attachments.map((f) => {
const url = URL.createObjectURL(f)
blobUrlsRef.current.push(url)
return { name: f.name, type: f.type, url }
})
: undefined
if (replyingTo) {
const replySender = replyingTo.senderId === user.id ? user.name : otherParticipant(conversation!).name
await addMessageToChat(messageInput.trim(), undefined, { senderName: replySender, content: replyingTo.content }, fileAttachments)
setReplyingTo(null)
} else {
await addMessageToChat(messageInput.trim(), undefined, undefined, fileAttachments)
}
setMessageInput("")
setTimeout(() => autoResizeTextarea(), 0)
setAttachments([])
}
const handleDeleteMessage = (messageId: string) => {
const files = messageAttachments.get(messageId)
if (files) files.forEach((f) => { URL.revokeObjectURL(f.url); blobUrlsRef.current = blobUrlsRef.current.filter((u) => u !== f.url) })
const voice = voiceMessages.get(messageId)
if (voice) { URL.revokeObjectURL(voice.url); blobUrlsRef.current = blobUrlsRef.current.filter((u) => u !== voice.url) }
setConversationMessages((prev) => {
const next = new Map(prev)
const msgs = (next.get(activeChat || "") || []).filter((m) => m.id !== messageId)
next.set(activeChat || "", msgs)
return next
})
setConversations((prev) =>
prev.map((conv) =>
conv.id === activeChat
? { ...conv, lastMessage: conversationMessages.get(activeChat || "")?.filter((m) => m.id !== messageId).at(-1)?.content ?? conv.lastMessage }
: conv
setConversations((prev) =>
prev.map((conv) =>
conv.id === activeChat
? { ...conv, lastMessage: getDisplayContent(prevMessages.filter((m) => m.id !== messageId).at(-1)?.content ?? "") }
: conv
)
)
)
toast.success("Message deleted")
toast.success("Message deleted")
} catch (err: any) {
toast.error(err?.message || "Unable to delete voice note. Please try again.")
} finally {
isDeletingRef.current = false
}
}
const startRecording = async () => {
try {
const stream = await navigator.mediaDevices.getUserMedia({ audio: true })
const recorder = new MediaRecorder(stream, { mimeType: "audio/webm" })
const mime = MediaRecorder.isTypeSupported("audio/webm;codecs=opus") ? "audio/webm;codecs=opus" : "audio/webm"
const recorder = new MediaRecorder(stream, { mimeType: mime })
recordingChunksRef.current = []
recorder.ondataavailable = (e) => {
if (e.data.size > 0) recordingChunksRef.current.push(e.data)
}
recorder.onstop = () => {
const blob = new Blob(recordingChunksRef.current, { type: "audio/webm" })
if (recordingChunksRef.current.length === 0) return
const blob = new Blob(recordingChunksRef.current, { type: mime })
const url = URL.createObjectURL(blob)
blobUrlsRef.current.push(url)
addMessageToChat("", { url, duration: recordingDurationRef.current })
setPreviewBlob(blob)
setPreviewUrl(url)
setPreviewDuration(recordingDurationRef.current)
stream.getTracks().forEach((t) => t.stop())
setRecordingDuration(0)
}
@@ -452,9 +658,12 @@ export default function ChatsPage() {
recorder.start()
setIsRecording(true)
setIsPaused(false)
} catch {
console.warn("Failed to start recording in chats page")
toast.error("Microphone access denied")
} catch (err: any) {
if (err?.name === "NotAllowedError" || err?.name === "PermissionDeniedError") {
toast.error("Microphone access is required to record voice notes.")
} else {
toast.error("Failed to start recording. Please check your microphone.")
}
}
}
@@ -488,6 +697,60 @@ export default function ChatsPage() {
setRecordingDuration(0)
recordingDurationRef.current = 0
recordingChunksRef.current = []
clearPreview()
}
const clearPreview = () => {
if (previewUrl) { URL.revokeObjectURL(previewUrl); blobUrlsRef.current = blobUrlsRef.current.filter((u) => u !== previewUrl) }
setPreviewBlob(null)
setPreviewUrl(null)
setPreviewDuration(0)
setIsPreviewPlaying(false)
setPreviewCurrent(0)
}
const cancelVoicePreview = () => {
if (previewAudioRef.current) { previewAudioRef.current.pause(); previewAudioRef.current = null! }
cancelAnimationFrame(previewAnimRef.current)
clearPreview()
}
const togglePreviewPlay = () => {
if (!previewUrl) return
if (isPreviewPlaying) {
previewAudioRef.current?.pause()
setIsPreviewPlaying(false)
} else {
const audio = new Audio(previewUrl)
audio.onloadedmetadata = () => { if (isFinite(audio.duration)) setPreviewDuration(audio.duration) }
audio.onended = () => { setIsPreviewPlaying(false); setPreviewCurrent(0) }
audio.onerror = () => { toast.error("Failed to play preview"); setIsPreviewPlaying(false) }
previewAudioRef.current = audio
audio.play().then(() => setIsPreviewPlaying(true)).catch(() => toast.error("Failed to play preview"))
const tick = () => { if (previewAudioRef.current) setPreviewCurrent(previewAudioRef.current.currentTime); previewAnimRef.current = requestAnimationFrame(tick) }
cancelAnimationFrame(previewAnimRef.current)
previewAnimRef.current = requestAnimationFrame(tick)
}
}
const sendVoiceRecording = async () => {
if (!previewBlob || !previewUrl) return
try {
cancelAnimationFrame(previewAnimRef.current)
previewAudioRef.current?.pause()
const formData = new FormData()
const ext = previewBlob.type.includes("webm") ? "webm" : "webm"
formData.append("audio", previewBlob, `voice.${ext}`)
formData.append("duration", previewDuration.toString())
const uploadRes = await fetch("/api/upload/voice", { method: "POST", body: formData })
if (!uploadRes.ok) { toast.error("Failed to upload voice recording"); return }
const { url: serverUrl, duration } = await uploadRes.json()
const voicePayload = JSON.stringify({ v: true, u: serverUrl, d: duration || previewDuration })
await addMessageToChat(voicePayload, { url: serverUrl, duration: duration || previewDuration })
clearPreview()
} catch {
toast.error("Failed to send voice recording")
}
}
useEffect(() => {
@@ -515,6 +778,21 @@ export default function ChatsPage() {
const isImageFile = (file: File) => file.type.startsWith("image/")
const getDisplayContent = (content: string) => {
try {
const parsed = JSON.parse(content)
if (parsed.text !== undefined) return parsed.text
} catch {}
return content
}
const fileToDataURL = (file: File): Promise<string> => new Promise((resolve, reject) => {
const reader = new FileReader()
reader.onload = () => resolve(reader.result as string)
reader.onerror = reject
reader.readAsDataURL(file)
})
return (
<div className="flex h-[calc(100vh-8rem)] -m-4 lg:-m-6 rounded-lg border bg-card overflow-hidden">
{/* Conversations list - left panel */}
@@ -564,7 +842,7 @@ export default function ChatsPage() {
<span className="text-sm font-medium truncate">{person.name}</span>
<span className="text-xs text-muted-foreground shrink-0">{conv.lastMessageTime}</span>
</div>
<p className="text-xs text-muted-foreground truncate mt-0.5">{conv.lastMessage}</p>
<p className="text-xs text-muted-foreground truncate mt-0.5">{formatPreviewContent(conv.lastMessage)}</p>
</div>
{!isActive && (unreadMap.get(conv.id) || 0) > 0 && (
<div className="shrink-0 h-3 w-3 rounded-full bg-red-500 animate-pulse shadow-[0_0_10px_#ef4444] self-center" />
@@ -644,9 +922,15 @@ export default function ChatsPage() {
</div>
</div>
<div className="flex items-center gap-1 shrink-0">
<Button variant="ghost" size="icon" className="h-8 w-8" onClick={() => setIsCallModalOpen(true)}>
<Button variant="ghost" size="icon" className="h-8 w-8" onClick={() => toast.info("Voice calling coming soon")}>
<Phone className="h-4 w-4" />
</Button>
<Button variant="ghost" size="icon" className="h-8 w-8" onClick={() => toast.info("Video calling coming soon")}>
<Video className="h-4 w-4" />
</Button>
<Button variant="ghost" size="icon" className="h-8 w-8" onClick={() => setScheduleDialogOpen(true)}>
<CalendarDays className="h-4 w-4" />
</Button>
<DropdownMenu>
<DropdownMenuTrigger asChild>
<Button variant="ghost" size="icon" className="h-8 w-8">
@@ -676,7 +960,17 @@ export default function ChatsPage() {
<div className="space-y-4 min-h-0">
{messages.map((msg) => {
const isMe = msg.senderId === user?.id
const voiceUrl = voiceMessages.get(msg.id)
let voiceUrl = voiceMessages.get(msg.id)
let gifData: { url: string; w: number; h: number } | null = null
let stickerData: { url: string; w: number; h: number; emoji?: string } | null = null
if (msg.content?.startsWith("{")) {
try {
const p = JSON.parse(msg.content)
if (p.v && p.u) voiceUrl = { url: p.u, duration: p.d || 0 }
else if (p.gif) gifData = { url: p.gif, w: p.w || 320, h: p.h || 240 }
else if (p.sticker) stickerData = { url: p.sticker, w: p.w || 200, h: p.h || 200, emoji: p.id ? undefined : p.sticker }
} catch {}
}
return (
<div key={msg.id} className={cn("group flex gap-3", isMe && "flex-row-reverse")}>
<Avatar className="h-8 w-8 mt-0.5 shrink-0 cursor-pointer" onClick={() => setPreviewAvatarUrl(msg.senderAvatar)}>
@@ -743,13 +1037,22 @@ export default function ChatsPage() {
<p className="text-[11px] text-white/60 truncate">{replyInfo.repliedToContent}</p>
</div>
)}
{voiceUrl ? (
<div>
<VoiceMessagePlayer src={voiceUrl.url} initialDuration={voiceUrl.duration} />
{msg.content && (
<p className="mt-1 text-sm text-black">{msg.content}</p>
{gifData ? (
<div className="max-w-[280px]">
<img src={gifData.url} alt="GIF" className="w-full rounded-lg" style={{ aspectRatio: `${gifData.w}/${gifData.h}` }} loading="lazy" />
</div>
) : stickerData ? (
<div className="max-w-[200px]">
{stickerData.emoji ? (
<span className="text-7xl block text-center">{stickerData.emoji}</span>
) : (
<div className="w-full" dangerouslySetInnerHTML={{ __html: stickerData.url.replace(/<svg /, '<svg style="width:100%;height:auto;max-height:200px" ') }} />
)}
</div>
) : voiceUrl ? (
<div>
<VoiceMessagePlayer src={voiceUrl.url} initialDuration={voiceUrl.duration} isOwn={isMe} />
</div>
) : (
<>
{(() => {
@@ -759,26 +1062,30 @@ export default function ChatsPage() {
{files.map((file, i) =>
file.type.startsWith("image/") ? (
<div key={i} className="relative group">
<img src={file.url} alt={file.name} className="max-w-full rounded-lg max-h-60 object-cover cursor-pointer" onClick={() => window.open(file.url, "_blank")} />
<img src={file.url} alt={file.name} className="max-w-full rounded-lg max-h-60 object-cover cursor-pointer" onClick={() => setPreviewImageUrl(file.url)} />
<a href={file.url} download={file.name} className="absolute top-2 right-2 h-7 w-7 rounded-full bg-black/50 flex items-center justify-center opacity-0 group-hover:opacity-100 transition-opacity">
<Download className="h-3.5 w-3.5 text-white" />
</a>
</div>
) : (
<a key={i} href={file.url} download={file.name} className="flex items-center gap-2 rounded-lg border bg-muted/50 px-3 py-2 text-sm hover:bg-muted/80 transition-colors">
<FileIcon className="h-4 w-4 shrink-0 text-muted-foreground" />
<span className="flex-1 min-w-0 truncate">{file.name}</span>
<Download className="h-3.5 w-3.5 shrink-0 text-muted-foreground" />
</a>
<div key={i} className="flex items-center gap-2 rounded-lg border bg-muted/50 px-3 py-2 text-sm hover:bg-muted/80 transition-colors">
<button type="button" className="flex items-center gap-2 flex-1 min-w-0 text-left" onClick={() => window.open(file.url, "_blank")} title="Open">
<FileIcon className="h-4 w-4 shrink-0 text-muted-foreground" />
<span className="flex-1 min-w-0 truncate">{file.name}</span>
</button>
<a href={file.url} download={file.name} className="shrink-0 text-muted-foreground hover:text-foreground" title="Download">
<Download className="h-3.5 w-3.5" />
</a>
</div>
)
)}
</div>
) : null
})()}
{isOnlyEmoji(msg.content) ? (
<span className="text-4xl leading-none">{msg.content.trim()}</span>
{isOnlyEmoji(getDisplayContent(msg.content)) ? (
<span className="text-4xl leading-none">{getDisplayContent(msg.content).trim()}</span>
) : (
msg.content
getDisplayContent(msg.content)
)}
</>
)}
@@ -847,9 +1154,27 @@ export default function ChatsPage() {
)}
<form onSubmit={handleSend} className="flex items-center gap-2">
<input ref={fileInputRef} type="file" multiple accept="image/*,.pdf,.doc,.docx,.xls,.xlsx,.txt" className="hidden" onChange={handleFileSelect} />
<Button type="button" variant="ghost" size="icon" className="h-9 w-9 shrink-0" onClick={() => fileInputRef.current?.click()}>
<Paperclip className="h-4 w-4 text-muted-foreground" />
</Button>
<div className="relative">
<Button type="button" variant="ghost" size="icon" className="h-9 w-9 shrink-0" onClick={() => setShowAttachMenu(!showAttachMenu)}>
<Paperclip className="h-4 w-4 text-muted-foreground" />
</Button>
{showAttachMenu && (
<div ref={attachMenuRef} className="absolute bottom-full left-0 mb-2 z-50 bg-popover border rounded-lg shadow-lg p-1.5 min-w-[140px]">
<button type="button" className="flex items-center gap-2 w-full px-2 py-1.5 text-sm rounded-md hover:bg-muted" onClick={() => { fileInputRef.current?.setAttribute("accept", "image/*"); fileInputRef.current?.click(); setShowAttachMenu(false) }}>
<Image className="h-4 w-4" /> Photos
</button>
<button type="button" className="flex items-center gap-2 w-full px-2 py-1.5 text-sm rounded-md hover:bg-muted" onClick={() => { fileInputRef.current?.setAttribute("accept", ".pdf,.doc,.docx,.xls,.xlsx,.txt"); fileInputRef.current?.click(); setShowAttachMenu(false) }}>
<FileIcon className="h-4 w-4" /> Documents
</button>
<button type="button" className="flex items-center gap-2 w-full px-2 py-1.5 text-sm rounded-md hover:bg-muted" onClick={() => { fileInputRef.current?.removeAttribute("accept"); fileInputRef.current?.click(); setShowAttachMenu(false) }}>
<FolderOpen className="h-4 w-4" /> Files
</button>
<button type="button" className="flex items-center gap-2 w-full px-2 py-1.5 text-sm rounded-md hover:bg-muted" onClick={() => { fileInputRef.current?.setAttribute("accept", ".eml,.msg"); fileInputRef.current?.click(); setShowAttachMenu(false) }}>
<Mail className="h-4 w-4" /> Emails
</button>
</div>
)}
</div>
<div className="relative flex-1">
{isRecording ? (
<div className="flex h-9 items-center gap-1 rounded-md border bg-muted/30 px-2">
@@ -866,9 +1191,25 @@ export default function ChatsPage() {
<Square className="h-3.5 w-3.5 fill-current" />
</Button>
</div>
) : previewUrl ? (
<div className="flex h-9 items-center gap-2 rounded-md border bg-muted/30 px-3">
<button type="button" onClick={togglePreviewPlay} className="h-6 w-6 shrink-0 rounded-full flex items-center justify-center hover:bg-black/10 transition-colors">
{isPreviewPlaying ? <Pause className="h-3.5 w-3.5 fill-current" /> : <Play className="h-3.5 w-3.5 ml-0.5 fill-current" />}
</button>
<div className="flex-1 h-1 rounded-full bg-muted-foreground/20 relative overflow-hidden">
<div className="h-full rounded-full bg-primary transition-all duration-100" style={{ width: `${previewDuration > 0 ? (previewCurrent / previewDuration) * 100 : 0}%` }} />
</div>
<span className="text-xs tabular-nums text-muted-foreground w-8 text-right">{formatDuration(Math.floor(previewCurrent))}/{formatDuration(previewDuration)}</span>
<Button type="button" variant="ghost" size="icon" className="h-6 w-6 shrink-0 text-destructive hover:text-destructive" onClick={cancelVoicePreview}>
<Trash2 className="h-3 w-3" />
</Button>
<Button type="button" variant="ghost" size="icon" className="h-6 w-6 shrink-0 text-primary hover:text-primary" onClick={sendVoiceRecording}>
<Send className="h-3 w-3" />
</Button>
</div>
) : (
<>
<textarea ref={textareaRef} value={messageInput} onChange={(e) => { setMessageInput(e.target.value); autoResizeTextarea() }} onPaste={(e) => { if (!e.clipboardData) return; const items = Array.from(e.clipboardData.items).filter((item) => item.type.startsWith("image/")); if (items.length > 0) { e.preventDefault(); const files = items.map((item) => item.getAsFile()).filter((f): f is File => f !== null); setAttachments((prev) => [...prev, ...files]) } }} placeholder={editingMessage ? "Edit message..." : "Type a message..."} className="w-full resize-none overflow-hidden rounded-md border border-input bg-transparent px-3 py-1.5 text-sm ring-offset-background placeholder:text-muted-foreground focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring focus-visible:ring-offset-2 min-h-[36px] max-h-[200px] pr-16" style={{ height: "auto" }} rows={1} />
<textarea ref={textareaRef} value={messageInput} onChange={(e) => { setMessageInput(e.target.value); autoResizeTextarea() }} onPaste={(e) => { if (!e.clipboardData) return; const items = Array.from(e.clipboardData.items).filter((item) => item.type.startsWith("image/")); if (items.length > 0) { e.preventDefault(); const files = items.map((item) => item.getAsFile()).filter((f): f is File => f !== null); setAttachments((prev) => [...prev, ...filterBlockedFiles(files).allowed]) } }} placeholder={editingMessage ? "Edit message..." : "Type a message..."} className="w-full resize-none overflow-hidden rounded-md border border-input bg-transparent px-3 py-1.5 text-sm ring-offset-background placeholder:text-muted-foreground focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring focus-visible:ring-offset-2 min-h-[36px] max-h-[200px] pr-16" style={{ height: "auto" }} rows={1} />
<div className="absolute right-0 top-0 bottom-0 flex items-center pr-1" ref={emojiPickerRef}>
<Button type="button" variant="ghost" size="icon" className="h-7 w-7 shrink-0" onClick={() => setShowEmojiPicker(!showEmojiPicker)}>
<Smile className="h-4 w-4 text-muted-foreground" />
@@ -878,14 +1219,14 @@ export default function ChatsPage() {
</Button>
{showEmojiPicker && (
<div className="absolute bottom-full right-0 mb-2 z-50">
<Picker data={data} onEmojiSelect={handleEmojiSelect} theme={theme === "dark" ? "dark" : "light"} previewPosition="none" skinTonePosition="none" set="native" emojiSize={36} maxFrequentRows={2} />
<MediaPicker onEmojiSelect={handleEmojiSelect} onMediaSelect={handleMediaSelect} onClose={() => setShowEmojiPicker(false)} theme={theme} />
</div>
)}
</div>
</>
)}
</div>
<Button type="submit" size="icon" className="h-9 w-9 shrink-0" disabled={!messageInput.trim() && attachments.length === 0}>
<Button type="submit" size="icon" className="h-9 w-9 shrink-0" disabled={(!messageInput.trim() && attachments.length === 0) || attachments.some(f => hasBlockedCodeExtension(f.name))}>
{editingMessage ? <Pencil className="h-4 w-4" /> : <Send className="h-4 w-4" />}
</Button>
</form>
@@ -918,6 +1259,20 @@ export default function ChatsPage() {
</DialogContent>
</Dialog>
{/* Image preview dialog */}
<Dialog open={!!previewImageUrl} onOpenChange={(o) => { if (!o) setPreviewImageUrl(null) }}>
<DialogContent className="sm:max-w-3xl p-0 overflow-hidden bg-transparent border-0 shadow-none">
<DialogTitle className="sr-only">Image preview</DialogTitle>
{previewImageUrl && (
<img
src={previewImageUrl}
alt="Image preview"
className="w-full h-auto max-h-[85vh] object-contain rounded-2xl"
/>
)}
</DialogContent>
</Dialog>
{/* Avatar preview dialog */}
<Dialog open={!!previewAvatarUrl} onOpenChange={(o) => { if (!o) setPreviewAvatarUrl(null) }}>
<DialogContent className="sm:max-w-sm p-0 overflow-hidden bg-transparent border-0 shadow-none">
@@ -994,8 +1349,104 @@ export default function ChatsPage() {
</ScrollArea>
</DialogContent>
</Dialog>
<<<<<<< Updated upstream
=======
<VoiceCallModal open={isCallModalOpen} onClose={() => setIsCallModalOpen(false)} />
{/* Schedule from Chat Dialog */}
<Dialog open={scheduleDialogOpen} onOpenChange={setScheduleDialogOpen}>
<DialogContent className="sm:max-w-[440px]">
<DialogHeader>
<DialogTitle>Schedule with {conversation ? otherParticipant(conversation).name : "..."}</DialogTitle>
<DialogDescription>Create an event and notify the participant</DialogDescription>
</DialogHeader>
<div className="space-y-4">
<div>
<Label htmlFor="sched-title">Title</Label>
<Input id="sched-title" value={scheduleTitle} onChange={(e) => setScheduleTitle(e.target.value)} placeholder="e.g. Follow-up call" />
</div>
<div className="grid grid-cols-2 gap-4">
<div>
<Label htmlFor="sched-date">Date</Label>
<Input id="sched-date" type="date" value={scheduleDate} onChange={(e) => setScheduleDate(e.target.value)} />
</div>
<div>
<Label htmlFor="sched-time">Time</Label>
<Input id="sched-time" type="time" value={scheduleTime} onChange={(e) => setScheduleTime(e.target.value)} />
</div>
</div>
<div className="grid grid-cols-2 gap-4">
<div>
<Label htmlFor="sched-type">Type</Label>
<Select value={scheduleType} onValueChange={setScheduleType}>
<SelectTrigger id="sched-type">
<SelectValue />
</SelectTrigger>
<SelectContent>
<SelectItem value="meeting">Meeting</SelectItem>
<SelectItem value="call">Call</SelectItem>
<SelectItem value="follow_up">Follow Up</SelectItem>
<SelectItem value="demo">Demo</SelectItem>
</SelectContent>
</Select>
</div>
<div>
<Label htmlFor="sched-duration">Duration</Label>
<Select value={scheduleDuration} onValueChange={setScheduleDuration}>
<SelectTrigger id="sched-duration">
<SelectValue />
</SelectTrigger>
<SelectContent>
<SelectItem value="15">15 min</SelectItem>
<SelectItem value="30">30 min</SelectItem>
<SelectItem value="60">1 hour</SelectItem>
<SelectItem value="90">1.5 hours</SelectItem>
</SelectContent>
</Select>
</div>
</div>
</div>
<DialogFooter>
<Button variant="outline" onClick={() => setScheduleDialogOpen(false)}>Cancel</Button>
<Button disabled={scheduleSaving || !scheduleTitle || !scheduleDate || !scheduleTime} onClick={async () => {
if (!conversation || !user) return
setScheduleSaving(true)
try {
const startTime = new Date(`${scheduleDate}T${scheduleTime}:00`).toISOString()
const end = new Date(startTime)
end.setMinutes(end.getMinutes() + parseInt(scheduleDuration))
const res = await fetch("/api/events", {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({
title: scheduleTitle,
eventType: scheduleType,
startTime,
endTime: end.toISOString(),
durationMinutes: parseInt(scheduleDuration),
participantId: otherParticipant(conversation).id,
conversationId: conversation.id,
}),
})
if (!res.ok) throw new Error("Failed")
toast.success("Event scheduled! Notification sent.")
setScheduleDialogOpen(false)
setScheduleTitle("")
setScheduleDate("")
setScheduleTime("")
} catch {
toast.error("Failed to schedule event")
} finally {
setScheduleSaving(false)
}
}}>
{scheduleSaving && <Loader2 className="h-4 w-4 animate-spin mr-2" />}
Schedule
</Button>
</DialogFooter>
</DialogContent>
</Dialog>
>>>>>>> Stashed changes
</div>
)
}
+56 -36
View File
@@ -24,8 +24,12 @@ import {
SelectValue,
} from "@/components/ui/select"
import { DashboardStats } from "@/types"
import { CrossedLightsabers } from "@/components/dashboard/crossed-lightsabers"
import { StarField } from "@/components/dashboard/star-field"
import { useWebsiteTheme } from "@/providers/website-theme-provider"
export default function DashboardPage() {
const { websiteTheme: theme } = useWebsiteTheme()
const [period, setPeriod] = useState("6months")
const [stats, setStats] = useState<DashboardStats | null>(null)
const pollingRef = useRef<NodeJS.Timeout | null>(null)
@@ -63,47 +67,63 @@ export default function DashboardPage() {
return (
<div className="space-y-6 relative">
{theme === "starforce" && <StarField />}
<div className="relative z-[1] space-y-6">
<div>
<PageHeader
title={<span style={{fontFamily:"'Audiowide',sans-serif"}}>Dashboard</span>}
description="Overview of your sales pipeline"
>
{theme === "starforce" && (
<span
className="pointer-events-none select-none text-5xl text-gray-600 dark:text-[#b0f0c0] tracking-wider font-bold text-glow whitespace-nowrap"
style={{ fontFamily: "'Cormorant Garamond', serif" }}
>
Trust the Force, Find your Path
</span>
)}
{theme === "starforce" && <CrossedLightsabers />}
<Select value={period} onValueChange={setPeriod}>
<SelectTrigger className="h-9 w-[160px]">
<ListFilter className="mr-2 h-4 w-4" />
<SelectValue />
</SelectTrigger>
<SelectContent>
<SelectItem value="7days">Last 7 days</SelectItem>
<SelectItem value="30days">Last 30 days</SelectItem>
<SelectItem value="6months">Last 6 months</SelectItem>
<SelectItem value="12months">Last 12 months</SelectItem>
</SelectContent>
</Select>
</PageHeader>
</div>
<p className="text-xs font-semibold tracking-widest uppercase text-[#888888] dark:text-[#666666]">Pipeline Overview</p>
<div className="grid gap-4 sm:grid-cols-2 lg:grid-cols-3 xl:grid-cols-6">
{stats
? statCards.map((card, i) => (
<StatCard key={card.title} {...card} index={i} monthlyBreakdown={stats.monthlyBreakdown} />
))
: Array.from({ length: 6 }).map((_, i) => <StatCardSkeleton key={i} />)
}
</div>
<p className="text-xs font-semibold tracking-widest uppercase text-[#888888] dark:text-[#666666]">Analytics</p>
<div className="grid gap-6 lg:grid-cols-2">
<LeadStatusChart data={stats?.statusDistribution ?? []} />
<LeadsPerMonthChart data={stats?.leadsPerMonth ?? []} />
</div>
<RecentLeadsTable leads={stats?.recentLeads ?? []} />
</div>
{/* Daily Bugle watermark */}
<div className="absolute top-12 right-4 pointer-events-none select-none z-0 opacity-[0.04] dark:opacity-[0.06]">
<span className="text-[96px] font-['Bangers',cursive] leading-none text-[#e62020] dark:text-[#FF6666] tracking-wider">
<span className="text-[96px] font-['Bangers',cursive] leading-none text-[#CC0000] dark:text-[#FF1111] tracking-wider">
DAILY BUGLE
</span>
</div>
<PageHeader title={<span style={{fontFamily:"'Bangers',cursive"}}>Dashboard</span>} description="Overview of your sales pipeline">
<Select value={period} onValueChange={setPeriod}>
<SelectTrigger className="h-9 w-[160px]">
<ListFilter className="mr-2 h-4 w-4" />
<SelectValue />
</SelectTrigger>
<SelectContent>
<SelectItem value="7days">Last 7 days</SelectItem>
<SelectItem value="30days">Last 30 days</SelectItem>
<SelectItem value="6months">Last 6 months</SelectItem>
<SelectItem value="12months">Last 12 months</SelectItem>
</SelectContent>
</Select>
</PageHeader>
<p className="text-xs font-semibold tracking-widest uppercase text-[#888888] dark:text-[#666666]">Pipeline Overview</p>
<div className="grid gap-4 sm:grid-cols-2 lg:grid-cols-3 xl:grid-cols-6">
{stats
? statCards.map((card, i) => (
<StatCard key={card.title} {...card} index={i} monthlyBreakdown={stats.monthlyBreakdown} />
))
: Array.from({ length: 6 }).map((_, i) => <StatCardSkeleton key={i} />)
}
</div>
<p className="text-xs font-semibold tracking-widest uppercase text-[#888888] dark:text-[#666666]">Analytics</p>
<div className="grid gap-6 lg:grid-cols-2">
<LeadStatusChart data={stats?.statusDistribution ?? []} />
<LeadsPerMonthChart data={stats?.leadsPerMonth ?? []} />
</div>
<RecentLeadsTable leads={stats?.recentLeads ?? []} />
</div>
)
}
+53
View File
@@ -0,0 +1,53 @@
"use client"
import { useState, useEffect } from "react"
import { useRouter } from "next/navigation"
import { useUser } from "@/providers/user-provider"
import { Mail, Clock } from "lucide-react"
export default function EmailsPage() {
const router = useRouter()
const { user } = useUser()
const [emails, setEmails] = useState<any[]>([])
useEffect(() => {
if (!user) { router.push("/login"); return }
if (user.role !== "admin" && user.role !== "super_admin") { router.push("/dashboard"); return }
fetch("/api/emails").then((r) => r.ok ? r.json() : { emails: [] }).then((d) => setEmails(d.emails || []))
}, [user, router])
if (!user || (user.role !== "admin" && user.role !== "super_admin")) return null
return (
<div className="p-6">
<h1 className="text-xl font-bold mb-4">Email Log</h1>
<p className="text-sm text-muted-foreground mb-6">
Emails are stored locally. To actually deliver them, add <code className="bg-muted px-1 rounded">EMAIL_API_KEY</code> to your .env.
</p>
{emails.length === 0 ? (
<p className="text-sm text-muted-foreground">No emails sent yet.</p>
) : (
<div className="space-y-2">
{emails.map((e: any) => {
const withMatch = e.bodyText?.match(/With:\s*(.+)/)
const withName = withMatch ? withMatch[1].trim() : null
return (
<div key={e.id} className="flex items-center gap-3 p-3 rounded-lg border bg-card">
<Mail className="h-4 w-4 text-muted-foreground shrink-0" />
<div className="flex-1 min-w-0">
<p className="text-sm font-medium truncate">{e.subject}</p>
<p className="text-xs text-muted-foreground">To: {e.recipient}</p>
{withName && <p className="text-xs text-muted-foreground/70 mt-0.5">With: {withName}</p>}
</div>
<div className="flex items-center gap-1 text-xs text-muted-foreground shrink-0">
<Clock className="h-3 w-3" />
{new Date(e.createdAt).toLocaleString()}
</div>
</div>
)
})}
</div>
)}
</div>
)
}
+12 -11
View File
@@ -1,12 +1,14 @@
import { NextRequest, NextResponse } from "next/server"
import { chatWithAI } from "@/lib/ai"
import { getSessionUser } from "@/lib/auth"
import { chatWithAI } from "@/lib/ai"
export async function POST(request: NextRequest) {
try {
const user = await getSessionUser()
if (!user) {
return NextResponse.json({ error: "Unauthorized" }, { status: 401 })
if (!user) return NextResponse.json({ error: "Unauthorized" }, { status: 401 })
if (!["sales", "admin", "super_admin"].includes(user.role)) {
return NextResponse.json({ error: "Forbidden" }, { status: 403 })
}
const { message } = await request.json()
@@ -14,16 +16,15 @@ export async function POST(request: NextRequest) {
return NextResponse.json({ error: "Message is required" }, { status: 400 })
}
const sessionCookie = request.cookies.get("session")
const jwtToken = sessionCookie?.value
if (!jwtToken) {
return NextResponse.json({ error: "No session token" }, { status: 401 })
}
// Forward the JWT from the session cookie to the Rust backend
const sessionCookie = request.cookies.get("session")?.value
if (!sessionCookie) return NextResponse.json({ error: "No session" }, { status: 401 })
const response = await chatWithAI(message, sessionCookie)
const response = await chatWithAI(message, jwtToken)
return NextResponse.json({ response })
} catch (error: any) {
} catch (error) {
console.error("AI chat error:", error)
return NextResponse.json({ error: error.message || "AI service error" }, { status: 500 })
return NextResponse.json({ error: "AI service unavailable" }, { status: 503 })
}
}
-2
View File
@@ -9,7 +9,6 @@ import {
resetFailedAttempts,
isAccountLocked,
createSession,
setSessionContext,
} from "@/lib/auth"
export async function POST(request: NextRequest) {
@@ -107,7 +106,6 @@ export async function POST(request: NextRequest) {
)
await createSession(dbUser.id, dbUser.role_name)
await setSessionContext(dbUser.id, ipAddress)
const user = mapDbUserToSessionUser(dbUser)
@@ -0,0 +1,85 @@
import { NextRequest, NextResponse } from "next/server"
import { getSessionUser } from "@/lib/auth"
import { query } from "@/lib/db"
import { unlink } from "node:fs/promises"
import { join } from "node:path"
import { existsSync } from "node:fs"
export async function DELETE(
_request: NextRequest,
{ params }: { params: Promise<{ id: string; messageId: string }> },
) {
try {
const user = await getSessionUser()
if (!user) return NextResponse.json({ error: "Unauthorized" }, { status: 401 })
const { id: conversationId, messageId } = await params
// Verify user is a participant in this conversation
const partCheck = await query(
`SELECT 1 FROM conversation_participants WHERE conversation_id = $1 AND user_id = $2`,
[conversationId, user.id],
)
if (partCheck.rows.length === 0) {
return NextResponse.json({ error: "Not a participant" }, { status: 403 })
}
// Fetch the message to verify it belongs to the sender and check if it's a voice message
const msgResult = await query(
`SELECT id, sender_id, content FROM messages WHERE id = $1 AND conversation_id = $2 AND deleted_at IS NULL`,
[messageId, conversationId],
)
if (msgResult.rows.length === 0) {
return NextResponse.json({ error: "Message not found" }, { status: 404 })
}
const message = msgResult.rows[0]
// Only the sender can delete their own message
if (message.sender_id !== user.id) {
return NextResponse.json({ error: "Cannot delete another user's message" }, { status: 403 })
}
// Delete the associated audio file if this is a voice message
let storageDeleted = false
try {
const content = message.content
if (content?.startsWith("{")) {
const parsed = JSON.parse(content)
if (parsed.v && parsed.u) {
const filename = parsed.u.replace(/^\/uploads\/voice\//, "")
const filePath = join(process.cwd(), "public", "uploads", "voice", filename)
if (existsSync(filePath)) {
await unlink(filePath)
storageDeleted = true
}
}
}
} catch {
// If file deletion fails, log but still proceed with DB deletion
console.warn(`[voice-delete] Failed to delete audio file for message ${messageId}`)
}
// Soft-delete the message in the database
const deleteResult = await query(
`UPDATE messages SET deleted_at = NOW() WHERE id = $1 AND conversation_id = $2 RETURNING id`,
[messageId, conversationId],
)
const dbDeleted = deleteResult.rows.length > 0
console.log(
`[voice-delete] id=${messageId} conv=${conversationId} user=${user.id} db=${dbDeleted} storage=${storageDeleted}`,
)
return NextResponse.json({
success: true,
messageId,
dbDeleted,
storageDeleted,
})
} catch (error) {
console.error("[voice-delete] Error:", error)
return NextResponse.json({ error: "Unable to delete voice note. Please try again." }, { status: 500 })
}
}
@@ -2,6 +2,7 @@ import { NextRequest, NextResponse } from "next/server"
import { getSessionUser } from "@/lib/auth"
import { query } from "@/lib/db"
import { avatarSvgUrl } from "@/lib/avatar"
import { hasBlockedCodeExtension } from "@/lib/blocked-extensions"
export async function GET(
_request: NextRequest,
@@ -103,6 +104,18 @@ export async function POST(
return NextResponse.json({ error: "Message content is required" }, { status: 400 })
}
// Server-side blocked code extension check
try {
const parsed = JSON.parse(content)
if (parsed.fileAttachments && Array.isArray(parsed.fileAttachments)) {
for (const f of parsed.fileAttachments) {
if (f.name && hasBlockedCodeExtension(f.name)) {
return NextResponse.json({ error: `Blocked file type: ${f.name}` }, { status: 400 })
}
}
}
} catch {}
const result = await query(
`INSERT INTO messages (conversation_id, sender_id, content)
VALUES ($1, $2, $3)
@@ -160,3 +173,62 @@ function formatTime(date: Date): string {
return date.toLocaleDateString([], { month: "short", day: "numeric" }) + " " +
date.toLocaleTimeString([], { hour: "2-digit", minute: "2-digit" })
}
export async function DELETE(
_request: NextRequest,
{ params }: { params: Promise<{ id: string }> },
) {
try {
const user = await getSessionUser()
if (!user) return NextResponse.json({ error: "Unauthorized" }, { status: 401 })
const { id } = await params
const body = await _request.json()
const messageId = body.messageId
if (!messageId) return NextResponse.json({ error: "messageId required" }, { status: 400 })
const result = await query(
`UPDATE messages SET deleted_at = NOW() WHERE id = $1 AND sender_id = $2 AND conversation_id = $3 RETURNING id`,
[messageId, user.id, id],
)
if (result.rows.length === 0) {
return NextResponse.json({ error: "Message not found or not yours" }, { status: 404 })
}
return NextResponse.json({ success: true })
} catch (error) {
console.error("Delete message error:", error)
return NextResponse.json({ error: "Failed to delete message" }, { status: 500 })
}
}
export async function PATCH(
request: NextRequest,
{ params }: { params: Promise<{ id: string }> },
) {
try {
const user = await getSessionUser()
if (!user) return NextResponse.json({ error: "Unauthorized" }, { status: 401 })
const { id } = await params
const body = await request.json()
const messageId = body.messageId
const newContent = body.content
if (!messageId || !newContent?.trim()) {
return NextResponse.json({ error: "messageId and content required" }, { status: 400 })
}
const result = await query(
`UPDATE messages SET content = $1, updated_at = NOW() WHERE id = $2 AND sender_id = $3 AND conversation_id = $4 AND deleted_at IS NULL RETURNING id`,
[newContent.trim(), messageId, user.id, id],
)
if (result.rows.length === 0) {
return NextResponse.json({ error: "Message not found or not yours" }, { status: 404 })
}
return NextResponse.json({ success: true })
} catch (error) {
console.error("Edit message error:", error)
return NextResponse.json({ error: "Failed to edit message" }, { status: 500 })
}
}
+3 -5
View File
@@ -18,8 +18,8 @@ export async function GET() {
u.email AS other_user_email,
u.phone AS other_user_phone,
u.avatar_url AS other_user_avatar_url,
(SELECT content FROM messages WHERE conversation_id = c.id ORDER BY created_at DESC LIMIT 1) AS last_message,
(SELECT created_at FROM messages WHERE conversation_id = c.id ORDER BY created_at DESC LIMIT 1) AS last_message_time,
(SELECT content FROM messages WHERE conversation_id = c.id AND deleted_at IS NULL ORDER BY created_at DESC LIMIT 1) AS last_message,
(SELECT created_at FROM messages WHERE conversation_id = c.id AND deleted_at IS NULL ORDER BY created_at DESC LIMIT 1) AS last_message_time,
(SELECT count(*) FROM messages WHERE conversation_id = c.id AND sender_id != $1 AND created_at > COALESCE(cp_me.last_read_at, '1970-01-01')) AS unread
FROM conversations c
JOIN conversation_participants cp_me ON cp_me.conversation_id = c.id AND cp_me.user_id = $1
@@ -40,7 +40,6 @@ export async function GET() {
id: row.other_user_id,
name: row.other_user_name,
email: row.other_user_email,
phone: row.other_user_phone || "",
avatar: avatarSvgUrl(row.other_user_name),
},
lastMessage: row.last_message || "",
@@ -92,7 +91,7 @@ export async function POST(request: NextRequest) {
)
const otherUser = await query(
`SELECT id, first_name || ' ' || last_name AS name, email, phone, avatar_url
`SELECT id, first_name || ' ' || last_name AS name, email, avatar_url
FROM users WHERE id = $1`,
[userId],
)
@@ -107,7 +106,6 @@ export async function POST(request: NextRequest) {
id: other.id,
name: other.name,
email: other.email,
phone: other.phone || "",
avatar: avatarSvgUrl(other.name),
},
lastMessage: "",
+28
View File
@@ -0,0 +1,28 @@
import { NextRequest, NextResponse } from "next/server"
import { getSessionUser } from "@/lib/auth"
import { query } from "@/lib/db"
export async function GET() {
try {
const user = await getSessionUser()
if (!user) return NextResponse.json({ error: "Unauthorized" }, { status: 401 })
const isAdmin = user.role === "admin" || user.role === "super_admin"
if (!isAdmin) return NextResponse.json({ error: "Forbidden" }, { status: 403 })
const result = await query(
`SELECT id, recipient, subject, body_text, created_at FROM sent_emails ORDER BY created_at DESC LIMIT 50`,
)
return NextResponse.json({ emails: result.rows.map((r: any) => ({
id: r.id,
recipient: r.recipient,
subject: r.subject,
bodyText: r.body_text,
createdAt: r.created_at,
})) })
} catch (error) {
console.error("Emails GET error:", error)
return NextResponse.json({ error: "Failed to load sent emails" }, { status: 500 })
}
}
+29
View File
@@ -0,0 +1,29 @@
import { NextResponse } from "next/server"
import { getSessionUser } from "@/lib/auth"
import { query } from "@/lib/db"
export async function GET() {
try {
const user = await getSessionUser()
if (!user) return NextResponse.json({ error: "Unauthorized" }, { status: 401 })
const result = await query(
`SELECT u.id, u.first_name, u.last_name, u.email
FROM users u
WHERE u.deleted_at IS NULL AND u.id != $1
ORDER BY u.first_name ASC`,
[user.id],
)
const users = result.rows.map((r: any) => ({
id: r.id,
name: `${r.first_name} ${r.last_name}`,
email: r.email,
}))
return NextResponse.json({ users })
} catch (error) {
console.error("Event users error:", error)
return NextResponse.json({ error: "Failed to load users" }, { status: 500 })
}
}
+54
View File
@@ -0,0 +1,54 @@
import { NextRequest, NextResponse } from "next/server"
import { getSessionUser } from "@/lib/auth"
import { query } from "@/lib/db"
import { buildIcs } from "@/lib/ics"
export async function GET(_request: NextRequest, { params }: { params: Promise<{ id: string }> }) {
try {
const user = await getSessionUser()
if (!user) return NextResponse.json({ error: "Unauthorized" }, { status: 401 })
const { id } = await params
const result = await query(
`SELECT e.id, e.user_id, e.participant_id, e.title, e.description, e.event_type,
e.start_time, e.end_time, e.duration_minutes, e.status,
u.email AS user_email, u.first_name AS user_first, u.last_name AS user_last,
p.email AS p_email, p.first_name AS p_first, p.last_name AS p_last
FROM scheduled_events e
JOIN users u ON u.id = e.user_id
LEFT JOIN users p ON p.id = e.participant_id
WHERE e.id = $1 AND e.user_id = $2`,
[id, user.id],
)
if (result.rows.length === 0) {
return NextResponse.json({ error: "Event not found" }, { status: 404 })
}
const r = result.rows[0]
const icsContent = buildIcs({
uid: r.id,
title: r.title,
description: r.description || undefined,
startTime: new Date(r.start_time),
endTime: r.end_time ? new Date(r.end_time) : undefined,
durationMinutes: r.duration_minutes || undefined,
organizerName: `${r.user_first} ${r.user_last}`,
organizerEmail: r.user_email,
attendeeName: r.p_first ? `${r.p_first} ${r.p_last}` : undefined,
attendeeEmail: r.p_email || undefined,
})
return new NextResponse(icsContent, {
headers: {
"Content-Type": "text/calendar; charset=utf-8",
"Content-Disposition": `attachment; filename="event-${r.id}.ics"`,
},
})
} catch (error) {
console.error("ICS GET error:", error)
return NextResponse.json({ error: "Failed to generate calendar file" }, { status: 500 })
}
}
+167
View File
@@ -0,0 +1,167 @@
import { NextRequest, NextResponse } from "next/server"
import { getSessionUser } from "@/lib/auth"
import { query } from "@/lib/db"
import { sendEventRescheduled } from "@/lib/email"
export async function PATCH(request: NextRequest, { params }: { params: Promise<{ id: string }> }) {
try {
const user = await getSessionUser()
if (!user) return NextResponse.json({ error: "Unauthorized" }, { status: 401 })
const { id } = await params
const { title, description, eventType, startTime, endTime, durationMinutes, status, participantId, participantNotes } = await request.json()
const existing = await query(
`SELECT e.user_id, e.participant_id, e.title, e.description, e.participant_notes, e.event_type,
e.start_time, e.end_time, e.duration_minutes, e.status,
u.email AS u_email, u.first_name AS u_first, u.last_name AS u_last,
p.email AS p_email, p.first_name AS p_first, p.last_name AS p_last
FROM scheduled_events e
JOIN users u ON u.id = e.user_id
LEFT JOIN users p ON p.id = e.participant_id
WHERE e.id = $1`,
[id],
user.id,
)
if (existing.rows.length === 0) {
return NextResponse.json({ error: "Event not found" }, { status: 404 })
}
const old = existing.rows[0]
const isCreator = old.user_id === user.id
const isParticipant = old.participant_id === user.id
if (!isCreator && !isParticipant) {
return NextResponse.json({ error: "Forbidden" }, { status: 403 })
}
if (!isCreator && (
(title !== undefined && title !== old.title) ||
(eventType !== undefined && eventType !== old.event_type) ||
(startTime !== undefined && startTime !== old.start_time) ||
(endTime !== undefined && endTime !== old.end_time) ||
(durationMinutes !== undefined && durationMinutes !== old.duration_minutes) ||
(participantId !== undefined && participantId !== old.participant_id)
)) {
return NextResponse.json({ error: "Only the creator can edit event details" }, { status: 403 })
}
const result = await query(
`UPDATE scheduled_events SET
title = COALESCE($1, title),
description = COALESCE($2, description),
participant_notes = COALESCE($3, participant_notes),
event_type = COALESCE($4, event_type),
start_time = COALESCE($5, start_time),
end_time = COALESCE($6, end_time),
duration_minutes = COALESCE($7, duration_minutes),
status = COALESCE($8, status),
participant_id = COALESCE($9, participant_id),
updated_at = NOW()
WHERE id = $10
RETURNING id, user_id, participant_id, lead_id, conversation_id, title, description, participant_notes, event_type, start_time, end_time, duration_minutes, status, created_at`,
[
title || null,
description ?? null,
participantNotes ?? null,
eventType || null,
startTime || null,
endTime ?? null,
durationMinutes ?? null,
status || null,
participantId ?? null,
id,
],
user.id,
)
const r = result.rows[0]
const isChanged = r.start_time !== old.start_time || r.end_time !== old.end_time ||
r.title !== old.title || r.event_type !== old.event_type ||
r.status !== old.status
if (isChanged && r.participant_id && r.participant_id !== user.id) {
sendEventRescheduled({
creatorName: `${user.firstName} ${user.lastName}`,
creatorEmail: user.email,
participantName: old.p_first ? `${old.p_first} ${old.p_last}` : null,
participantEmail: old.p_email || null,
title: r.title,
description: r.description || undefined,
eventType: r.event_type,
startTime: r.start_time,
endTime: r.end_time || undefined,
durationMinutes: r.duration_minutes || undefined,
}).catch((err) => console.error("Reschedule email error:", err))
}
const creatorResult = await query(
`SELECT u.id, u.first_name, u.last_name, u.email, u.avatar_url,
ur.role_id, r.name AS role_name, r.display_name AS role_display
FROM users u
LEFT JOIN user_roles ur ON ur.user_id = u.id
LEFT JOIN roles r ON r.id = ur.role_id
WHERE u.id = $1`,
[old.user_id],
)
const creatorInfo = creatorResult.rows[0]
const participantInfo = old.participant_id ? { id: old.participant_id, name: `${old.p_first} ${old.p_last}` || old.participant_id, email: old.p_email || null } : null
return NextResponse.json({
event: {
id: r.id,
userId: r.user_id,
participantId: r.participant_id,
leadId: r.lead_id,
conversationId: r.conversation_id,
title: r.title,
description: r.description,
participantNotes: r.participant_notes,
eventType: r.event_type,
startTime: r.start_time,
endTime: r.end_time,
durationMinutes: r.duration_minutes,
status: r.status,
creator: creatorInfo ? { id: creatorInfo.id, name: `${creatorInfo.first_name} ${creatorInfo.last_name}`, email: creatorInfo.email, role: creatorInfo.role_display || creatorInfo.role_name, avatar: creatorInfo.avatar_url } : { id: old.user_id, name: "Unknown" },
participant: participantInfo,
lead: null,
createdAt: r.created_at,
},
})
} catch (error) {
console.error("Events PATCH error:", error)
return NextResponse.json({ error: "Failed to update event" }, { status: 500 })
}
}
export async function DELETE(request: NextRequest, { params }: { params: Promise<{ id: string }> }) {
try {
const user = await getSessionUser()
if (!user) return NextResponse.json({ error: "Unauthorized" }, { status: 401 })
const { id } = await params
const existing = await query(
`SELECT user_id FROM scheduled_events WHERE id = $1`,
[id],
user.id,
)
if (existing.rows.length === 0) {
return NextResponse.json({ error: "Event not found" }, { status: 404 })
}
if (existing.rows[0].user_id !== user.id) {
return NextResponse.json({ error: "Forbidden" }, { status: 403 })
}
await query(`DELETE FROM scheduled_events WHERE id = $1`, [id], user.id)
return NextResponse.json({ success: true })
} catch (error) {
console.error("Events DELETE error:", error)
return NextResponse.json({ error: "Failed to delete event" }, { status: 500 })
}
}
+188
View File
@@ -0,0 +1,188 @@
import { NextRequest, NextResponse } from "next/server"
import { getSessionUser } from "@/lib/auth"
import { query } from "@/lib/db"
import { sendEventConfirmation } from "@/lib/email"
export async function GET(request: NextRequest) {
try {
const user = await getSessionUser()
if (!user) return NextResponse.json({ error: "Unauthorized" }, { status: 401 })
const { searchParams } = new URL(request.url)
const start = searchParams.get("start")
const end = searchParams.get("end")
const status = searchParams.get("status")
let sql = `SELECT e.id, e.user_id, e.participant_id, e.lead_id, e.conversation_id,
e.title, e.description, e.participant_notes, e.event_type,
e.start_time, e.end_time, e.duration_minutes, e.status, e.created_at,
u.id AS u_id, u.first_name AS u_first, u.last_name AS u_last, u.email AS u_email, u.avatar_url AS u_avatar,
ur.role_id AS u_role_id, r.name AS u_role_name, r.display_name AS u_role_display,
p.id AS p_id, p.first_name AS p_first, p.last_name AS p_last, p.email AS p_email, p.avatar_url AS p_avatar,
pr.role_id AS p_role_id, pr2.name AS p_role_name, pr2.display_name AS p_role_display,
l.id AS l_id, l.company_name, l.contact_name
FROM scheduled_events e
JOIN users u ON u.id = e.user_id
LEFT JOIN users p ON p.id = e.participant_id
LEFT JOIN leads l ON l.id = e.lead_id
LEFT JOIN user_roles ur ON ur.user_id = e.user_id
LEFT JOIN roles r ON r.id = ur.role_id
LEFT JOIN user_roles pr ON pr.user_id = e.participant_id
LEFT JOIN roles pr2 ON pr2.id = pr.role_id`
const params: unknown[] = []
let idx = 1
if (user.role !== "super_admin") {
sql += ` WHERE e.user_id = $${idx} OR e.participant_id = $${idx}`
params.push(user.id)
idx++
} else {
sql += ` WHERE 1=1`
}
if (start) {
sql += ` AND e.start_time >= $${idx}`
params.push(start)
idx++
}
if (end) {
sql += ` AND e.start_time <= $${idx}`
params.push(end)
idx++
}
if (status) {
sql += ` AND e.status = $${idx}`
params.push(status)
idx++
}
sql += " ORDER BY e.start_time ASC"
const result = await query(sql, params, user.id)
const events = result.rows.map((r: any) => ({
id: r.id,
userId: r.user_id,
participantId: r.participant_id,
leadId: r.lead_id,
conversationId: r.conversation_id,
title: r.title,
description: r.description,
participantNotes: r.participant_notes,
eventType: r.event_type,
startTime: r.start_time,
endTime: r.end_time,
durationMinutes: r.duration_minutes,
status: r.status,
creator: { id: r.u_id, name: `${r.u_first} ${r.u_last}`, email: r.u_email, role: r.u_role_display || r.u_role_name, avatar: r.u_avatar },
participant: r.p_id ? { id: r.p_id, name: `${r.p_first} ${r.p_last}`, email: r.p_email, role: r.p_role_display || r.p_role_name, avatar: r.p_avatar } : null,
lead: r.l_id ? { id: r.l_id, companyName: r.company_name, contactName: r.contact_name } : null,
createdAt: r.created_at,
}))
return NextResponse.json({ events })
} catch (error) {
console.error("Events GET error:", error)
return NextResponse.json({ error: "Failed to load events" }, { status: 500 })
}
}
export async function POST(request: NextRequest) {
try {
const user = await getSessionUser()
if (!user) return NextResponse.json({ error: "Unauthorized" }, { status: 401 })
const {
participantId, leadId, conversationId,
title, description, eventType,
startTime, endTime, durationMinutes,
} = await request.json()
if (!title || !startTime) {
return NextResponse.json({ error: "Title and start time are required" }, { status: 400 })
}
const result = await query(
`INSERT INTO scheduled_events (user_id, participant_id, lead_id, conversation_id, title, description, event_type, start_time, end_time, duration_minutes)
VALUES ($1, $2, $3, $4, $5, $6, $7, $8, $9, $10)
RETURNING id, user_id, participant_id, lead_id, conversation_id, title, description, participant_notes, event_type, start_time, end_time, duration_minutes, status, created_at`,
[
user.id,
participantId || null,
leadId || null,
conversationId || null,
title,
description || null,
eventType || "meeting",
startTime,
endTime || null,
durationMinutes || null,
],
user.id,
)
const r = result.rows[0]
if (participantId && participantId !== user.id) {
await query(
`INSERT INTO notifications (user_id, type, title, description, link, context_id, context_type)
VALUES ($1, $2, $3, $4, $5, $6, $7)`,
[
participantId,
"event_scheduled",
`Meeting scheduled: ${title}`,
`${user.firstName} ${user.lastName} scheduled a ${eventType || "meeting"} with you`,
`/calendar`,
r.id,
"scheduled_event",
],
)
}
const participantResult = participantId ? await query(
`SELECT email, first_name, last_name FROM users WHERE id = $1`,
[participantId],
) : null
const participant = participantResult?.rows[0] || null
sendEventConfirmation({
creatorName: `${user.firstName} ${user.lastName}`,
creatorEmail: user.email,
participantName: participant ? `${participant.first_name} ${participant.last_name}` : null,
participantEmail: participant?.email || null,
title,
description: description || undefined,
eventType: eventType || "meeting",
startTime,
endTime: endTime || undefined,
durationMinutes: durationMinutes || undefined,
}).catch((err) => console.error("Email error:", err))
return NextResponse.json({
event: {
id: r.id,
userId: r.user_id,
participantId: r.participant_id,
leadId: r.lead_id,
conversationId: r.conversation_id,
title: r.title,
description: r.description,
participantNotes: r.participant_notes,
eventType: r.event_type,
startTime: r.start_time,
endTime: r.end_time,
durationMinutes: r.duration_minutes,
status: r.status,
creator: { id: user.id, name: `${user.firstName} ${user.lastName}`, email: user.email, role: user.role },
participant: participantId ? { id: participantId, name: participant ? `${participant.first_name} ${participant.last_name}` : participantId, email: participant?.email || null } : null,
lead: leadId ? { id: leadId, companyName: "", contactName: "" } : null,
createdAt: r.created_at,
},
}, { status: 201 })
} catch (error) {
console.error("Events POST error:", error)
return NextResponse.json({ error: "Failed to create event" }, { status: 500 })
}
}
+56
View File
@@ -0,0 +1,56 @@
import { NextRequest, NextResponse } from "next/server"
import { getSessionUser } from "@/lib/auth"
const TENOR_API_KEY = process.env.TENOR_API_KEY || ""
const TENOR_BASE = "https://tenor.googleapis.com/v2"
export async function GET(request: NextRequest) {
try {
const user = await getSessionUser()
if (!user) return NextResponse.json({ error: "Unauthorized" }, { status: 401 })
const { searchParams } = new URL(request.url)
const q = searchParams.get("q") || ""
const limit = Math.min(parseInt(searchParams.get("limit") || "20", 10), 50)
const pos = searchParams.get("pos") || ""
if (!TENOR_API_KEY) {
return NextResponse.json({
results: [],
error: "TENOR_API_KEY not configured",
noKey: true,
})
}
const endpoint = q
? `${TENOR_BASE}/search?q=${encodeURIComponent(q)}&key=${TENOR_API_KEY}&limit=${limit}&media_filter=minimal`
: `${TENOR_BASE}/featured?key=${TENOR_API_KEY}&limit=${limit}&media_filter=minimal`
const url = pos ? `${endpoint}&pos=${pos}` : endpoint
const res = await fetch(url, { signal: AbortSignal.timeout(8000) })
if (!res.ok) {
return NextResponse.json({ results: [], error: "Tenor API error" }, { status: 502 })
}
const data = await res.json()
const results = (data.results || []).map((item: any) => {
const media = item.media_formats?.gif || item.media_formats?.tinygif || {}
const preview = item.media_formats?.tinygif || item.media_formats?.gif || {}
return {
id: item.id,
title: item.title || "",
url: media.url || "",
previewUrl: preview.url || "",
width: media.dims?.[0] || 200,
height: media.dims?.[1] || 200,
}
})
return NextResponse.json({ results, next: data.next || "" })
} catch (error) {
console.error("GIF API error:", error)
return NextResponse.json({ results: [], error: "Failed to fetch GIFs" }, { status: 500 })
}
}
+2 -2
View File
@@ -12,7 +12,7 @@ export async function GET() {
[user.id],
)
const websiteTheme = result.rows[0]?.website_theme || "spidey"
const websiteTheme = result.rows[0]?.website_theme || "starforce"
return NextResponse.json({ websiteTheme })
} catch (error) {
console.error("Website theme GET error:", error)
@@ -26,7 +26,7 @@ export async function PUT(request: NextRequest) {
if (!user) return NextResponse.json({ error: "Unauthorized" }, { status: 401 })
const body = await request.json()
const theme = body.websiteTheme || "spidey"
const theme = body.websiteTheme || "starforce"
await query(
`UPDATE users SET preferences = preferences || $2::jsonb WHERE id = $1`,
+29
View File
@@ -0,0 +1,29 @@
import { NextRequest, NextResponse } from "next/server"
import { getSessionUser } from "@/lib/auth"
import { writeFile } from "node:fs/promises"
import { join } from "node:path"
import crypto from "node:crypto"
export async function POST(request: NextRequest) {
try {
const user = await getSessionUser()
if (!user) return NextResponse.json({ error: "Unauthorized" }, { status: 401 })
const formData = await request.formData()
const file = formData.get("audio") as File | null
if (!file) return NextResponse.json({ error: "No audio file" }, { status: 400 })
const duration = parseFloat(formData.get("duration")?.toString() || "0")
const ext = file.name.endsWith(".webm") ? "webm" : "webm"
const filename = `${crypto.randomUUID()}.${ext}`
const buffer = Buffer.from(await file.arrayBuffer())
const savePath = join(process.cwd(), "public", "uploads", "voice", filename)
await writeFile(savePath, buffer)
return NextResponse.json({ url: `/uploads/voice/${filename}`, duration })
} catch (error) {
console.error("Voice upload error:", error)
return NextResponse.json({ error: "Upload failed" }, { status: 500 })
}
}
+4 -7
View File
@@ -1,6 +1,6 @@
import { NextRequest, NextResponse } from "next/server"
import { query } from "@/lib/db"
import { hashPassword, getSessionUser, encryptPassword, setSessionContext } from "@/lib/auth"
import { hashPassword, getSessionUser } from "@/lib/auth"
import { avatarSvgUrl } from "@/lib/avatar"
export async function GET(request: NextRequest) {
@@ -63,20 +63,17 @@ export async function POST(request: NextRequest) {
return NextResponse.json({ error: "Invalid role" }, { status: 400 })
}
await setSessionContext(sessionUser.id)
const nameParts = name.trim().split(/\s+/)
const firstName = nameParts[0]
const lastName = nameParts.slice(1).join(" ") || firstName
const username = email.split("@")[0]
const passwordHash = await hashPassword(password)
const passwordEncrypted = await encryptPassword(password)
const result = await query(
`INSERT INTO users (username, email, password_hash, password_encrypted, first_name, last_name, is_active, created_by)
VALUES ($1, $2, $3, $4, $5, $6, $7, $8)
`INSERT INTO users (username, email, password_hash, first_name, last_name, is_active, created_by)
VALUES ($1, $2, $3, $4, $5, $6, $7)
RETURNING id`,
[username.toLowerCase(), email.toLowerCase(), passwordHash, passwordEncrypted, firstName, lastName, active ?? true, sessionUser.id]
[username.toLowerCase(), email.toLowerCase(), passwordHash, firstName, lastName, active ?? true, sessionUser.id]
)
const roleId = (
+102 -264
View File
@@ -1,29 +1,94 @@
@import url('https://fonts.googleapis.com/css2?family=Bangers&display=swap');
@import url('https://fonts.googleapis.com/css2?family=Raleway:wght@800&display=swap');
@import url('https://fonts.googleapis.com/css2?family=Cormorant+Garamond:ital,wght@0,400;0,500;0,600;0,700;1,400;1,500;1,600;1,700&family=Audiowide&display=swap');
@tailwind base;
@tailwind components;
@tailwind utilities;
.text-glow {
text-shadow: 0 0 15px #39ff14, 0 0 30px #39ff14, 0 0 50px #39ff14, 0 0 80px rgba(57, 255, 20, 0.6);
}
.dark .text-glow {
text-shadow: 0 0 10px #39ff14, 0 0 20px #39ff14, 0 0 35px #39ff14;
}
@keyframes loading {
0% { transform: translateX(-100%); }
100% { transform: translateX(400%); }
}
@keyframes pulse-glow-red {
0%, 100% { opacity: 0.12; }
50% { opacity: 0.5; }
}
@keyframes pulse-glow-blue {
0%, 100% { opacity: 0.12; }
50% { opacity: 0.5; }
}
@keyframes pulse-glow-intersection {
0%, 100% { opacity: 0.15; }
50% { opacity: 0.65; }
}
.saber-outer { stroke-width: 30; }
.dark .saber-outer { stroke-width: 40; }
.saber-mid { stroke-width: 18; }
.dark .saber-mid { stroke-width: 24; }
.saber-core { stroke-width: 6; }
.dark .saber-core { stroke-width: 8; }
.saber-blue-outer { stroke-width: 24; }
.dark .saber-blue-outer { stroke-width: 40; }
.saber-blue-mid { stroke-width: 13; }
.dark .saber-blue-mid { stroke-width: 24; }
.saber-blue-core { stroke-width: 4; }
.dark .saber-blue-core { stroke-width: 8; }
.animate-pulse-red {
animation: pulse-glow-red 2.5s ease-in-out infinite;
}
.animate-pulse-blue {
animation: pulse-glow-blue 2.8s ease-in-out infinite;
}
.animate-pulse-intersection {
animation: pulse-glow-intersection 2.5s ease-in-out infinite;
}
@keyframes star-twinkle {
0%, 100% { opacity: 0.1; }
50% { opacity: 0.6; }
}
@keyframes star-drift {
0% { transform: translate(0, 0); }
50% { transform: translate(3px, -2px); }
100% { transform: translate(0, 0); }
}
.star-twinkle {
animation: star-twinkle 4s ease-in-out infinite, star-drift 20s ease-in-out infinite;
}
:root {
--background: 210 40% 98%;
--background: 220 14% 84%;
--foreground: 222.2 84% 4.9%;
--card: 0 0% 100%;
--card: 220 10% 91%;
--card-foreground: 222.2 84% 4.9%;
--popover: 0 0% 100%;
--popover: 220 14% 96%;
--popover-foreground: 222.2 84% 4.9%;
--primary: 0 100% 40%;
--primary-foreground: 210 40% 98%;
--secondary: 210 40% 96.1%;
--primary: 120 100% 50%;
--primary-foreground: 0 0% 100%;
--secondary: 220 14% 88%;
--secondary-foreground: 222.2 47.4% 11.2%;
--muted: 210 40% 96.1%;
--muted: 220 14% 88%;
--muted-foreground: 215.4 16.3% 46.9%;
--accent: 210 40% 96.1%;
--accent: 220 14% 88%;
--accent-foreground: 222.2 47.4% 11.2%;
--destructive: 0 84.2% 60.2%;
--destructive-foreground: 210 40% 98%;
@@ -40,14 +105,20 @@
--sidebar-ring: 0 76.3% 48%;
--radius: 0.5rem;
--theme-primary: hsl(var(--primary));
--event-meeting: 217 91% 55%;
--event-call: 160 84% 39%;
--event-chat: 271 81% 53%;
--event-follow_up: 38 92% 48%;
--event-demo: 346 77% 48%;
--event-other: 215 20% 55%;
}
.dark {
--background: 222.2 84% 4.9%;
--background: 0 0% 10%;
--foreground: 210 40% 98%;
--card: 222.2 84% 4.9%;
--card: 0 0% 9%;
--card-foreground: 210 40% 98%;
--popover: 222.2 84% 4.9%;
--popover: 222.2 84% 11%;
--popover-foreground: 210 40% 98%;
--primary: 0 100% 53%;
--primary-foreground: 222.2 47.4% 11.2%;
@@ -62,266 +133,15 @@
--border: 217.2 32.6% 17.5%;
--input: 217.2 32.6% 17.5%;
--ring: 0 100% 53%;
--sidebar: 222.2 84% 4.9%;
--sidebar: 0 0% 11%;
--sidebar-foreground: 210 40% 98%;
--sidebar-primary: 0 100% 53%;
--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%;
}
.ocean {
--primary: 187 75% 42%;
--primary-foreground: 210 40% 98%;
--ring: 187 75% 42%;
--sidebar: 0 0% 100%;
--sidebar-foreground: 222.2 84% 4.9%;
--sidebar-primary: 187 75% 42%;
--sidebar-primary-foreground: 0 0% 100%;
--sidebar-accent: 187 30% 94%;
--sidebar-accent-foreground: 187 50% 20%;
--sidebar-border: 187 20% 88%;
--sidebar-ring: 187 75% 42%;
}
.dark.ocean {
--primary: 187 75% 50%;
--primary-foreground: 222.2 47.4% 11.2%;
--ring: 187 75% 50%;
--sidebar: 222.2 84% 4.9%;
--sidebar-foreground: 210 40% 98%;
--sidebar-primary: 187 75% 55%;
--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: 187 75% 50%;
}
.forest {
--primary: 142 76% 36%;
--primary-foreground: 210 40% 98%;
--ring: 142 76% 36%;
--sidebar: 0 0% 100%;
--sidebar-foreground: 222.2 84% 4.9%;
--sidebar-primary: 142 76% 36%;
--sidebar-primary-foreground: 0 0% 100%;
--sidebar-accent: 142 30% 94%;
--sidebar-accent-foreground: 142 50% 20%;
--sidebar-border: 142 20% 88%;
--sidebar-ring: 142 76% 36%;
}
.dark.forest {
--primary: 142 76% 44%;
--primary-foreground: 222.2 47.4% 11.2%;
--ring: 142 76% 44%;
--sidebar: 222.2 84% 4.9%;
--sidebar-foreground: 210 40% 98%;
--sidebar-primary: 142 76% 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: 142 76% 44%;
}
.sunset {
--primary: 24 95% 53%;
--primary-foreground: 210 40% 98%;
--ring: 24 95% 53%;
--sidebar: 0 0% 100%;
--sidebar-foreground: 222.2 84% 4.9%;
--sidebar-primary: 24 95% 53%;
--sidebar-primary-foreground: 0 0% 100%;
--sidebar-accent: 24 30% 94%;
--sidebar-accent-foreground: 24 50% 20%;
--sidebar-border: 24 20% 88%;
--sidebar-ring: 24 95% 53%;
}
.dark.sunset {
--primary: 24 95% 58%;
--primary-foreground: 222.2 47.4% 11.2%;
--ring: 24 95% 58%;
--sidebar: 222.2 84% 4.9%;
--sidebar-foreground: 210 40% 98%;
--sidebar-primary: 24 95% 65%;
--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: 24 95% 58%;
}
.midnight {
--primary: 230 75% 55%;
--primary-foreground: 210 40% 98%;
--ring: 230 75% 55%;
--sidebar: 0 0% 100%;
--sidebar-foreground: 222.2 84% 4.9%;
--sidebar-primary: 230 75% 55%;
--sidebar-primary-foreground: 0 0% 100%;
--sidebar-accent: 230 30% 94%;
--sidebar-accent-foreground: 230 50% 20%;
--sidebar-border: 230 20% 88%;
--sidebar-ring: 230 75% 55%;
}
.dark.midnight {
--primary: 230 75% 62%;
--primary-foreground: 222.2 47.4% 11.2%;
--ring: 230 75% 62%;
--sidebar: 222.2 84% 4.9%;
--sidebar-foreground: 210 40% 98%;
--sidebar-primary: 230 75% 65%;
--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: 230 75% 62%;
}
.rose {
--primary: 346 77% 50%;
--primary-foreground: 210 40% 98%;
--ring: 346 77% 50%;
--sidebar: 0 0% 100%;
--sidebar-foreground: 222.2 84% 4.9%;
--sidebar-primary: 346 77% 50%;
--sidebar-primary-foreground: 0 0% 100%;
--sidebar-accent: 346 30% 94%;
--sidebar-accent-foreground: 346 50% 20%;
--sidebar-border: 346 20% 88%;
--sidebar-ring: 346 77% 50%;
}
.dark.rose {
--primary: 346 77% 58%;
--primary-foreground: 222.2 47.4% 11.2%;
--ring: 346 77% 58%;
--sidebar: 222.2 84% 4.9%;
--sidebar-foreground: 210 40% 98%;
--sidebar-primary: 346 77% 60%;
--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: 346 77% 58%;
}
.amber {
--primary: 38 92% 50%;
--primary-foreground: 210 40% 98%;
--ring: 38 92% 50%;
--sidebar: 0 0% 100%;
--sidebar-foreground: 222.2 84% 4.9%;
--sidebar-primary: 38 92% 50%;
--sidebar-primary-foreground: 0 0% 100%;
--sidebar-accent: 38 30% 94%;
--sidebar-accent-foreground: 38 50% 20%;
--sidebar-border: 38 20% 88%;
--sidebar-ring: 38 92% 50%;
}
.dark.amber {
--primary: 38 92% 56%;
--primary-foreground: 222.2 47.4% 11.2%;
--ring: 38 92% 56%;
--sidebar: 222.2 84% 4.9%;
--sidebar-foreground: 210 40% 98%;
--sidebar-primary: 38 92% 60%;
--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: 38 92% 56%;
}
.violet {
--primary: 262 83% 58%;
--primary-foreground: 210 40% 98%;
--ring: 262 83% 58%;
--sidebar: 0 0% 100%;
--sidebar-foreground: 222.2 84% 4.9%;
--sidebar-primary: 262 83% 58%;
--sidebar-primary-foreground: 0 0% 100%;
--sidebar-accent: 262 30% 94%;
--sidebar-accent-foreground: 262 50% 20%;
--sidebar-border: 262 20% 88%;
--sidebar-ring: 262 83% 58%;
}
.dark.violet {
--primary: 262 83% 65%;
--primary-foreground: 222.2 47.4% 11.2%;
--ring: 262 83% 65%;
--sidebar: 222.2 84% 4.9%;
--sidebar-foreground: 210 40% 98%;
--sidebar-primary: 262 83% 68%;
--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: 262 83% 65%;
}
.slate {
--primary: 215 20% 45%;
--primary-foreground: 210 40% 98%;
--ring: 215 20% 45%;
--sidebar: 0 0% 100%;
--sidebar-foreground: 222.2 84% 4.9%;
--sidebar-primary: 215 20% 45%;
--sidebar-primary-foreground: 0 0% 100%;
--sidebar-accent: 215 20% 94%;
--sidebar-accent-foreground: 215 50% 20%;
--sidebar-border: 215 20% 88%;
--sidebar-ring: 215 20% 45%;
}
.dark.slate {
--primary: 215 20% 60%;
--primary-foreground: 222.2 47.4% 11.2%;
--ring: 215 20% 60%;
--sidebar: 222.2 84% 4.9%;
--sidebar-foreground: 210 40% 98%;
--sidebar-primary: 215 20% 65%;
--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: 215 20% 60%;
}
.ruby {
--primary: 351 85% 45%;
--primary-foreground: 210 40% 98%;
--ring: 351 85% 45%;
--sidebar: 0 0% 100%;
--sidebar-foreground: 222.2 84% 4.9%;
--sidebar-primary: 351 85% 45%;
--sidebar-primary-foreground: 0 0% 100%;
--sidebar-accent: 351 30% 94%;
--sidebar-accent-foreground: 351 50% 20%;
--sidebar-border: 351 20% 88%;
--sidebar-ring: 351 85% 45%;
}
.dark.ruby {
--primary: 351 85% 52%;
--primary-foreground: 222.2 47.4% 11.2%;
--ring: 351 85% 52%;
--sidebar: 222.2 84% 4.9%;
--sidebar-foreground: 210 40% 98%;
--sidebar-primary: 351 85% 55%;
--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: 351 85% 52%;
}
.ocean {
@@ -643,6 +463,20 @@
}
}
[data-radix-select-trigger] {
background-color: hsl(0 0% 100%);
}
main {
position: relative;
z-index: 0;
}
@keyframes drift {
0% { background-position: 0% 0%; }
100% { background-position: 100% 100%; }
}
/* Login page custom styles */
.left-panel {
background: #0a0a0f;
@@ -888,6 +722,10 @@
display: block;
margin-bottom: 6px;
}
@keyframes float {
0%, 100% { transform: translateY(0px); }
50% { transform: translateY(-5px); }
}
.body-text { color: rgba(232,232,239,0.5); }
.subheading-text { color: rgba(232,232,239,0.38); }
.checkbox-text { color: rgba(232,232,239,0.38); }
+2
View File
@@ -4,6 +4,8 @@ import { ThemeProvider } from "@/providers/theme-provider"
import { WebsiteThemeProvider } from "@/providers/website-theme-provider"
import { Toaster } from "@/components/ui/sonner"
import "./globals.css"
import "../../Web_Backgrounds/spidey-theme.css"
import "../../Web_Backgrounds/starforce-theme.css"
const inter = Inter({
variable: "--font-inter",
+2 -3
View File
@@ -13,8 +13,7 @@ const waves = [
]
export default function LoginPage() {
const searchParams = useSearchParams()
const redirectTo = searchParams.get("redirect") || "/dashboard"
const router = useRouter()
const [email, setEmail] = useState("")
const [password, setPassword] = useState("")
const [showPassword, setShowPassword] = useState(false)
@@ -215,7 +214,7 @@ export default function LoginPage() {
})
if (res.ok) {
window.location.href = redirectTo
router.push("/dashboard")
} else {
const data = await res.json().catch(() => ({}))
setError(data.error || "Invalid email or password.")
+33 -111
View File
@@ -1,7 +1,7 @@
"use client"
import { useState, useRef, useEffect, Fragment } from "react"
import { Send, Bot, User, RefreshCw, AlertCircle, Check, Terminal } from "lucide-react"
import { Send, Loader2, Bot, User, RefreshCw, AlertCircle } from "lucide-react"
function linkifyText(text: string) {
const urlRegex = /(https?:\/\/[^\s<]+[^\s<.,;:!?)\]}>])/
@@ -24,26 +24,9 @@ export function AIChat() {
const [input, setInput] = useState("")
const [loading, setLoading] = useState(false)
const [error, setError] = useState("")
const [bootState, setBootState] = useState<"booting" | "ready" | "error">("booting")
const [ollamaStatus, setOllamaStatus] = useState<boolean | null>(null)
const messagesEndRef = useRef<HTMLDivElement>(null)
const checkServer = async () => {
try {
const res = await fetch("/api/ai/chat", {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({ message: "ping" }),
})
if (res.status !== 503) {
setBootState("ready")
} else {
setTimeout(checkServer, 2000)
}
} catch {
setTimeout(checkServer, 2000)
}
}
useEffect(() => {
fetch("/api/ai/jobs")
.then((r) => r.json())
@@ -73,13 +56,29 @@ export function AIChat() {
},
])
})
checkServer()
}, [])
useEffect(() => {
messagesEndRef.current?.scrollIntoView({ behavior: "smooth" })
}, [messages])
const checkOllama = async () => {
try {
const res = await fetch("/api/ai/chat", {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({ message: "__ping__" }),
})
setOllamaStatus(res.status !== 503)
} catch {
setOllamaStatus(false)
}
}
useEffect(() => { checkOllama() }, [])
const sendMessage = async () => {
const msg = input.trim()
if (!msg || loading) return
@@ -97,8 +96,8 @@ export function AIChat() {
})
if (!res.ok) {
const data = await res.json().catch(() => ({}))
throw new Error(data.error || `Error ${res.status}`)
const data = await res.json()
throw new Error(data.error || "Failed to get response")
}
const data = await res.json()
@@ -122,86 +121,17 @@ export function AIChat() {
}
}
const bootOverlay = (
<div className="flex-1 flex items-center justify-center">
<style>{`
@keyframes walk {
0%, 100% { transform: translateX(0) translateY(0); }
25% { transform: translateX(12px) translateY(-4px); }
50% { transform: translateX(24px) translateY(0); }
75% { transform: translateX(12px) translateY(-4px); }
}
@keyframes legLeft {
0%, 100% { transform: rotate(-10deg); }
50% { transform: rotate(10deg); }
}
@keyframes legRight {
0%, 100% { transform: rotate(10deg); }
50% { transform: rotate(-10deg); }
}
@keyframes armLeft {
0%, 100% { transform: rotate(15deg); }
50% { transform: rotate(-15deg); }
}
@keyframes armRight {
0%, 100% { transform: rotate(-15deg); }
50% { transform: rotate(15deg); }
}
@keyframes blink {
0%, 45%, 55%, 100% { height: 4px; }
50% { height: 1px; }
}
.robot-walk { animation: walk 0.6s ease-in-out infinite; }
.robot-leg-l { transform-origin: top center; animation: legLeft 0.3s ease-in-out infinite; }
.robot-leg-r { transform-origin: top center; animation: legRight 0.3s ease-in-out infinite; }
.robot-arm-l { transform-origin: top center; animation: armLeft 0.3s ease-in-out infinite; }
.robot-arm-r { transform-origin: top center; animation: armRight 0.3s ease-in-out infinite; }
.robot-eye { animation: blink 2s ease-in-out infinite; }
`}</style>
<div className="flex flex-col items-center gap-4">
<p className="text-sm text-[#6a6a75]">Servers booting...</p>
<div className="h-[50px] w-[100px] bg-[#1a1a24] border border-[#2a2a35] rounded-lg flex items-center justify-center overflow-hidden">
<div className="robot-walk relative">
<svg width="40" height="36" viewBox="0 0 40 36" fill="none">
<rect x="10" y="2" width="20" height="16" rx="3" fill="#1BB0CE" opacity="0.9"/>
<rect x="6" y="6" width="6" height="2" rx="1" className="robot-arm-l" fill="#1BB0CE" opacity="0.7"/>
<rect x="28" y="6" width="6" height="2" rx="1" className="robot-arm-r" fill="#1BB0CE" opacity="0.7"/>
<rect x="14" y="5" width="4" height="4" rx="1" fill="#0d1117"/>
<rect x="22" y="5" width="4" height="4" rx="1" fill="#0d1117"/>
<circle cx="16" cy="7" r="1.5" className="robot-eye" fill="#1BB0CE"/>
<circle cx="24" cy="7" r="1.5" className="robot-eye" fill="#1BB0CE"/>
<rect x="17" y="10" width="6" height="2" rx="1" fill="#0d1117"/>
<rect x="12" y="18" width="5" height="10" rx="2" className="robot-leg-l" fill="#1BB0CE" opacity="0.8"/>
<rect x="23" y="18" width="5" height="10" rx="2" className="robot-leg-r" fill="#1BB0CE" opacity="0.8"/>
</svg>
</div>
</div>
</div>
</div>
)
const readyOverlay = (
<div className="flex-1 flex items-center justify-center">
<div className="flex flex-col items-center gap-4">
<div className="h-[50px] w-[100px] bg-[#1a1a24] border border-[#2a2a35] rounded-lg flex items-center justify-center">
<Check className="h-6 w-6 text-green-400" />
</div>
<div className="text-center space-y-1">
<p className="text-xs text-[#6a6a75]">Try these commands:</p>
<div className="flex gap-2">
<code className="text-xs px-2 py-1 rounded bg-[#2a2a35] text-[#1BB0CE] flex items-center gap-1"><Terminal className="h-3 w-3" /> lists</code>
<code className="text-xs px-2 py-1 rounded bg-[#2a2a35] text-[#1BB0CE] flex items-center gap-1"><Terminal className="h-3 w-3" /> leads</code>
</div>
</div>
</div>
</div>
)
if (bootState === "booting") return bootOverlay
return (
<div className="flex flex-col h-full">
{bootState === "ready" && readyOverlay}
{ollamaStatus === false && (
<div className="flex items-center gap-2 px-4 py-2 bg-amber-500/10 border-b border-amber-500/20 text-amber-400 text-xs">
<AlertCircle className="h-3.5 w-3.5 flex-none" />
<span className="flex-1">Ollama not responding. Start it with <code className="bg-amber-500/20 px-1 rounded">ollama serve</code></span>
<button type="button" onClick={checkOllama} className="hover:text-amber-300">
<RefreshCw className="h-3.5 w-3.5" />
</button>
</div>
)}
<div className="flex-1 overflow-y-auto p-4 space-y-4 scrollbar-thin">
{messages.map((msg, i) => (
@@ -233,10 +163,7 @@ export function AIChat() {
<Bot className="h-4 w-4 text-[#1BB0CE]" />
</div>
<div className="max-w-[75%] rounded-lg px-4 py-2.5 bg-[#1a1a24] border border-[#2a2a35]">
<svg className="h-4 w-4 animate-spin text-[#1BB0CE]" viewBox="0 0 24 24" fill="none">
<circle cx="12" cy="12" r="10" stroke="currentColor" strokeWidth="4" opacity="0.25"/>
<path d="M12 2a10 10 0 0 1 10 10" stroke="currentColor" strokeWidth="4" strokeLinecap="round"/>
</svg>
<Loader2 className="h-4 w-4 animate-spin text-[#1BB0CE]" />
</div>
</div>
)}
@@ -265,15 +192,10 @@ export function AIChat() {
disabled={loading || !input.trim()}
className="h-9 w-9 rounded-lg bg-[#1BB0CE] hover:bg-[#1BB0CE]/80 disabled:opacity-40 flex items-center justify-center flex-none transition-colors"
>
{loading ? (
<svg className="h-4 w-4 animate-spin" viewBox="0 0 24 24" fill="none">
<circle cx="12" cy="12" r="10" stroke="currentColor" strokeWidth="4" opacity="0.25"/>
<path d="M12 2a10 10 0 0 1 10 10" stroke="currentColor" strokeWidth="4" strokeLinecap="round"/>
</svg>
) : <Send className="h-4 w-4" />}
{loading ? <Loader2 className="h-4 w-4 animate-spin" /> : <Send className="h-4 w-4" />}
</button>
</div>
</div>
</div>
)
}
}
+294
View File
@@ -0,0 +1,294 @@
"use client"
import { useState, useEffect, useRef, useCallback } from "react"
import { cn } from "@/lib/utils"
import { Button } from "@/components/ui/button"
import { Input } from "@/components/ui/input"
import { Search, Image, Sticker, X, Loader2, TrendingUp } from "lucide-react"
import data from "@emoji-mart/data"
import Picker from "@emoji-mart/react"
import { getStickerPacks, type StickerPack } from "@/data/stickers"
type Tab = "emoji" | "gif" | "sticker"
interface MediaPickerProps {
onEmojiSelect: (emoji: string) => void
onMediaSelect: (content: string) => void
onClose: () => void
theme?: string
}
const RECENT_GIFS_KEY = "recent-gifs"
const RECENT_STICKERS_KEY = "recent-stickers"
const MAX_RECENT = 12
function getRecent(key: string): string[] {
if (typeof window === "undefined") return []
try { return JSON.parse(localStorage.getItem(key) || "[]") } catch { return [] }
}
function addRecent(key: string, id: string) {
if (typeof window === "undefined") return
const items = [id, ...getRecent(key).filter((x) => x !== id)].slice(0, MAX_RECENT)
localStorage.setItem(key, JSON.stringify(items))
}
export default function MediaPicker({ onEmojiSelect, onMediaSelect, onClose, theme }: MediaPickerProps) {
const [tab, setTab] = useState<Tab>("emoji")
const [gifQuery, setGifQuery] = useState("")
const [gifResults, setGifResults] = useState<any[]>([])
const [gifLoading, setGifLoading] = useState(false)
const [gifError, setGifError] = useState("")
const [gifNoKey, setGifNoKey] = useState(false)
const [gifNext, setGifNext] = useState("")
const [recentGifs, setRecentGifs] = useState<string[]>([])
const [recentStickers, setRecentStickers] = useState<string[]>([])
const [stickerPacks] = useState<StickerPack[]>(getStickerPacks)
const [activePack, setActivePack] = useState(stickerPacks[0]?.id || "")
const searchTimer = useRef<ReturnType<typeof setTimeout> | null>(null)
const pickerRef = useRef<HTMLDivElement>(null)
useEffect(() => {
const handleClick = (e: MouseEvent) => {
if (pickerRef.current && !pickerRef.current.contains(e.target as Node)) onClose()
}
document.addEventListener("mousedown", handleClick)
return () => document.removeEventListener("mousedown", handleClick)
}, [onClose])
useEffect(() => { setRecentGifs(getRecent(RECENT_GIFS_KEY)) }, [])
useEffect(() => { setRecentStickers(getRecent(RECENT_STICKERS_KEY)) }, [])
const fetchGifs = useCallback(async (query: string, pos = "") => {
setGifLoading(true)
setGifError("")
try {
const params = new URLSearchParams({ limit: "20", pos })
if (query) params.set("q", query)
const res = await fetch(`/api/gifs?${params}`)
const data = await res.json()
if (data.noKey) {
setGifNoKey(true)
setGifResults([])
} else if (data.results) {
setGifResults((prev) => (pos ? [...prev, ...data.results] : data.results))
setGifNext(data.next || "")
setGifNoKey(false)
} else {
setGifError("No results found")
}
} catch {
setGifError("Failed to load GIFs")
}
setGifLoading(false)
}, [])
useEffect(() => {
if (tab !== "gif") return
if (searchTimer.current) clearTimeout(searchTimer.current)
if (gifQuery.trim()) {
searchTimer.current = setTimeout(() => fetchGifs(gifQuery.trim()), 400)
} else {
fetchGifs("")
}
return () => { if (searchTimer.current) clearTimeout(searchTimer.current) }
}, [tab, gifQuery, fetchGifs])
const handleGifSelect = (gif: any) => {
const content = JSON.stringify({ gif: gif.url, w: gif.width, h: gif.height })
addRecent(RECENT_GIFS_KEY, gif.id)
setRecentGifs(getRecent(RECENT_GIFS_KEY))
onMediaSelect(content)
}
const handleStickerSelect = (sticker: any) => {
const content = sticker.svg
? JSON.stringify({ sticker: sticker.svg, w: 200, h: 200, id: sticker.id })
: JSON.stringify({ sticker: sticker.emoji, w: 120, h: 120, id: sticker.id })
addRecent(RECENT_STICKERS_KEY, sticker.id)
setRecentStickers(getRecent(RECENT_STICKERS_KEY))
onMediaSelect(content)
}
const activeStickers = stickerPacks.find((p) => p.id === activePack)?.stickers || []
const renderGifGrid = (items: any[], emptyMsg: string) => {
if (items.length === 0) {
return (
<div className="flex flex-col items-center justify-center h-48 text-muted-foreground text-sm gap-2">
<Image className="h-8 w-8 opacity-40" />
<span>{emptyMsg}</span>
</div>
)
}
return (
<div className="grid grid-cols-2 gap-1.5">
{items.map((gif: any) => (
<button
key={gif.id}
type="button"
onClick={() => handleGifSelect(gif)}
className="rounded-lg overflow-hidden bg-muted/50 hover:ring-2 hover:ring-primary/50 transition-all aspect-video"
>
<img src={gif.previewUrl || gif.url} alt={gif.title || "GIF"} className="w-full h-full object-cover" loading="lazy" />
</button>
))}
</div>
)
}
return (
<div ref={pickerRef} className="w-[360px] rounded-xl border bg-popover shadow-xl overflow-hidden">
{/* Tabs */}
<div className="flex border-b">
<button type="button" onClick={() => setTab("emoji")} className={cn("flex-1 py-2.5 text-sm font-medium transition-colors relative", tab === "emoji" ? "text-foreground" : "text-muted-foreground hover:text-foreground")}>
Emoji
{tab === "emoji" && <div className="absolute bottom-0 left-4 right-4 h-0.5 bg-primary rounded-full" />}
</button>
<button type="button" onClick={() => setTab("gif")} className={cn("flex-1 py-2.5 text-sm font-medium transition-colors relative", tab === "gif" ? "text-foreground" : "text-muted-foreground hover:text-foreground")}>
GIFs
{tab === "gif" && <div className="absolute bottom-0 left-4 right-4 h-0.5 bg-primary rounded-full" />}
</button>
<button type="button" onClick={() => setTab("sticker")} className={cn("flex-1 py-2.5 text-sm font-medium transition-colors relative", tab === "sticker" ? "text-foreground" : "text-muted-foreground hover:text-foreground")}>
Stickers
{tab === "sticker" && <div className="absolute bottom-0 left-4 right-4 h-0.5 bg-primary rounded-full" />}
</button>
</div>
{/* Emoji Tab */}
{tab === "emoji" && (
<div className="max-h-[380px] overflow-hidden">
<Picker
data={data}
onEmojiSelect={(emoji: any) => { onEmojiSelect(emoji.native); onClose() }}
theme={theme === "dark" ? "dark" : "light"}
previewPosition="none"
skinTonePosition="none"
set="native"
emojiSize={32}
maxFrequentRows={2}
perLine={8}
/>
</div>
)}
{/* GIF Tab */}
{tab === "gif" && (
<div className="flex flex-col h-[380px]">
<div className="p-2 border-b">
<div className="relative">
<Search className="absolute left-2.5 top-1/2 h-3.5 w-3.5 -translate-y-1/2 text-muted-foreground" />
<Input
value={gifQuery}
onChange={(e) => setGifQuery(e.target.value)}
placeholder="Search GIFs..."
className="h-8 pl-8 text-sm"
/>
{gifQuery && (
<button type="button" onClick={() => setGifQuery("")} className="absolute right-2 top-1/2 -translate-y-1/2">
<X className="h-3.5 w-3.5 text-muted-foreground" />
</button>
)}
</div>
</div>
<div className="flex-1 overflow-y-auto p-2">
{gifLoading && gifResults.length === 0 ? (
<div className="flex items-center justify-center h-48">
<Loader2 className="h-6 w-6 animate-spin text-muted-foreground" />
</div>
) : gifNoKey ? (
<div className="flex flex-col items-center justify-center h-48 text-muted-foreground text-sm gap-2">
<Image className="h-8 w-8 opacity-40" />
<span>GIF search requires a Tenor API key</span>
<span className="text-xs opacity-60">Set TENOR_API_KEY in .env.local</span>
</div>
) : gifError ? (
<div className="flex flex-col items-center justify-center h-48 text-muted-foreground text-sm gap-2">
<TrendingUp className="h-8 w-8 opacity-40" />
<span>{gifError}</span>
<button type="button" onClick={() => fetchGifs(gifQuery)} className="text-xs text-primary hover:underline">Retry</button>
</div>
) : gifQuery.trim() ? (
renderGifGrid(gifResults, "No GIFs found")
) : recentGifs.length > 0 && gifResults.length === 0 ? (
<>
<div className="text-xs font-medium text-muted-foreground px-1 pb-2">Recent GIFs</div>
{renderGifGrid(
recentGifs.map((id) => gifResults.find((g: any) => g.id === id)).filter(Boolean),
"No recent GIFs"
)}
</>
) : (
<>
<div className="text-xs font-medium text-muted-foreground px-1 pb-2">Trending</div>
{renderGifGrid(gifResults, "No trending GIFs available")}
</>
)}
{gifNext && !gifLoading && (
<div className="flex justify-center pt-2 pb-1">
<Button type="button" variant="ghost" size="sm" className="text-xs h-7" onClick={() => fetchGifs(gifQuery, gifNext)}>
Load more
</Button>
</div>
)}
</div>
</div>
)}
{/* Stickers Tab */}
{tab === "sticker" && (
<div className="flex flex-col h-[380px]">
<div className="flex gap-1 p-2 border-b overflow-x-auto shrink-0">
{stickerPacks.map((pack) => (
<button
key={pack.id}
type="button"
onClick={() => setActivePack(pack.id)}
className={cn(
"px-3 py-1.5 text-xs rounded-md whitespace-nowrap transition-colors",
activePack === pack.id ? "bg-primary text-primary-foreground" : "bg-muted hover:bg-muted/80 text-muted-foreground",
)}
>
{pack.name}
</button>
))}
</div>
<div className="flex-1 overflow-y-auto p-2">
<div className="grid grid-cols-3 gap-2">
{activeStickers.map((sticker) => (
<button
key={sticker.id}
type="button"
onClick={() => handleStickerSelect(sticker)}
className="rounded-xl bg-muted/30 hover:bg-muted/60 transition-colors flex items-center justify-center p-2 aspect-square"
>
{sticker.svg ? (
<div className="w-full h-full flex items-center justify-center" dangerouslySetInnerHTML={{ __html: sticker.svg.replace(/<svg /, '<svg style="width:100%;height:100%" ') }} />
) : (
<span className="text-5xl">{sticker.emoji}</span>
)}
</button>
))}
</div>
{recentStickers.length > 0 && (
<>
<div className="text-xs font-medium text-muted-foreground pt-4 pb-2">Recently Used</div>
<div className="grid grid-cols-3 gap-2">
{recentStickers.map((id) => {
const s = stickerPacks.flatMap((p) => p.stickers).find((x) => x.id === id)
if (!s) return null
return (
<button key={s.id} type="button" onClick={() => handleStickerSelect(s)} className="rounded-xl bg-muted/30 hover:bg-muted/60 transition-colors flex items-center justify-center p-2 aspect-square opacity-70 hover:opacity-100">
{s.svg ? <div className="w-full h-full flex items-center justify-center" dangerouslySetInnerHTML={{ __html: s.svg.replace(/<svg /, '<svg style="width:100%;height:100%" ') }} /> : <span className="text-5xl">{s.emoji}</span>}
</button>
)
})}
</div>
</>
)}
</div>
</div>
)}
</div>
)
}
@@ -0,0 +1,49 @@
"use client"
export function CrossedLightsabers() {
return (
<svg
width="120"
height="120"
viewBox="0 0 200 200"
className="pointer-events-none select-none overflow-visible shrink-0"
style={{ overflow: "visible" }}
aria-hidden="true"
>
<defs>
<radialGradient id="purpleGlow" cx="50%" cy="50%" r="50%">
<stop offset="0%" stopColor="#a855f7" stopOpacity="0.6" />
<stop offset="40%" stopColor="#a855f7" stopOpacity="0.25" />
<stop offset="100%" stopColor="#a855f7" stopOpacity="0" />
</radialGradient>
</defs>
<circle cx="100" cy="73" r="24" fill="url(#purpleGlow)" className="animate-pulse-intersection" />
<g className="animate-pulse-red">
<line x1="35" y1="145" x2="125" y2="45" strokeLinecap="round" opacity="0.12" className="stroke-[#dc2626] dark:stroke-[#ef4444] saber-outer" />
<line x1="35" y1="145" x2="125" y2="45" strokeLinecap="round" opacity="0.25" className="stroke-[#dc2626] dark:stroke-[#ef4444] saber-mid" />
<line x1="35" y1="145" x2="125" y2="45" strokeLinecap="round" className="stroke-[#f87171] dark:stroke-[#fca5a5] saber-core" />
</g>
<g className="animate-pulse-blue">
<line x1="165" y1="145" x2="75" y2="45" strokeLinecap="round" opacity="0.12" className="stroke-[#1d4ed8] dark:stroke-[#3b82f6] saber-blue-outer" />
<line x1="165" y1="145" x2="75" y2="45" strokeLinecap="round" opacity="0.25" className="stroke-[#1d4ed8] dark:stroke-[#3b82f6] saber-blue-mid" />
<line x1="165" y1="145" x2="75" y2="45" strokeLinecap="round" className="stroke-[#60a5fa] dark:stroke-[#93c5fd] saber-blue-core" />
</g>
<rect x="30" y="145" width="10" height="55" rx="2" fill="#374151" />
<rect x="160" y="145" width="10" height="55" rx="2" fill="#374151" />
<rect x="30" y="155" width="10" height="5" fill="#1f2937" />
<rect x="30" y="166" width="10" height="5" fill="#1f2937" />
<rect x="30" y="177" width="10" height="5" fill="#1f2937" />
<rect x="160" y="155" width="10" height="5" fill="#1f2937" />
<rect x="160" y="166" width="10" height="5" fill="#1f2937" />
<rect x="160" y="177" width="10" height="5" fill="#1f2937" />
<rect x="32" y="143" width="6" height="4" rx="1" fill="#6b7280" />
<rect x="162" y="143" width="6" height="4" rx="1" fill="#6b7280" />
</svg>
)
}
@@ -69,19 +69,6 @@ export function LeadStatusChart({ data }: LeadStatusChartProps) {
className="h-full"
>
<Card className="h-full relative overflow-hidden">
{/* Spider watermark */}
<div className="absolute top-1/2 left-1/2 -translate-x-1/2 -translate-y-1/2 pointer-events-none z-0 opacity-[0.03] dark:opacity-[0.05]">
<svg width="220" height="220" viewBox="0 0 180 180" fill="none" xmlns="http://www.w3.org/2000/svg">
<line x1="90" y1="0" x2="90" y2="180" stroke="#CC0000" strokeWidth="1" />
<line x1="0" y1="90" x2="180" y2="90" stroke="#CC0000" strokeWidth="1" />
<line x1="30" y1="30" x2="150" y2="150" stroke="#0033CC" strokeWidth="1" />
<line x1="150" y1="30" x2="30" y2="150" stroke="#0033CC" strokeWidth="1" />
<circle cx="90" cy="90" r="40" stroke="#CC0000" strokeWidth="1" fill="none" />
<circle cx="90" cy="90" r="60" stroke="#0033CC" strokeWidth="0.8" fill="none" strokeDasharray="4 4" />
<circle cx="90" cy="90" r="80" stroke="#CC0000" strokeWidth="0.5" fill="none" strokeDasharray="2 6" />
</svg>
</div>
<CardHeader>
<CardTitle>Lead Status</CardTitle>
</CardHeader>
+47
View File
@@ -0,0 +1,47 @@
"use client"
import { useMemo } from "react"
interface Star {
left: number
top: number
size: number
delay: number
duration: number
}
export function StarField() {
const stars = useMemo<Star[]>(() => {
const result: Star[] = []
for (let i = 0; i < 160; i++) {
result.push({
left: Math.random() * 100,
top: Math.random() * 100,
size: 1.5 + Math.random() * 2,
delay: Math.random() * 6,
duration: 3 + Math.random() * 4,
})
}
return result
}, [])
return (
<div className="fixed inset-0 pointer-events-none select-none z-0 overflow-hidden">
{stars.map((s, i) => (
<div
key={i}
className="absolute rounded-full bg-[#222222] dark:bg-white star-twinkle"
style={{
left: `${s.left}%`,
top: `${s.top}%`,
width: `${s.size}px`,
height: `${s.size}px`,
opacity: 0.35 + Math.random() * 0.45,
animationDelay: `${s.delay}s`,
animationDuration: `${s.duration}s`,
}}
/>
))}
</div>
)
}
@@ -3,14 +3,11 @@
export function StatCardSkeleton() {
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-3">
<p className="text-[#444444] dark:text-[#AAAAAA] text-sm">
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 className="w-full h-full rounded-full bg-sidebar animate-[loading_1.5s_ease-in-out_infinite]" />
</div>
</div>
)
-31
View File
@@ -118,37 +118,6 @@ export function StatCard({ title, value, icon: Icon, description, index = 0, tre
transition={{ duration: 0.3, delay: index * 0.05 }}
className="relative"
>
{/* Web overlay decorative */}
<div className="absolute inset-0 pointer-events-none z-0 opacity-[0.03] dark:opacity-[0.06]">
<svg width="100%" height="100%" viewBox="0 0 180 180" preserveAspectRatio="none" fill="none" xmlns="http://www.w3.org/2000/svg">
{Array.from({ length: 6 }).map((_, i) => (
<line key={`wl-${i}`} x1="0" y1={i * 36} x2="180" y2={i * 36} stroke="#CC0000" strokeWidth="0.5" />
))}
{Array.from({ length: 6 }).map((_, i) => (
<line key={`wc-${i}`} x1={i * 36} y1="0" x2={i * 36} y2="180" stroke="#CC0000" strokeWidth="0.5" />
))}
{Array.from({ length: 6 }).map((_, i) => (
<line key={`wd-${i}`} x1="0" y1={i * 36} x2={180 - i * 36} y2="180" stroke="#0033CC" strokeWidth="0.5" />
))}
</svg>
</div>
{/* Comic action lines on hover */}
<div className="absolute -inset-2 pointer-events-none z-0 opacity-0 group-hover:opacity-100 transition-opacity duration-300">
<svg width="100%" height="100%" viewBox="0 0 200 200" fill="none" xmlns="http://www.w3.org/2000/svg">
<line x1="100" y1="0" x2="100" y2="20" stroke="#CC0000" strokeWidth="2" strokeLinecap="round" />
<line x1="100" y1="180" x2="100" y2="200" stroke="#CC0000" strokeWidth="2" strokeLinecap="round" />
<line x1="0" y1="100" x2="20" y2="100" stroke="#CC0000" strokeWidth="2" strokeLinecap="round" />
<line x1="180" y1="100" x2="200" y2="100" stroke="#CC0000" strokeWidth="2" strokeLinecap="round" />
<line x1="30" y1="30" x2="44" y2="44" stroke="#0033CC" strokeWidth="1.5" strokeLinecap="round" />
<line x1="170" y1="30" x2="156" y2="44" stroke="#0033CC" strokeWidth="1.5" strokeLinecap="round" />
<line x1="30" y1="170" x2="44" y2="156" stroke="#0033CC" strokeWidth="1.5" strokeLinecap="round" />
<line x1="170" y1="170" x2="156" y2="156" stroke="#0033CC" strokeWidth="1.5" strokeLinecap="round" />
<circle cx="100" cy="100" r="90" stroke="#CC0000" strokeWidth="0.5" strokeDasharray="4 4" opacity="0.4" />
<circle cx="100" cy="100" r="80" stroke="#0033CC" strokeWidth="0.5" strokeDasharray="3 5" opacity="0.3" />
</svg>
</div>
<Card className="group h-full hover:shadow-xl transition-all duration-200 relative z-10 overflow-hidden">
{/* Red/Blue gradient top strip */}
<div className={`h-1 w-full bg-gradient-to-r ${stripColor} ${stripGlow} shadow-sm`} />
+28 -28
View File
@@ -35,47 +35,47 @@ export function AppShell({ children }: AppShellProps) {
return (
<div className="min-h-screen bg-[#F8F8F8] dark:bg-[#0A0A0A] relative overflow-hidden"
style={{
backgroundImage: "radial-gradient(circle, #e6202010 1px, transparent 1px), radial-gradient(circle, #0033CC06 1px, transparent 1px)",
backgroundImage: "radial-gradient(circle, #CC000010 1px, transparent 1px), radial-gradient(circle, #0033CC06 1px, transparent 1px)",
backgroundSize: "28px 28px, 14px 14px",
backgroundPosition: "0 0, 7px 7px",
}}
>
{/* Spider-Man top gradient bar */}
<div className="fixed top-0 left-0 right-0 h-[3px] w-full bg-gradient-to-r from-[#e62020] via-[#FFFFFF] to-[#0033CC] dark:from-[#FF6666] 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 */}
<div className="hidden lg:block fixed top-0 left-0 pointer-events-none z-0 opacity-[0.07] dark:opacity-[0.12]">
<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="#e62020" strokeWidth="0.8"/>
<line x1="0" y1="0" x2="180" y2="60" stroke="#e62020" strokeWidth="0.8"/>
<line x1="0" y1="0" x2="180" y2="120" stroke="#e62020" strokeWidth="0.8"/>
<line x1="0" y1="0" x2="180" y2="180" stroke="#e62020" strokeWidth="0.8"/>
<line x1="0" y1="0" x2="120" y2="180" stroke="#e62020" strokeWidth="0.8"/>
<line x1="0" y1="0" x2="60" y2="180" stroke="#e62020" strokeWidth="0.8"/>
<line x1="0" y1="0" x2="0" y2="180" stroke="#e62020" strokeWidth="0.8"/>
<path d="M0,30 Q30,30 30,0" stroke="#e62020" strokeWidth="0.8" fill="none"/>
<path d="M0,60 Q60,60 60,0" stroke="#e62020" strokeWidth="0.8" fill="none"/>
<path d="M0,90 Q90,90 90,0" stroke="#e62020" strokeWidth="0.8" fill="none"/>
<path d="M0,120 Q120,120 120,0" stroke="#e62020" strokeWidth="0.8" fill="none"/>
<path d="M0,150 Q150,150 150,0" stroke="#e62020" strokeWidth="0.8" fill="none"/>
<path d="M0,180 Q180,180 180,0" stroke="#e62020" strokeWidth="0.8" fill="none"/>
<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="120" stroke="#CC0000" strokeWidth="0.8"/>
<line x1="0" y1="0" x2="180" y2="180" stroke="#CC0000" strokeWidth="0.8"/>
<line x1="0" y1="0" x2="120" y2="180" stroke="#CC0000" strokeWidth="0.8"/>
<line x1="0" y1="0" x2="60" y2="180" stroke="#CC0000" strokeWidth="0.8"/>
<line x1="0" y1="0" x2="0" y2="180" stroke="#CC0000" strokeWidth="0.8"/>
<path d="M0,30 Q30,30 30,0" stroke="#CC0000" strokeWidth="0.8" fill="none"/>
<path d="M0,60 Q60,60 60,0" stroke="#CC0000" strokeWidth="0.8" fill="none"/>
<path d="M0,90 Q90,90 90,0" stroke="#CC0000" strokeWidth="0.8" fill="none"/>
<path d="M0,120 Q120,120 120,0" stroke="#CC0000" strokeWidth="0.8" fill="none"/>
<path d="M0,150 Q150,150 150,0" stroke="#CC0000" strokeWidth="0.8" fill="none"/>
<path d="M0,180 Q180,180 180,0" stroke="#CC0000" strokeWidth="0.8" fill="none"/>
</svg>
</div>
<div className="hidden lg:block fixed top-0 right-0 pointer-events-none z-0 opacity-[0.07] dark:opacity-[0.12]">
<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="#e62020" strokeWidth="0.8"/>
<line x1="0" y1="0" x2="180" y2="60" stroke="#e62020" strokeWidth="0.8"/>
<line x1="0" y1="0" x2="180" y2="120" stroke="#e62020" strokeWidth="0.8"/>
<line x1="0" y1="0" x2="180" y2="180" stroke="#e62020" strokeWidth="0.8"/>
<line x1="0" y1="0" x2="120" y2="180" stroke="#e62020" strokeWidth="0.8"/>
<line x1="0" y1="0" x2="60" y2="180" stroke="#e62020" strokeWidth="0.8"/>
<line x1="0" y1="0" x2="0" y2="180" stroke="#e62020" strokeWidth="0.8"/>
<path d="M0,30 Q30,30 30,0" stroke="#e62020" strokeWidth="0.8" fill="none"/>
<path d="M0,60 Q60,60 60,0" stroke="#e62020" strokeWidth="0.8" fill="none"/>
<path d="M0,90 Q90,90 90,0" stroke="#e62020" strokeWidth="0.8" fill="none"/>
<path d="M0,120 Q120,120 120,0" stroke="#e62020" strokeWidth="0.8" fill="none"/>
<path d="M0,150 Q150,150 150,0" stroke="#e62020" strokeWidth="0.8" fill="none"/>
<path d="M0,180 Q180,180 180,0" stroke="#e62020" strokeWidth="0.8" fill="none"/>
<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="120" stroke="#CC0000" strokeWidth="0.8"/>
<line x1="0" y1="0" x2="180" y2="180" stroke="#CC0000" strokeWidth="0.8"/>
<line x1="0" y1="0" x2="120" y2="180" stroke="#CC0000" strokeWidth="0.8"/>
<line x1="0" y1="0" x2="60" y2="180" stroke="#CC0000" strokeWidth="0.8"/>
<line x1="0" y1="0" x2="0" y2="180" stroke="#CC0000" strokeWidth="0.8"/>
<path d="M0,30 Q30,30 30,0" stroke="#CC0000" strokeWidth="0.8" fill="none"/>
<path d="M0,60 Q60,60 60,0" stroke="#CC0000" strokeWidth="0.8" fill="none"/>
<path d="M0,90 Q90,90 90,0" stroke="#CC0000" strokeWidth="0.8" fill="none"/>
<path d="M0,120 Q120,120 120,0" stroke="#CC0000" strokeWidth="0.8" fill="none"/>
<path d="M0,150 Q150,150 150,0" stroke="#CC0000" strokeWidth="0.8" fill="none"/>
<path d="M0,180 Q180,180 180,0" stroke="#CC0000" strokeWidth="0.8" fill="none"/>
</svg>
</div>
+37
View File
@@ -0,0 +1,37 @@
"use client"
export function CrmIcon() {
return (
<svg
width="24"
height="24"
viewBox="0 0 64 64"
aria-hidden="true"
>
<g className="fill-[#333333] dark:fill-white/[0.15] dark:stroke-[#e5e7eb] dark:[stroke-width:1.5px]">
<path className="dark:[stroke-linejoin:round]" d="m10.35 35.62s-.75.14-1-1.48-.58-8.61-.3-8.87a10 10 0 0 1 2.07-.63s0-7.54 2.67-11.86 5.4-6.09 10.65-7.56a29.38 29.38 0 0 1 15.18.3c4.41 1.18 8.7 4.62 9.89 8.8a49.27 49.27 0 0 1 1.49 9.55 9.53 9.53 0 0 1 2.52.9c.17.33.43 9.12 0 9.38a1.6 1.6 0 0 1 -.87.23s7.86 18 7.59 19-12.93 6.36-30.1 6.23-26.05-5.41-26.36-6.11 6.57-17.88 6.57-17.88z" />
</g>
<g className="fill-[#777777] dark:fill-white/[0.1] dark:stroke-[#d4d4d4] dark:[stroke-width:1px]">
<path d="m13.24 27.92s-.6-7.65 1.3-11.91a19.6 19.6 0 0 1 7.75-8.23 20.47 20.47 0 0 1 6.51-1.45c.05.16.67 10.23.66 11.61a27 27 0 0 1 -.57 4s-4-2.44-8.16-1.38a10.47 10.47 0 0 0 -7.49 7.36z" />
<path d="m30.11 22.18s.53-3.69.54-4.89-.8-10.82-.6-10.95a4.11 4.11 0 0 1 1.88 0c.07.16 1.07 11.66 1.28 13.22a9.33 9.33 0 0 1 .27 2.44 2.85 2.85 0 0 1 -3.37.18z" />
<path d="m34.8 21.44a76.48 76.48 0 0 1 -1.4-9.35c-.17-3.62-.51-5.65-.06-5.83s6.66.17 10.42 3.45 4.06 5.61 3.81 5.66-2.52-1-2.64-.77-.15.8.06.92 2.69 1.1 2.82 1.26a4.18 4.18 0 0 1 .28.95 16.94 16.94 0 0 0 -3-1.13c-.12.17-.23.92 0 1s3 1.3 3 1.3l.11.79s-2.83-1.18-3-1.14-.36.8-.15.92 3.16 1.26 3.25 1.43.41 1.36.41 1.36l-1.71-.54s.31.87.48 1 1.3.42 1.31.76a31.35 31.35 0 0 1 .2 3.33c-.12 0-2.2-6.45-5.21-7.09s-8.98 1.72-8.98 1.72z" />
<path d="m22.44 22.28c2.21 0 7.19 2.45 9.76 1.84s6.89-2.79 9.51-2.81 4.07 2.48 6.36 7.84 7.14 17.34 7 17.34-8.1-17.89-9.66-20.23-2.66-3-5.86-2.77-6.81 1-6.85 1.41 0 7.17.49 7.75 3.06.71 6.18.51 5-.33 5.77-.64a10.14 10.14 0 0 0 1.69-1l7.79 16.48a30.15 30.15 0 0 0 -2.94-2.56c-.21.05-.31.8-.19.88s3.67 3.33 4 3.7a12.43 12.43 0 0 1 1.61 2.63c-.12.17-.45.55-.66.47s-5-5.71-5.39-5.83-.45.6-.45.6 5.18 5.5 5.06 5.63a8.62 8.62 0 0 1 -1.15.57s-4.34-5-4.5-4.94-.69.6-.4.81a45.71 45.71 0 0 1 4.07 4.36 7 7 0 0 1 -1.2.4 46.49 46.49 0 0 0 -3.58-3.72c-.25 0-.61.39-.49.55s3.34 3.29 3.22 3.46a4 4 0 0 1 -1.16.37s-2.47-2.59-2.65-2.59-.74.31-.53.6 2.39 2.19 2.27 2.28a4.86 4.86 0 0 1 -1 .19c-.13 0-2-2-2.22-2s-.78.19-.65.48 1.83 1.79 1.63 1.92-5.17 1.66-15.76 1.51-14.84-1.85-14.84-1.85a46 46 0 0 0 -4.22-3.69c-.37 0-.78.6-.61.72a29.31 29.31 0 0 1 2.86 2.6c-.16 0-3.27-.92-4.32-1.1a7.6 7.6 0 0 1 -2.26-.61c0-.13 6-14.45 6.27-14.41s4.29 10.07 4.29 10.07a2.77 2.77 0 0 0 1.2 2.89 3.9 3.9 0 0 0 3.49-.25l15.51.08c.12 0 2.65 1.27 3.69-.68s.44-2.67.44-2.67 4-11.27 3.76-11.52a5.63 5.63 0 0 0 -1.76-.35s-2.75 10.32-3 10.33-1.26-.3-1.68-.34a3.18 3.18 0 0 0 -.7 0l-5.48-7.62s1.1-6-3.28-6-3.14 6-3.14 6-1-.06-2 1.51-3.39 6.29-3.39 6.29a3.77 3.77 0 0 0 -1.45.2c-.66.27-1.07.49-1.07.49s-3.54-8.29-3.46-8.46.82-2.15 1.2-2.16 2.93.39 3.93.41 1.16-.11 1.2-.28.27-.8.1-.8-5.11-.57-5.48-.57-1.07 2.14-1.36 2.23-1.32.25-1.2 0 2.16-5.13 2.29-5.1 7.53 1.07 9.73.64 2.76-1.11 3.07-2.16a31.45 31.45 0 0 0 .25-6.59 19.1 19.1 0 0 0 -6.2-1.31c-2.79-.09-4.67-.13-5.87 1.82s-10.79 27.71-10.96 27.76a3.64 3.64 0 0 1 -.92-.29s4.91-14 6.78-19.26 3.47-11.56 9.47-11.46z" />
<path d="m28.76 40.3a14.69 14.69 0 0 0 2.25 0l2.29-.09s-.39 9.67 0 9.75.67.27.66-.06-.19-7.75.06-7.79.75 1.52.75 1.52-.14 6.42 0 6.46.88.27.84.06 0-4.75 0-4.75l1.43 2.26-.79.06s-.08 2 .17 2a4.75 4.75 0 0 0 .71 0c.12 0 .11 1.16.11 1.16s-7.26-.19-9.8-.13-2.74.11-2.74.11a6.75 6.75 0 0 0 -.55-2l-.62-1.28s4.86-7.15 5.23-7.28z" />
</g>
<g className="fill-[#333333] dark:fill-white/[0.15] dark:stroke-[#d4d4d4] dark:[stroke-width:0.7px]">
<path d="m30.57 41.13c.28-.1.79-.06.84.1s.41 8.41.17 8.63-.88.14-.88 0-.25-8.69-.13-8.73z" />
<path d="m28.74 41.26c.21-.09.7-.19.79 0s.55 8.61.38 8.66-.87.18-.87 0-.3-8.66-.3-8.66z" />
<path d="m27 44.8c.06-.14.87-.4.87-.23a52.12 52.12 0 0 1 .21 5.25 3 3 0 0 1 -.79.06s-.38-4.88-.29-5.08z" />
<path d="m25.25 46.8c0-.17.57-.64.65-.51a14 14 0 0 1 .26 3.45 4 4 0 0 1 -.79.14s-.16-2.88-.12-3.08z" />
<path d="m41.23 35.62c.19-.1 2.79-.16 4.58-.37a11 11 0 0 1 1.78-.25c.21 0 .48.87.36 1a17.41 17.41 0 0 1 -4.32.69c-1.46 0-2.29 0-2.29 0s-.48-.9-.11-1.07z" />
<path d="m42 38.39c.34-.09 1.41 1.51 1.41 1.51s-.19.79-.4.63a9.86 9.86 0 0 1 -1.5-1.63c-.08-.21.29-.46.49-.51z" />
<path d="m40.41 39.68c.29-.1 2.3 1.78 2.22 2s-.15.63-.28.59a19.84 19.84 0 0 1 -2.43-2.11c-.04-.22.37-.44.49-.48z" />
<path d="m39.41 41.16s2.55 2 2.47 2.32a2.36 2.36 0 0 1 -.36.67s-2.93-2.38-2.81-2.6.53-.26.7-.39z" />
<path d="m14.45 48.4c.2.07 2.81 2.31 2.82 2.72s-.11.63-.24.63a27 27 0 0 1 -2.93-2.38c-.05-.17.14-1.05.35-.97z" />
<path d="m13.67 50.5s3.83 3.16 3.83 3.37-.11.67-.23.67a35.1 35.1 0 0 1 -4.08-3.07c-.04-.25.48-.97.48-.97z" />
<path d="m16.27 17c-.17-.24.44-2.3 1.57-3.91s2.19-2.52 2.65-2.4.77.61.6.82a7.05 7.05 0 0 0 -2.72 2.86c-.79 1.85-.55 3-.8 3.06a1.86 1.86 0 0 1 -1.3-.43z" />
<path d="m44.85 13.23a2.79 2.79 0 0 1 1.6.54c.09.25.27.83-.11.76a10.8 10.8 0 0 1 -1.43-.53c-.17-.06-.31-.72-.06-.77z" />
</g>
</svg>
)
}
@@ -0,0 +1,15 @@
"use client"
export function SidebarNameLogo() {
return (
<svg
viewBox="0 0 192.756 192.756"
className="fill-[#333333] dark:fill-[#e5e7eb] w-full h-auto"
aria-hidden="true"
>
<g fill-rule="evenodd" clip-rule="evenodd">
<path d="M5.669 81.215v12.65h37.003c4.301 0 9.796-3.977 9.796-10.556 0-2.646 1.012-4.372-2.098-7.872l-4.733-5.608c-2.712-2.53.324-2.53 2.602-2.53h15.623v26.566h12.39V67.299h16.699V55.922H38.877c-6.579 0-9.796 6.317-9.615 9.606.182 3.289.787 7.427 6.254 12.398 4.987 4.533-2.469 3.289-3.218 3.289H5.669zM120.348 55.922H100.36L89.155 93.866h12.47l2.023-5.313h13.156l1.953 5.313h12.215l-10.624-37.944zm-13.916 23.522l4.301-13.916 4.049 13.916h-8.35zM170.443 81.215c-4.807 0-4.807-1.771-4.807-1.771 4.119 0 7.771-6.001 7.771-12.145s-6-11.377-10.809-11.377h-26.891v37.944h13.664v-12.65s5.818 6.831 8.854 9.614c3.037 2.783 3.289 3.036 7.41 3.036h21.449v-12.65c.002-.001-11.834-.001-16.641-.001zm-12.398-8.855h-8.672v-6.832h8.672c3.976 0 4.664 6.832 0 6.832zM5.669 98.672h13.979l3.542 12.652 3.289-12.652h14.675l3.795 12.652 3.796-12.652h12.144l-11.133 37.953H38.624l-4.878-17.965-5.496 17.965H16.865L5.669 98.672zM89.578 98.891H69.59l-11.204 37.943h12.469l2.024-5.312h13.157l1.953 5.312h12.216L89.578 98.891zm-13.915 23.521l4.301-13.916 4.048 13.916h-8.349zM170.695 110.059c-2.275 0-4.756.266-2.043 2.795l4.734 5.609c3.109 3.5 3.059 4.959 3.059 7.607 0 6.578-6.508 10.555-10.809 10.555l-29.896.201c-4.119 0-4.371-.252-7.408-3.035-3.035-2.783-8.855-9.615-8.855-9.615v12.65h-13.662V98.883h26.891c4.807 0 10.809 5.234 10.809 11.377 0 6.145-3.652 12.145-7.773 12.145 0 0 1.812 1.822 4.848 1.822 3.037 0 14.727.012 14.727.012.748 0 8.203 1.244 3.217-3.289-5.467-4.971-6.072-9.107-6.254-12.396s2.662-9.881 9.238-9.881h25.57v11.387h-16.393v-.001zm-42.545 5.261h-8.674v-6.832h8.674c3.977 0 4.664 6.832 0 6.832z" />
</g>
</svg>
)
}
+2
View File
@@ -20,6 +20,7 @@ import {
MessageSquare,
Bot,
Facebook,
Calendar,
} from "lucide-react"
import { useUser } from "@/providers/user-provider"
@@ -29,6 +30,7 @@ import { FacebookAccountsDialog } from "@/components/settings/facebook-accounts-
const navItems = [
{ href: "/dashboard", label: "Dashboard", icon: LayoutDashboard },
{ href: "/leads", label: "Leads", icon: Users },
{ href: "/calendar", label: "Calendar", icon: Calendar },
{ href: "/chats", label: "Chats", icon: MessageSquare },
{ href: "/ai-assistant", label: "AI Assistant", icon: Bot, roles: ["sales", "admin", "super_admin"] },
{ href: "/users", label: "Users", icon: Building2 },
+17 -20
View File
@@ -32,6 +32,9 @@ import {
CheckCheck,
Trash2,
} from "lucide-react";
import { CrmIcon } from "./crm-icon";
import { CaptainAmericaShield } from "@/components/shared/captain-america-shield";
import { useWebsiteTheme } from "@/providers/website-theme-provider";
interface TopbarProps {
onMenuClick: () => void;
@@ -40,6 +43,7 @@ interface TopbarProps {
export function Topbar({ onMenuClick }: TopbarProps) {
const router = useRouter();
const { theme, setTheme } = useTheme();
const { websiteTheme } = useWebsiteTheme();
const { user, logout } = useUser();
const { notifications, unreadCount, markAsRead, markAllAsRead, dismiss } =
useNotifications();
@@ -70,17 +74,10 @@ export function Topbar({ onMenuClick }: TopbarProps) {
.toUpperCase();
return (
<header className="sticky top-0 z-20 flex h-16 items-center gap-4 border-b bg-white dark:bg-[#141414] px-4 lg:px-6 relative overflow-hidden">
{/* Red/Blue diagonal split accent */}
<div className="absolute right-0 top-0 bottom-0 w-[6px] bg-gradient-to-b from-[#CC0000] via-[#0033CC] to-[#CC0000] dark:from-[#FF1111] dark:via-[#1144FF] dark:to-[#FF1111]" />
<header className="sticky top-0 z-20 flex h-16 items-center gap-4 border-b bg-card dark:bg-[#141414] px-4 lg:px-6 relative overflow-hidden">
{/* Logo */}
<div className="flex items-center gap-2 mr-2 shrink-0">
<svg width="28" height="28" viewBox="0 0 28 28" fill="none" xmlns="http://www.w3.org/2000/svg">
<circle cx="14" cy="14" r="13" fill="#CC0000" />
<circle cx="14" cy="14" r="8" fill="#0033CC" />
<path d="M14 6 L16 12 L22 12 L17 15 L19 21 L14 17 L9 21 L11 15 L6 12 L12 12 Z" fill="white" />
</svg>
{websiteTheme === "spidey" ? <CaptainAmericaShield /> : <CrmIcon />}
<span className="text-[#CC0000] dark:text-[#FF1111] font-bold text-sm tracking-wide">
CRM
</span>
@@ -116,17 +113,6 @@ export function Topbar({ onMenuClick }: TopbarProps) {
<Search className="h-5 w-5" />
</Button>
{/* Report a Bug */}
<Button
variant="ghost"
size="icon"
onClick={() => setBugModalOpen(true)}
className="text-muted-foreground"
title="Report a Bug"
>
<Bug className="h-5 w-5" />
</Button>
{/* Theme toggle */}
<Button
variant="ghost"
@@ -145,6 +131,17 @@ export function Topbar({ onMenuClick }: TopbarProps) {
)}
</Button>
{/* Report a Bug */}
<Button
variant="ghost"
size="icon"
onClick={() => setBugModalOpen(true)}
className="text-muted-foreground"
title="Report a Bug"
>
<Bug className="h-5 w-5" />
</Button>
{/* Notifications */}
<DropdownMenu>
<DropdownMenuTrigger asChild>
+24 -62
View File
@@ -6,8 +6,8 @@ import { Card, CardContent, CardDescription, CardHeader, CardTitle } from "@/com
import { Label } from "@/components/ui/label"
import { RadioGroup, RadioGroupItem } from "@/components/ui/radio-group"
import { cn } from "@/lib/utils"
import { Sun, Moon, Monitor, Sparkles, Shield } from "lucide-react"
import { useWebsiteTheme } from "@/providers/website-theme-provider"
import { Sun, Moon, Monitor, Check } from "lucide-react"
const COLOR_THEME_KEY = "crm-color-theme"
@@ -30,6 +30,11 @@ const themeOptions = [
{ value: "ruby", label: "Ruby", color: "bg-red-600", ring: "ring-red-600" },
]
const backgroundOptions = [
{ value: "spidey", label: "Spidey", icon: Shield, color: "bg-red-600", ring: "ring-red-600", desc: "Dark theme with red accents" },
{ value: "starforce", label: "StarForce", icon: Sparkles, color: "bg-red-700", ring: "ring-red-700", desc: "Cosmic dark with starfield & green glow" },
]
function getStoredColorTheme(): string {
if (typeof window === "undefined") return "default"
return localStorage.getItem(COLOR_THEME_KEY) || "default"
@@ -45,14 +50,6 @@ function applyColorTheme(theme: string) {
localStorage.setItem(COLOR_THEME_KEY, theme)
}
const websiteThemes = [
{
id: "spidey",
name: "Spidey",
description: "Current website theme",
},
]
export function ThemeSettings() {
const { theme, setTheme } = useTheme()
const { websiteTheme, setWebsiteTheme } = useWebsiteTheme()
@@ -132,66 +129,31 @@ export function ThemeSettings() {
<Card>
<CardHeader>
<CardTitle>Website Themes</CardTitle>
<CardTitle>Color Theme for Background</CardTitle>
<CardDescription>
Select your website&apos;s visual theme.
Choose the website background theme.
</CardDescription>
</CardHeader>
<CardContent>
<div className="grid grid-cols-2 gap-4 sm:grid-cols-3 md:grid-cols-4">
{websiteThemes.map((wt) => {
const isActive = websiteTheme === wt.id
return (
<button
key={wt.id}
type="button"
onClick={() => setWebsiteTheme(wt.id)}
<RadioGroup value={websiteTheme} onValueChange={setWebsiteTheme} className="grid grid-cols-2 gap-4">
{backgroundOptions.map(({ value, label, icon: Icon, color, ring, desc }) => (
<div key={value}>
<RadioGroupItem value={value} id={`bg-${value}`} className="peer sr-only" />
<Label
htmlFor={`bg-${value}`}
className={cn(
"group relative flex flex-col items-start gap-3 rounded-xl border-2 p-4 text-left transition-all",
"hover:bg-accent cursor-pointer",
isActive
? "border-primary bg-primary/5 shadow-sm"
: "border-border hover:border-muted-foreground/30"
"flex flex-col items-center gap-3 rounded-lg border-2 p-4 hover:bg-accent cursor-pointer transition-all",
"peer-data-[state=checked]:border-primary peer-data-[state=checked]:bg-primary/5"
)}
>
{isActive && (
<span className="absolute right-2 top-2 flex h-5 w-5 items-center justify-center rounded-full bg-primary text-[10px] text-primary-foreground">
<Check className="h-3 w-3" />
</span>
)}
<div className="flex h-20 w-full items-center justify-center overflow-hidden rounded-lg border border-border bg-background">
<div className="flex h-full w-full">
<div className="flex w-1/3 flex-col gap-0.5 bg-[#0a0a0f] p-1.5">
<div className="h-1 w-full rounded-sm bg-[#1a1a24]" />
<div className="h-1 w-2/3 rounded-sm bg-[#1a1a24]" />
<div className="mt-auto h-1.5 w-full rounded-sm bg-[#1a1a24]" />
</div>
<div className="flex flex-1 flex-col">
<div className="flex h-5 items-center gap-1 bg-[#CC0000] px-1.5">
<div className="h-1.5 w-1.5 rounded-full bg-white/30" />
<div className="h-1.5 w-1.5 rounded-full bg-white/30" />
<div className="h-1.5 w-1.5 rounded-full bg-white/30" />
</div>
<div className="flex flex-1 items-center justify-center bg-[#141414]">
<div className="h-2 w-2 rounded-full bg-[#CC0000]/40" />
</div>
</div>
</div>
</div>
<div className="flex w-full items-center justify-between gap-2">
<span className="text-sm font-medium">{wt.name}</span>
{isActive && (
<span className="rounded-full bg-primary/10 px-2 py-0.5 text-[10px] font-semibold text-primary">
Current
</span>
)}
</div>
</button>
)
})}
</div>
<Icon className="h-6 w-6" />
<div className={cn("h-8 w-8 rounded-full", color, ring, "ring-2 ring-offset-2 ring-offset-background")} />
<span className="text-sm font-medium">{label}</span>
<span className="text-xs text-muted-foreground">{desc}</span>
</Label>
</div>
))}
</RadioGroup>
</CardContent>
</Card>
</div>
@@ -0,0 +1,31 @@
"use client"
export function CaptainAmericaShield() {
return (
<svg
width="28"
height="28"
viewBox="0 0 100 100"
aria-hidden="true"
>
<defs>
<clipPath id="shield-clip">
<polygon points="50,95 5,60 5,30 50,5 95,30 95,60" />
</clipPath>
</defs>
<g clip-path="url(#shield-clip)">
<circle cx="50" cy="45" r="45" fill="#c62828" />
<circle cx="50" cy="45" r="35" fill="#efefef" />
<circle cx="50" cy="45" r="25" fill="#c62828" />
<circle cx="50" cy="45" r="15" fill="#1565c0" />
<polygon points="50,32 56,47 72,48 60,58 64,75 50,64 36,75 40,58 28,48 44,47" fill="#efefef" />
</g>
<polygon
points="50,95 5,60 5,30 50,5 95,30 95,60"
fill="none"
stroke="#c62828"
stroke-width="1.5"
/>
</svg>
)
}
+25 -60
View File
@@ -1,76 +1,41 @@
import http from "http"
const AI_SERVICE = process.env.AI_SERVICE_URL || "http://localhost:3001"
function parseUrl(url: string) {
const u = new URL(url)
return { hostname: u.hostname, port: parseInt(u.port) || 80, path: u.pathname + u.search }
}
export async function chatWithAI(message: string, jwtToken: string) {
const body = JSON.stringify({ message })
const { hostname, port, path } = parseUrl(`${AI_SERVICE}/ai/chat`)
const text = await new Promise<string>((resolve, reject) => {
const req = http.request(
{ hostname, port, path, method: "POST", headers: { "Content-Type": "application/json", "Authorization": `Bearer ${jwtToken}`, "Content-Length": Buffer.byteLength(body) } },
(res) => {
let data = ""
res.on("data", (chunk) => data += chunk)
res.on("end", () => {
if (res.statusCode && res.statusCode >= 200 && res.statusCode < 300) {
resolve(data)
} else {
reject(new Error(`AI error ${res.statusCode}: ${data.substring(0, 200)}`))
}
})
}
)
req.on("error", reject)
req.write(body)
req.end()
const res = await fetch(`${AI_SERVICE}/ai/chat`, {
method: "POST",
headers: { "Content-Type": "application/json", "Authorization": `Bearer ${jwtToken}` },
body: JSON.stringify({ message }),
})
const data = JSON.parse(text)
if (!res.ok) {
const text = await res.text()
throw new Error(`AI service error (${res.status}): ${text}`)
}
const data = await res.json()
return data.response || ""
}
export async function checkAiServiceStatus() {
const { hostname, port, path } = parseUrl(`${AI_SERVICE}/health`)
try {
await new Promise<void>((resolve, reject) => {
const req = http.get({ hostname, port, path, timeout: 3000 }, (res) => {
let data = ""
res.on("data", (chunk) => data += chunk)
res.on("end", () => {
try { resolve(JSON.parse(data).status === "ok" ? undefined : reject(new Error("not ok"))) } catch { reject(new Error("bad response")) }
})
})
req.on("error", reject)
req.on("timeout", () => { req.destroy(); reject(new Error("timeout")) })
})
return true
} catch {
return false
}
}
export async function fetchJobs() {
const { hostname, port, path } = parseUrl(`${AI_SERVICE}/ai/jobs`)
try {
const text = await new Promise<string>((resolve, reject) => {
const req = http.get({ hostname, port, path, timeout: 5000 }, (res) => {
let data = ""
res.on("data", (chunk) => data += chunk)
res.on("end", () => resolve(data))
})
req.on("error", reject)
req.on("timeout", () => { req.destroy(); reject(new Error("timeout")) })
})
const data = JSON.parse(text)
const res = await fetch(`${AI_SERVICE}/ai/jobs`)
if (!res.ok) return []
const data = await res.json()
return data.jobs || []
} catch {
console.warn("Failed to fetch AI jobs")
return []
}
}
export async function checkAiServiceStatus() {
try {
const res = await fetch(`${AI_SERVICE}/health`)
if (!res.ok) return false
const data = await res.json()
return data.status === "ok"
} catch {
console.warn("Failed to check AI service status")
return false
}
}
+2 -24
View File
@@ -17,7 +17,6 @@ export interface SessionUser {
id: string;
username: string;
email: string;
phone: string | null;
firstName: string;
lastName: string;
role: string;
@@ -86,7 +85,7 @@ export async function getUserByUsername(username: string) {
export async function getUserById(id: string) {
const result = await query(
` SELECT u.id, u.username, u.email, u.phone, u.first_name, u.last_name,
` SELECT u.id, u.username, u.email, u.first_name, u.last_name,
u.is_active, u.avatar_url,
r.name AS role_name
FROM users u
@@ -186,7 +185,6 @@ export function mapDbUserToSessionUser(
id: dbUser.id as string,
username: dbUser.username as string,
email: dbUser.email as string,
phone: (dbUser.phone as string) || null,
firstName: dbUser.first_name as string,
lastName: dbUser.last_name as string,
role: roleMapping[roleName] || roleName.toLowerCase(),
@@ -194,27 +192,6 @@ export function mapDbUserToSessionUser(
};
}
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 createSession(userId: string, role: string) {
const token = await signToken({ userId, role });
@@ -224,6 +201,7 @@ export async function createSession(userId: string, role: string) {
secure: process.env.NODE_ENV === "production",
sameSite: "strict",
path: "/",
maxAge: 60 * 60 * 24, // 24 hours
});
return token;
+91
View File
@@ -0,0 +1,91 @@
export const BLOCKED_CODE_EXTENSIONS = new Set([
"py", "pyc", "pyo", "pyw", "pyx", "ipynb",
"js", "mjs", "cjs", "jsx", "ts", "tsx",
"php", "phtml", "php3", "php4", "php5",
"rb", "erb",
"pl", "pm",
"lua",
"sh", "bash", "zsh", "ksh", "fish",
"ps1", "psm1", "psd1",
"bat", "cmd",
"vbs",
"applescript",
"ahk",
"au3",
"tcl",
"c", "h", "cpp", "cc", "cxx", "hpp", "hh", "hxx",
"cs", "vb",
"java", "class", "jar",
"go",
"rs",
"swift",
"kt", "kts",
"scala",
"groovy", "gradle",
"d",
"nim",
"cr",
"zig",
"v",
"vhd", "vhdl",
"asm", "s",
"f", "f90", "f95", "for",
"pas", "pp",
"ada", "adb", "ads",
"cob", "cbl",
"hs", "lhs",
"ml", "mli",
"fs", "fsx", "fsi",
"clj", "cljs", "cljc", "edn",
"lisp", "lsp",
"scm", "ss",
"rkt",
"ex", "exs",
"erl", "hrl",
"jl",
"elm",
"purs",
"re", "rei",
"sol",
"move",
"pro",
"m",
"html", "htm",
"css", "scss", "sass", "less",
"vue", "svelte",
"json5",
"o", "so", "dll", "exe", "dylib",
"wasm", "wat", "bin",
"out",
"deb", "rpm",
"app", "apk", "ipa",
"sql", "psql",
"ino", "pde",
"sas", "do",
])
export function getAllExtensions(name: string): string[] {
const parts = name.split(".")
if (parts.length <= 1) return []
return parts.slice(1).map((ext) => ext.toLowerCase())
}
export function hasBlockedCodeExtension(name: string): boolean {
const exts = getAllExtensions(name)
return exts.some((ext) => BLOCKED_CODE_EXTENSIONS.has(ext))
}
export function filterBlockedFiles<T extends { name: string }>(
files: T[],
): { allowed: T[]; rejected: T[] } {
const allowed: T[] = []
const rejected: T[] = []
for (const f of files) {
if (hasBlockedCodeExtension(f.name)) {
rejected.push(f)
} else {
allowed.push(f)
}
}
return { allowed, rejected }
}
+4 -1
View File
@@ -16,9 +16,12 @@ pool.on("error", (err) => {
console.error("Unexpected database pool error:", err)
})
export async function query(text: string, params?: unknown[]) {
export async function query(text: string, params?: unknown[], userId?: string) {
const client = await pool.connect()
try {
if (userId) {
await client.query("SELECT set_config('app.current_user_id', $1, true)", [userId])
}
const result = await client.query(text, params)
return result
} finally {
+190
View File
@@ -0,0 +1,190 @@
import nodemailer from "nodemailer"
import { query } from "@/lib/db"
const ADMIN_EMAIL = process.env.ADMIN_EMAIL || ""
const SMTP_HOST = process.env.SMTP_HOST || ""
const SMTP_PORT = parseInt(process.env.SMTP_PORT || "587", 10)
const SMTP_USER = process.env.SMTP_USER || ""
const SMTP_PASS = process.env.SMTP_PASS || ""
const EMAIL_FROM = process.env.EMAIL_FROM || "noreply@coastit.co.za"
function createTransporter() {
const hasAuth = !!(SMTP_USER && SMTP_PASS)
const isLocal = SMTP_HOST === "127.0.0.1" || SMTP_HOST === "localhost"
const tlsOptions = hasAuth && !isLocal
? { rejectUnauthorized: true }
: { rejectUnauthorized: false }
return nodemailer.createTransport({
host: SMTP_HOST,
port: SMTP_PORT,
secure: SMTP_PORT === 465,
requireTLS: hasAuth && !isLocal,
...(hasAuth ? { auth: { user: SMTP_USER, pass: SMTP_PASS } } : {}),
tls: tlsOptions,
connectionTimeout: 5000,
greetingTimeout: 5000,
})
}
function formatDate(iso: string) {
return new Date(iso).toLocaleDateString("en-US", {
weekday: "long", month: "long", day: "numeric", year: "numeric",
})
}
function formatTime(iso: string) {
return new Date(iso).toLocaleTimeString("en-US", {
hour: "numeric", minute: "2-digit", hour12: true,
})
}
function formatDuration(min: number) {
if (min < 60) return `${min} minutes`
const h = Math.floor(min / 60)
const m = min % 60
return m > 0 ? `${h}h ${m}m` : `${h} hour${h > 1 ? "s" : ""}`
}
export interface EventConfirmationInput {
creatorName: string
creatorEmail: string
participantName: string | null
participantEmail: string | null
title: string
description?: string
eventType: string
startTime: string
endTime?: string
durationMinutes?: number
}
function buildEmail(event: EventConfirmationInput, variant: "scheduled" | "rescheduled" = "scheduled") {
const dateStr = formatDate(event.startTime)
const timeStr = formatTime(event.startTime)
const durStr = event.durationMinutes ? formatDuration(event.durationMinutes) : ""
const typeLabel = event.eventType.charAt(0).toUpperCase() + event.eventType.slice(1)
const heading = variant === "rescheduled" ? "Event Rescheduled" : "Event Scheduled"
const badge = variant === "rescheduled"
? '<div style="background:#fef3c7;color:#92400e;font-size:12px;padding:6px 12px;border-radius:6px;margin-bottom:16px;display:inline-block">This event has been rescheduled</div>'
: ""
const html = `<!DOCTYPE html>
<html><head><meta charset="utf-8"></head>
<body style="font-family:Arial,Helvetica,sans-serif;background:#f4f4f5;padding:40px 20px">
<div style="max-width:520px;margin:0 auto;background:#fff;border-radius:12px;overflow:hidden;box-shadow:0 2px 12px rgba(0,0,0,0.08)">
<div style="background:linear-gradient(135deg,#CC0000,#0033CC);padding:24px 32px">
<h1 style="color:#fff;margin:0;font-size:20px">${heading}</h1>
</div>
<div style="padding:32px">
${badge}
<p style="font-size:15px;margin:0 0 16px"><strong>${event.title}</strong></p>
<table style="width:100%;font-size:13px;border-collapse:collapse">
<tr><td style="padding:6px 0;color:#666;width:90px">Type</td><td style="padding:6px 0">${typeLabel}</td></tr>
<tr><td style="padding:6px 0;color:#666">Date</td><td style="padding:6px 0">${dateStr}</td></tr>
<tr><td style="padding:6px 0;color:#666">Time</td><td style="padding:6px 0">${timeStr}</td></tr>
${durStr ? `<tr><td style="padding:6px 0;color:#666">Duration</td><td style="padding:6px 0">${durStr}</td></tr>` : ""}
${event.description ? `<tr><td style="padding:6px 0;color:#666;vertical-align:top">Notes</td><td style="padding:6px 0">${event.description}</td></tr>` : ""}
<tr><td style="padding:6px 0;color:#666">Created by</td><td style="padding:6px 0">${event.creatorName}</td></tr>
${event.participantName ? `<tr><td style="padding:6px 0;color:#666">With</td><td style="padding:6px 0">${event.participantName}</td></tr>` : ""}
</table>
<p style="font-size:11px;color:#999;margin-top:24px;padding-top:16px;border-top:1px solid #eee">Automated confirmation from CRM.</p>
</div>
</div>
</body></html>`
const text = `${variant === "rescheduled" ? "[RESCHEDULED] " : ""}Event: ${event.title}
Type: ${typeLabel}
Date: ${dateStr}
Time: ${timeStr}
${durStr ? `Duration: ${durStr}` : ""}
${event.description ? `Notes: ${event.description}` : ""}
Created by: ${event.creatorName}
${event.participantName ? `With: ${event.participantName}` : ""}`
const prefix = variant === "rescheduled" ? "🔄 Rescheduled: " : ""
const subject = `${prefix}${event.title}${dateStr}`
return { html, text, subject, dateStr }
}
export async function sendEventConfirmation(event: EventConfirmationInput) {
const { html, text, subject } = buildEmail(event)
const recipients: string[] = []
if (event.creatorEmail) recipients.push(event.creatorEmail)
if (event.participantEmail && !recipients.includes(event.participantEmail)) recipients.push(event.participantEmail)
if (ADMIN_EMAIL && !recipients.includes(ADMIN_EMAIL)) recipients.push(ADMIN_EMAIL)
// 1. Always store in database
for (const to of recipients) {
await query(
`INSERT INTO sent_emails (recipient, subject, body_html, body_text)
VALUES ($1, $2, $3, $4)`,
[to, subject, html, text],
).catch(() => {})
}
if (recipients.length === 0) return
// 2. Send via SMTP if configured
if (SMTP_HOST) {
const transporter = createTransporter()
await transporter.sendMail({
from: EMAIL_FROM,
to: recipients.join(", "),
subject,
text,
html,
}).catch(() => {})
return
}
// 3. Fallback: log to console with instructions
console.log("=".repeat(60))
console.log(`📧 Email ready — ${subject}`)
console.log(` To: ${recipients.join(", ")}`)
console.log(` Body:\n${text}`)
console.log("=".repeat(60))
console.log("💡 To send real emails:")
console.log(" Fill in SMTP_HOST in .env (maildev works at localhost:1025 with no auth)")
console.log(" Or set SMTP_HOST/SMTP_USER/SMTP_PASS for authenticated relays")
}
export async function sendEventRescheduled(event: EventConfirmationInput) {
const { html, text, subject } = buildEmail(event, "rescheduled")
const recipients: string[] = []
if (event.creatorEmail) recipients.push(event.creatorEmail)
if (event.participantEmail && !recipients.includes(event.participantEmail)) recipients.push(event.participantEmail)
if (ADMIN_EMAIL && !recipients.includes(ADMIN_EMAIL)) recipients.push(ADMIN_EMAIL)
for (const to of recipients) {
await query(
`INSERT INTO sent_emails (recipient, subject, body_html, body_text)
VALUES ($1, $2, $3, $4)`,
[to, subject, html, text],
).catch(() => {})
}
if (recipients.length === 0) return
if (SMTP_HOST) {
const transporter = createTransporter()
await transporter.sendMail({
from: EMAIL_FROM,
to: recipients.join(", "),
subject,
text,
html,
}).catch(() => {})
return
}
console.log("=".repeat(60))
console.log(`🔄 Reschedule email ready — ${subject}`)
console.log(` To: ${recipients.join(", ")}`)
console.log(` Body:\n${text}`)
console.log("=".repeat(60))
}
+80
View File
@@ -0,0 +1,80 @@
export interface IcsEvent {
uid: string
title: string
description?: string
location?: string
startTime: Date
endTime?: Date
durationMinutes?: number
organizerName?: string
organizerEmail?: string
attendeeName?: string
attendeeEmail?: string
}
function formatIcsDate(d: Date): string {
return d.toISOString().replace(/[-:]/g, "").split(".")[0] + "Z"
}
function escapeIcsText(text: string): string {
return text
.replace(/\\/g, "\\\\")
.replace(/;/g, "\\;")
.replace(/,/g, "\\,")
.replace(/\n/g, "\\n")
}
export function buildIcs(event: IcsEvent): string {
const now = formatIcsDate(new Date())
const dtStart = formatIcsDate(event.startTime)
let dtEnd: string
if (event.endTime) {
dtEnd = formatIcsDate(event.endTime)
} else if (event.durationMinutes) {
const end = new Date(event.startTime.getTime() + event.durationMinutes * 60000)
dtEnd = formatIcsDate(end)
} else {
const end = new Date(event.startTime.getTime() + 60 * 60000)
dtEnd = formatIcsDate(end)
}
const lines: string[] = [
"BEGIN:VCALENDAR",
"VERSION:2.0",
"PRODID:-//CRM//Calendar//EN",
"CALSCALE:GREGORIAN",
"METHOD:PUBLISH",
"BEGIN:VEVENT",
`UID:${event.uid}`,
`DTSTAMP:${now}`,
`DTSTART:${dtStart}`,
`DTEND:${dtEnd}`,
`SUMMARY:${escapeIcsText(event.title)}`,
]
if (event.description) {
lines.push(`DESCRIPTION:${escapeIcsText(event.description)}`)
}
if (event.organizerEmail) {
const cn = event.organizerName ? `;CN=${escapeIcsText(event.organizerName)}` : ""
lines.push(`ORGANIZER${cn}:mailto:${event.organizerEmail}`)
}
if (event.attendeeEmail) {
const cn = event.attendeeName ? `;CN=${escapeIcsText(event.attendeeName)}` : ""
lines.push(`ATTENDEE${cn};ROLE=REQ-PARTICIPANT;PARTSTAT=NEEDS-ACTION:mailto:${event.attendeeEmail}`)
}
lines.push("STATUS:CONFIRMED")
lines.push("BEGIN:VALARM")
lines.push("TRIGGER:-PT15M")
lines.push("ACTION:DISPLAY")
lines.push(`DESCRIPTION:Reminder: ${escapeIcsText(event.title)}`)
lines.push("END:VALARM")
lines.push("END:VEVENT")
lines.push("END:VCALENDAR")
return lines.join("\r\n")
}
-1
View File
@@ -10,7 +10,6 @@ const JWT_SECRET = new TextEncoder().encode(RAW_SECRET)
const publicRoutes = [
"/login",
"/join",
"/api/auth/login",
"/api/auth/logout",
"/_next/static",
-1
View File
@@ -30,7 +30,6 @@ export function UserProvider({ children }: { children: ReactNode }) {
id: u.id,
name: `${u.firstName} ${u.lastName}`,
email: u.email,
phone: u.phone || "",
role: u.role,
active: true,
avatar: u.avatar,
+5 -4
View File
@@ -5,7 +5,8 @@ import { createContext, useContext, useState, useEffect, ReactNode, useCallback
const WEBSITE_THEME_KEY = "crm-website-theme"
const themeClasses: Record<string, string> = {
spidey: "",
spidey: "theme-spidey",
starforce: "theme-starforce",
}
interface WebsiteThemeContextValue {
@@ -38,7 +39,7 @@ function storeTheme(theme: string) {
}
export function WebsiteThemeProvider({ children }: { children: ReactNode }) {
const [websiteTheme, setWebsiteThemeState] = useState<string>("spidey")
const [websiteTheme, setWebsiteThemeState] = useState<string>("starforce")
const [loading, setLoading] = useState(true)
useEffect(() => {
@@ -52,13 +53,13 @@ export function WebsiteThemeProvider({ children }: { children: ReactNode }) {
fetch("/api/settings/website-theme")
.then((res) => (res.ok ? res.json() : null))
.then((data) => {
const theme = data?.websiteTheme || "spidey"
const theme = data?.websiteTheme || "starforce"
setWebsiteThemeState(theme)
storeTheme(theme)
applyWebsiteTheme(theme)
})
.catch(() => {
applyWebsiteTheme("spidey")
applyWebsiteTheme("starforce")
})
.finally(() => setLoading(false))
}, [])
+26 -3
View File
@@ -4,13 +4,12 @@ export type LeadStatus =
| "pending"
| "closed"
| "ignored";
export type UserRole = "super_admin" | "admin" | "sales" | "dev";
export type UserRole = "super_admin" | "admin" | "sales";
export interface User {
id: string;
name: string;
email: string;
phone?: string;
role: UserRole;
active: boolean;
avatar: string;
@@ -78,7 +77,8 @@ export type NotificationType =
| "lead_status_changed"
| "lead_assigned"
| "chat_message"
| "note_added";
| "note_added"
| "event_scheduled";
export interface Notification {
id: string;
@@ -108,3 +108,26 @@ export interface Conversation {
unread: number;
messages: ChatMessage[];
}
export type EventType = "meeting" | "call" | "chat" | "follow_up" | "demo" | "other";
export type EventStatus = "scheduled" | "completed" | "cancelled" | "rescheduled";
export interface CalendarEvent {
id: string;
userId: string;
participantId: string | null;
leadId: string | null;
conversationId: string | null;
title: string;
description: string | null;
participantNotes: string | null;
eventType: EventType;
startTime: string;
endTime: string | null;
durationMinutes: number | null;
status: EventStatus;
creator: { id: string; name: string; email?: string; role?: string; avatar?: string } | null;
participant: { id: string; name: string; email?: string; role?: string; avatar?: string } | null;
lead: { id: string; companyName: string; contactName: string } | null;
createdAt: string;
}