diff --git a/src/app/api/ai/giphy/route.ts b/src/app/api/ai/giphy/route.ts new file mode 100644 index 0000000..be69b06 --- /dev/null +++ b/src/app/api/ai/giphy/route.ts @@ -0,0 +1,57 @@ +import { NextRequest, NextResponse } from "next/server" +import { getSessionUser } from "@/lib/auth" + +const GIPHY_API_KEY = process.env.GIPHY_API_KEY +const GIPHY_BASE = "https://api.giphy.com/v1/gifs" + +export async function GET(request: NextRequest) { + try { + const user = await getSessionUser() + if (!user) { + return NextResponse.json({ error: "Unauthorized" }, { status: 401 }) + } + + if (!GIPHY_API_KEY) { + return NextResponse.json({ error: "GIPHY API key not configured" }, { status: 500 }) + } + + const { searchParams } = new URL(request.url) + const type = searchParams.get("type") || "trending" + const query = searchParams.get("q") || "" + const offset = parseInt(searchParams.get("offset") || "0", 10) + const limit = Math.min(parseInt(searchParams.get("limit") || "20", 10), 50) + + let url: string + if (type === "search" && query) { + url = `${GIPHY_BASE}/search?api_key=${GIPHY_API_KEY}&q=${encodeURIComponent(query)}&limit=${limit}&offset=${offset}&rating=g` + } else { + url = `${GIPHY_BASE}/trending?api_key=${GIPHY_API_KEY}&limit=${limit}&offset=${offset}&rating=g` + } + + const res = await fetch(url) + if (!res.ok) { + const errBody = await res.text() + console.error("GIPHY API error:", res.status, errBody) + return NextResponse.json({ error: `GIPHY API error (${res.status}): ${errBody}` }, { status: res.status }) + } + + const data = await res.json() + const gifs = (data.data || []).map((gif: any) => ({ + id: gif.id, + title: gif.title, + url: gif.images?.original?.url || "", + previewUrl: gif.images?.fixed_width?.url || "", + previewHeight: gif.images?.fixed_width?.height || 150, + width: gif.images?.original?.width || 0, + height: gif.images?.original?.height || 0, + })) + + return NextResponse.json({ + gifs, + pagination: data.pagination || { total_count: 0, count: gifs.length, offset }, + }) + } catch (error: any) { + console.error("GIPHY proxy error:", error) + return NextResponse.json({ error: error.message || "Internal server error" }, { status: 500 }) + } +} diff --git a/src/app/api/gifs/route.ts b/src/app/api/gifs/route.ts index 8ecf18e..1b7c5b6 100644 --- a/src/app/api/gifs/route.ts +++ b/src/app/api/gifs/route.ts @@ -1,8 +1,8 @@ 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" +const GIPHY_API_KEY = process.env.GIPHY_API_KEY +const GIPHY_BASE = "https://api.giphy.com/v1/gifs" export async function GET(request: NextRequest) { try { @@ -12,41 +12,78 @@ export async function GET(request: NextRequest) { 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") || "" + const pos = parseInt(searchParams.get("pos") || "0", 10) || 0 - if (!TENOR_API_KEY) { + if (!GIPHY_API_KEY) { + console.error("Missing GIPHY_API_KEY environment variable") return NextResponse.json({ results: [], 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` + let data: any - const url = pos ? `${endpoint}&pos=${pos}` : endpoint + if (q) { + const url = `${GIPHY_BASE}/search?api_key=${GIPHY_API_KEY}&q=${encodeURIComponent(q)}&limit=${limit}&offset=${pos}&rating=g` + const res = await fetch(url, { signal: AbortSignal.timeout(8000) }) + if (!res.ok) { + const errBody = await res.text() + console.error("GIPHY API search error:", res.status, errBody) + return NextResponse.json({ results: [], error: `GIPHY error: ${errBody}` }, { status: 502 }) + } + data = await res.json() + } else { + const trendingUrl = `${GIPHY_BASE}/trending?api_key=${GIPHY_API_KEY}&limit=${limit}&offset=${pos}&rating=g` + const trendingRes = await fetch(trendingUrl, { signal: AbortSignal.timeout(8000) }) - const res = await fetch(url, { signal: AbortSignal.timeout(8000) }) - if (!res.ok) { - return NextResponse.json({ results: [], error: "Tenor API error" }, { status: 502 }) + if (trendingRes.ok) { + data = await trendingRes.json() + if (!data.data?.length && pos === 0) { + console.log("Trending returned no results, falling back to default search") + const fallbackUrl = `${GIPHY_BASE}/search?api_key=${GIPHY_API_KEY}&q=funny&limit=${limit}&offset=${pos}&rating=g` + const fallbackRes = await fetch(fallbackUrl, { signal: AbortSignal.timeout(8000) }) + if (!fallbackRes.ok) { + const errBody = await fallbackRes.text() + console.error("GIPHY fallback search error:", fallbackRes.status, errBody) + return NextResponse.json({ results: [], error: `GIPHY error: ${errBody}` }, { status: 502 }) + } + data = await fallbackRes.json() + } + } else { + const errBody = await trendingRes.text() + console.error("GIPHY trending error:", trendingRes.status, errBody) + console.log("Trending failed, falling back to default search") + const fallbackUrl = `${GIPHY_BASE}/search?api_key=${GIPHY_API_KEY}&q=funny&limit=${limit}&offset=${pos}&rating=g` + const fallbackRes = await fetch(fallbackUrl, { signal: AbortSignal.timeout(8000) }) + if (!fallbackRes.ok) { + const fallbackErr = await fallbackRes.text() + console.error("GIPHY fallback search error:", fallbackRes.status, fallbackErr) + return NextResponse.json({ results: [], error: `GIPHY error: ${fallbackErr}` }, { status: 502 }) + } + data = await fallbackRes.json() + } } - - 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 || {} + const results = (data.data || []).map((item: any) => { + const dims = item.images?.original return { id: item.id, title: item.title || "", - url: media.url || "", - previewUrl: preview.url || "", - width: media.dims?.[0] || 200, - height: media.dims?.[1] || 200, + url: dims?.url || "", + previewUrl: item.images?.fixed_width?.url || "", + width: dims?.width ? parseInt(dims.width, 10) : 200, + height: dims?.height ? parseInt(dims.height, 10) : 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 }) + const nextOffset = pos + results.length + const total = data.pagination?.total_count || 0 + const hasMore = total ? nextOffset < total : results.length === limit + const nextPos = hasMore ? String(nextOffset) : "" + + return NextResponse.json({ results, next: nextPos }) + } catch (error: any) { + console.error("GIF proxy error:", error) + const message = error?.message === "Failed to fetch" + ? "Could not reach the GIF service." + : "Failed to fetch GIFs." + return NextResponse.json({ results: [], error: message }, { status: 500 }) } } diff --git a/src/components/ai/ai-chat.tsx b/src/components/ai/ai-chat.tsx index 9c337b4..81b3381 100644 --- a/src/components/ai/ai-chat.tsx +++ b/src/components/ai/ai-chat.tsx @@ -2,6 +2,7 @@ import { useState, useRef, useEffect, Fragment, forwardRef, useImperativeHandle, useCallback } from "react" import { Bot, Terminal } from "lucide-react" +import { GifPicker } from "./gif-picker" function linkifyText(text: string) { const urlRegex = /(https?:\/\/[^\s<]+[^\s<.,;:!?)\]}>])/ @@ -35,27 +36,17 @@ function formatContent(text: string) { interface ChatMessage { role: "user" | "assistant" content: string + gif?: { + url: string + previewUrl: string + title: string + } } interface AIChatProps { onMessageSent?: (msg: string) => void } -const quickActions = [ - { icon: "✉️", iconBg: "bg-primary/10", iconColor: "text-primary", title: "Cold Email Template", desc: "Generate targeted outreach emails", prompt: "Write a cold email template for a Software Developer" }, - { icon: "🛡️", iconBg: "bg-[#3b82f6]/10", iconColor: "text-[#3b82f6]", title: "Handle Objections", desc: "Get scripts for common objections", prompt: "Give me objection handling scripts for sales" }, - { icon: "🎯", iconBg: "bg-[#8b5cf6]/10", iconColor: "text-[#8b5cf6]", title: "Target Industry", desc: "Find leads in specific industries", prompt: "How do I target leads in the tech industry" }, - { icon: "📋", iconBg: "bg-[#22c55e]/10", iconColor: "text-[#22c55e]", title: "Build Lead List", desc: "Strategies to grow your pipeline", prompt: "Help me build a lead list strategy" }, - { icon: "📞", iconBg: "bg-primary/10", iconColor: "text-primary", title: "Call Scripts", desc: "Proven phone sales scripts", prompt: "Give me a cold call script for outreach" }, - { icon: "📊", iconBg: "bg-[#ec4899]/10", iconColor: "text-[#ec4899]", title: "Sales Strategy", desc: "Industry specific sales tactics", prompt: "What sales strategies work best per industry" }, -] - -const commandPills = [ - { icon: "📋", label: "Lists", prompt: "lists" }, - { icon: "👥", label: "Leads", prompt: "leads" }, - { icon: "💡", label: "Tips", prompt: "Give me some sales tips" }, - { icon: "✉️", label: "Templates", prompt: "Show me email templates" }, -] export const AIChat = forwardRef<{ fillInput: (text: string) => void }, AIChatProps>(({ onMessageSent }, ref) => { const [messages, setMessages] = useState([]) @@ -63,9 +54,12 @@ export const AIChat = forwardRef<{ fillInput: (text: string) => void }, AIChatPr const [loading, setLoading] = useState(false) const [error, setError] = useState("") const [bootState, setBootState] = useState<"booting" | "ready" | "error">("booting") + const [showGifPicker, setShowGifPicker] = useState(false) const messagesEndRef = useRef(null) const textareaRef = useRef(null) + const containerRef = useRef(null) const hasUserMessage = messages.some(m => m.role === "user") + const loadedFromStorage = useRef(false) useImperativeHandle(ref, () => ({ fillInput(text: string) { @@ -74,6 +68,44 @@ export const AIChat = forwardRef<{ fillInput: (text: string) => void }, AIChatPr }, }), []) + const handleGifSelect = useCallback((gif: { url: string; previewUrl: string; title: string }) => { + setMessages((prev) => [...prev, { role: "user", content: gif.title || "Sent a GIF", gif }]) + setShowGifPicker(false) + }, []) + + useEffect(() => { + if (showGifPicker) { + const handler = (e: MouseEvent) => { + if (containerRef.current && !containerRef.current.contains(e.target as Node)) { + setShowGifPicker(false) + } + } + document.addEventListener("mousedown", handler) + return () => document.removeEventListener("mousedown", handler) + } + }, [showGifPicker]) + + useEffect(() => { + const saved = localStorage.getItem("ai-chat-messages") + if (saved) { + try { + const parsed = JSON.parse(saved) + if (Array.isArray(parsed) && parsed.length > 0) { + setMessages(parsed) + loadedFromStorage.current = true + } + } catch {} + } else { + loadedFromStorage.current = false + } + }, []) + + useEffect(() => { + if (messages.length > 0) { + localStorage.setItem("ai-chat-messages", JSON.stringify(messages)) + } + }, [messages]) + const checkServer = useCallback(async () => { try { const res = await fetch("/api/ai/chat", { @@ -92,6 +124,7 @@ export const AIChat = forwardRef<{ fillInput: (text: string) => void }, AIChatPr }, []) useEffect(() => { + if (loadedFromStorage.current) return fetch("/api/ai/jobs") .then((r) => r.json()) .then((data) => { @@ -176,16 +209,6 @@ export const AIChat = forwardRef<{ fillInput: (text: string) => void }, AIChatPr t.style.height = t.scrollHeight + "px" }, []) - const handleQuickAction = useCallback((prompt: string) => { - setInput(prompt) - setTimeout(() => sendMessage(prompt), 50) - }, [sendMessage]) - - const handleCommandPill = useCallback((prompt: string) => { - setInput(prompt) - setTimeout(() => sendMessage(prompt), 50) - }, [sendMessage]) - if (bootState === "booting") { return (
@@ -216,46 +239,21 @@ export const AIChat = forwardRef<{ fillInput: (text: string) => void }, AIChatPr return (
-
+

What can I help you with?

-

Your AI sales assistant is ready. Choose a quick action or type your question below.

-
-
- {quickActions.map((action, i) => ( -
handleQuickAction(action.prompt)} - className="float-in bg-card hover:bg-card/80 rounded-xl p-4 border border-border hover:border-primary/30 cursor-pointer transition-all duration-200 hover:shadow-[0_4px_20px_hsl(var(--primary)_/_0.1)] hover:-translate-y-0.5" - style={{ animationDelay: `${0.1 + i * 0.05}s` }} - > -
{action.icon}
-

{action.title}

-

{action.desc}

-
- ))} -
-
- {commandPills.map((pill, i) => ( -
handleCommandPill(pill.prompt)} - className="bg-card border border-border hover:border-primary/40 hover:bg-card/80 rounded-full px-4 py-2 text-xs text-muted-foreground hover:text-primary transition-all duration-200 cursor-pointer flex items-center gap-1.5" - > - {pill.icon} - {pill.label} -
- ))} +

Your AI sales assistant is ready. Choose a quick action or type your question below.

-
- +
+ + {showGifPicker && setShowGifPicker(false)} />}