Merge remote-tracking branch 'origin/main' into master
This commit is contained in:
+9
-3
@@ -47,9 +47,6 @@ error-log
|
|||||||
# vercel
|
# vercel
|
||||||
.vercel
|
.vercel
|
||||||
|
|
||||||
# rust
|
|
||||||
rust-ai/target/
|
|
||||||
|
|
||||||
# python
|
# python
|
||||||
browser-use-service/venv/
|
browser-use-service/venv/
|
||||||
browser-use-service/__pycache__/
|
browser-use-service/__pycache__/
|
||||||
@@ -58,3 +55,12 @@ browser-use-service/.env
|
|||||||
# typescript
|
# typescript
|
||||||
*.tsbuildinfo
|
*.tsbuildinfo
|
||||||
next-env.d.ts
|
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
|
||||||
|
|||||||
@@ -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;
|
||||||
|
}
|
||||||
@@ -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"
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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;
|
||||||
|
}
|
||||||
@@ -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"
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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 })
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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 })
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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 })
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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 })
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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;
|
||||||
@@ -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
@@ -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 http from "node:http"
|
||||||
import fs from "node:fs"
|
import fs from "node:fs"
|
||||||
import path from "node:path"
|
import path from "node:path"
|
||||||
|
import { spawn } from "node:child_process"
|
||||||
import { fileURLToPath } from "node:url"
|
import { fileURLToPath } from "node:url"
|
||||||
|
|
||||||
const __dirname = path.dirname(fileURLToPath(import.meta.url))
|
const __dirname = path.dirname(fileURLToPath(import.meta.url))
|
||||||
const ROOT = path.resolve(__dirname, "..")
|
const ROOT = path.resolve(__dirname, "..")
|
||||||
|
|
||||||
// ── Load .env.local ──────────────────────────────────────────────
|
// ── 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 {
|
try {
|
||||||
const envPath = path.join(ROOT, ".env.local")
|
const envPath = path.join(ROOT, ".env.local")
|
||||||
const envContent = fs.readFileSync(envPath, "utf-8")
|
const envContent = fs.readFileSync(envPath, "utf-8")
|
||||||
@@ -22,7 +36,7 @@ try {
|
|||||||
}
|
}
|
||||||
console.log("Loaded .env.local")
|
console.log("Loaded .env.local")
|
||||||
} catch {
|
} catch {
|
||||||
// .env.local may not exist, ignore
|
// .env.local may not exist (first run), which is fine
|
||||||
}
|
}
|
||||||
|
|
||||||
// ── Config from env ─────────────────────────────────────────────
|
// ── 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 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")
|
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 ─────────────────────────────────────────────────
|
// ── 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() {
|
function loadJobs() {
|
||||||
try {
|
try {
|
||||||
const content = fs.readFileSync(JOBS_PATH, "utf-8")
|
const content = fs.readFileSync(JOBS_PATH, "utf-8")
|
||||||
@@ -59,6 +81,8 @@ function loadJobs() {
|
|||||||
}
|
}
|
||||||
|
|
||||||
// ── ai.md management ────────────────────────────────────────────
|
// ── 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() {
|
function readInstructions() {
|
||||||
try {
|
try {
|
||||||
return fs.readFileSync(AI_MD_PATH, "utf-8")
|
return fs.readFileSync(AI_MD_PATH, "utf-8")
|
||||||
@@ -73,6 +97,7 @@ function writeInstructions(content) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
function appendToImprovementLog(entry) {
|
function appendToImprovementLog(entry) {
|
||||||
|
// Adds a timestamped entry to the ## Improvement Log section of ai.md
|
||||||
const current = readInstructions()
|
const current = readInstructions()
|
||||||
const timestamp = new Date().toISOString().replace("T", " ").substring(0, 16)
|
const timestamp = new Date().toISOString().replace("T", " ").substring(0, 16)
|
||||||
const logEntry = `\n- ${timestamp} — ${entry}`
|
const logEntry = `\n- ${timestamp} — ${entry}`
|
||||||
@@ -96,10 +121,13 @@ function appendToImprovementLog(entry) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
// ── Chat handler ────────────────────────────────────────────────
|
// ── 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() {
|
async function scrapeFacebook() {
|
||||||
const profilePath = process.env.FX_PROFILE || ""
|
const profilePath = process.env.FX_PROFILE || ""
|
||||||
const urlPath = `/scrape/facebook?force=true${profilePath ? `&profile_path=${encodeURIComponent(profilePath)}` : ""}`
|
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 {
|
try {
|
||||||
const body = await new Promise((resolve, reject) => {
|
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) => {
|
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) {
|
async function handleChat(userMessage, userId, userRole) {
|
||||||
|
// If the user asks for leads, trigger the scraper
|
||||||
const lowerMsg = userMessage.toLowerCase()
|
const lowerMsg = userMessage.toLowerCase()
|
||||||
const triggerWords = ["lists", "listings", "leads", "recent leads", "pull leads", "show me leads", "show listings"]
|
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."
|
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 jobs = loadedJobs
|
||||||
const instructions = readInstructions()
|
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 data = await ollamaRes.json()
|
||||||
const responseText = data.message?.content || ""
|
const responseText = data.message?.content || ""
|
||||||
|
|
||||||
// Try to persist to DB (best-effort)
|
// Persist conversation to PostgreSQL (best-effort — table may not exist yet)
|
||||||
try {
|
try {
|
||||||
if (pgPool && userId) {
|
if (pgPool && userId) {
|
||||||
await pgPool.query(
|
await pgPool.query(
|
||||||
@@ -200,6 +230,8 @@ Provide concise, actionable sales advice. When asked about a specific job catego
|
|||||||
}
|
}
|
||||||
|
|
||||||
// ── PG pool (lazy init) ────────────────────────────────────────
|
// ── PG pool (lazy init) ────────────────────────────────────────
|
||||||
|
// PostgreSQL connection pool for storing conversation history.
|
||||||
|
// Lazy-initialized so the server starts even without a DB.
|
||||||
let pgPool = null
|
let pgPool = null
|
||||||
async function initPg() {
|
async function initPg() {
|
||||||
if (!DATABASE_URL) return
|
if (!DATABASE_URL) return
|
||||||
@@ -241,8 +273,9 @@ function parseURL(req) {
|
|||||||
return { pathname: url.pathname, searchParams: url.searchParams }
|
return { pathname: url.pathname, searchParams: url.searchParams }
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// ── HTTP Server ─────────────────────────────────────────────────
|
||||||
const server = http.createServer(async (req, res) => {
|
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-Origin", "*")
|
||||||
res.setHeader("Access-Control-Allow-Methods", "GET, POST, OPTIONS")
|
res.setHeader("Access-Control-Allow-Methods", "GET, POST, OPTIONS")
|
||||||
res.setHeader("Access-Control-Allow-Headers", "Content-Type")
|
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
|
// 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") {
|
if (req.method === "GET" && pathname === "/status") {
|
||||||
const { default: http } = await import("http")
|
const { default: http } = await import("http")
|
||||||
const results = { ai: true }
|
const results = { ai: true }
|
||||||
// Check scraper
|
// Check scraper (port 3008)
|
||||||
try {
|
try {
|
||||||
await new Promise((resolve, reject) => {
|
await new Promise((resolve, reject) => {
|
||||||
const r = http.get("http://127.0.0.1:3008/health", { timeout: 3000 }, (res) => { res.resume(); resolve() })
|
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
|
results.scraper = true
|
||||||
} catch { results.scraper = false }
|
} catch { results.scraper = false }
|
||||||
// Check frontend
|
// Check frontend (port 3006)
|
||||||
try {
|
try {
|
||||||
await new Promise((resolve, reject) => {
|
await new Promise((resolve, reject) => {
|
||||||
const r = http.get("http://127.0.0.1:3006", { timeout: 3000 }, (res) => { res.resume(); resolve() })
|
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)
|
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") {
|
if (req.method === "GET" && pathname === "/ai/jobs") {
|
||||||
return sendJSON(res, 200, { jobs: loadedJobs })
|
return sendJSON(res, 200, { jobs: loadedJobs })
|
||||||
}
|
}
|
||||||
|
|
||||||
// GET /ai/instructions
|
// GET /ai/instructions — return current ai.md content
|
||||||
if (req.method === "GET" && pathname === "/ai/instructions") {
|
if (req.method === "GET" && pathname === "/ai/instructions") {
|
||||||
const instructions = readInstructions()
|
const instructions = readInstructions()
|
||||||
return sendJSON(res, 200, { success: true, instructions })
|
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") {
|
if (req.method === "POST" && pathname === "/ai/instructions") {
|
||||||
const body = await parseBody(req)
|
const body = await parseBody(req)
|
||||||
if (body.content) {
|
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") {
|
if (req.method === "POST" && pathname === "/ai/chat") {
|
||||||
const startTime = Date.now()
|
const startTime = Date.now()
|
||||||
const chunks = []
|
const chunks = []
|
||||||
req.on("data", c => chunks.push(c))
|
req.on("data", c => chunks.push(c))
|
||||||
req.on("end", () => {
|
req.on("end", () => {
|
||||||
const rawBody = Buffer.concat(chunks).toString()
|
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 {
|
try {
|
||||||
const body = JSON.parse(rawBody)
|
const body = JSON.parse(rawBody)
|
||||||
// Continue processing
|
|
||||||
processRequest(req, res, body, startTime)
|
processRequest(req, res, body, startTime)
|
||||||
} catch {
|
} catch {
|
||||||
sendJSON(res, 400, { error: "Invalid JSON" })
|
sendJSON(res, 400, { error: "Invalid JSON" })
|
||||||
@@ -342,7 +551,7 @@ const server = http.createServer(async (req, res) => {
|
|||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
|
||||||
// Separate handler
|
// Separate handler for /ai/chat (defined here due to hoisting within the IIFE)
|
||||||
async function processRequest(req, res, body, startTime) {
|
async function processRequest(req, res, body, startTime) {
|
||||||
const { message, user_id, user_role } = body
|
const { message, user_id, user_role } = body
|
||||||
|
|
||||||
@@ -359,7 +568,7 @@ async function processRequest(req, res, body, startTime) {
|
|||||||
return sendJSON(res, 200, { response })
|
return sendJSON(res, 200, { response })
|
||||||
}
|
}
|
||||||
|
|
||||||
// 404
|
// 404 fallback
|
||||||
sendJSON(res, 404, { error: "Not found" })
|
sendJSON(res, 404, { error: "Not found" })
|
||||||
} catch (err) {
|
} catch (err) {
|
||||||
console.error("Request error:", err)
|
console.error("Request error:", err)
|
||||||
|
|||||||
+507
-31
@@ -1,15 +1,15 @@
|
|||||||
|
# ── Imports ──────────────────────────────────────────────────────────
|
||||||
import os, json, asyncio, re, shutil, sqlite3, urllib.parse, random, logging, tempfile
|
import os, json, asyncio, re, shutil, sqlite3, urllib.parse, random, logging, tempfile
|
||||||
from datetime import datetime, timedelta
|
from datetime import datetime, timedelta
|
||||||
from fastapi import FastAPI, Query, Body
|
from fastapi import FastAPI, Query, Body
|
||||||
from fastapi.middleware.cors import CORSMiddleware
|
from fastapi.middleware.cors import CORSMiddleware
|
||||||
import uvicorn
|
import uvicorn
|
||||||
from playwright.async_api import async_playwright
|
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:
|
# ── Lazy Imports for browser-use (not always available) ──────────────
|
||||||
"""Create ChatOllama with required attrs for browser-use Agent compatibility."""
|
def _make_ollama(model: str | None = None, **kwargs):
|
||||||
|
from langchain_ollama import ChatOllama
|
||||||
llm = ChatOllama(model=model or CLASSIFY_MODEL, **kwargs)
|
llm = ChatOllama(model=model or CLASSIFY_MODEL, **kwargs)
|
||||||
object.__setattr__(llm, 'provider', 'ollama')
|
object.__setattr__(llm, 'provider', 'ollama')
|
||||||
object.__setattr__(llm, 'name', 'ChatOllama')
|
object.__setattr__(llm, 'name', 'ChatOllama')
|
||||||
@@ -17,6 +17,17 @@ def make_ollama(model: str | None = None, **kwargs) -> ChatOllama:
|
|||||||
return llm
|
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')
|
logging.basicConfig(level=logging.INFO, format='%(asctime)s [%(levelname)s] %(message)s')
|
||||||
logger = logging.getLogger(__name__)
|
logger = logging.getLogger(__name__)
|
||||||
|
|
||||||
@@ -29,13 +40,24 @@ app.add_middleware(
|
|||||||
)
|
)
|
||||||
PORT = int(os.getenv("PORT", "3008"))
|
PORT = int(os.getenv("PORT", "3008"))
|
||||||
|
|
||||||
|
# ── AI / Ollama Config ───────────────────────────────────────────────
|
||||||
OLLAMA_URL = os.getenv("OLLAMA_URL", "http://localhost:11434")
|
OLLAMA_URL = os.getenv("OLLAMA_URL", "http://localhost:11434")
|
||||||
CLASSIFY_MODEL = os.getenv("CLASSIFY_MODEL", "dolphin-llama3:8b")
|
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', '')
|
FX_PROFILE = os.getenv('FX_PROFILE', '')
|
||||||
CHROME_PROFILE = os.getenv('CHROME_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 = [
|
CHROME_LAUNCH_ARGS = [
|
||||||
'--headless=new',
|
'--headless=new',
|
||||||
'--disable-blink-features=AutomationControlled',
|
'--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:
|
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:
|
if not profile_path:
|
||||||
return None
|
return None
|
||||||
p = profile_path.lower().replace('\\', '/')
|
p = profile_path.lower().replace('\\', '/')
|
||||||
if 'firefox' in p or '.default' in p or '.dev-edition' in p:
|
if 'firefox' in p or '.default' in p or '.dev-edition' in p:
|
||||||
return 'firefox'
|
return 'firefox'
|
||||||
if 'chrome' in p or 'chromium' in p or 'edge' in p:
|
if 'opera' in p:
|
||||||
return 'chromium'
|
return 'opera'
|
||||||
|
if 'edge' in p:
|
||||||
|
return 'edge'
|
||||||
|
if 'chrome' in p or 'chromium' in p:
|
||||||
|
return 'chrome'
|
||||||
return None
|
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:
|
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']
|
essential = ['cookies.sqlite', 'webappsstore.sqlite', 'permissions.sqlite']
|
||||||
dst = tempfile.mkdtemp(prefix='fb_fx_profile_')
|
dst = tempfile.mkdtemp(prefix='fb_fx_profile_')
|
||||||
for f_name in essential:
|
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:
|
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']
|
essential = ['Cookies', 'Login Data', 'Bookmarks', 'Web Data']
|
||||||
dst = tempfile.mkdtemp(prefix='fb_chrome_udir_')
|
dst = tempfile.mkdtemp(prefix='fb_chrome_udir_')
|
||||||
src_profile = os.path.join(user_data_dir, profile_dir)
|
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:
|
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')
|
ext_dir = os.path.join(tempfile.gettempdir(), 'fb_ublock_origin')
|
||||||
manifest = os.path.join(ext_dir, 'manifest.json')
|
manifest = os.path.join(ext_dir, 'manifest.json')
|
||||||
if os.path.exists(manifest):
|
if os.path.exists(manifest):
|
||||||
@@ -134,10 +272,15 @@ def ensure_ublock_extension() -> str | None:
|
|||||||
return 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]:
|
def extract_agent_posts(agent_result: str, page_text: str) -> list[dict]:
|
||||||
"""Parse posts from agent output or page text fallback."""
|
"""Parse posts from agent output or page text fallback."""
|
||||||
posts = []
|
posts = []
|
||||||
# Try JSON from agent output
|
|
||||||
json_match = re.search(r'\[.*?\]', agent_result, re.DOTALL)
|
json_match = re.search(r'\[.*?\]', agent_result, re.DOTALL)
|
||||||
if json_match:
|
if json_match:
|
||||||
try:
|
try:
|
||||||
@@ -173,6 +316,12 @@ def extract_agent_posts(agent_result: str, page_text: str) -> list[dict]:
|
|||||||
return posts
|
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 = [
|
BROAD_KEYWORDS = [
|
||||||
"website", "web design", "web develop", "web dev",
|
"website", "web design", "web develop", "web dev",
|
||||||
"build my website", "build a website", "create a website",
|
"build my website", "build a website", "create a website",
|
||||||
@@ -242,6 +391,13 @@ REQUEST_PATTERNS = [
|
|||||||
r"\bquote\s+(please|for|to)\b",
|
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:
|
def firefox_init_script() -> str:
|
||||||
return r"""// Firefox Anti-Detection
|
return r"""// Firefox Anti-Detection
|
||||||
Object.defineProperty(navigator, 'webdriver', { get: () => undefined });
|
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]}] });"""
|
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:
|
def kw_match(text: str) -> bool:
|
||||||
t = text.lower()
|
t = text.lower()
|
||||||
return any(kw in t for kw in BROAD_KEYWORDS)
|
return any(kw in t for kw in BROAD_KEYWORDS)
|
||||||
|
|
||||||
def is_request(text: str) -> bool:
|
def is_request(text: str) -> bool:
|
||||||
|
"""Detect if text contains a request for services (e.g. "I need", "looking for")."""
|
||||||
t = text.lower()
|
t = text.lower()
|
||||||
return any(re.search(p, t) for p in REQUEST_PATTERNS)
|
return any(re.search(p, t) for p in REQUEST_PATTERNS)
|
||||||
|
|
||||||
def is_offer(text: str) -> bool:
|
def is_offer(text: str) -> bool:
|
||||||
|
"""Detect if text is an offer/advertisement (we skip these, not leads)."""
|
||||||
t = text.lower()
|
t = text.lower()
|
||||||
return any(re.search(p, t) for p in OFFER_PATTERNS)
|
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 = [
|
FB_SEARCHES = [
|
||||||
"looking for web developer",
|
"looking for web developer",
|
||||||
"need a website designed",
|
"need a website designed",
|
||||||
@@ -380,6 +543,12 @@ VIEWPORTS = [
|
|||||||
{'width': 1920, 'height': 1080},
|
{'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):
|
async def get_fb_cookies(profile_path: str | None = None):
|
||||||
cookie_db_path = profile_path or FX_PROFILE
|
cookie_db_path = profile_path or FX_PROFILE
|
||||||
if not cookie_db_path:
|
if not cookie_db_path:
|
||||||
@@ -424,6 +593,9 @@ async def get_fb_cookies(profile_path: str | None = None):
|
|||||||
pass
|
pass
|
||||||
return []
|
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']
|
WEEKDAY_ORDER = ['monday', 'tuesday', 'wednesday', 'thursday', 'friday', 'saturday', 'sunday']
|
||||||
|
|
||||||
def _parse_fb_date(block: list[str]) -> str:
|
def _parse_fb_date(block: list[str]) -> str:
|
||||||
@@ -477,6 +649,12 @@ def _clean_fb_text(text: str) -> str:
|
|||||||
cleaned_lines.append(stripped)
|
cleaned_lines.append(stripped)
|
||||||
return '\n'.join(cleaned_lines)
|
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]:
|
def _extract_posts_from_elements(elements: list[dict], base_url: str) -> list[dict]:
|
||||||
posts = []
|
posts = []
|
||||||
seen_texts = set()
|
seen_texts = set()
|
||||||
@@ -569,6 +747,11 @@ def _extract_posts_from_text(raw: str, url: str) -> list[dict]:
|
|||||||
cur.pop(0)
|
cur.pop(0)
|
||||||
return posts
|
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):
|
async def human_scroll(page, steps: int = None, total_delay: float = None):
|
||||||
steps = steps or random.randint(2, 5)
|
steps = steps or random.randint(2, 5)
|
||||||
total_delay = total_delay or random.uniform(6, 18)
|
total_delay = total_delay or random.uniform(6, 18)
|
||||||
@@ -604,6 +787,12 @@ async def random_idle(page):
|
|||||||
except Exception:
|
except Exception:
|
||||||
pass
|
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]:
|
async def _get_article_elements(page) -> list[dict]:
|
||||||
return await page.evaluate('''() => {
|
return await page.evaluate('''() => {
|
||||||
const results = [];
|
const results = [];
|
||||||
@@ -665,6 +854,8 @@ async def _get_article_elements(page) -> list[dict]:
|
|||||||
}''')
|
}''')
|
||||||
|
|
||||||
async def _ensure_page(page, context):
|
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:
|
try:
|
||||||
await page.evaluate('1')
|
await page.evaluate('1')
|
||||||
return page
|
return page
|
||||||
@@ -680,6 +871,12 @@ async def _ensure_page(page, context):
|
|||||||
await page.wait_for_timeout(random.randint(3000, 8000))
|
await page.wait_for_timeout(random.randint(3000, 8000))
|
||||||
return page
|
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):
|
async def search_facebook(page, context, query: str):
|
||||||
page = await _ensure_page(page, context)
|
page = await _ensure_page(page, context)
|
||||||
url = f'https://www.facebook.com/search/posts/?q={urllib.parse.quote(query)}'
|
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, []
|
||||||
return page, posts
|
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 = [
|
DETECTION_SIGNALS = [
|
||||||
'/checkpoint/', '/login.php?', 'action=security_check',
|
'/checkpoint/', '/login.php?', 'action=security_check',
|
||||||
'unusual activity', 'suspicious login', 'suspicious activity',
|
'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():
|
def cleanup_chrome():
|
||||||
|
"""Kill orphaned Chrome headless shell processes. These sometimes linger
|
||||||
|
after failed scrapes and interfere with subsequent runs."""
|
||||||
import subprocess, signal
|
import subprocess, signal
|
||||||
try:
|
try:
|
||||||
subprocess.run(["taskkill", "/F", "/IM", "chrome-headless-shell.exe"], capture_output=True, timeout=5)
|
subprocess.run(["taskkill", "/F", "/IM", "chrome-headless-shell.exe"], capture_output=True, timeout=5)
|
||||||
except Exception:
|
except Exception:
|
||||||
pass
|
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:
|
async def scrape_facebook(profile_path: str | None = None, force: bool = False) -> dict:
|
||||||
"""Dispatcher — Firefox primary, browser-use Agent fallback."""
|
"""Dispatcher — respect SELECTED_BROWSER, fall through on flag, then Agent."""
|
||||||
effective_path = profile_path or FX_PROFILE
|
effective_path = profile_path or ""
|
||||||
browser_type = detect_browser_from_profile(effective_path)
|
browser_type = ""
|
||||||
|
|
||||||
if not browser_type and CHROME_PROFILE:
|
# Resolve profile and browser type
|
||||||
browser_type = detect_browser_from_profile(CHROME_PROFILE)
|
if effective_path:
|
||||||
effective_path = CHROME_PROFILE
|
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":
|
if browser_type == "firefox":
|
||||||
result = await _scrape_with_firefox(effective_path, force)
|
result = await _scrape_with_firefox(effective_path, force)
|
||||||
if result.get("success") or not result.get("flagged"):
|
if result.get("success") or not result.get("flagged"):
|
||||||
return result
|
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)
|
return await _scrape_with_agent(force)
|
||||||
|
|
||||||
# CHROME_PROFILE set or no profile → Agent
|
# Chromium-based (chrome / opera / edge)
|
||||||
if browser_type == "chromium":
|
result = await _scrape_with_chromium(effective_path, browser_type, force)
|
||||||
return await _scrape_with_agent(force)
|
if result.get("success") or not result.get("flagged"):
|
||||||
|
return result
|
||||||
# No profile at all → try Agent (fresh Chromium)
|
logger.warning("%s flagged (%s), trying Agent", browser_type, result.get("flag_reason", "unknown"))
|
||||||
return await _scrape_with_agent(force)
|
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:
|
async def _scrape_with_firefox(profile_path: str, force: bool) -> dict:
|
||||||
"""Scrape Facebook using Firefox + persistent real profile (no cookie injection)."""
|
"""Scrape Facebook using Firefox + persistent real profile (no cookie injection)."""
|
||||||
if not profile_path:
|
if not profile_path:
|
||||||
@@ -876,6 +1128,136 @@ async def _scrape_with_firefox(profile_path: str, force: bool) -> dict:
|
|||||||
pass
|
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:
|
async def _scrape_with_agent(force: bool = False) -> dict:
|
||||||
"""Fallback scraper — browser-use Agent + ChatOllama (free/local, Chromium)."""
|
"""Fallback scraper — browser-use Agent + ChatOllama (free/local, Chromium)."""
|
||||||
cleanup_chrome()
|
cleanup_chrome()
|
||||||
@@ -886,7 +1268,7 @@ async def _scrape_with_agent(force: bool = False) -> dict:
|
|||||||
profile_dir = copy_chrome_profile(CHROME_PROFILE)
|
profile_dir = copy_chrome_profile(CHROME_PROFILE)
|
||||||
user_data_dir = profile_dir
|
user_data_dir = profile_dir
|
||||||
|
|
||||||
browser = Browser(
|
browser = _make_browser(
|
||||||
headless=True,
|
headless=True,
|
||||||
args=CHROME_LAUNCH_ARGS,
|
args=CHROME_LAUNCH_ARGS,
|
||||||
user_data_dir=user_data_dir,
|
user_data_dir=user_data_dir,
|
||||||
@@ -896,7 +1278,7 @@ async def _scrape_with_agent(force: bool = False) -> dict:
|
|||||||
|
|
||||||
all_posts = []
|
all_posts = []
|
||||||
for query in random.sample(FB_SEARCHES, k=random.randint(2, 4)):
|
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:
|
task=f"""You are logged into Facebook. Do the following:
|
||||||
1. Navigate to facebook.com and make sure you are on the homepage
|
1. Navigate to facebook.com and make sure you are on the homepage
|
||||||
2. Use the Facebook search to find: {query}
|
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)
|
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.""",
|
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,
|
browser=browser,
|
||||||
max_actions_per_step=3,
|
max_actions_per_step=3,
|
||||||
use_vision=False,
|
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:
|
except Exception as e:
|
||||||
logger.warning("Failed to clean up Chrome profile %s: %s", profile_dir, 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:
|
async def ask_ollama(prompt: str) -> str:
|
||||||
import httpx
|
import httpx
|
||||||
async with httpx.AsyncClient(timeout=120) as c:
|
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 filtered[:10]
|
||||||
return []
|
return []
|
||||||
|
|
||||||
|
# ══════════════════════════════════════════════════════════════════════
|
||||||
|
# FastAPI Endpoints
|
||||||
|
# ══════════════════════════════════════════════════════════════════════
|
||||||
|
|
||||||
@app.get("/health")
|
@app.get("/health")
|
||||||
async def health():
|
async def health():
|
||||||
|
"""Simple health check for the splash page status polling."""
|
||||||
return {"status": "ok"}
|
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")
|
@app.post("/agent/run")
|
||||||
async def agent_run(task: str = Body(..., embed=True)):
|
async def agent_run(task: str = Body(..., embed=True)):
|
||||||
"""Run a browser-use Agent with ChatOllama (free/local)."""
|
"""Run a browser-use Agent with ChatOllama (free/local)."""
|
||||||
try:
|
try:
|
||||||
browser = Browser(headless=True, args=CHROME_LAUNCH_ARGS)
|
browser = _make_browser(headless=True, args=CHROME_LAUNCH_ARGS)
|
||||||
await browser.start()
|
await browser.start()
|
||||||
agent = Agent(
|
agent = _make_agent(
|
||||||
task=task,
|
task=task,
|
||||||
llm=make_ollama(num_ctx=32000, temperature=0.3),
|
llm=_make_ollama(num_ctx=32000, temperature=0.3),
|
||||||
browser=browser,
|
browser=browser,
|
||||||
use_vision=False,
|
use_vision=False,
|
||||||
max_actions_per_step=5,
|
max_actions_per_step=5,
|
||||||
|
|||||||
@@ -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);
|
||||||
@@ -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);
|
||||||
@@ -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;
|
||||||
@@ -34,11 +34,34 @@ BEGIN;
|
|||||||
\echo '=== Running 009_settings.sql (Company Settings + User Preferences) ==='
|
\echo '=== Running 009_settings.sql (Company Settings + User Preferences) ==='
|
||||||
\i 009_settings.sql
|
\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
|
\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) ==='
|
\echo '=== Running 014_bug_reports.sql (Bug Reporting System) ==='
|
||||||
\i 014_bug_reports.sql
|
\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 ==='
|
\echo '=== Migration Complete ==='
|
||||||
COMMIT;
|
COMMIT;
|
||||||
|
|||||||
@@ -1,12 +1,4 @@
|
|||||||
import type { NextConfig } from "next"
|
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 = {
|
const nextConfig: NextConfig = {
|
||||||
eslint: {
|
eslint: {
|
||||||
@@ -20,7 +12,6 @@ const nextConfig: NextConfig = {
|
|||||||
},
|
},
|
||||||
],
|
],
|
||||||
},
|
},
|
||||||
|
|
||||||
}
|
}
|
||||||
|
|
||||||
export default nextConfig
|
export default nextConfig
|
||||||
|
|||||||
Generated
+1358
-142
File diff suppressed because it is too large
Load Diff
+10
-4
@@ -5,13 +5,14 @@
|
|||||||
"scripts": {
|
"scripts": {
|
||||||
"dev": "npm run dev:precheck & npm run dev:ollama & npm run dev:start",
|
"dev": "npm run dev:precheck & npm run dev:ollama & npm run dev:start",
|
||||||
"dev:signaling": "node signaling-server.mjs",
|
"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: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: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:precheck": "node scripts/precheck.mjs",
|
||||||
"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:ollama": "node scripts/ensure-ollama.mjs",
|
||||||
"dev:rust": "node ai-server/index.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",
|
"build": "next build",
|
||||||
"start": "next start -p 3006",
|
"start": "next start -p 3006",
|
||||||
"lint": "eslint"
|
"lint": "eslint"
|
||||||
@@ -41,11 +42,14 @@
|
|||||||
"bcryptjs": "^3.0.3",
|
"bcryptjs": "^3.0.3",
|
||||||
"class-variance-authority": "^0.7.1",
|
"class-variance-authority": "^0.7.1",
|
||||||
"clsx": "^2.1.1",
|
"clsx": "^2.1.1",
|
||||||
|
"devenv": "^1.0.1",
|
||||||
|
"dotenv": "^17.4.2",
|
||||||
"framer-motion": "^11.15.0",
|
"framer-motion": "^11.15.0",
|
||||||
"jose": "^6.2.3",
|
"jose": "^6.2.3",
|
||||||
"lucide-react": "^0.468.0",
|
"lucide-react": "^0.468.0",
|
||||||
"next": "15.0.4",
|
"next": "15.0.4",
|
||||||
"next-themes": "^0.4.4",
|
"next-themes": "^0.4.4",
|
||||||
|
"nodemailer": "^9.0.1",
|
||||||
"pg": "^8.21.0",
|
"pg": "^8.21.0",
|
||||||
"react": "^18.3.1",
|
"react": "^18.3.1",
|
||||||
"react-dom": "^18.3.1",
|
"react-dom": "^18.3.1",
|
||||||
@@ -60,12 +64,14 @@
|
|||||||
},
|
},
|
||||||
"devDependencies": {
|
"devDependencies": {
|
||||||
"@types/node": "^20",
|
"@types/node": "^20",
|
||||||
|
"@types/nodemailer": "^8.0.1",
|
||||||
"@types/pg": "^8.20.0",
|
"@types/pg": "^8.20.0",
|
||||||
"@types/react": "^18",
|
"@types/react": "^18",
|
||||||
"@types/react-dom": "^18",
|
"@types/react-dom": "^18",
|
||||||
"concurrently": "^10.0.3",
|
"concurrently": "^10.0.3",
|
||||||
"eslint": "^9",
|
"eslint": "^9",
|
||||||
"eslint-config-next": "15.0.4",
|
"eslint-config-next": "15.0.4",
|
||||||
|
"maildev": "^2.2.1",
|
||||||
"postcss": "^8.4.49",
|
"postcss": "^8.4.49",
|
||||||
"tailwindcss": "^3.4.17",
|
"tailwindcss": "^3.4.17",
|
||||||
"typescript": "^5"
|
"typescript": "^5"
|
||||||
|
|||||||
@@ -0,0 +1 @@
|
|||||||
|
|
||||||
+1
-1
@@ -446,7 +446,7 @@ async fn main() {
|
|||||||
let database_url = std::env::var("DATABASE_URL").expect("DATABASE_URL must be set");
|
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 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 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 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");
|
let port: u16 = std::env::var("AI_PORT").unwrap_or_else(|_| "3001".to_string()).parse().expect("AI_PORT must be a number");
|
||||||
|
|
||||||
|
|||||||
@@ -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")
|
||||||
|
}
|
||||||
@@ -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()
|
||||||
@@ -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 {}
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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))
|
||||||
@@ -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! ===")
|
||||||
@@ -143,6 +143,25 @@ io.on("connection", (socket) => {
|
|||||||
io.to(targetSocketId).emit("call:busy", { from: userId })
|
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 }) => {
|
socket.on("room:join", ({ roomId, displayName }) => {
|
||||||
|
|||||||
+451
-69
@@ -5,6 +5,7 @@
|
|||||||
<meta name="viewport" content="width=device-width, initial-scale=1.0">
|
<meta name="viewport" content="width=device-width, initial-scale=1.0">
|
||||||
<title>Loading CoastIT CRM</title>
|
<title>Loading CoastIT CRM</title>
|
||||||
<style>
|
<style>
|
||||||
|
/* ── Global Reset ──────────────────────────────────────────────── */
|
||||||
* { margin: 0; padding: 0; box-sizing: border-box; }
|
* { margin: 0; padding: 0; box-sizing: border-box; }
|
||||||
body {
|
body {
|
||||||
background: #0a0a1a;
|
background: #0a0a1a;
|
||||||
@@ -18,7 +19,8 @@
|
|||||||
overflow: hidden;
|
overflow: hidden;
|
||||||
}
|
}
|
||||||
|
|
||||||
/* Robot */
|
/* ── Robot Animation ────────────────────────────────────────────── */
|
||||||
|
/* Animated robot mascot that bobs, swings arms, and blinks while loading */
|
||||||
.robot {
|
.robot {
|
||||||
position: relative;
|
position: relative;
|
||||||
width: 120px;
|
width: 120px;
|
||||||
@@ -128,7 +130,9 @@
|
|||||||
100% { transform: translateY(-4px) rotate(8deg); }
|
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 {
|
.services {
|
||||||
display: flex;
|
display: flex;
|
||||||
flex-direction: column;
|
flex-direction: column;
|
||||||
@@ -207,7 +211,7 @@
|
|||||||
color: #ef4444;
|
color: #ef4444;
|
||||||
}
|
}
|
||||||
|
|
||||||
/* Loading text */
|
/* ── Loading Text ──────────────────────────────────────────────── */
|
||||||
.loading-text {
|
.loading-text {
|
||||||
font-size: 16px;
|
font-size: 16px;
|
||||||
font-weight: 600;
|
font-weight: 600;
|
||||||
@@ -222,7 +226,9 @@
|
|||||||
50% { opacity: 1; }
|
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 {
|
.launch-overlay {
|
||||||
display: none;
|
display: none;
|
||||||
flex-direction: column;
|
flex-direction: column;
|
||||||
@@ -255,7 +261,9 @@
|
|||||||
100% { transform: scale(1); opacity: 1; }
|
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 {
|
.loading-text.error {
|
||||||
background: linear-gradient(135deg, #ef4444, #f97316);
|
background: linear-gradient(135deg, #ef4444, #f97316);
|
||||||
-webkit-background-clip: text;
|
-webkit-background-clip: text;
|
||||||
@@ -293,11 +301,135 @@
|
|||||||
.retry-btn.active {
|
.retry-btn.active {
|
||||||
display: inline-block;
|
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>
|
</style>
|
||||||
</head>
|
</head>
|
||||||
<body>
|
<body>
|
||||||
|
|
||||||
<!-- Robot -->
|
<!-- ── Robot Mascot ────────────────────────────────────────────── -->
|
||||||
<div class="robot">
|
<div class="robot">
|
||||||
<div class="robot-antenna"></div>
|
<div class="robot-antenna"></div>
|
||||||
<div class="robot-head">
|
<div class="robot-head">
|
||||||
@@ -312,7 +444,7 @@
|
|||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<!-- Services -->
|
<!-- ── Service Status Indicators ───────────────────────────────── -->
|
||||||
<div class="services">
|
<div class="services">
|
||||||
<div class="service" id="svc-ai">
|
<div class="service" id="svc-ai">
|
||||||
<span class="service-name">AI Server</span>
|
<span class="service-name">AI Server</span>
|
||||||
@@ -345,109 +477,359 @@
|
|||||||
|
|
||||||
<div class="loading-text" id="loading-text">Loading...</div>
|
<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-overlay" id="launch-overlay">
|
||||||
<div class="launch-gear"></div>
|
<div class="launch-gear"></div>
|
||||||
<div class="launch-text">🚀 Launching gear!</div>
|
<div class="launch-text">Launching gear!</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div class="error-msg" id="error-msg"></div>
|
<div class="error-msg" id="error-msg"></div>
|
||||||
<button class="retry-btn" id="retry-btn" onclick="location.reload()">Try Again</button>
|
<button class="retry-btn" id="retry-btn" onclick="location.reload()">Try Again</button>
|
||||||
|
|
||||||
<script>
|
<!-- ══════════════════════════════════════════════════════════════
|
||||||
const MAX_ATTEMPTS = 30;
|
Setup Wizard (4 steps, shown only on first run)
|
||||||
let attempts = 0;
|
══════════════════════════════════════════════════════════════ -->
|
||||||
|
<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 = [
|
const CHECKS = [
|
||||||
{ id: 'ai', key: 'ai', ready: false, failed: false },
|
{ id: 'ai', key: 'ai', ready: false, failed: false },
|
||||||
{ id: 'scraper', key: 'scraper', ready: false, failed: false },
|
{ id: 'scraper', key: 'scraper', ready: false, failed: false },
|
||||||
{ id: 'frontend',key: 'frontend', ready: false, failed: false },
|
{ id: 'frontend',key: 'frontend', ready: false, failed: false },
|
||||||
];
|
];
|
||||||
|
|
||||||
const MSGS = { ai: 'AI Ready', scraper: 'Scraper Ready', frontend: 'Frontend Ready' };
|
const MSGS = { ai: 'AI Ready', scraper: 'Scraper Ready', frontend: 'Frontend Ready' };
|
||||||
const NAMES = { ai: 'AI Server', scraper: 'Scraper', frontend: 'Frontend' };
|
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() {
|
async function checkAllServices() {
|
||||||
try {
|
try {
|
||||||
const res = await fetch('/status', { cache: 'no-store' });
|
const res = await fetch('/status', { cache: 'no-store' });
|
||||||
const data = await res.json();
|
const data = await res.json();
|
||||||
for (const svc of CHECKS) {
|
for (const svc of CHECKS) svc.ready = data[svc.key] === true;
|
||||||
svc.ready = data[svc.key] === true;
|
} catch { for (const svc of CHECKS) svc.ready = false }
|
||||||
}
|
|
||||||
} catch {
|
|
||||||
for (const svc of CHECKS) svc.ready = false;
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|
||||||
function updateUI() {
|
function updateUI() {
|
||||||
let allReady = true;
|
let allReady = true, anyFailed = false;
|
||||||
let anyFailed = false;
|
|
||||||
const failedNames = [];
|
const failedNames = [];
|
||||||
|
|
||||||
for (const svc of CHECKS) {
|
for (const svc of CHECKS) {
|
||||||
const el = document.getElementById('svc-' + svc.id);
|
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');
|
el.classList.remove('ready', 'failed');
|
||||||
|
if (svc.ready) { el.classList.add('ready'); st.textContent = MSGS[svc.id] }
|
||||||
if (svc.ready) {
|
else if (svc.failed) { el.classList.add('failed'); st.textContent = 'Failed'; anyFailed = true; allReady = false; failedNames.push(NAMES[svc.id]) }
|
||||||
el.classList.add('ready');
|
else { st.textContent = 'waiting...'; allReady = false }
|
||||||
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;
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
const lt = document.getElementById('loading-text'), em = document.getElementById('error-msg'), rb = document.getElementById('retry-btn'), lo = document.getElementById('launch-overlay');
|
||||||
const loadingText = document.getElementById('loading-text');
|
|
||||||
const errorMsg = document.getElementById('error-msg');
|
|
||||||
const retryBtn = document.getElementById('retry-btn');
|
|
||||||
|
|
||||||
if (anyFailed) {
|
if (anyFailed) {
|
||||||
loadingText.className = 'loading-text error';
|
lt.className = 'loading-text error'; lt.textContent = 'Something went wrong';
|
||||||
loadingText.textContent = 'Something went wrong';
|
em.textContent = failedNames.join(', ') + ' failed to start. Check your terminal.';
|
||||||
errorMsg.textContent = failedNames.join(', ') + ' failed to start. Check your terminal for details.';
|
em.classList.add('active'); rb.classList.add('active'); lo.classList.remove('active');
|
||||||
errorMsg.classList.add('active');
|
|
||||||
retryBtn.classList.add('active');
|
|
||||||
document.getElementById('launch-overlay').classList.remove('active');
|
|
||||||
} else if (allReady) {
|
} else if (allReady) {
|
||||||
loadingText.className = 'loading-text';
|
lt.className = 'loading-text'; lt.textContent = 'All systems ready!';
|
||||||
loadingText.textContent = 'All systems ready!';
|
em.classList.remove('active'); rb.classList.remove('active');
|
||||||
errorMsg.classList.remove('active');
|
lo.classList.add('active');
|
||||||
retryBtn.classList.remove('active');
|
|
||||||
document.getElementById('launch-overlay').classList.add('active');
|
|
||||||
setTimeout(() => { window.location.href = 'http://localhost:3006/login'; }, 5000);
|
setTimeout(() => { window.location.href = 'http://localhost:3006/login'; }, 5000);
|
||||||
} else {
|
} else {
|
||||||
loadingText.className = 'loading-text';
|
lt.className = 'loading-text';
|
||||||
const ready = CHECKS.filter(s => s.ready).length;
|
lt.textContent = `Loading... (${CHECKS.filter(s => s.ready).length}/${CHECKS.length})`;
|
||||||
loadingText.textContent = `Loading... (${ready}/${CHECKS.length})`;
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
async function poll() {
|
async function poll() {
|
||||||
attempts++;
|
attempts++;
|
||||||
if (attempts > MAX_ATTEMPTS) {
|
if (attempts > MAX_ATTEMPTS) {
|
||||||
for (const svc of CHECKS) {
|
for (const svc of CHECKS) { if (!svc.ready) svc.failed = true }
|
||||||
if (!svc.ready) svc.failed = true;
|
updateUI(); return;
|
||||||
}
|
|
||||||
updateUI();
|
|
||||||
return;
|
|
||||||
}
|
}
|
||||||
|
await checkAllServices(); updateUI();
|
||||||
await checkAllServices();
|
if (!CHECKS.every(s => s.ready) && !CHECKS.some(s => s.failed))
|
||||||
updateUI();
|
|
||||||
|
|
||||||
if (!CHECKS.every(s => s.ready) && !CHECKS.some(s => s.failed)) {
|
|
||||||
setTimeout(poll, 2000);
|
setTimeout(poll, 2000);
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|
||||||
poll();
|
// ── Boot ──
|
||||||
|
// Entry point: check environment → shows wizard or starts normal flow
|
||||||
|
checkEnv();
|
||||||
</script>
|
</script>
|
||||||
</body>
|
</body>
|
||||||
</html>
|
</html>
|
||||||
|
|||||||
@@ -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
@@ -15,51 +15,52 @@ import {
|
|||||||
DropdownMenuTrigger,
|
DropdownMenuTrigger,
|
||||||
} from "@/components/ui/dropdown-menu"
|
} from "@/components/ui/dropdown-menu"
|
||||||
import {
|
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,
|
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"
|
} from "lucide-react"
|
||||||
|
import { hasBlockedCodeExtension, filterBlockedFiles } from "@/lib/blocked-extensions"
|
||||||
import {
|
import {
|
||||||
Dialog, DialogContent, DialogHeader, DialogTitle, DialogDescription,
|
Dialog, DialogContent, DialogHeader, DialogTitle, DialogDescription,
|
||||||
DialogFooter, DialogClose,
|
DialogFooter, DialogClose,
|
||||||
} from "@/components/ui/dialog"
|
} from "@/components/ui/dialog"
|
||||||
import { Label } from "@/components/ui/label"
|
import { Label } from "@/components/ui/label"
|
||||||
import { Textarea } from "@/components/ui/textarea"
|
import { Textarea } from "@/components/ui/textarea"
|
||||||
|
import {
|
||||||
|
Select, SelectContent, SelectItem, SelectTrigger, SelectValue,
|
||||||
|
} from "@/components/ui/select"
|
||||||
import { useTheme } from "next-themes"
|
import { useTheme } from "next-themes"
|
||||||
import { useUser } from "@/providers/user-provider"
|
import { useUser } from "@/providers/user-provider"
|
||||||
import { toast } from "sonner"
|
import { toast } from "sonner"
|
||||||
|
import { io, Socket } from "socket.io-client"
|
||||||
import VoiceCallModal from "@/components/chats/voice-call-modal"
|
import VoiceCallModal from "@/components/chats/voice-call-modal"
|
||||||
import data from "@emoji-mart/data"
|
import MediaPicker from "@/components/chats/media-picker"
|
||||||
import Picker from "@emoji-mart/react"
|
|
||||||
|
|
||||||
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 [playing, setPlaying] = useState(false)
|
||||||
const [current, setCurrent] = useState(0)
|
const [current, setCurrent] = useState(0)
|
||||||
const [duration, setDuration] = useState(initialDuration)
|
const [duration, setDuration] = useState(initialDuration)
|
||||||
const audioRef = useRef<HTMLAudioElement>(null)
|
const audioRef = useRef<HTMLAudioElement>(null)
|
||||||
const animRef = useRef<number>(0)
|
const animRef = useRef<number>(0)
|
||||||
|
const progRef = useRef<HTMLInputElement>(null)
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
const audio = audioRef.current
|
const audio = audioRef.current
|
||||||
if (!audio) return
|
if (!audio) return
|
||||||
const onLoaded = () => {
|
const onLoaded = () => { if (isFinite(audio.duration)) setDuration(audio.duration) }
|
||||||
if (isFinite(audio.duration)) setDuration(audio.duration)
|
|
||||||
}
|
|
||||||
const onEnded = () => { setPlaying(false); setCurrent(0) }
|
const onEnded = () => { setPlaying(false); setCurrent(0) }
|
||||||
audio.addEventListener("loadedmetadata", onLoaded)
|
audio.addEventListener("loadedmetadata", onLoaded)
|
||||||
audio.addEventListener("ended", onEnded)
|
audio.addEventListener("ended", onEnded)
|
||||||
return () => {
|
return () => { audio.removeEventListener("loadedmetadata", onLoaded); audio.removeEventListener("ended", onEnded) }
|
||||||
audio.removeEventListener("loadedmetadata", onLoaded)
|
|
||||||
audio.removeEventListener("ended", onEnded)
|
|
||||||
}
|
|
||||||
}, [src])
|
}, [src])
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
if (!playing) { cancelAnimationFrame(animRef.current); return }
|
if (!playing) { cancelAnimationFrame(animRef.current); return }
|
||||||
const tick = () => {
|
const tick = () => { if (audioRef.current) setCurrent(audioRef.current.currentTime); animRef.current = requestAnimationFrame(tick) }
|
||||||
if (audioRef.current) setCurrent(audioRef.current.currentTime)
|
|
||||||
animRef.current = requestAnimationFrame(tick)
|
|
||||||
}
|
|
||||||
animRef.current = requestAnimationFrame(tick)
|
animRef.current = requestAnimationFrame(tick)
|
||||||
return () => cancelAnimationFrame(animRef.current)
|
return () => cancelAnimationFrame(animRef.current)
|
||||||
}, [playing])
|
}, [playing])
|
||||||
@@ -67,26 +68,55 @@ function VoiceMessagePlayer({ src, initialDuration }: { src: string; initialDura
|
|||||||
const toggle = () => {
|
const toggle = () => {
|
||||||
if (!audioRef.current) return
|
if (!audioRef.current) return
|
||||||
if (playing) { audioRef.current.pause(); setPlaying(false) }
|
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 displayDuration = isFinite(duration) && duration > 0 ? duration : initialDuration
|
||||||
const pct = displayDuration > 0 ? (current / displayDuration) * 100 : 0
|
const pct = displayDuration > 0 ? (current / displayDuration) * 100 : 0
|
||||||
const fmt = (s: number) => {
|
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 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 (
|
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" />
|
<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">
|
<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" />}
|
{playing ? <Pause className="h-4 w-4 fill-current" /> : <Play className="h-4 w-4 ml-0.5 fill-current" />}
|
||||||
</button>
|
</button>
|
||||||
<div className="flex-1 h-1.5 rounded-full bg-white/30 relative overflow-hidden">
|
<div className="flex-1 flex flex-col gap-0.5 min-w-0">
|
||||||
<div className="h-full rounded-full bg-white transition-all duration-100" style={{ width: `${pct}%` }} />
|
<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>
|
</div>
|
||||||
<span className="text-[11px] tabular-nums opacity-70 w-8 text-right">{fmt(displayDuration)}</span>
|
|
||||||
</div>
|
</div>
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
@@ -100,6 +130,7 @@ export default function ChatsPage() {
|
|||||||
const [activeChat, setActiveChat] = useState<string | null>(null)
|
const [activeChat, setActiveChat] = useState<string | null>(null)
|
||||||
const [messageInput, setMessageInput] = useState("")
|
const [messageInput, setMessageInput] = useState("")
|
||||||
const [showEmojiPicker, setShowEmojiPicker] = useState(false)
|
const [showEmojiPicker, setShowEmojiPicker] = useState(false)
|
||||||
|
const [showAttachMenu, setShowAttachMenu] = useState(false)
|
||||||
const [attachments, setAttachments] = useState<File[]>([])
|
const [attachments, setAttachments] = useState<File[]>([])
|
||||||
const [panelWidth, setPanelWidth] = useState(320)
|
const [panelWidth, setPanelWidth] = useState(320)
|
||||||
const [isResizing, setIsResizing] = useState(false)
|
const [isResizing, setIsResizing] = useState(false)
|
||||||
@@ -113,6 +144,11 @@ export default function ChatsPage() {
|
|||||||
const [isPaused, setIsPaused] = useState(false)
|
const [isPaused, setIsPaused] = useState(false)
|
||||||
const [recordingDuration, setRecordingDuration] = useState(0)
|
const [recordingDuration, setRecordingDuration] = useState(0)
|
||||||
const recordingDurationRef = useRef(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 [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 [messageAttachments, setMessageAttachments] = useState<Map<string, { name: string; type: string; url: string }[]>>(new Map())
|
||||||
const [replyingTo, setReplyingTo] = useState<any>(null)
|
const [replyingTo, setReplyingTo] = useState<any>(null)
|
||||||
@@ -125,20 +161,46 @@ export default function ChatsPage() {
|
|||||||
const [searchResults, setSearchResults] = useState<any[]>([])
|
const [searchResults, setSearchResults] = useState<any[]>([])
|
||||||
const [searchingUsers, setSearchingUsers] = useState(false)
|
const [searchingUsers, setSearchingUsers] = useState(false)
|
||||||
const [unreadMap, setUnreadMap] = useState<Map<string, number>>(new Map())
|
const [unreadMap, setUnreadMap] = useState<Map<string, number>>(new Map())
|
||||||
const [isCallModalOpen, setIsCallModalOpen] = useState(false)
|
|
||||||
const [previewAvatarUrl, setPreviewAvatarUrl] = useState<string | null>(null)
|
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 fileInputRef = useRef<HTMLInputElement>(null)
|
||||||
const textareaRef = useRef<HTMLTextAreaElement>(null)
|
const textareaRef = useRef<HTMLTextAreaElement>(null)
|
||||||
const emojiPickerRef = useRef<HTMLDivElement>(null)
|
const emojiPickerRef = useRef<HTMLDivElement>(null)
|
||||||
|
const isSendingRef = useRef(false)
|
||||||
|
const attachMenuRef = useRef<HTMLDivElement>(null)
|
||||||
const messagesEndRef = useRef<HTMLDivElement>(null)
|
const messagesEndRef = useRef<HTMLDivElement>(null)
|
||||||
const mediaRecorderRef = useRef<MediaRecorder | null>(null)
|
const mediaRecorderRef = useRef<MediaRecorder | null>(null)
|
||||||
const recordingChunksRef = useRef<Blob[]>([])
|
const recordingChunksRef = useRef<Blob[]>([])
|
||||||
const resizeStartRef = useRef({ x: 0, width: 0 })
|
const resizeStartRef = useRef({ x: 0, width: 0 })
|
||||||
const blobUrlsRef = useRef<string[]>([])
|
const blobUrlsRef = useRef<string[]>([])
|
||||||
const conversationOrderRef = 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 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 autoResizeTextarea = useCallback(() => {
|
||||||
const ta = textareaRef.current
|
const ta = textareaRef.current
|
||||||
if (!ta) return
|
if (!ta) return
|
||||||
@@ -208,6 +270,17 @@ export default function ChatsPage() {
|
|||||||
setMessageAttachments((a) => {
|
setMessageAttachments((a) => {
|
||||||
const n = new Map(a)
|
const n = new Map(a)
|
||||||
for (const k of n.keys()) if (!validMsgIds.has(k)) n.delete(k)
|
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
|
return n
|
||||||
})
|
})
|
||||||
setReplyMap((r) => {
|
setReplyMap((r) => {
|
||||||
@@ -257,6 +330,26 @@ export default function ChatsPage() {
|
|||||||
return () => document.removeEventListener("mousedown", handleClickOutside)
|
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
|
// revoke blob URLs on unmount
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
return () => {
|
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) => {
|
const handleResizeStart = useCallback((e: React.MouseEvent) => {
|
||||||
e.preventDefault()
|
e.preventDefault()
|
||||||
setIsResizing(true)
|
setIsResizing(true)
|
||||||
@@ -291,23 +443,32 @@ export default function ChatsPage() {
|
|||||||
messagesEndRef.current?.scrollIntoView({ behavior: "smooth" })
|
messagesEndRef.current?.scrollIntoView({ behavior: "smooth" })
|
||||||
}, [conversations])
|
}, [conversations])
|
||||||
|
|
||||||
const handleEmojiSelect = (emoji: { native: string }) => {
|
const handleEmojiSelect = (emoji: string) => {
|
||||||
setMessageInput((prev) => prev + emoji.native)
|
setMessageInput((prev) => prev + emoji)
|
||||||
setShowEmojiPicker(false)
|
setShowEmojiPicker(false)
|
||||||
setTimeout(() => autoResizeTextarea(), 0)
|
setTimeout(() => autoResizeTextarea(), 0)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
const handleMediaSelect = (content: string) => {
|
||||||
|
addMessageToChat(content)
|
||||||
|
setShowEmojiPicker(false)
|
||||||
|
}
|
||||||
|
|
||||||
const MAX_FILE_SIZE = 10 * 1024 * 1024 // 10MB
|
const MAX_FILE_SIZE = 10 * 1024 * 1024 // 10MB
|
||||||
|
|
||||||
const handleFileSelect = (e: React.ChangeEvent<HTMLInputElement>) => {
|
const handleFileSelect = (e: React.ChangeEvent<HTMLInputElement>) => {
|
||||||
const files = Array.from(e.target.files ?? [])
|
const raw = Array.from(e.target.files ?? [])
|
||||||
const oversized = files.filter((f) => f.size > MAX_FILE_SIZE)
|
const oversized = raw.filter((f) => f.size > MAX_FILE_SIZE)
|
||||||
if (oversized.length > 0) {
|
if (oversized.length > 0) {
|
||||||
toast.error(`${oversized[0].name} exceeds the 10MB file size limit`)
|
toast.error(`${oversized[0].name} exceeds the 10MB file size limit`)
|
||||||
if (e.target) e.target.value = ""
|
if (e.target) e.target.value = ""
|
||||||
return
|
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 = ""
|
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 }[]) => {
|
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
|
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 {
|
try {
|
||||||
const res = await fetch(`/api/conversations/${activeChat}/messages`, {
|
const res = await fetch(`/api/conversations/${activeChat}/messages`, {
|
||||||
method: "POST",
|
method: "POST",
|
||||||
@@ -336,7 +501,7 @@ export default function ChatsPage() {
|
|||||||
setConversations((prev) => {
|
setConversations((prev) => {
|
||||||
const updated = prev.map((conv) =>
|
const updated = prev.map((conv) =>
|
||||||
conv.id === activeChat
|
conv.id === activeChat
|
||||||
? { ...conv, lastMessage: content, lastMessageTime: "Just now" }
|
? { ...conv, lastMessage: getDisplayContent(content), lastMessageTime: "Just now" }
|
||||||
: conv
|
: conv
|
||||||
)
|
)
|
||||||
const idx = updated.findIndex((c) => c.id === activeChat)
|
const idx = updated.findIndex((c) => c.id === activeChat)
|
||||||
@@ -378,73 +543,114 @@ export default function ChatsPage() {
|
|||||||
const handleSend = async (e: React.FormEvent) => {
|
const handleSend = async (e: React.FormEvent) => {
|
||||||
e.preventDefault()
|
e.preventDefault()
|
||||||
if (!messageInput.trim() && attachments.length === 0) return
|
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) => {
|
setConversationMessages((prev) => {
|
||||||
const next = new Map(prev)
|
const next = new Map(prev)
|
||||||
const msgs = (next.get(activeChat || "") || []).map((m) =>
|
const msgs = (next.get(activeChat || "") || []).filter((m) => m.id !== messageId)
|
||||||
m.id === editingMessage.id ? { ...m, content: messageInput.trim() } : m
|
|
||||||
)
|
|
||||||
next.set(activeChat || "", msgs)
|
next.set(activeChat || "", msgs)
|
||||||
return next
|
return next
|
||||||
})
|
})
|
||||||
setEditingMessage(null)
|
setConversations((prev) =>
|
||||||
setMessageInput("")
|
prev.map((conv) =>
|
||||||
setTimeout(() => autoResizeTextarea(), 0)
|
conv.id === activeChat
|
||||||
return
|
? { ...conv, lastMessage: getDisplayContent(prevMessages.filter((m) => m.id !== messageId).at(-1)?.content ?? "") }
|
||||||
}
|
: conv
|
||||||
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
|
|
||||||
)
|
)
|
||||||
)
|
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 () => {
|
const startRecording = async () => {
|
||||||
try {
|
try {
|
||||||
const stream = await navigator.mediaDevices.getUserMedia({ audio: true })
|
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 = []
|
recordingChunksRef.current = []
|
||||||
recorder.ondataavailable = (e) => {
|
recorder.ondataavailable = (e) => {
|
||||||
if (e.data.size > 0) recordingChunksRef.current.push(e.data)
|
if (e.data.size > 0) recordingChunksRef.current.push(e.data)
|
||||||
}
|
}
|
||||||
recorder.onstop = () => {
|
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)
|
const url = URL.createObjectURL(blob)
|
||||||
blobUrlsRef.current.push(url)
|
blobUrlsRef.current.push(url)
|
||||||
addMessageToChat("", { url, duration: recordingDurationRef.current })
|
setPreviewBlob(blob)
|
||||||
|
setPreviewUrl(url)
|
||||||
|
setPreviewDuration(recordingDurationRef.current)
|
||||||
stream.getTracks().forEach((t) => t.stop())
|
stream.getTracks().forEach((t) => t.stop())
|
||||||
setRecordingDuration(0)
|
setRecordingDuration(0)
|
||||||
}
|
}
|
||||||
@@ -452,9 +658,12 @@ export default function ChatsPage() {
|
|||||||
recorder.start()
|
recorder.start()
|
||||||
setIsRecording(true)
|
setIsRecording(true)
|
||||||
setIsPaused(false)
|
setIsPaused(false)
|
||||||
} catch {
|
} catch (err: any) {
|
||||||
console.warn("Failed to start recording in chats page")
|
if (err?.name === "NotAllowedError" || err?.name === "PermissionDeniedError") {
|
||||||
toast.error("Microphone access denied")
|
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)
|
setRecordingDuration(0)
|
||||||
recordingDurationRef.current = 0
|
recordingDurationRef.current = 0
|
||||||
recordingChunksRef.current = []
|
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(() => {
|
useEffect(() => {
|
||||||
@@ -515,6 +778,21 @@ export default function ChatsPage() {
|
|||||||
|
|
||||||
const isImageFile = (file: File) => file.type.startsWith("image/")
|
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 (
|
return (
|
||||||
<div className="flex h-[calc(100vh-8rem)] -m-4 lg:-m-6 rounded-lg border bg-card overflow-hidden">
|
<div className="flex h-[calc(100vh-8rem)] -m-4 lg:-m-6 rounded-lg border bg-card overflow-hidden">
|
||||||
{/* Conversations list - left panel */}
|
{/* 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-sm font-medium truncate">{person.name}</span>
|
||||||
<span className="text-xs text-muted-foreground shrink-0">{conv.lastMessageTime}</span>
|
<span className="text-xs text-muted-foreground shrink-0">{conv.lastMessageTime}</span>
|
||||||
</div>
|
</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>
|
</div>
|
||||||
{!isActive && (unreadMap.get(conv.id) || 0) > 0 && (
|
{!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" />
|
<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>
|
</div>
|
||||||
<div className="flex items-center gap-1 shrink-0">
|
<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" />
|
<Phone className="h-4 w-4" />
|
||||||
</Button>
|
</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>
|
<DropdownMenu>
|
||||||
<DropdownMenuTrigger asChild>
|
<DropdownMenuTrigger asChild>
|
||||||
<Button variant="ghost" size="icon" className="h-8 w-8">
|
<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">
|
<div className="space-y-4 min-h-0">
|
||||||
{messages.map((msg) => {
|
{messages.map((msg) => {
|
||||||
const isMe = msg.senderId === user?.id
|
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 (
|
return (
|
||||||
<div key={msg.id} className={cn("group flex gap-3", isMe && "flex-row-reverse")}>
|
<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)}>
|
<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>
|
<p className="text-[11px] text-white/60 truncate">{replyInfo.repliedToContent}</p>
|
||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
{voiceUrl ? (
|
{gifData ? (
|
||||||
<div>
|
<div className="max-w-[280px]">
|
||||||
<VoiceMessagePlayer src={voiceUrl.url} initialDuration={voiceUrl.duration} />
|
<img src={gifData.url} alt="GIF" className="w-full rounded-lg" style={{ aspectRatio: `${gifData.w}/${gifData.h}` }} loading="lazy" />
|
||||||
{msg.content && (
|
</div>
|
||||||
<p className="mt-1 text-sm text-black">{msg.content}</p>
|
) : 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>
|
</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) =>
|
{files.map((file, i) =>
|
||||||
file.type.startsWith("image/") ? (
|
file.type.startsWith("image/") ? (
|
||||||
<div key={i} className="relative group">
|
<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">
|
<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" />
|
<Download className="h-3.5 w-3.5 text-white" />
|
||||||
</a>
|
</a>
|
||||||
</div>
|
</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">
|
<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">
|
||||||
<FileIcon className="h-4 w-4 shrink-0 text-muted-foreground" />
|
<button type="button" className="flex items-center gap-2 flex-1 min-w-0 text-left" onClick={() => window.open(file.url, "_blank")} title="Open">
|
||||||
<span className="flex-1 min-w-0 truncate">{file.name}</span>
|
<FileIcon className="h-4 w-4 shrink-0 text-muted-foreground" />
|
||||||
<Download className="h-3.5 w-3.5 shrink-0 text-muted-foreground" />
|
<span className="flex-1 min-w-0 truncate">{file.name}</span>
|
||||||
</a>
|
</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>
|
</div>
|
||||||
) : null
|
) : null
|
||||||
})()}
|
})()}
|
||||||
{isOnlyEmoji(msg.content) ? (
|
{isOnlyEmoji(getDisplayContent(msg.content)) ? (
|
||||||
<span className="text-4xl leading-none">{msg.content.trim()}</span>
|
<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">
|
<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} />
|
<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()}>
|
<div className="relative">
|
||||||
<Paperclip className="h-4 w-4 text-muted-foreground" />
|
<Button type="button" variant="ghost" size="icon" className="h-9 w-9 shrink-0" onClick={() => setShowAttachMenu(!showAttachMenu)}>
|
||||||
</Button>
|
<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">
|
<div className="relative flex-1">
|
||||||
{isRecording ? (
|
{isRecording ? (
|
||||||
<div className="flex h-9 items-center gap-1 rounded-md border bg-muted/30 px-2">
|
<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" />
|
<Square className="h-3.5 w-3.5 fill-current" />
|
||||||
</Button>
|
</Button>
|
||||||
</div>
|
</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}>
|
<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)}>
|
<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" />
|
<Smile className="h-4 w-4 text-muted-foreground" />
|
||||||
@@ -878,14 +1219,14 @@ export default function ChatsPage() {
|
|||||||
</Button>
|
</Button>
|
||||||
{showEmojiPicker && (
|
{showEmojiPicker && (
|
||||||
<div className="absolute bottom-full right-0 mb-2 z-50">
|
<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>
|
</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" />}
|
{editingMessage ? <Pencil className="h-4 w-4" /> : <Send className="h-4 w-4" />}
|
||||||
</Button>
|
</Button>
|
||||||
</form>
|
</form>
|
||||||
@@ -918,6 +1259,20 @@ export default function ChatsPage() {
|
|||||||
</DialogContent>
|
</DialogContent>
|
||||||
</Dialog>
|
</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 */}
|
{/* Avatar preview dialog */}
|
||||||
<Dialog open={!!previewAvatarUrl} onOpenChange={(o) => { if (!o) setPreviewAvatarUrl(null) }}>
|
<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">
|
<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>
|
</ScrollArea>
|
||||||
</DialogContent>
|
</DialogContent>
|
||||||
</Dialog>
|
</Dialog>
|
||||||
|
<<<<<<< Updated upstream
|
||||||
|
=======
|
||||||
|
|
||||||
<VoiceCallModal open={isCallModalOpen} onClose={() => setIsCallModalOpen(false)} />
|
<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>
|
</div>
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -24,8 +24,12 @@ import {
|
|||||||
SelectValue,
|
SelectValue,
|
||||||
} from "@/components/ui/select"
|
} from "@/components/ui/select"
|
||||||
import { DashboardStats } from "@/types"
|
import { DashboardStats } from "@/types"
|
||||||
|
import { CrossedLightsabers } from "@/components/dashboard/crossed-lightsabers"
|
||||||
|
import { StarField } from "@/components/dashboard/star-field"
|
||||||
|
import { useWebsiteTheme } from "@/providers/website-theme-provider"
|
||||||
|
|
||||||
export default function DashboardPage() {
|
export default function DashboardPage() {
|
||||||
|
const { websiteTheme: theme } = useWebsiteTheme()
|
||||||
const [period, setPeriod] = useState("6months")
|
const [period, setPeriod] = useState("6months")
|
||||||
const [stats, setStats] = useState<DashboardStats | null>(null)
|
const [stats, setStats] = useState<DashboardStats | null>(null)
|
||||||
const pollingRef = useRef<NodeJS.Timeout | null>(null)
|
const pollingRef = useRef<NodeJS.Timeout | null>(null)
|
||||||
@@ -63,47 +67,63 @@ export default function DashboardPage() {
|
|||||||
|
|
||||||
return (
|
return (
|
||||||
<div className="space-y-6 relative">
|
<div className="space-y-6 relative">
|
||||||
|
{theme === "starforce" && <StarField />}
|
||||||
|
<div className="relative z-[1] space-y-6">
|
||||||
|
<div>
|
||||||
|
<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 */}
|
{/* Daily Bugle watermark */}
|
||||||
<div className="absolute top-12 right-4 pointer-events-none select-none z-0 opacity-[0.04] dark:opacity-[0.06]">
|
<div className="absolute top-12 right-4 pointer-events-none select-none z-0 opacity-[0.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
|
DAILY BUGLE
|
||||||
</span>
|
</span>
|
||||||
</div>
|
</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>
|
</div>
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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>
|
||||||
|
)
|
||||||
|
}
|
||||||
@@ -1,12 +1,14 @@
|
|||||||
import { NextRequest, NextResponse } from "next/server"
|
import { NextRequest, NextResponse } from "next/server"
|
||||||
import { chatWithAI } from "@/lib/ai"
|
|
||||||
import { getSessionUser } from "@/lib/auth"
|
import { getSessionUser } from "@/lib/auth"
|
||||||
|
import { chatWithAI } from "@/lib/ai"
|
||||||
|
|
||||||
export async function POST(request: NextRequest) {
|
export async function POST(request: NextRequest) {
|
||||||
try {
|
try {
|
||||||
const user = await getSessionUser()
|
const user = await getSessionUser()
|
||||||
if (!user) {
|
if (!user) return NextResponse.json({ error: "Unauthorized" }, { status: 401 })
|
||||||
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()
|
const { message } = await request.json()
|
||||||
@@ -14,16 +16,15 @@ export async function POST(request: NextRequest) {
|
|||||||
return NextResponse.json({ error: "Message is required" }, { status: 400 })
|
return NextResponse.json({ error: "Message is required" }, { status: 400 })
|
||||||
}
|
}
|
||||||
|
|
||||||
const sessionCookie = request.cookies.get("session")
|
// Forward the JWT from the session cookie to the Rust backend
|
||||||
const jwtToken = sessionCookie?.value
|
const sessionCookie = request.cookies.get("session")?.value
|
||||||
if (!jwtToken) {
|
if (!sessionCookie) return NextResponse.json({ error: "No session" }, { status: 401 })
|
||||||
return NextResponse.json({ error: "No session token" }, { status: 401 })
|
|
||||||
}
|
const response = await chatWithAI(message, sessionCookie)
|
||||||
|
|
||||||
const response = await chatWithAI(message, jwtToken)
|
|
||||||
return NextResponse.json({ response })
|
return NextResponse.json({ response })
|
||||||
} catch (error: any) {
|
} catch (error) {
|
||||||
console.error("AI chat error:", 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 })
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -9,7 +9,6 @@ import {
|
|||||||
resetFailedAttempts,
|
resetFailedAttempts,
|
||||||
isAccountLocked,
|
isAccountLocked,
|
||||||
createSession,
|
createSession,
|
||||||
setSessionContext,
|
|
||||||
} from "@/lib/auth"
|
} from "@/lib/auth"
|
||||||
|
|
||||||
export async function POST(request: NextRequest) {
|
export async function POST(request: NextRequest) {
|
||||||
@@ -107,7 +106,6 @@ export async function POST(request: NextRequest) {
|
|||||||
)
|
)
|
||||||
|
|
||||||
await createSession(dbUser.id, dbUser.role_name)
|
await createSession(dbUser.id, dbUser.role_name)
|
||||||
await setSessionContext(dbUser.id, ipAddress)
|
|
||||||
|
|
||||||
const user = mapDbUserToSessionUser(dbUser)
|
const user = mapDbUserToSessionUser(dbUser)
|
||||||
|
|
||||||
|
|||||||
@@ -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 { getSessionUser } from "@/lib/auth"
|
||||||
import { query } from "@/lib/db"
|
import { query } from "@/lib/db"
|
||||||
import { avatarSvgUrl } from "@/lib/avatar"
|
import { avatarSvgUrl } from "@/lib/avatar"
|
||||||
|
import { hasBlockedCodeExtension } from "@/lib/blocked-extensions"
|
||||||
|
|
||||||
export async function GET(
|
export async function GET(
|
||||||
_request: NextRequest,
|
_request: NextRequest,
|
||||||
@@ -103,6 +104,18 @@ export async function POST(
|
|||||||
return NextResponse.json({ error: "Message content is required" }, { status: 400 })
|
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(
|
const result = await query(
|
||||||
`INSERT INTO messages (conversation_id, sender_id, content)
|
`INSERT INTO messages (conversation_id, sender_id, content)
|
||||||
VALUES ($1, $2, $3)
|
VALUES ($1, $2, $3)
|
||||||
@@ -160,3 +173,62 @@ function formatTime(date: Date): string {
|
|||||||
return date.toLocaleDateString([], { month: "short", day: "numeric" }) + " " +
|
return date.toLocaleDateString([], { month: "short", day: "numeric" }) + " " +
|
||||||
date.toLocaleTimeString([], { hour: "2-digit", minute: "2-digit" })
|
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 })
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|||||||
@@ -18,8 +18,8 @@ export async function GET() {
|
|||||||
u.email AS other_user_email,
|
u.email AS other_user_email,
|
||||||
u.phone AS other_user_phone,
|
u.phone AS other_user_phone,
|
||||||
u.avatar_url AS other_user_avatar_url,
|
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 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 ORDER BY created_at DESC LIMIT 1) AS last_message_time,
|
(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
|
(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
|
FROM conversations c
|
||||||
JOIN conversation_participants cp_me ON cp_me.conversation_id = c.id AND cp_me.user_id = $1
|
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,
|
id: row.other_user_id,
|
||||||
name: row.other_user_name,
|
name: row.other_user_name,
|
||||||
email: row.other_user_email,
|
email: row.other_user_email,
|
||||||
phone: row.other_user_phone || "",
|
|
||||||
avatar: avatarSvgUrl(row.other_user_name),
|
avatar: avatarSvgUrl(row.other_user_name),
|
||||||
},
|
},
|
||||||
lastMessage: row.last_message || "",
|
lastMessage: row.last_message || "",
|
||||||
@@ -92,7 +91,7 @@ export async function POST(request: NextRequest) {
|
|||||||
)
|
)
|
||||||
|
|
||||||
const otherUser = await query(
|
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`,
|
FROM users WHERE id = $1`,
|
||||||
[userId],
|
[userId],
|
||||||
)
|
)
|
||||||
@@ -107,7 +106,6 @@ export async function POST(request: NextRequest) {
|
|||||||
id: other.id,
|
id: other.id,
|
||||||
name: other.name,
|
name: other.name,
|
||||||
email: other.email,
|
email: other.email,
|
||||||
phone: other.phone || "",
|
|
||||||
avatar: avatarSvgUrl(other.name),
|
avatar: avatarSvgUrl(other.name),
|
||||||
},
|
},
|
||||||
lastMessage: "",
|
lastMessage: "",
|
||||||
|
|||||||
@@ -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 })
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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 })
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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 })
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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 })
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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 })
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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 })
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -12,7 +12,7 @@ export async function GET() {
|
|||||||
[user.id],
|
[user.id],
|
||||||
)
|
)
|
||||||
|
|
||||||
const websiteTheme = result.rows[0]?.website_theme || "spidey"
|
const websiteTheme = result.rows[0]?.website_theme || "starforce"
|
||||||
return NextResponse.json({ websiteTheme })
|
return NextResponse.json({ websiteTheme })
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
console.error("Website theme GET error:", error)
|
console.error("Website theme GET error:", error)
|
||||||
@@ -26,7 +26,7 @@ export async function PUT(request: NextRequest) {
|
|||||||
if (!user) return NextResponse.json({ error: "Unauthorized" }, { status: 401 })
|
if (!user) return NextResponse.json({ error: "Unauthorized" }, { status: 401 })
|
||||||
|
|
||||||
const body = await request.json()
|
const body = await request.json()
|
||||||
const theme = body.websiteTheme || "spidey"
|
const theme = body.websiteTheme || "starforce"
|
||||||
|
|
||||||
await query(
|
await query(
|
||||||
`UPDATE users SET preferences = preferences || $2::jsonb WHERE id = $1`,
|
`UPDATE users SET preferences = preferences || $2::jsonb WHERE id = $1`,
|
||||||
|
|||||||
@@ -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 })
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -1,6 +1,6 @@
|
|||||||
import { NextRequest, NextResponse } from "next/server"
|
import { NextRequest, NextResponse } from "next/server"
|
||||||
import { query } from "@/lib/db"
|
import { query } from "@/lib/db"
|
||||||
import { hashPassword, getSessionUser, encryptPassword, setSessionContext } from "@/lib/auth"
|
import { hashPassword, getSessionUser } from "@/lib/auth"
|
||||||
import { avatarSvgUrl } from "@/lib/avatar"
|
import { avatarSvgUrl } from "@/lib/avatar"
|
||||||
|
|
||||||
export async function GET(request: NextRequest) {
|
export async function GET(request: NextRequest) {
|
||||||
@@ -63,20 +63,17 @@ export async function POST(request: NextRequest) {
|
|||||||
return NextResponse.json({ error: "Invalid role" }, { status: 400 })
|
return NextResponse.json({ error: "Invalid role" }, { status: 400 })
|
||||||
}
|
}
|
||||||
|
|
||||||
await setSessionContext(sessionUser.id)
|
|
||||||
|
|
||||||
const nameParts = name.trim().split(/\s+/)
|
const nameParts = name.trim().split(/\s+/)
|
||||||
const firstName = nameParts[0]
|
const firstName = nameParts[0]
|
||||||
const lastName = nameParts.slice(1).join(" ") || firstName
|
const lastName = nameParts.slice(1).join(" ") || firstName
|
||||||
const username = email.split("@")[0]
|
const username = email.split("@")[0]
|
||||||
const passwordHash = await hashPassword(password)
|
const passwordHash = await hashPassword(password)
|
||||||
const passwordEncrypted = await encryptPassword(password)
|
|
||||||
|
|
||||||
const result = await query(
|
const result = await query(
|
||||||
`INSERT INTO users (username, email, password_hash, password_encrypted, first_name, last_name, is_active, created_by)
|
`INSERT INTO users (username, email, password_hash, first_name, last_name, is_active, created_by)
|
||||||
VALUES ($1, $2, $3, $4, $5, $6, $7, $8)
|
VALUES ($1, $2, $3, $4, $5, $6, $7)
|
||||||
RETURNING id`,
|
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 = (
|
const roleId = (
|
||||||
|
|||||||
+102
-264
@@ -1,29 +1,94 @@
|
|||||||
@import url('https://fonts.googleapis.com/css2?family=Bangers&display=swap');
|
@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=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 base;
|
||||||
@tailwind components;
|
@tailwind components;
|
||||||
@tailwind utilities;
|
@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 {
|
@keyframes loading {
|
||||||
0% { transform: translateX(-100%); }
|
0% { transform: translateX(-100%); }
|
||||||
100% { transform: translateX(400%); }
|
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 {
|
:root {
|
||||||
--background: 210 40% 98%;
|
--background: 220 14% 84%;
|
||||||
--foreground: 222.2 84% 4.9%;
|
--foreground: 222.2 84% 4.9%;
|
||||||
--card: 0 0% 100%;
|
--card: 220 10% 91%;
|
||||||
--card-foreground: 222.2 84% 4.9%;
|
--card-foreground: 222.2 84% 4.9%;
|
||||||
--popover: 0 0% 100%;
|
--popover: 220 14% 96%;
|
||||||
--popover-foreground: 222.2 84% 4.9%;
|
--popover-foreground: 222.2 84% 4.9%;
|
||||||
--primary: 0 100% 40%;
|
--primary: 120 100% 50%;
|
||||||
--primary-foreground: 210 40% 98%;
|
--primary-foreground: 0 0% 100%;
|
||||||
--secondary: 210 40% 96.1%;
|
--secondary: 220 14% 88%;
|
||||||
--secondary-foreground: 222.2 47.4% 11.2%;
|
--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%;
|
--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%;
|
--accent-foreground: 222.2 47.4% 11.2%;
|
||||||
--destructive: 0 84.2% 60.2%;
|
--destructive: 0 84.2% 60.2%;
|
||||||
--destructive-foreground: 210 40% 98%;
|
--destructive-foreground: 210 40% 98%;
|
||||||
@@ -40,14 +105,20 @@
|
|||||||
--sidebar-ring: 0 76.3% 48%;
|
--sidebar-ring: 0 76.3% 48%;
|
||||||
--radius: 0.5rem;
|
--radius: 0.5rem;
|
||||||
--theme-primary: hsl(var(--primary));
|
--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 {
|
.dark {
|
||||||
--background: 222.2 84% 4.9%;
|
--background: 0 0% 10%;
|
||||||
--foreground: 210 40% 98%;
|
--foreground: 210 40% 98%;
|
||||||
--card: 222.2 84% 4.9%;
|
--card: 0 0% 9%;
|
||||||
--card-foreground: 210 40% 98%;
|
--card-foreground: 210 40% 98%;
|
||||||
--popover: 222.2 84% 4.9%;
|
--popover: 222.2 84% 11%;
|
||||||
--popover-foreground: 210 40% 98%;
|
--popover-foreground: 210 40% 98%;
|
||||||
--primary: 0 100% 53%;
|
--primary: 0 100% 53%;
|
||||||
--primary-foreground: 222.2 47.4% 11.2%;
|
--primary-foreground: 222.2 47.4% 11.2%;
|
||||||
@@ -62,266 +133,15 @@
|
|||||||
--border: 217.2 32.6% 17.5%;
|
--border: 217.2 32.6% 17.5%;
|
||||||
--input: 217.2 32.6% 17.5%;
|
--input: 217.2 32.6% 17.5%;
|
||||||
--ring: 0 100% 53%;
|
--ring: 0 100% 53%;
|
||||||
--sidebar: 222.2 84% 4.9%;
|
--sidebar: 0 0% 11%;
|
||||||
--sidebar-foreground: 210 40% 98%;
|
--sidebar-foreground: 210 40% 98%;
|
||||||
--sidebar-primary: 0 100% 53%;
|
--sidebar-primary: 145 65% 50%;
|
||||||
--sidebar-primary-foreground: 0 0% 100%;
|
--sidebar-primary-foreground: 0 0% 100%;
|
||||||
--sidebar-accent: 217.2 32.6% 17.5%;
|
--sidebar-accent: 217.2 32.6% 17.5%;
|
||||||
--sidebar-accent-foreground: 210 40% 98%;
|
--sidebar-accent-foreground: 210 40% 98%;
|
||||||
--sidebar-border: 217.2 32.6% 17.5%;
|
--sidebar-border: 217.2 32.6% 17.5%;
|
||||||
--sidebar-ring: 0 100% 53%;
|
--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 {
|
.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 */
|
/* Login page custom styles */
|
||||||
.left-panel {
|
.left-panel {
|
||||||
background: #0a0a0f;
|
background: #0a0a0f;
|
||||||
@@ -888,6 +722,10 @@
|
|||||||
display: block;
|
display: block;
|
||||||
margin-bottom: 6px;
|
margin-bottom: 6px;
|
||||||
}
|
}
|
||||||
|
@keyframes float {
|
||||||
|
0%, 100% { transform: translateY(0px); }
|
||||||
|
50% { transform: translateY(-5px); }
|
||||||
|
}
|
||||||
.body-text { color: rgba(232,232,239,0.5); }
|
.body-text { color: rgba(232,232,239,0.5); }
|
||||||
.subheading-text { color: rgba(232,232,239,0.38); }
|
.subheading-text { color: rgba(232,232,239,0.38); }
|
||||||
.checkbox-text { color: rgba(232,232,239,0.38); }
|
.checkbox-text { color: rgba(232,232,239,0.38); }
|
||||||
|
|||||||
@@ -4,6 +4,8 @@ import { ThemeProvider } from "@/providers/theme-provider"
|
|||||||
import { WebsiteThemeProvider } from "@/providers/website-theme-provider"
|
import { WebsiteThemeProvider } from "@/providers/website-theme-provider"
|
||||||
import { Toaster } from "@/components/ui/sonner"
|
import { Toaster } from "@/components/ui/sonner"
|
||||||
import "./globals.css"
|
import "./globals.css"
|
||||||
|
import "../../Web_Backgrounds/spidey-theme.css"
|
||||||
|
import "../../Web_Backgrounds/starforce-theme.css"
|
||||||
|
|
||||||
const inter = Inter({
|
const inter = Inter({
|
||||||
variable: "--font-inter",
|
variable: "--font-inter",
|
||||||
|
|||||||
@@ -13,8 +13,7 @@ const waves = [
|
|||||||
]
|
]
|
||||||
|
|
||||||
export default function LoginPage() {
|
export default function LoginPage() {
|
||||||
const searchParams = useSearchParams()
|
const router = useRouter()
|
||||||
const redirectTo = searchParams.get("redirect") || "/dashboard"
|
|
||||||
const [email, setEmail] = useState("")
|
const [email, setEmail] = useState("")
|
||||||
const [password, setPassword] = useState("")
|
const [password, setPassword] = useState("")
|
||||||
const [showPassword, setShowPassword] = useState(false)
|
const [showPassword, setShowPassword] = useState(false)
|
||||||
@@ -215,7 +214,7 @@ export default function LoginPage() {
|
|||||||
})
|
})
|
||||||
|
|
||||||
if (res.ok) {
|
if (res.ok) {
|
||||||
window.location.href = redirectTo
|
router.push("/dashboard")
|
||||||
} else {
|
} else {
|
||||||
const data = await res.json().catch(() => ({}))
|
const data = await res.json().catch(() => ({}))
|
||||||
setError(data.error || "Invalid email or password.")
|
setError(data.error || "Invalid email or password.")
|
||||||
|
|||||||
+33
-111
@@ -1,7 +1,7 @@
|
|||||||
"use client"
|
"use client"
|
||||||
|
|
||||||
import { useState, useRef, useEffect, Fragment } from "react"
|
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) {
|
function linkifyText(text: string) {
|
||||||
const urlRegex = /(https?:\/\/[^\s<]+[^\s<.,;:!?)\]}>])/
|
const urlRegex = /(https?:\/\/[^\s<]+[^\s<.,;:!?)\]}>])/
|
||||||
@@ -24,26 +24,9 @@ export function AIChat() {
|
|||||||
const [input, setInput] = useState("")
|
const [input, setInput] = useState("")
|
||||||
const [loading, setLoading] = useState(false)
|
const [loading, setLoading] = useState(false)
|
||||||
const [error, setError] = useState("")
|
const [error, setError] = useState("")
|
||||||
const [bootState, setBootState] = useState<"booting" | "ready" | "error">("booting")
|
const [ollamaStatus, setOllamaStatus] = useState<boolean | null>(null)
|
||||||
const messagesEndRef = useRef<HTMLDivElement>(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(() => {
|
useEffect(() => {
|
||||||
fetch("/api/ai/jobs")
|
fetch("/api/ai/jobs")
|
||||||
.then((r) => r.json())
|
.then((r) => r.json())
|
||||||
@@ -73,13 +56,29 @@ export function AIChat() {
|
|||||||
},
|
},
|
||||||
])
|
])
|
||||||
})
|
})
|
||||||
checkServer()
|
|
||||||
}, [])
|
}, [])
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
messagesEndRef.current?.scrollIntoView({ behavior: "smooth" })
|
messagesEndRef.current?.scrollIntoView({ behavior: "smooth" })
|
||||||
}, [messages])
|
}, [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 sendMessage = async () => {
|
||||||
const msg = input.trim()
|
const msg = input.trim()
|
||||||
if (!msg || loading) return
|
if (!msg || loading) return
|
||||||
@@ -97,8 +96,8 @@ export function AIChat() {
|
|||||||
})
|
})
|
||||||
|
|
||||||
if (!res.ok) {
|
if (!res.ok) {
|
||||||
const data = await res.json().catch(() => ({}))
|
const data = await res.json()
|
||||||
throw new Error(data.error || `Error ${res.status}`)
|
throw new Error(data.error || "Failed to get response")
|
||||||
}
|
}
|
||||||
|
|
||||||
const data = await res.json()
|
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 (
|
return (
|
||||||
<div className="flex flex-col h-full">
|
<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">
|
<div className="flex-1 overflow-y-auto p-4 space-y-4 scrollbar-thin">
|
||||||
{messages.map((msg, i) => (
|
{messages.map((msg, i) => (
|
||||||
@@ -233,10 +163,7 @@ export function AIChat() {
|
|||||||
<Bot className="h-4 w-4 text-[#1BB0CE]" />
|
<Bot className="h-4 w-4 text-[#1BB0CE]" />
|
||||||
</div>
|
</div>
|
||||||
<div className="max-w-[75%] rounded-lg px-4 py-2.5 bg-[#1a1a24] border border-[#2a2a35]">
|
<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">
|
<Loader2 className="h-4 w-4 animate-spin text-[#1BB0CE]" />
|
||||||
<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>
|
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
@@ -265,15 +192,10 @@ export function AIChat() {
|
|||||||
disabled={loading || !input.trim()}
|
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"
|
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 ? (
|
{loading ? <Loader2 className="h-4 w-4 animate-spin" /> : <Send className="h-4 w-4" />}
|
||||||
<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" />}
|
|
||||||
</button>
|
</button>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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"
|
className="h-full"
|
||||||
>
|
>
|
||||||
<Card className="h-full relative overflow-hidden">
|
<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>
|
<CardHeader>
|
||||||
<CardTitle>Lead Status</CardTitle>
|
<CardTitle>Lead Status</CardTitle>
|
||||||
</CardHeader>
|
</CardHeader>
|
||||||
|
|||||||
@@ -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() {
|
export function StatCardSkeleton() {
|
||||||
return (
|
return (
|
||||||
<div className="col-span-full flex flex-col items-center justify-center py-20">
|
<div className="col-span-full flex flex-col items-center justify-center py-20">
|
||||||
<p className="text-[#CC0000] dark:text-[#FF1111] text-5xl font-['Bangers',cursive] animate-pulse">
|
<p className="text-[#444444] dark:text-[#AAAAAA] text-sm">
|
||||||
THWIP!
|
|
||||||
</p>
|
|
||||||
<p className="text-[#444444] dark:text-[#AAAAAA] text-sm mt-3">
|
|
||||||
Loading your data...
|
Loading your data...
|
||||||
</p>
|
</p>
|
||||||
<div className="w-48 h-1 rounded-full overflow-hidden bg-[#E0E0E0] dark:bg-[#222222] mt-4">
|
<div className="w-48 h-1 rounded-full overflow-hidden bg-[#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>
|
||||||
</div>
|
</div>
|
||||||
)
|
)
|
||||||
|
|||||||
@@ -118,37 +118,6 @@ export function StatCard({ title, value, icon: Icon, description, index = 0, tre
|
|||||||
transition={{ duration: 0.3, delay: index * 0.05 }}
|
transition={{ duration: 0.3, delay: index * 0.05 }}
|
||||||
className="relative"
|
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">
|
<Card className="group h-full hover:shadow-xl transition-all duration-200 relative z-10 overflow-hidden">
|
||||||
{/* Red/Blue gradient top strip */}
|
{/* Red/Blue gradient top strip */}
|
||||||
<div className={`h-1 w-full bg-gradient-to-r ${stripColor} ${stripGlow} shadow-sm`} />
|
<div className={`h-1 w-full bg-gradient-to-r ${stripColor} ${stripGlow} shadow-sm`} />
|
||||||
|
|||||||
@@ -35,47 +35,47 @@ export function AppShell({ children }: AppShellProps) {
|
|||||||
return (
|
return (
|
||||||
<div className="min-h-screen bg-[#F8F8F8] dark:bg-[#0A0A0A] relative overflow-hidden"
|
<div className="min-h-screen bg-[#F8F8F8] dark:bg-[#0A0A0A] relative overflow-hidden"
|
||||||
style={{
|
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",
|
backgroundSize: "28px 28px, 14px 14px",
|
||||||
backgroundPosition: "0 0, 7px 7px",
|
backgroundPosition: "0 0, 7px 7px",
|
||||||
}}
|
}}
|
||||||
>
|
>
|
||||||
{/* Spider-Man top gradient bar */}
|
{/* 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 */}
|
{/* Corner spider webs */}
|
||||||
<div className="hidden lg:block fixed top-0 left-0 pointer-events-none z-0 opacity-[0.07] dark:opacity-[0.12]">
|
<div className="hidden lg:block fixed top-0 left-0 pointer-events-none z-0 opacity-[0.07] dark:opacity-[0.12]">
|
||||||
<svg width="180" height="180" viewBox="0 0 180 180" fill="none" xmlns="http://www.w3.org/2000/svg">
|
<svg width="180" height="180" viewBox="0 0 180 180" fill="none" xmlns="http://www.w3.org/2000/svg">
|
||||||
<line x1="0" y1="0" x2="180" y2="0" stroke="#e62020" strokeWidth="0.8"/>
|
<line x1="0" y1="0" x2="180" y2="0" stroke="#CC0000" strokeWidth="0.8"/>
|
||||||
<line x1="0" y1="0" x2="180" y2="60" stroke="#e62020" 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="#e62020" 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="#e62020" 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="#e62020" 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="#e62020" 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="#e62020" 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="#e62020" strokeWidth="0.8" fill="none"/>
|
<path d="M0,30 Q30,30 30,0" stroke="#CC0000" strokeWidth="0.8" fill="none"/>
|
||||||
<path d="M0,60 Q60,60 60,0" stroke="#e62020" 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="#e62020" 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="#e62020" 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="#e62020" 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="#e62020" strokeWidth="0.8" fill="none"/>
|
<path d="M0,180 Q180,180 180,0" stroke="#CC0000" strokeWidth="0.8" fill="none"/>
|
||||||
</svg>
|
</svg>
|
||||||
</div>
|
</div>
|
||||||
<div className="hidden lg:block fixed top-0 right-0 pointer-events-none z-0 opacity-[0.07] dark:opacity-[0.12]">
|
<div className="hidden lg:block fixed top-0 right-0 pointer-events-none z-0 opacity-[0.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)" }}>
|
<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="0" stroke="#CC0000" 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="60" stroke="#CC0000" 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="120" stroke="#CC0000" strokeWidth="0.8"/>
|
||||||
<line x1="0" y1="0" x2="180" y2="180" stroke="#e62020" 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="#e62020" 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="#e62020" 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="#e62020" 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="#e62020" strokeWidth="0.8" fill="none"/>
|
<path d="M0,30 Q30,30 30,0" stroke="#CC0000" strokeWidth="0.8" fill="none"/>
|
||||||
<path d="M0,60 Q60,60 60,0" stroke="#e62020" 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="#e62020" 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="#e62020" 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="#e62020" 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="#e62020" strokeWidth="0.8" fill="none"/>
|
<path d="M0,180 Q180,180 180,0" stroke="#CC0000" strokeWidth="0.8" fill="none"/>
|
||||||
</svg>
|
</svg>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
|||||||
@@ -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>
|
||||||
|
)
|
||||||
|
}
|
||||||
@@ -20,6 +20,7 @@ import {
|
|||||||
MessageSquare,
|
MessageSquare,
|
||||||
Bot,
|
Bot,
|
||||||
Facebook,
|
Facebook,
|
||||||
|
Calendar,
|
||||||
} from "lucide-react"
|
} from "lucide-react"
|
||||||
|
|
||||||
import { useUser } from "@/providers/user-provider"
|
import { useUser } from "@/providers/user-provider"
|
||||||
@@ -29,6 +30,7 @@ import { FacebookAccountsDialog } from "@/components/settings/facebook-accounts-
|
|||||||
const navItems = [
|
const navItems = [
|
||||||
{ href: "/dashboard", label: "Dashboard", icon: LayoutDashboard },
|
{ href: "/dashboard", label: "Dashboard", icon: LayoutDashboard },
|
||||||
{ href: "/leads", label: "Leads", icon: Users },
|
{ href: "/leads", label: "Leads", icon: Users },
|
||||||
|
{ href: "/calendar", label: "Calendar", icon: Calendar },
|
||||||
{ href: "/chats", label: "Chats", icon: MessageSquare },
|
{ href: "/chats", label: "Chats", icon: MessageSquare },
|
||||||
{ href: "/ai-assistant", label: "AI Assistant", icon: Bot, roles: ["sales", "admin", "super_admin"] },
|
{ href: "/ai-assistant", label: "AI Assistant", icon: Bot, roles: ["sales", "admin", "super_admin"] },
|
||||||
{ href: "/users", label: "Users", icon: Building2 },
|
{ href: "/users", label: "Users", icon: Building2 },
|
||||||
|
|||||||
@@ -32,6 +32,9 @@ import {
|
|||||||
CheckCheck,
|
CheckCheck,
|
||||||
Trash2,
|
Trash2,
|
||||||
} from "lucide-react";
|
} from "lucide-react";
|
||||||
|
import { CrmIcon } from "./crm-icon";
|
||||||
|
import { CaptainAmericaShield } from "@/components/shared/captain-america-shield";
|
||||||
|
import { useWebsiteTheme } from "@/providers/website-theme-provider";
|
||||||
|
|
||||||
interface TopbarProps {
|
interface TopbarProps {
|
||||||
onMenuClick: () => void;
|
onMenuClick: () => void;
|
||||||
@@ -40,6 +43,7 @@ interface TopbarProps {
|
|||||||
export function Topbar({ onMenuClick }: TopbarProps) {
|
export function Topbar({ onMenuClick }: TopbarProps) {
|
||||||
const router = useRouter();
|
const router = useRouter();
|
||||||
const { theme, setTheme } = useTheme();
|
const { theme, setTheme } = useTheme();
|
||||||
|
const { websiteTheme } = useWebsiteTheme();
|
||||||
const { user, logout } = useUser();
|
const { user, logout } = useUser();
|
||||||
const { notifications, unreadCount, markAsRead, markAllAsRead, dismiss } =
|
const { notifications, unreadCount, markAsRead, markAllAsRead, dismiss } =
|
||||||
useNotifications();
|
useNotifications();
|
||||||
@@ -70,17 +74,10 @@ export function Topbar({ onMenuClick }: TopbarProps) {
|
|||||||
.toUpperCase();
|
.toUpperCase();
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<header className="sticky top-0 z-20 flex h-16 items-center gap-4 border-b bg-white dark:bg-[#141414] px-4 lg:px-6 relative overflow-hidden">
|
<header className="sticky top-0 z-20 flex h-16 items-center gap-4 border-b bg-card dark:bg-[#141414] px-4 lg:px-6 relative overflow-hidden">
|
||||||
{/* 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]" />
|
|
||||||
|
|
||||||
{/* Logo */}
|
{/* Logo */}
|
||||||
<div className="flex items-center gap-2 mr-2 shrink-0">
|
<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">
|
{websiteTheme === "spidey" ? <CaptainAmericaShield /> : <CrmIcon />}
|
||||||
<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>
|
|
||||||
<span className="text-[#CC0000] dark:text-[#FF1111] font-bold text-sm tracking-wide">
|
<span className="text-[#CC0000] dark:text-[#FF1111] font-bold text-sm tracking-wide">
|
||||||
CRM
|
CRM
|
||||||
</span>
|
</span>
|
||||||
@@ -116,17 +113,6 @@ export function Topbar({ onMenuClick }: TopbarProps) {
|
|||||||
<Search className="h-5 w-5" />
|
<Search className="h-5 w-5" />
|
||||||
</Button>
|
</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 */}
|
{/* Theme toggle */}
|
||||||
<Button
|
<Button
|
||||||
variant="ghost"
|
variant="ghost"
|
||||||
@@ -145,6 +131,17 @@ export function Topbar({ onMenuClick }: TopbarProps) {
|
|||||||
)}
|
)}
|
||||||
</Button>
|
</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 */}
|
{/* Notifications */}
|
||||||
<DropdownMenu>
|
<DropdownMenu>
|
||||||
<DropdownMenuTrigger asChild>
|
<DropdownMenuTrigger asChild>
|
||||||
|
|||||||
@@ -6,8 +6,8 @@ import { Card, CardContent, CardDescription, CardHeader, CardTitle } from "@/com
|
|||||||
import { Label } from "@/components/ui/label"
|
import { Label } from "@/components/ui/label"
|
||||||
import { RadioGroup, RadioGroupItem } from "@/components/ui/radio-group"
|
import { RadioGroup, RadioGroupItem } from "@/components/ui/radio-group"
|
||||||
import { cn } from "@/lib/utils"
|
import { cn } from "@/lib/utils"
|
||||||
|
import { Sun, Moon, Monitor, Sparkles, Shield } from "lucide-react"
|
||||||
import { useWebsiteTheme } from "@/providers/website-theme-provider"
|
import { useWebsiteTheme } from "@/providers/website-theme-provider"
|
||||||
import { Sun, Moon, Monitor, Check } from "lucide-react"
|
|
||||||
|
|
||||||
const COLOR_THEME_KEY = "crm-color-theme"
|
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" },
|
{ 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 {
|
function getStoredColorTheme(): string {
|
||||||
if (typeof window === "undefined") return "default"
|
if (typeof window === "undefined") return "default"
|
||||||
return localStorage.getItem(COLOR_THEME_KEY) || "default"
|
return localStorage.getItem(COLOR_THEME_KEY) || "default"
|
||||||
@@ -45,14 +50,6 @@ function applyColorTheme(theme: string) {
|
|||||||
localStorage.setItem(COLOR_THEME_KEY, theme)
|
localStorage.setItem(COLOR_THEME_KEY, theme)
|
||||||
}
|
}
|
||||||
|
|
||||||
const websiteThemes = [
|
|
||||||
{
|
|
||||||
id: "spidey",
|
|
||||||
name: "Spidey",
|
|
||||||
description: "Current website theme",
|
|
||||||
},
|
|
||||||
]
|
|
||||||
|
|
||||||
export function ThemeSettings() {
|
export function ThemeSettings() {
|
||||||
const { theme, setTheme } = useTheme()
|
const { theme, setTheme } = useTheme()
|
||||||
const { websiteTheme, setWebsiteTheme } = useWebsiteTheme()
|
const { websiteTheme, setWebsiteTheme } = useWebsiteTheme()
|
||||||
@@ -132,66 +129,31 @@ export function ThemeSettings() {
|
|||||||
|
|
||||||
<Card>
|
<Card>
|
||||||
<CardHeader>
|
<CardHeader>
|
||||||
<CardTitle>Website Themes</CardTitle>
|
<CardTitle>Color Theme for Background</CardTitle>
|
||||||
<CardDescription>
|
<CardDescription>
|
||||||
Select your website's visual theme.
|
Choose the website background theme.
|
||||||
</CardDescription>
|
</CardDescription>
|
||||||
</CardHeader>
|
</CardHeader>
|
||||||
<CardContent>
|
<CardContent>
|
||||||
<div className="grid grid-cols-2 gap-4 sm:grid-cols-3 md:grid-cols-4">
|
<RadioGroup value={websiteTheme} onValueChange={setWebsiteTheme} className="grid grid-cols-2 gap-4">
|
||||||
{websiteThemes.map((wt) => {
|
{backgroundOptions.map(({ value, label, icon: Icon, color, ring, desc }) => (
|
||||||
const isActive = websiteTheme === wt.id
|
<div key={value}>
|
||||||
return (
|
<RadioGroupItem value={value} id={`bg-${value}`} className="peer sr-only" />
|
||||||
<button
|
<Label
|
||||||
key={wt.id}
|
htmlFor={`bg-${value}`}
|
||||||
type="button"
|
|
||||||
onClick={() => setWebsiteTheme(wt.id)}
|
|
||||||
className={cn(
|
className={cn(
|
||||||
"group relative flex flex-col items-start gap-3 rounded-xl border-2 p-4 text-left transition-all",
|
"flex flex-col items-center gap-3 rounded-lg border-2 p-4 hover:bg-accent cursor-pointer transition-all",
|
||||||
"hover:bg-accent cursor-pointer",
|
"peer-data-[state=checked]:border-primary peer-data-[state=checked]:bg-primary/5"
|
||||||
isActive
|
|
||||||
? "border-primary bg-primary/5 shadow-sm"
|
|
||||||
: "border-border hover:border-muted-foreground/30"
|
|
||||||
)}
|
)}
|
||||||
>
|
>
|
||||||
{isActive && (
|
<Icon className="h-6 w-6" />
|
||||||
<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">
|
<div className={cn("h-8 w-8 rounded-full", color, ring, "ring-2 ring-offset-2 ring-offset-background")} />
|
||||||
<Check className="h-3 w-3" />
|
<span className="text-sm font-medium">{label}</span>
|
||||||
</span>
|
<span className="text-xs text-muted-foreground">{desc}</span>
|
||||||
)}
|
</Label>
|
||||||
|
</div>
|
||||||
<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">
|
</RadioGroup>
|
||||||
<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>
|
|
||||||
</CardContent>
|
</CardContent>
|
||||||
</Card>
|
</Card>
|
||||||
</div>
|
</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
@@ -1,76 +1,41 @@
|
|||||||
import http from "http"
|
|
||||||
|
|
||||||
const AI_SERVICE = process.env.AI_SERVICE_URL || "http://localhost:3001"
|
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) {
|
export async function chatWithAI(message: string, jwtToken: string) {
|
||||||
const body = JSON.stringify({ message })
|
const res = await fetch(`${AI_SERVICE}/ai/chat`, {
|
||||||
const { hostname, port, path } = parseUrl(`${AI_SERVICE}/ai/chat`)
|
method: "POST",
|
||||||
|
headers: { "Content-Type": "application/json", "Authorization": `Bearer ${jwtToken}` },
|
||||||
const text = await new Promise<string>((resolve, reject) => {
|
body: JSON.stringify({ message }),
|
||||||
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 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 || ""
|
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() {
|
export async function fetchJobs() {
|
||||||
const { hostname, port, path } = parseUrl(`${AI_SERVICE}/ai/jobs`)
|
|
||||||
try {
|
try {
|
||||||
const text = await new Promise<string>((resolve, reject) => {
|
const res = await fetch(`${AI_SERVICE}/ai/jobs`)
|
||||||
const req = http.get({ hostname, port, path, timeout: 5000 }, (res) => {
|
if (!res.ok) return []
|
||||||
let data = ""
|
const data = await res.json()
|
||||||
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)
|
|
||||||
return data.jobs || []
|
return data.jobs || []
|
||||||
} catch {
|
} catch {
|
||||||
console.warn("Failed to fetch AI jobs")
|
console.warn("Failed to fetch AI jobs")
|
||||||
return []
|
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
@@ -17,7 +17,6 @@ export interface SessionUser {
|
|||||||
id: string;
|
id: string;
|
||||||
username: string;
|
username: string;
|
||||||
email: string;
|
email: string;
|
||||||
phone: string | null;
|
|
||||||
firstName: string;
|
firstName: string;
|
||||||
lastName: string;
|
lastName: string;
|
||||||
role: string;
|
role: string;
|
||||||
@@ -86,7 +85,7 @@ export async function getUserByUsername(username: string) {
|
|||||||
|
|
||||||
export async function getUserById(id: string) {
|
export async function getUserById(id: string) {
|
||||||
const result = await query(
|
const result = await query(
|
||||||
` SELECT u.id, u.username, u.email, u.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,
|
u.is_active, u.avatar_url,
|
||||||
r.name AS role_name
|
r.name AS role_name
|
||||||
FROM users u
|
FROM users u
|
||||||
@@ -186,7 +185,6 @@ export function mapDbUserToSessionUser(
|
|||||||
id: dbUser.id as string,
|
id: dbUser.id as string,
|
||||||
username: dbUser.username as string,
|
username: dbUser.username as string,
|
||||||
email: dbUser.email as string,
|
email: dbUser.email as string,
|
||||||
phone: (dbUser.phone as string) || null,
|
|
||||||
firstName: dbUser.first_name as string,
|
firstName: dbUser.first_name as string,
|
||||||
lastName: dbUser.last_name as string,
|
lastName: dbUser.last_name as string,
|
||||||
role: roleMapping[roleName] || roleName.toLowerCase(),
|
role: roleMapping[roleName] || roleName.toLowerCase(),
|
||||||
@@ -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) {
|
export async function createSession(userId: string, role: string) {
|
||||||
const token = await signToken({ userId, role });
|
const token = await signToken({ userId, role });
|
||||||
|
|
||||||
@@ -224,6 +201,7 @@ export async function createSession(userId: string, role: string) {
|
|||||||
secure: process.env.NODE_ENV === "production",
|
secure: process.env.NODE_ENV === "production",
|
||||||
sameSite: "strict",
|
sameSite: "strict",
|
||||||
path: "/",
|
path: "/",
|
||||||
|
maxAge: 60 * 60 * 24, // 24 hours
|
||||||
});
|
});
|
||||||
|
|
||||||
return token;
|
return token;
|
||||||
|
|||||||
@@ -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
@@ -16,9 +16,12 @@ pool.on("error", (err) => {
|
|||||||
console.error("Unexpected database pool 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()
|
const client = await pool.connect()
|
||||||
try {
|
try {
|
||||||
|
if (userId) {
|
||||||
|
await client.query("SELECT set_config('app.current_user_id', $1, true)", [userId])
|
||||||
|
}
|
||||||
const result = await client.query(text, params)
|
const result = await client.query(text, params)
|
||||||
return result
|
return result
|
||||||
} finally {
|
} finally {
|
||||||
|
|||||||
@@ -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))
|
||||||
|
}
|
||||||
@@ -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")
|
||||||
|
}
|
||||||
@@ -10,7 +10,6 @@ const JWT_SECRET = new TextEncoder().encode(RAW_SECRET)
|
|||||||
|
|
||||||
const publicRoutes = [
|
const publicRoutes = [
|
||||||
"/login",
|
"/login",
|
||||||
"/join",
|
|
||||||
"/api/auth/login",
|
"/api/auth/login",
|
||||||
"/api/auth/logout",
|
"/api/auth/logout",
|
||||||
"/_next/static",
|
"/_next/static",
|
||||||
|
|||||||
@@ -30,7 +30,6 @@ export function UserProvider({ children }: { children: ReactNode }) {
|
|||||||
id: u.id,
|
id: u.id,
|
||||||
name: `${u.firstName} ${u.lastName}`,
|
name: `${u.firstName} ${u.lastName}`,
|
||||||
email: u.email,
|
email: u.email,
|
||||||
phone: u.phone || "",
|
|
||||||
role: u.role,
|
role: u.role,
|
||||||
active: true,
|
active: true,
|
||||||
avatar: u.avatar,
|
avatar: u.avatar,
|
||||||
|
|||||||
@@ -5,7 +5,8 @@ import { createContext, useContext, useState, useEffect, ReactNode, useCallback
|
|||||||
const WEBSITE_THEME_KEY = "crm-website-theme"
|
const WEBSITE_THEME_KEY = "crm-website-theme"
|
||||||
|
|
||||||
const themeClasses: Record<string, string> = {
|
const themeClasses: Record<string, string> = {
|
||||||
spidey: "",
|
spidey: "theme-spidey",
|
||||||
|
starforce: "theme-starforce",
|
||||||
}
|
}
|
||||||
|
|
||||||
interface WebsiteThemeContextValue {
|
interface WebsiteThemeContextValue {
|
||||||
@@ -38,7 +39,7 @@ function storeTheme(theme: string) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
export function WebsiteThemeProvider({ children }: { children: ReactNode }) {
|
export function WebsiteThemeProvider({ children }: { children: ReactNode }) {
|
||||||
const [websiteTheme, setWebsiteThemeState] = useState<string>("spidey")
|
const [websiteTheme, setWebsiteThemeState] = useState<string>("starforce")
|
||||||
const [loading, setLoading] = useState(true)
|
const [loading, setLoading] = useState(true)
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
@@ -52,13 +53,13 @@ export function WebsiteThemeProvider({ children }: { children: ReactNode }) {
|
|||||||
fetch("/api/settings/website-theme")
|
fetch("/api/settings/website-theme")
|
||||||
.then((res) => (res.ok ? res.json() : null))
|
.then((res) => (res.ok ? res.json() : null))
|
||||||
.then((data) => {
|
.then((data) => {
|
||||||
const theme = data?.websiteTheme || "spidey"
|
const theme = data?.websiteTheme || "starforce"
|
||||||
setWebsiteThemeState(theme)
|
setWebsiteThemeState(theme)
|
||||||
storeTheme(theme)
|
storeTheme(theme)
|
||||||
applyWebsiteTheme(theme)
|
applyWebsiteTheme(theme)
|
||||||
})
|
})
|
||||||
.catch(() => {
|
.catch(() => {
|
||||||
applyWebsiteTheme("spidey")
|
applyWebsiteTheme("starforce")
|
||||||
})
|
})
|
||||||
.finally(() => setLoading(false))
|
.finally(() => setLoading(false))
|
||||||
}, [])
|
}, [])
|
||||||
|
|||||||
+26
-3
@@ -4,13 +4,12 @@ export type LeadStatus =
|
|||||||
| "pending"
|
| "pending"
|
||||||
| "closed"
|
| "closed"
|
||||||
| "ignored";
|
| "ignored";
|
||||||
export type UserRole = "super_admin" | "admin" | "sales" | "dev";
|
export type UserRole = "super_admin" | "admin" | "sales";
|
||||||
|
|
||||||
export interface User {
|
export interface User {
|
||||||
id: string;
|
id: string;
|
||||||
name: string;
|
name: string;
|
||||||
email: string;
|
email: string;
|
||||||
phone?: string;
|
|
||||||
role: UserRole;
|
role: UserRole;
|
||||||
active: boolean;
|
active: boolean;
|
||||||
avatar: string;
|
avatar: string;
|
||||||
@@ -78,7 +77,8 @@ export type NotificationType =
|
|||||||
| "lead_status_changed"
|
| "lead_status_changed"
|
||||||
| "lead_assigned"
|
| "lead_assigned"
|
||||||
| "chat_message"
|
| "chat_message"
|
||||||
| "note_added";
|
| "note_added"
|
||||||
|
| "event_scheduled";
|
||||||
|
|
||||||
export interface Notification {
|
export interface Notification {
|
||||||
id: string;
|
id: string;
|
||||||
@@ -108,3 +108,26 @@ export interface Conversation {
|
|||||||
unread: number;
|
unread: number;
|
||||||
messages: ChatMessage[];
|
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;
|
||||||
|
}
|
||||||
|
|||||||
Reference in New Issue
Block a user