327 lines
10 KiB
TypeScript
327 lines
10 KiB
TypeScript
"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-2 px-4 py-3">
|
|
<CardTitle className="text-sm">{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="max-h-[260px] space-y-2 overflow-auto px-4 pb-4 text-xs">
|
|
{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-2"
|
|
>
|
|
<div className="space-y-1">
|
|
<div className="font-medium text-sm">{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-2 px-4 py-3">
|
|
<CardTitle className="text-sm">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="max-h-[260px] space-y-2 overflow-auto px-4 pb-4 text-xs 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>
|
|
)
|
|
}
|