Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
Original file line number Diff line number Diff line change
Expand Up @@ -2,6 +2,7 @@

import Link from "next/link";
import { EmptyState } from "@/components/empty-state";
import { ConversationsCard } from "@/components/geo/conversations-card";
import { PromptManager } from "@/components/geo/prompt-manager";
import { PromptResultsCard } from "@/components/geo/prompt-results-card";
import { WebsiteGenerateCard } from "@/components/geo/website-generate-card";
Expand Down Expand Up @@ -67,6 +68,7 @@ export default function PageClient({ organizationSlug }: PageClientProps) {
</p>
</header>
<PromptManager organizationId={organizationId} />
<ConversationsCard organizationId={organizationId} />
<WebsiteGenerateCard compact organizationId={organizationId} />
<PromptResultsCard results={promptResults?.results ?? []} />
</div>
Expand Down
165 changes: 165 additions & 0 deletions apps/dashboard/src/components/geo/conversation-builder-dialog.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,165 @@
"use client";

import { Cancel01Icon, PlusSignIcon } from "@hugeicons/core-free-icons";
import { HugeiconsIcon } from "@hugeicons/react";
import {
ResponsiveDialog,
ResponsiveDialogContent,
ResponsiveDialogDescription,
ResponsiveDialogFooter,
ResponsiveDialogHeader,
ResponsiveDialogTitle,
} from "@notra/ui/components/shared/responsive-dialog";
import { Button } from "@notra/ui/components/ui/button";
import { Input } from "@notra/ui/components/ui/input";
import { Label } from "@notra/ui/components/ui/label";
import { Loader2Icon } from "lucide-react";
import { useId, useState } from "react";
import { GEO_PROMPT_MIN_LENGTH, GEO_SEQUENCE_MAX_TURNS } from "@/constants/geo";
import {
useGeoSequenceCreate,
useGeoSequenceUpdate,
} from "@/lib/hooks/use-geo";
import type { ConversationBuilderDialogProps } from "@/types/geo";

export function ConversationBuilderDialog({
open,
onOpenChange,
organizationId,
sequence,
}: ConversationBuilderDialogProps) {
const nameId = useId();
const create = useGeoSequenceCreate(organizationId);
const update = useGeoSequenceUpdate(organizationId);
const [name, setName] = useState(sequence?.name ?? "");
const [steps, setSteps] = useState<string[]>(
sequence && sequence.steps.length > 0 ? sequence.steps : [""]
);

const pending = create.isPending || update.isPending;
const validSteps = steps

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

React Doctor · react-doctor/js-combine-iterations (warning)

This loops over your list twice because .map().filter() makes two passes, so do it in one pass with .reduce() or a for...of loop

Fix → Combine .map().filter() style chains into one pass with .reduce() or a for...of loop, so you only loop over the list once

Docs

.map((step) => step.trim())
.filter((step) => step.length >= GEO_PROMPT_MIN_LENGTH);
const canSave = name.trim().length > 0 && validSteps.length > 0 && !pending;

const handleOpenChange = (next: boolean) => {
if (!next) {
setName(sequence?.name ?? "");
setSteps(sequence && sequence.steps.length > 0 ? sequence.steps : [""]);
}
onOpenChange(next);
};

const handleSave = async () => {
if (!canSave) {
return;
}
if (sequence) {
await update.mutateAsync({
sequenceId: sequence.id,
name: name.trim(),
steps: validSteps,
});
} else {
await create.mutateAsync({ name: name.trim(), steps: validSteps });
setName("");
setSteps([""]);
}
onOpenChange(false);
};

return (
<ResponsiveDialog onOpenChange={handleOpenChange} open={open}>
<ResponsiveDialogContent className="sm:max-w-lg">
<ResponsiveDialogHeader>
<ResponsiveDialogTitle>
{sequence ? `Edit ${sequence.name}` : "New conversation"}
</ResponsiveDialogTitle>
<ResponsiveDialogDescription>
A real buyer conversation: an opening question and the follow-ups
that decide the purchase. Every turn is checked for your brand.
</ResponsiveDialogDescription>
</ResponsiveDialogHeader>
<div className="space-y-4 px-4 md:px-0">
<div className="space-y-1.5">
<Label htmlFor={nameId}>Name</Label>
<Input
id={nameId}
onChange={(event) => setName(event.target.value)}
placeholder="Changelog tool research"
value={name}
/>
</div>
<div className="space-y-2">
<Label>Turns</Label>
<div className="space-y-2">
{steps.map((step, index) => (
<div
className="flex items-start gap-2"
key={`turn-${index.toString()}`}

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

React Doctor · react-doctor/no-array-index-as-key (warning)

Your users can see & submit the wrong data when this list reorders or filters, so use a stable id like key={item.id}, not the array index "index".

Fix → Use a stable id from the item, like key={item.id} or key={item.slug}. Index keys break when the list reorders or filters.

Docs

>
<span className="mt-2 w-5 shrink-0 text-right text-muted-foreground text-xs tabular-nums">
{index + 1}
</span>
<div className="min-w-0 flex-1 rounded-2xl rounded-tl-sm border border-border bg-muted/40 px-3 py-2">
<textarea
className="block w-full resize-none bg-transparent text-sm outline-none placeholder:text-muted-foreground"
onChange={(event) =>
setSteps((previous) =>
previous.map((item, itemIndex) =>
itemIndex === index ? event.target.value : item
)
)
}
placeholder={
index === 0
? "What is the best tool to automate changelogs?"
: "Which of those is the cheapest?"
}
rows={2}
value={step}
/>
</div>
{steps.length > 1 && (
<Button
aria-label={`Remove turn ${index + 1}`}
className="mt-1 shrink-0"
onClick={() =>
setSteps((previous) =>
previous.filter((_, itemIndex) => itemIndex !== index)
)
}
size="icon"
type="button"
variant="ghost"
>
<HugeiconsIcon icon={Cancel01Icon} size={14} />
</Button>
)}
</div>
))}
</div>
{steps.length < GEO_SEQUENCE_MAX_TURNS && (
<Button
className="ml-7"
onClick={() => setSteps((previous) => [...previous, ""])}
size="sm"
type="button"
variant="outline"
>
<HugeiconsIcon icon={PlusSignIcon} size={14} />
Add follow-up
</Button>
)}
</div>
</div>
<ResponsiveDialogFooter>
<Button disabled={!canSave} onClick={handleSave} type="button">
{pending && <Loader2Icon className="size-4 animate-spin" />}
{sequence ? "Save changes" : "Create conversation"}
</Button>
</ResponsiveDialogFooter>
</ResponsiveDialogContent>
</ResponsiveDialog>
);
}
102 changes: 102 additions & 0 deletions apps/dashboard/src/components/geo/conversation-results-dialog.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,102 @@
"use client";

import {
ResponsiveDialog,
ResponsiveDialogContent,
ResponsiveDialogDescription,
ResponsiveDialogHeader,
ResponsiveDialogTitle,
} from "@notra/ui/components/shared/responsive-dialog";
import { Skeleton } from "@notra/ui/components/ui/skeleton";
import { useMemo } from "react";
import { EngineIcon } from "@/components/geo/engine-icon";
import { useGeoSequenceResults } from "@/lib/hooks/use-geo";
import type {
ConversationResultsDialogProps,
GeoSequenceTurnResult,
} from "@/types/geo";
import { engineFamilyLabel, engineFamilyOf } from "@/utils/geo-charts";
import { buildSequenceTurnGroups } from "@/utils/geo-sequences";

function EngineResult({ result }: { result: GeoSequenceTurnResult }) {
const label = engineFamilyLabel(engineFamilyOf(result.engine));
return (
<span
className="inline-flex items-center gap-1.5 rounded-full border border-border px-2 py-0.5 text-xs"
title={result.excerpt}
>
<EngineIcon className="size-3.5" engine={result.engine} />
{label}
{result.mentioned ? (
<span className="font-medium text-emerald-600 tabular-nums dark:text-emerald-400">
{result.position !== null ? `#${result.position}` : "Mentioned"}
</span>
) : (
<span className="text-muted-foreground">Absent</span>
)}
</span>
);
}

export function ConversationResultsDialog({
open,
onOpenChange,
organizationId,
sequence,
}: ConversationResultsDialogProps) {
const { data, isLoading } = useGeoSequenceResults(
organizationId,
open ? sequence?.id : undefined
);

const turns = useMemo(

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

React Doctor · react-doctor/react-compiler-no-manual-memoization (warning)

React Compiler can cache this value automatically. Verify that removing useMemo preserves behavior before simplifying it.

Fix → Profile compiler-managed code and remove useMemo, useCallback, or memo only when the manual cache no longer carries behavioral or performance intent.

Docs

() => buildSequenceTurnGroups(data?.results ?? [], sequence?.id),
[data, sequence]
);

if (!sequence) {
return null;
}

return (
<ResponsiveDialog onOpenChange={onOpenChange} open={open}>
<ResponsiveDialogContent className="sm:max-w-2xl">
<ResponsiveDialogHeader>
<ResponsiveDialogTitle>{sequence.name}</ResponsiveDialogTitle>
<ResponsiveDialogDescription>
Where your brand shows up as the conversation unfolds.
</ResponsiveDialogDescription>
</ResponsiveDialogHeader>
<div className="max-h-[60svh] space-y-4 overflow-y-auto px-4 md:px-0">
{isLoading && <Skeleton className="h-40 w-full" />}
{!isLoading && turns.length === 0 && (
<p className="py-8 text-center text-muted-foreground text-sm">
No results yet. Run a scan to play this conversation against the
engines.
</p>
)}
{turns.map(([turn, results]) => (
<div className="flex items-start gap-2" key={turn}>
<span className="mt-2 w-5 shrink-0 text-right text-muted-foreground text-xs tabular-nums">
{turn}
</span>
<div className="min-w-0 flex-1 space-y-2">
<div className="rounded-2xl rounded-tl-sm border border-border bg-muted/40 px-3 py-2 text-sm">
{results[0]?.prompt}
</div>
<div className="flex flex-wrap gap-1.5">
{results.map((result) => (
<EngineResult
key={`${result.turn}-${result.engine}`}
result={result}
/>
))}
</div>
</div>
</div>
))}
</div>
</ResponsiveDialogContent>
</ResponsiveDialog>
);
}
Loading
Loading