This commit is contained in:
2026-02-04 01:05:00 +00:00
parent f9222627ef
commit d02d95e680
30 changed files with 2449 additions and 326 deletions

View File

@@ -0,0 +1,326 @@
"use client"
import * as React from "react"
import { Button } from "@/components/ui/button"
import { Card, CardContent, CardHeader, CardTitle } from "@/components/ui/card"
import {
Dialog,
DialogContent,
DialogDescription,
DialogFooter,
DialogHeader,
DialogTitle,
} from "@/components/ui/dialog"
import { Textarea } from "@/components/ui/textarea"
import { Badge } from "@/components/ui/badge"
import { useMutation } from "convex/react"
import { api } from "@/convex/_generated/api"
type SectionKey =
| "profile"
| "features"
| "competitors"
| "keywords"
| "problems"
| "personas"
| "useCases"
| "dorkQueries"
function summarizeItem(item: any) {
if (typeof item === "string") return item
if (!item || typeof item !== "object") return String(item)
if (item.name && item.role) return `${item.name} · ${item.role}`
if (item.name) return item.name
if (item.term) return item.term
if (item.problem) return item.problem
if (item.scenario) return item.scenario
if (item.query) return item.query
return JSON.stringify(item)
}
export function SectionEditor({
analysisId,
sectionKey,
title,
items,
}: {
analysisId: string
sectionKey: SectionKey
title: string
items: any[]
}) {
const addItem = useMutation(api.analysisSections.addItem)
const removeItem = useMutation(api.analysisSections.removeItem)
const [isRepromptOpen, setIsRepromptOpen] = React.useState(false)
const [repromptText, setRepromptText] = React.useState("")
const [isAddOpen, setIsAddOpen] = React.useState(false)
const [addText, setAddText] = React.useState("")
const [isBusy, setIsBusy] = React.useState(false)
const handleAdd = async () => {
setIsBusy(true)
try {
let parsed: any = addText
if (addText.trim().startsWith("{") || addText.trim().startsWith("[")) {
parsed = JSON.parse(addText)
}
await addItem({ analysisId: analysisId as any, sectionKey, item: parsed })
setAddText("")
setIsAddOpen(false)
} finally {
setIsBusy(false)
}
}
const handleReprompt = async () => {
setIsBusy(true)
try {
const response = await fetch("/api/analysis/reprompt", {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({
analysisId,
sectionKey,
prompt: repromptText.trim() || undefined,
}),
})
if (!response.ok) {
const data = await response.json()
throw new Error(data.error || "Reprompt failed")
}
setRepromptText("")
setIsRepromptOpen(false)
} finally {
setIsBusy(false)
}
}
return (
<Card>
<CardHeader className="flex flex-row items-center justify-between gap-3">
<CardTitle className="text-base">{title}</CardTitle>
<div className="flex flex-wrap items-center gap-2">
<Button variant="outline" size="sm" onClick={() => setIsAddOpen(true)}>
Add
</Button>
<Button variant="secondary" size="sm" onClick={() => setIsRepromptOpen(true)}>
Reprompt
</Button>
</div>
</CardHeader>
<CardContent className="space-y-2 text-sm">
{items.length === 0 ? (
<div className="text-muted-foreground">No items yet.</div>
) : (
items.map((item, index) => (
<div
key={`${sectionKey}-${index}`}
className="flex flex-wrap items-start justify-between gap-2 rounded-md border border-border/60 p-3"
>
<div className="space-y-2">
<div className="font-medium">{summarizeItem(item)}</div>
<Badge variant="outline">#{index + 1}</Badge>
</div>
<Button
variant="ghost"
size="sm"
onClick={() => removeItem({ analysisId: analysisId as any, sectionKey, index })}
>
Remove
</Button>
</div>
))
)}
</CardContent>
<Dialog open={isAddOpen} onOpenChange={setIsAddOpen}>
<DialogContent>
<DialogHeader>
<DialogTitle>Add to {title}</DialogTitle>
<DialogDescription>
Paste JSON for an item, or plain text for a string entry.
</DialogDescription>
</DialogHeader>
<Textarea
value={addText}
onChange={(event) => setAddText(event.target.value)}
placeholder='{"name":"...", "description":"..."}'
className="min-h-[160px]"
/>
<DialogFooter>
<Button variant="outline" onClick={() => setIsAddOpen(false)} disabled={isBusy}>
Cancel
</Button>
<Button onClick={handleAdd} disabled={isBusy || !addText.trim()}>
Save
</Button>
</DialogFooter>
</DialogContent>
</Dialog>
<Dialog open={isRepromptOpen} onOpenChange={setIsRepromptOpen}>
<DialogContent>
<DialogHeader>
<DialogTitle>Reprompt {title}</DialogTitle>
<DialogDescription>
Provide guidance to regenerate just this section.
</DialogDescription>
</DialogHeader>
<Textarea
value={repromptText}
onChange={(event) => setRepromptText(event.target.value)}
placeholder="Focus on B2B teams in healthcare..."
className="min-h-[140px]"
/>
<DialogFooter>
<Button variant="outline" onClick={() => setIsRepromptOpen(false)} disabled={isBusy}>
Cancel
</Button>
<Button onClick={handleReprompt} disabled={isBusy}>
Reprompt
</Button>
</DialogFooter>
</DialogContent>
</Dialog>
</Card>
)
}
export function ProfileSectionEditor({
analysisId,
items,
}: {
analysisId: string
items: Record<string, any>
}) {
const replaceSection = useMutation(api.analysisSections.replaceSection)
const [isRepromptOpen, setIsRepromptOpen] = React.useState(false)
const [isEditOpen, setIsEditOpen] = React.useState(false)
const [repromptText, setRepromptText] = React.useState("")
const [editText, setEditText] = React.useState(JSON.stringify(items, null, 2))
const [isBusy, setIsBusy] = React.useState(false)
React.useEffect(() => {
setEditText(JSON.stringify(items, null, 2))
}, [items])
const handleSave = async () => {
setIsBusy(true)
try {
const parsed = JSON.parse(editText)
await replaceSection({
analysisId: analysisId as any,
sectionKey: "profile",
items: parsed,
source: "mixed",
})
setIsEditOpen(false)
} finally {
setIsBusy(false)
}
}
const handleReprompt = async () => {
setIsBusy(true)
try {
const response = await fetch("/api/analysis/reprompt", {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({
analysisId,
sectionKey: "profile",
prompt: repromptText.trim() || undefined,
}),
})
if (!response.ok) {
const data = await response.json()
throw new Error(data.error || "Reprompt failed")
}
setRepromptText("")
setIsRepromptOpen(false)
} finally {
setIsBusy(false)
}
}
return (
<Card>
<CardHeader className="flex flex-row items-center justify-between gap-3">
<CardTitle className="text-base">Product Profile</CardTitle>
<div className="flex flex-wrap items-center gap-2">
<Button variant="outline" size="sm" onClick={() => setIsEditOpen(true)}>
Edit
</Button>
<Button variant="secondary" size="sm" onClick={() => setIsRepromptOpen(true)}>
Reprompt
</Button>
</div>
</CardHeader>
<CardContent className="space-y-2 text-sm text-muted-foreground">
<div>
<span className="font-medium text-foreground">Product:</span>{" "}
{items.productName || "Not set"}
</div>
<div>
<span className="font-medium text-foreground">Tagline:</span>{" "}
{items.tagline || "Not set"}
</div>
<div>
<span className="font-medium text-foreground">Category:</span>{" "}
{items.category || "Not set"}
</div>
<div>
<span className="font-medium text-foreground">Positioning:</span>{" "}
{items.positioning || "Not set"}
</div>
</CardContent>
<Dialog open={isEditOpen} onOpenChange={setIsEditOpen}>
<DialogContent>
<DialogHeader>
<DialogTitle>Edit Profile</DialogTitle>
<DialogDescription>Update the profile JSON.</DialogDescription>
</DialogHeader>
<Textarea
value={editText}
onChange={(event) => setEditText(event.target.value)}
className="min-h-[200px]"
/>
<DialogFooter>
<Button variant="outline" onClick={() => setIsEditOpen(false)} disabled={isBusy}>
Cancel
</Button>
<Button onClick={handleSave} disabled={isBusy}>
Save
</Button>
</DialogFooter>
</DialogContent>
</Dialog>
<Dialog open={isRepromptOpen} onOpenChange={setIsRepromptOpen}>
<DialogContent>
<DialogHeader>
<DialogTitle>Reprompt Profile</DialogTitle>
<DialogDescription>
Provide guidance to regenerate the profile.
</DialogDescription>
</DialogHeader>
<Textarea
value={repromptText}
onChange={(event) => setRepromptText(event.target.value)}
className="min-h-[140px]"
/>
<DialogFooter>
<Button variant="outline" onClick={() => setIsRepromptOpen(false)} disabled={isBusy}>
Cancel
</Button>
<Button onClick={handleReprompt} disabled={isBusy}>
Reprompt
</Button>
</DialogFooter>
</DialogContent>
</Dialog>
</Card>
)
}

