Skip to content

Commit e0e340d

Browse files
committed
feat: prompt tags, delete responses, multi-website URLs, enhanced competitors, scraper timeout
- Add TaggedPrompt type with inline tag editing and filter bar (#2) - Add delete individual responses with confirmation dialog (#2) - Support multiple website URLs with chip-based input (#5) - Structured Competitor type with name, aliases, websites (#4) - Increase Bright Data scraper timeout with exponential backoff (#3) - Backward-compatible migrations for all new data types
1 parent 9bf79b8 commit e0e340d

10 files changed

Lines changed: 412 additions & 80 deletions

components/dashboard/tabs/battlecards-tab.tsx

Lines changed: 86 additions & 9 deletions
Original file line numberDiff line numberDiff line change
@@ -1,10 +1,10 @@
11
import { useState } from "react";
2-
import type { Battlecard } from "@/components/dashboard/types";
2+
import type { Battlecard, Competitor } from "@/components/dashboard/types";
33

44
type BattlecardsTabProps = {
5-
competitors: string;
5+
competitors: Competitor[];
66
battlecards: Battlecard[];
7-
onCompetitorsChange: (value: string) => void;
7+
onCompetitorsChange: (value: Competitor[]) => void;
88
onBuildBattlecards: () => void;
99
};
1010

@@ -28,17 +28,94 @@ export function BattlecardsTab({
2828
onBuildBattlecards,
2929
}: BattlecardsTabProps) {
3030
const [expandedCard, setExpandedCard] = useState<string | null>(null);
31+
const [newName, setNewName] = useState("");
32+
33+
function addCompetitor() {
34+
const name = newName.trim();
35+
if (!name) return;
36+
onCompetitorsChange([...competitors, { name, aliases: [], websites: [] }]);
37+
setNewName("");
38+
}
39+
40+
function removeCompetitor(index: number) {
41+
onCompetitorsChange(competitors.filter((_, i) => i !== index));
42+
}
43+
44+
function updateCompetitor(index: number, patch: Partial<Competitor>) {
45+
onCompetitorsChange(competitors.map((c, i) => (i === index ? { ...c, ...patch } : c)));
46+
}
3147

3248
return (
3349
<div className="space-y-4">
3450
<label className="text-sm font-medium uppercase tracking-wider text-th-text-muted">
35-
Competitors (comma-separated)
51+
Competitors
3652
</label>
37-
<input
38-
value={competitors}
39-
onChange={(e) => onCompetitorsChange(e.target.value)}
40-
className="bd-input w-full rounded-lg p-2.5 text-sm"
41-
/>
53+
54+
{/* Competitor list */}
55+
{competitors.length > 0 && (
56+
<div className="space-y-2">
57+
{competitors.map((comp, i) => (
58+
<div key={i} className="rounded-lg border border-th-border bg-th-card p-3">
59+
<div className="flex items-center gap-2">
60+
<span className="text-sm font-semibold text-th-text">{comp.name}</span>
61+
{comp.aliases.length > 0 && (
62+
<span className="text-xs text-th-text-muted">
63+
aka {comp.aliases.join(", ")}
64+
</span>
65+
)}
66+
<button
67+
onClick={() => removeCompetitor(i)}
68+
className="ml-auto rounded p-1 text-xs text-th-text-muted hover:bg-th-danger-soft hover:text-th-danger"
69+
title="Remove"
70+
>
71+
72+
</button>
73+
</div>
74+
<div className="mt-2 grid gap-2 sm:grid-cols-2">
75+
<input
76+
value={comp.aliases.join(", ")}
77+
onChange={(e) =>
78+
updateCompetitor(i, {
79+
aliases: e.target.value.split(",").map((a) => a.trim()).filter(Boolean),
80+
})
81+
}
82+
placeholder="Aliases (comma-separated)"
83+
className="bd-input rounded-lg p-2 text-xs"
84+
/>
85+
<input
86+
value={comp.websites.join(", ")}
87+
onChange={(e) =>
88+
updateCompetitor(i, {
89+
websites: e.target.value.split(",").map((w) => w.trim()).filter(Boolean),
90+
})
91+
}
92+
placeholder="Websites (comma-separated)"
93+
className="bd-input rounded-lg p-2 text-xs"
94+
/>
95+
</div>
96+
</div>
97+
))}
98+
</div>
99+
)}
100+
101+
{/* Add competitor */}
102+
<div className="flex gap-2">
103+
<input
104+
value={newName}
105+
onChange={(e) => setNewName(e.target.value)}
106+
onKeyDown={(e) => { if (e.key === "Enter") { e.preventDefault(); addCompetitor(); } }}
107+
placeholder="Competitor name…"
108+
className="bd-input flex-1 rounded-lg p-2.5 text-sm"
109+
/>
110+
<button
111+
onClick={addCompetitor}
112+
disabled={!newName.trim()}
113+
className="rounded-lg bg-th-accent px-4 py-2.5 text-sm font-medium text-white hover:bg-th-accent-hover disabled:opacity-50"
114+
>
115+
Add
116+
</button>
117+
</div>
118+
42119
<button
43120
onClick={onBuildBattlecards}
44121
className="bd-btn-primary rounded-lg px-4 py-2.5 text-sm"

components/dashboard/tabs/citation-opportunities-tab.tsx

Lines changed: 5 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -4,7 +4,7 @@ import { PROVIDER_LABELS, type Provider } from "@/components/dashboard/types";
44

55
type CitationOpportunitiesTabProps = {
66
runs: ScrapeRun[];
7-
brandWebsite?: string;
7+
brandWebsites?: string[];
88
};
99

1010
type Opportunity = {
@@ -37,13 +37,13 @@ function downloadCsv(filename: string, content: string) {
3737

3838
type SortKey = "citations" | "prompts" | "competitors" | "domain";
3939

40-
export function CitationOpportunitiesTab({ runs, brandWebsite }: CitationOpportunitiesTabProps) {
40+
export function CitationOpportunitiesTab({ runs, brandWebsites = [] }: CitationOpportunitiesTabProps) {
4141
const [search, setSearch] = useState("");
4242
const [sortBy, setSortBy] = useState<SortKey>("citations");
4343
const [expandedOpp, setExpandedOpp] = useState<Record<string, boolean>>({});
4444
const [view, setView] = useState<"domain" | "url">("domain");
4545

46-
const brandDomain = brandWebsite ? extractDomain(brandWebsite) : null;
46+
const brandDomains = useMemo(() => new Set(brandWebsites.map((w) => extractDomain(w)).filter(Boolean)), [brandWebsites]);
4747

4848
// Core computation: find opportunities
4949
const opportunities = useMemo(() => {
@@ -61,7 +61,7 @@ export function CitationOpportunitiesTab({ runs, brandWebsite }: CitationOpportu
6161
qualifyingRuns.forEach((run) => {
6262
run.sources.forEach((source) => {
6363
const domain = extractDomain(source);
64-
if (brandDomain && domain === brandDomain) return;
64+
if (brandDomains.size > 0 && brandDomains.has(domain)) return;
6565
const existing = urlMap.get(source) ?? {
6666
count: 0,
6767
prompts: new Set<string>(),
@@ -94,7 +94,7 @@ export function CitationOpportunitiesTab({ runs, brandWebsite }: CitationOpportu
9494
if (a.highPriority !== b.highPriority) return a.highPriority ? -1 : 1;
9595
return b.citationCount - a.citationCount;
9696
});
97-
}, [runs, brandDomain]);
97+
}, [runs, brandDomains]);
9898

9999
// Domain-grouped view
100100
const domainGroups = useMemo(() => {

components/dashboard/tabs/partner-discovery-tab.tsx

Lines changed: 4 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -2,7 +2,7 @@ import { useMemo, useState, useCallback } from "react";
22

33
type PartnerDiscoveryTabProps = {
44
partnerLeaderboard: Array<{ url: string; count: number; prompts: string[] }>;
5-
brandWebsite?: string;
5+
brandWebsites?: string[];
66
};
77

88
function extractDomain(url: string): string {
@@ -24,7 +24,7 @@ function extractPath(url: string): string {
2424

2525
type SortKey = "citations" | "pages" | "prompts" | "domain";
2626

27-
export function PartnerDiscoveryTab({ partnerLeaderboard, brandWebsite }: PartnerDiscoveryTabProps) {
27+
export function PartnerDiscoveryTab({ partnerLeaderboard, brandWebsites = [] }: PartnerDiscoveryTabProps) {
2828
const [search, setSearch] = useState("");
2929
const [view, setView] = useState<"domain" | "url">("domain");
3030
const [expandedDomains, setExpandedDomains] = useState<Record<string, boolean>>({});
@@ -47,10 +47,10 @@ export function PartnerDiscoveryTab({ partnerLeaderboard, brandWebsite }: Partne
4747
urls: data.urls,
4848
totalCount: data.totalCount,
4949
prompts: [...data.prompts],
50-
isOwn: brandWebsite ? domain === extractDomain(brandWebsite) : false,
50+
isOwn: brandWebsites.length > 0 ? brandWebsites.some((w) => domain === extractDomain(w)) : false,
5151
}))
5252
.sort((a, b) => b.totalCount - a.totalCount);
53-
}, [partnerLeaderboard, brandWebsite]);
53+
}, [partnerLeaderboard, brandWebsites]);
5454

5555
// Filter + sort
5656
const filtered = useMemo(() => {

components/dashboard/tabs/project-settings-tab.tsx

Lines changed: 68 additions & 7 deletions
Original file line numberDiff line numberDiff line change
@@ -1,3 +1,4 @@
1+
import { useState } from "react";
12
import type { BrandConfig } from "@/components/dashboard/types";
23

34
type ProjectSettingsTabProps = {
@@ -30,12 +31,12 @@ export function ProjectSettingsTab({ brand, onBrandChange, onReset }: ProjectSet
3031
value={brand.brandAliases}
3132
onChange={(v) => onBrandChange({ brandAliases: v })}
3233
/>
33-
<Field
34-
label="Website URL"
35-
placeholder="https://acme.com"
36-
value={brand.website}
37-
onChange={(v) => onBrandChange({ website: v })}
38-
/>
34+
<div className="xl:col-span-2">
35+
<WebsiteListField
36+
websites={brand.websites}
37+
onChange={(websites) => onBrandChange({ websites })}
38+
/>
39+
</div>
3940
<Field
4041
label="Industry / Vertical"
4142
placeholder="B2B SaaS, E-commerce, Healthcare…"
@@ -70,7 +71,7 @@ export function ProjectSettingsTab({ brand, onBrandChange, onReset }: ProjectSet
7071
/>
7172
<StatusChip
7273
label="Website"
73-
ok={brand.website.trim().length > 0}
74+
ok={brand.websites.length > 0 && brand.websites.some((w) => w.trim().length > 0)}
7475
/>
7576
<StatusChip
7677
label="Keywords"
@@ -95,6 +96,66 @@ export function ProjectSettingsTab({ brand, onBrandChange, onReset }: ProjectSet
9596
);
9697
}
9798

99+
function WebsiteListField({
100+
websites,
101+
onChange,
102+
}: {
103+
websites: string[];
104+
onChange: (websites: string[]) => void;
105+
}) {
106+
const [draft, setDraft] = useState("");
107+
108+
function addUrl() {
109+
const url = draft.trim();
110+
if (!url) return;
111+
onChange([...websites, url]);
112+
setDraft("");
113+
}
114+
115+
return (
116+
<div>
117+
<label className="mb-1 block text-xs font-medium uppercase tracking-wider text-th-text-muted">
118+
Website URLs
119+
</label>
120+
{websites.length > 0 && (
121+
<div className="mb-2 flex flex-wrap gap-2">
122+
{websites.map((url, i) => (
123+
<span
124+
key={i}
125+
className="inline-flex items-center gap-1.5 rounded-full bg-th-card-alt border border-th-border px-3 py-1 text-sm text-th-text"
126+
>
127+
{url.replace(/^https?:\/\//, "")}
128+
<button
129+
onClick={() => onChange(websites.filter((_, j) => j !== i))}
130+
className="rounded-full p-0.5 hover:bg-th-danger-soft hover:text-th-danger"
131+
title="Remove"
132+
>
133+
134+
</button>
135+
</span>
136+
))}
137+
</div>
138+
)}
139+
<div className="flex gap-2">
140+
<input
141+
value={draft}
142+
onChange={(e) => setDraft(e.target.value)}
143+
onKeyDown={(e) => { if (e.key === "Enter") { e.preventDefault(); addUrl(); } }}
144+
placeholder="https://acme.com"
145+
className="bd-input flex-1 rounded-lg p-2.5 text-sm"
146+
/>
147+
<button
148+
onClick={addUrl}
149+
disabled={!draft.trim()}
150+
className="rounded-lg bg-th-accent px-4 py-2 text-sm font-medium text-white hover:bg-th-accent-hover disabled:opacity-50"
151+
>
152+
Add
153+
</button>
154+
</div>
155+
</div>
156+
);
157+
}
158+
98159
function Field({
99160
label,
100161
placeholder,

0 commit comments

Comments
 (0)