View File

@@ -6,9 +6,7 @@ import { usePathname } from "next/navigation"
import {
Command,
Frame,
HelpCircle,
Settings,
Settings2,
Terminal,
Target,
Plus,
@@ -66,11 +64,16 @@ export function AppSidebar({ ...props }: React.ComponentProps<typeof Sidebar>) {
const [isSubmittingProject, setIsSubmittingProject] = React.useState(false);
const createProject = useMutation(api.projects.createProject);
const updateProject = useMutation(api.projects.updateProject);
const deleteProject = useMutation(api.projects.deleteProject);
const [isEditingProject, setIsEditingProject] = React.useState(false);
const [editingProjectId, setEditingProjectId] = React.useState<string | null>(null);
const [editingProjectName, setEditingProjectName] = React.useState("");
const [editingProjectDefault, setEditingProjectDefault] = React.useState(false);
const [editingProjectError, setEditingProjectError] = React.useState<string | null>(null);
const [deleteConfirmName, setDeleteConfirmName] = React.useState("");
const [deleteProjectError, setDeleteProjectError] = React.useState<string | null>(null);
const [isDeletingProject, setIsDeletingProject] = React.useState(false);
const [isDeleteConfirmOpen, setIsDeleteConfirmOpen] = React.useState(false);
const [isSubmittingEdit, setIsSubmittingEdit] = React.useState(false);
const [sourceUrl, setSourceUrl] = React.useState("");
const [sourceName, setSourceName] = React.useState("");
@@ -107,6 +110,8 @@ export function AppSidebar({ ...props }: React.ComponentProps<typeof Sidebar>) {
const toggleConfig = useMutation(api.projects.toggleDataSourceConfig);
const selectedProject = projects?.find(p => p._id === selectedProjectId);
const editingProject = projects?.find((project) => project._id === editingProjectId);
const canDeleteProject = (projects?.length ?? 0) > 1;
const selectedSourceIds = selectedProject?.dorkingConfig?.selectedSourceIds || [];
const selectedProjectName = selectedProject?.name || "Select Project";
@@ -291,9 +296,6 @@ export function AppSidebar({ ...props }: React.ComponentProps<typeof Sidebar>) {
<DropdownMenu>
<DropdownMenuTrigger asChild>
<SidebarMenuButton size="lg">
<div className="flex aspect-square size-8 items-center justify-center rounded-lg bg-sidebar-primary text-sidebar-primary-foreground">
<Command className="size-4" />
</div>
<div className="grid flex-1 text-left text-sm leading-tight">
<span className="truncate font-semibold">{selectedProjectName}</span>
<span className="truncate text-xs text-muted-foreground">Projects</span>
@@ -332,6 +334,9 @@ export function AppSidebar({ ...props }: React.ComponentProps<typeof Sidebar>) {
setEditingProjectName(project.name);
setEditingProjectDefault(project.isDefault);
setEditingProjectError(null);
setDeleteConfirmName("");
setDeleteProjectError(null);
setIsDeleteConfirmOpen(false);
setIsEditingProject(true);
}}
aria-label={`Project settings for ${project.name}`}
@@ -353,54 +358,41 @@ export function AppSidebar({ ...props }: React.ComponentProps<typeof Sidebar>) {
<SidebarContent>
{/* Platform Nav */}
<SidebarGroup>
<SidebarGroupLabel>Platform</SidebarGroupLabel>
<SidebarGroupLabel>Main</SidebarGroupLabel>
<SidebarGroupContent>
<SidebarMenu>
<SidebarMenuItem>
<SidebarMenuButton
asChild
tooltip="Dashboard"
tooltip="Overview"
isActive={pathname === "/dashboard"}
>
<Link href="/dashboard">
<Terminal />
<span>Dashboard</span>
<span>Overview</span>
</Link>
</SidebarMenuButton>
</SidebarMenuItem>
<SidebarMenuItem>
<SidebarMenuButton
asChild
tooltip="Opportunities"
tooltip="Search"
isActive={pathname === "/opportunities"}
>
<Link href="/opportunities">
<Target />
<span>Opportunities</span>
<span>Search</span>
</Link>
</SidebarMenuButton>
</SidebarMenuItem>
<SidebarMenuItem>
<SidebarMenuButton
asChild
tooltip="Settings"
isActive={pathname === "/settings"}
tooltip="Inbox"
isActive={pathname === "/leads"}
>
<Link href="/settings">
<Settings2 />
<span>Settings</span>
</Link>
</SidebarMenuButton>
</SidebarMenuItem>
<SidebarMenuItem>
<SidebarMenuButton
asChild
tooltip="Help"
isActive={pathname === "/help"}
>
<Link href="/help">
<HelpCircle />
<span>Help</span>
<Link href="/leads" className="pl-8 text-sm text-muted-foreground hover:text-foreground">
<span>Inbox</span>
</Link>
</SidebarMenuButton>
</SidebarMenuItem>
@@ -412,7 +404,7 @@ export function AppSidebar({ ...props }: React.ComponentProps<typeof Sidebar>) {
{selectedProjectId && (
<SidebarGroup>
<SidebarGroupLabel>
Active Data Sources
Selected Sources
<span className="ml-2 text-xs font-normal text-muted-foreground">({selectedProject?.name})</span>
</SidebarGroupLabel>
<SidebarGroupContent>
@@ -624,6 +616,33 @@ export function AppSidebar({ ...props }: React.ComponentProps<typeof Sidebar>) {
{editingProjectError && (
<div className="text-sm text-destructive">{editingProjectError}</div>
)}
<div className="border-t border-border pt-4 space-y-2">
<div className="flex items-center justify-between">
<div>
<div className="text-sm font-semibold text-destructive">Delete project</div>
<p className="text-xs text-muted-foreground">
This removes the project and all related data sources, analyses, and opportunities.
</p>
</div>
<Button
variant="destructive"
size="sm"
disabled={!canDeleteProject}
onClick={() => {
setDeleteConfirmName("");
setDeleteProjectError(null);
setIsDeleteConfirmOpen(true);
}}
>
Delete
</Button>
</div>
{!canDeleteProject && (
<div className="text-xs text-muted-foreground">
You must keep at least one project.
</div>
)}
</div>
<div className="flex justify-end gap-2">
<Button
variant="outline"
@@ -662,6 +681,77 @@ export function AppSidebar({ ...props }: React.ComponentProps<typeof Sidebar>) {
</div>
</DialogContent>
</Dialog>
<Dialog open={isDeleteConfirmOpen} onOpenChange={setIsDeleteConfirmOpen}>
<DialogContent>
<DialogHeader>
<DialogTitle>Delete project</DialogTitle>
<DialogDescription>
This action is permanent. You are deleting{" "}
<span className="font-semibold text-foreground">
{editingProject?.name || "this project"}
</span>
. Type the project name to confirm deletion.
</DialogDescription>
</DialogHeader>
<div className="space-y-3">
<div className="space-y-2">
<Label htmlFor="deleteProjectConfirm">Project name</Label>
<Input
id="deleteProjectConfirm"
value={deleteConfirmName}
onChange={(event) => setDeleteConfirmName(event.target.value)}
disabled={isDeletingProject || !canDeleteProject}
/>
</div>
{deleteProjectError && (
<div className="text-sm text-destructive">{deleteProjectError}</div>
)}
<div className="flex justify-end gap-2">
<Button
variant="outline"
onClick={() => setIsDeleteConfirmOpen(false)}
disabled={isDeletingProject}
>
Cancel
</Button>
<Button
variant="destructive"
disabled={
isDeletingProject ||
!canDeleteProject ||
!editingProject ||
deleteConfirmName.trim() !== editingProject.name
}
onClick={async () => {
if (!editingProjectId || !editingProject) return;
if (deleteConfirmName.trim() !== editingProject.name) {
setDeleteProjectError("Project name does not match.");
return;
}
setDeleteProjectError(null);
setIsDeletingProject(true);
try {
const result = await deleteProject({
projectId: editingProjectId as any,
});
if (selectedProjectId === editingProjectId && result?.newDefaultProjectId) {
setSelectedProjectId(result.newDefaultProjectId);
}
setIsDeleteConfirmOpen(false);
setIsEditingProject(false);
} catch (err: any) {
setDeleteProjectError(err?.message || "Failed to delete project.");
} finally {
setIsDeletingProject(false);
}
}}
>
{isDeletingProject ? "Deleting..." : "Delete Project"}
</Button>
</div>
</div>
</DialogContent>
</Dialog>
<Dialog open={isCreatingProject} onOpenChange={setIsCreatingProject}>
<DialogContent>
<DialogHeader>

View File

@@ -4,9 +4,9 @@ import * as React from "react"
import {
BadgeCheck,
Bell,
ChevronsUpDown,
CreditCard,
HelpCircle,
LogOut,
Sparkles,
} from "lucide-react"
@@ -32,6 +32,7 @@ import {
useSidebar,
} from "@/components/ui/sidebar"
import { useAuthActions } from "@convex-dev/auth/react"
import { useRouter } from "next/navigation"
export function NavUser({
user,
@@ -44,6 +45,7 @@ export function NavUser({
}) {
const { isMobile } = useSidebar()
const { signOut } = useAuthActions()
const router = useRouter()
const seed = React.useMemo(() => {
const base = user.email || user.name || "";
return base.trim() || "user";
@@ -98,27 +100,28 @@ export function NavUser({
</DropdownMenuLabel>
<DropdownMenuSeparator />
<DropdownMenuGroup>
<DropdownMenuItem>
<DropdownMenuItem onSelect={() => router.push("/settings?tab=upgrade")}>
<Sparkles />
Upgrade to Pro
</DropdownMenuItem>
</DropdownMenuGroup>
<DropdownMenuSeparator />
<DropdownMenuGroup>
<DropdownMenuItem>
<DropdownMenuItem onSelect={() => router.push("/settings?tab=account")}>
<BadgeCheck />
Account
</DropdownMenuItem>
<DropdownMenuItem>
<DropdownMenuItem onSelect={() => router.push("/settings?tab=billing")}>
<CreditCard />
Billing
</DropdownMenuItem>
<DropdownMenuItem>
<Bell />
Notifications
</DropdownMenuItem>
</DropdownMenuGroup>
<DropdownMenuSeparator />
<DropdownMenuItem onSelect={() => router.push("/help")}>
<HelpCircle />
Support
</DropdownMenuItem>
<DropdownMenuSeparator />
<DropdownMenuItem onClick={() => signOut()}>
<LogOut />
Log out

View File

@@ -8,9 +8,6 @@ import { ScrollArea } from '@/components/ui/scroll-area'
import { Separator } from '@/components/ui/separator'
import {
LayoutDashboard,
Search,
Settings,
HelpCircle,
LogOut,
Sparkles,
Target
@@ -25,30 +22,11 @@ export function Sidebar({ productName }: SidebarProps) {
const routes = [
{
label: 'Dashboard',
label: 'Overview',
icon: LayoutDashboard,
href: '/dashboard',
active: pathname === '/dashboard',
},
{
label: 'Opportunities',
icon: Target,
href: '/opportunities',
active: pathname === '/opportunities',
},
]
const bottomRoutes = [
{
label: 'Settings',
icon: Settings,
href: '/settings',
},
{
label: 'Help',
icon: HelpCircle,
href: '/help',
},
]
return (
@@ -89,22 +67,32 @@ export function Sidebar({ productName }: SidebarProps) {
{route.label}
</Link>
))}
<Link
href="/opportunities"
className={cn(
'flex items-center gap-3 rounded-md px-3 py-2 text-sm font-medium transition-colors',
pathname === '/opportunities'
? 'bg-primary text-primary-foreground'
: 'text-muted-foreground hover:bg-muted hover:text-foreground'
)}
>
<Target className="h-4 w-4" />
Search
</Link>
<Link
href="/leads"
className={cn(
'flex items-center rounded-md px-3 py-2 pl-9 text-sm font-medium transition-colors',
pathname === '/leads'
? 'bg-primary text-primary-foreground'
: 'text-muted-foreground hover:bg-muted hover:text-foreground'
)}
>
Inbox
</Link>
</nav>
<Separator className="my-4" />
<nav className="space-y-1 px-2">
{bottomRoutes.map((route) => (
<Link
key={route.href}
href={route.href}
className="flex items-center gap-3 rounded-md px-3 py-2 text-sm font-medium text-muted-foreground transition-colors hover:bg-muted hover:text-foreground"
>
<route.icon className="h-4 w-4" />
{route.label}
</Link>
))}
</nav>
</ScrollArea>
{/* Bottom */}