create-supyagent-app 0.1.13 → 0.1.14

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (38) hide show
  1. package/dist/index.js +37 -0
  2. package/dist/index.js.map +1 -1
  3. package/package.json +1 -1
  4. package/templates/base/src/app/globals.css +0 -1
  5. package/templates/base/src/components/ai-elements/tool.tsx +166 -0
  6. package/templates/base/src/components/chat-message.tsx +2 -2
  7. package/templates/base/src/components/supyagent/tool-message.tsx +98 -0
  8. package/templates/base/src/components/supyagent/tool-renderers.tsx +69 -0
  9. package/templates/base/src/components/supyagent/tools/brevo.tsx +140 -0
  10. package/templates/base/src/components/supyagent/tools/calendar.tsx +158 -0
  11. package/templates/base/src/components/supyagent/tools/calendly.tsx +161 -0
  12. package/templates/base/src/components/supyagent/tools/compute.tsx +113 -0
  13. package/templates/base/src/components/supyagent/tools/discord.tsx +174 -0
  14. package/templates/base/src/components/supyagent/tools/docs.tsx +94 -0
  15. package/templates/base/src/components/supyagent/tools/drive.tsx +124 -0
  16. package/templates/base/src/components/supyagent/tools/generic.tsx +19 -0
  17. package/templates/base/src/components/supyagent/tools/github.tsx +142 -0
  18. package/templates/base/src/components/supyagent/tools/gmail.tsx +161 -0
  19. package/templates/base/src/components/supyagent/tools/hubspot.tsx +206 -0
  20. package/templates/base/src/components/supyagent/tools/inbox.tsx +158 -0
  21. package/templates/base/src/components/supyagent/tools/jira.tsx +164 -0
  22. package/templates/base/src/components/supyagent/tools/linear.tsx +196 -0
  23. package/templates/base/src/components/supyagent/tools/linkedin.tsx +151 -0
  24. package/templates/base/src/components/supyagent/tools/notion.tsx +223 -0
  25. package/templates/base/src/components/supyagent/tools/pipedrive.tsx +154 -0
  26. package/templates/base/src/components/supyagent/tools/resend.tsx +92 -0
  27. package/templates/base/src/components/supyagent/tools/salesforce.tsx +163 -0
  28. package/templates/base/src/components/supyagent/tools/search.tsx +141 -0
  29. package/templates/base/src/components/supyagent/tools/sheets.tsx +109 -0
  30. package/templates/base/src/components/supyagent/tools/slack.tsx +135 -0
  31. package/templates/base/src/components/supyagent/tools/slides.tsx +64 -0
  32. package/templates/base/src/components/supyagent/tools/stripe.tsx +235 -0
  33. package/templates/base/src/components/supyagent/tools/telegram.tsx +116 -0
  34. package/templates/base/src/components/supyagent/tools/twilio.tsx +118 -0
  35. package/templates/base/src/components/supyagent/tools/twitter.tsx +139 -0
  36. package/templates/base/src/components/ui/badge.tsx +35 -0
  37. package/templates/base/src/components/ui/collapsible.tsx +11 -0
  38. package/templates/package-json/package.json.tmpl +3 -1
@@ -0,0 +1,154 @@
1
+ import React from "react";
2
+ import { Briefcase, DollarSign } from "lucide-react";
3
+
4
+ interface PipedriveDealData {
5
+ id?: number;
6
+ title?: string;
7
+ value?: number;
8
+ currency?: string;
9
+ status?: string;
10
+ stage_id?: number;
11
+ stage_name?: string;
12
+ person_name?: string;
13
+ org_name?: string;
14
+ owner_name?: string;
15
+ }
16
+
17
+ interface PipedriveRendererProps {
18
+ data: unknown;
19
+ }
20
+
21
+ function isPipedriveDeal(data: unknown): data is PipedriveDealData {
22
+ return typeof data === "object" && data !== null && ("title" in data || "value" in data || "status" in data) && !("subject" in data);
23
+ }
24
+
25
+ function formatCurrency(value: number, currency?: string): string {
26
+ try {
27
+ return new Intl.NumberFormat(undefined, {
28
+ style: "currency",
29
+ currency: currency || "USD",
30
+ minimumFractionDigits: 0,
31
+ maximumFractionDigits: 0,
32
+ }).format(value);
33
+ } catch {
34
+ return `${currency || "$"}${value.toLocaleString()}`;
35
+ }
36
+ }
37
+
38
+ function getStatusBadge(status?: string) {
39
+ switch (status) {
40
+ case "won":
41
+ return { label: "Won", className: "text-green-500 bg-green-500/10" };
42
+ case "lost":
43
+ return { label: "Lost", className: "text-destructive bg-destructive/10" };
44
+ case "open":
45
+ return { label: "Open", className: "text-primary bg-primary/10" };
46
+ case "deleted":
47
+ return { label: "Deleted", className: "text-muted-foreground bg-muted" };
48
+ default:
49
+ return status ? { label: status, className: "text-muted-foreground bg-muted" } : null;
50
+ }
51
+ }
52
+
53
+ function DealCard({ deal }: { deal: PipedriveDealData }) {
54
+ const statusBadge = getStatusBadge(deal.status);
55
+
56
+ return (
57
+ <div className="rounded-lg border border-border bg-card p-3 space-y-1.5">
58
+ <div className="flex items-start gap-2">
59
+ <Briefcase className="h-4 w-4 text-muted-foreground mt-0.5 shrink-0" />
60
+ <div className="min-w-0 flex-1">
61
+ <p className="text-sm font-medium text-foreground">{deal.title || "Untitled deal"}</p>
62
+ <div className="flex items-center gap-2 mt-0.5 flex-wrap">
63
+ {deal.value !== undefined && (
64
+ <span className="flex items-center gap-1 text-xs font-medium text-foreground">
65
+ <DollarSign className="h-3 w-3" />
66
+ {formatCurrency(deal.value, deal.currency)}
67
+ </span>
68
+ )}
69
+ {statusBadge && (
70
+ <span className={`rounded-full px-2 py-0.5 text-xs ${statusBadge.className}`}>
71
+ {statusBadge.label}
72
+ </span>
73
+ )}
74
+ </div>
75
+ </div>
76
+ </div>
77
+ <div className="flex flex-wrap gap-x-4 gap-y-1 pl-6 text-xs text-muted-foreground">
78
+ {deal.person_name && <span>{deal.person_name}</span>}
79
+ {deal.org_name && <span>{deal.org_name}</span>}
80
+ {deal.stage_name && <span>{deal.stage_name}</span>}
81
+ </div>
82
+ </div>
83
+ );
84
+ }
85
+
86
+ export function PipedriveRenderer({ data }: PipedriveRendererProps) {
87
+ if (isPipedriveDeal(data)) {
88
+ return <DealCard deal={data} />;
89
+ }
90
+
91
+ if (Array.isArray(data)) {
92
+ const deals = data.filter(isPipedriveDeal);
93
+ if (deals.length > 0) {
94
+ return (
95
+ <div className="space-y-2">
96
+ {deals.map((deal, i) => (
97
+ <DealCard key={deal.id || i} deal={deal} />
98
+ ))}
99
+ </div>
100
+ );
101
+ }
102
+ }
103
+
104
+ // Supyagent API shape: { deals: [...], pagination }
105
+ if (typeof data === "object" && data !== null && "deals" in data) {
106
+ const items = (data as any).deals;
107
+ if (Array.isArray(items)) {
108
+ const deals = items.filter(isPipedriveDeal);
109
+ if (deals.length > 0) {
110
+ return (
111
+ <div className="space-y-2">
112
+ {deals.map((deal, i) => (
113
+ <DealCard key={deal.id || i} deal={deal} />
114
+ ))}
115
+ </div>
116
+ );
117
+ }
118
+ }
119
+ }
120
+
121
+ // Single deal wrapper: { deal: {...} }
122
+ if (typeof data === "object" && data !== null && "deal" in data) {
123
+ const item = (data as any).deal;
124
+ if (isPipedriveDeal(item)) {
125
+ return <DealCard deal={item} />;
126
+ }
127
+ }
128
+
129
+ // Raw Pipedrive shape: { data: [...] }
130
+ if (typeof data === "object" && data !== null && "data" in data) {
131
+ const items = (data as any).data;
132
+ if (Array.isArray(items)) {
133
+ const deals = items.filter(isPipedriveDeal);
134
+ if (deals.length > 0) {
135
+ return (
136
+ <div className="space-y-2">
137
+ {deals.map((deal, i) => (
138
+ <DealCard key={deal.id || i} deal={deal} />
139
+ ))}
140
+ </div>
141
+ );
142
+ }
143
+ }
144
+ if (isPipedriveDeal(items)) {
145
+ return <DealCard deal={items} />;
146
+ }
147
+ }
148
+
149
+ return (
150
+ <pre className="rounded-lg border border-border bg-background p-3 text-xs text-foreground overflow-x-auto max-h-96 overflow-y-auto">
151
+ {JSON.stringify(data, null, 2)}
152
+ </pre>
153
+ );
154
+ }
@@ -0,0 +1,92 @@
1
+ import React from "react";
2
+ import { Send, Clock } from "lucide-react";
3
+
4
+ interface ResendEmailData {
5
+ id?: string;
6
+ to?: string | string[];
7
+ from?: string;
8
+ subject?: string;
9
+ created_at?: string;
10
+ last_event?: string;
11
+ }
12
+
13
+ interface ResendRendererProps {
14
+ data: unknown;
15
+ }
16
+
17
+ function isResendData(data: unknown): data is ResendEmailData {
18
+ return typeof data === "object" && data !== null && ("id" in data || "to" in data) && ("subject" in data || "from" in data || "created_at" in data);
19
+ }
20
+
21
+ function formatRecipients(to: string | string[] | undefined): string {
22
+ if (!to) return "Unknown";
23
+ if (Array.isArray(to)) return to.join(", ");
24
+ return to;
25
+ }
26
+
27
+ function formatTimestamp(dateStr: string): string {
28
+ try {
29
+ const date = new Date(dateStr);
30
+ return date.toLocaleString(undefined, { dateStyle: "medium", timeStyle: "short" });
31
+ } catch {
32
+ return dateStr;
33
+ }
34
+ }
35
+
36
+ function ResendCard({ email }: { email: ResendEmailData }) {
37
+ return (
38
+ <div className="rounded-lg border border-border bg-card p-3 space-y-2">
39
+ <div className="flex items-start gap-2">
40
+ <div className="flex h-8 w-8 items-center justify-center rounded-full bg-green-500/10 shrink-0">
41
+ <Send className="h-4 w-4 text-green-500" />
42
+ </div>
43
+ <div className="min-w-0 flex-1">
44
+ <p className="text-sm font-medium text-foreground">Email sent</p>
45
+ {email.subject && (
46
+ <p className="text-xs text-muted-foreground truncate">{email.subject}</p>
47
+ )}
48
+ </div>
49
+ </div>
50
+ <div className="pl-10 space-y-1 text-xs text-muted-foreground">
51
+ <p>
52
+ <span className="text-foreground">To:</span> {formatRecipients(email.to)}
53
+ </p>
54
+ {email.from && (
55
+ <p>
56
+ <span className="text-foreground">From:</span> {email.from}
57
+ </p>
58
+ )}
59
+ {email.created_at && (
60
+ <p className="flex items-center gap-1">
61
+ <Clock className="h-3 w-3" />
62
+ {formatTimestamp(email.created_at)}
63
+ </p>
64
+ )}
65
+ {email.last_event && (
66
+ <span className="inline-block rounded-full bg-muted px-2 py-0.5 text-xs text-muted-foreground capitalize">
67
+ {email.last_event}
68
+ </span>
69
+ )}
70
+ </div>
71
+ </div>
72
+ );
73
+ }
74
+
75
+ export function ResendRenderer({ data }: ResendRendererProps) {
76
+ if (isResendData(data)) {
77
+ return <ResendCard email={data} />;
78
+ }
79
+
80
+ if (typeof data === "object" && data !== null && "data" in data) {
81
+ const inner = (data as any).data;
82
+ if (isResendData(inner)) {
83
+ return <ResendCard email={inner} />;
84
+ }
85
+ }
86
+
87
+ return (
88
+ <pre className="rounded-lg border border-border bg-background p-3 text-xs text-foreground overflow-x-auto max-h-96 overflow-y-auto">
89
+ {JSON.stringify(data, null, 2)}
90
+ </pre>
91
+ );
92
+ }
@@ -0,0 +1,163 @@
1
+ import React from "react";
2
+ import { User, Briefcase, Mail, Phone, Building2, Calendar } from "lucide-react";
3
+
4
+ interface SalesforceContactData {
5
+ Id?: string;
6
+ Name?: string;
7
+ Title?: string;
8
+ Email?: string;
9
+ Phone?: string;
10
+ Account?: { Name?: string } | null;
11
+ AccountName?: string;
12
+ }
13
+
14
+ interface SalesforceOpportunityData {
15
+ Id?: string;
16
+ Name?: string;
17
+ Amount?: number;
18
+ StageName?: string;
19
+ CloseDate?: string;
20
+ Probability?: number;
21
+ }
22
+
23
+ interface SalesforceRendererProps {
24
+ data: unknown;
25
+ }
26
+
27
+ function isContact(data: unknown): data is SalesforceContactData {
28
+ if (typeof data !== "object" || data === null) return false;
29
+ return "Name" in data && ("Email" in data || "Phone" in data || "Title" in data);
30
+ }
31
+
32
+ function isOpportunity(data: unknown): data is SalesforceOpportunityData {
33
+ if (typeof data !== "object" || data === null) return false;
34
+ return "Name" in data && ("StageName" in data || "Amount" in data || "CloseDate" in data);
35
+ }
36
+
37
+ function ContactCard({ contact }: { contact: SalesforceContactData }) {
38
+ const account = contact.AccountName || contact.Account?.Name;
39
+
40
+ return (
41
+ <div className="rounded-lg border border-border bg-card p-3 space-y-1.5">
42
+ <div className="flex items-start gap-2">
43
+ <User className="h-4 w-4 text-muted-foreground mt-0.5 shrink-0" />
44
+ <div className="min-w-0 flex-1">
45
+ <p className="text-sm font-medium text-foreground">{contact.Name}</p>
46
+ {contact.Title && <p className="text-xs text-muted-foreground">{contact.Title}</p>}
47
+ </div>
48
+ </div>
49
+ <div className="flex flex-wrap gap-x-4 gap-y-1 pl-6">
50
+ {contact.Email && (
51
+ <span className="flex items-center gap-1 text-xs text-muted-foreground">
52
+ <Mail className="h-3 w-3" />
53
+ {contact.Email}
54
+ </span>
55
+ )}
56
+ {contact.Phone && (
57
+ <span className="flex items-center gap-1 text-xs text-muted-foreground">
58
+ <Phone className="h-3 w-3" />
59
+ {contact.Phone}
60
+ </span>
61
+ )}
62
+ {account && (
63
+ <span className="flex items-center gap-1 text-xs text-muted-foreground">
64
+ <Building2 className="h-3 w-3" />
65
+ {account}
66
+ </span>
67
+ )}
68
+ </div>
69
+ </div>
70
+ );
71
+ }
72
+
73
+ function OpportunityCard({ opp }: { opp: SalesforceOpportunityData }) {
74
+ return (
75
+ <div className="rounded-lg border border-border bg-card p-3 space-y-1.5">
76
+ <div className="flex items-start gap-2">
77
+ <Briefcase className="h-4 w-4 text-muted-foreground mt-0.5 shrink-0" />
78
+ <div className="min-w-0 flex-1">
79
+ <p className="text-sm font-medium text-foreground">{opp.Name}</p>
80
+ <div className="flex items-center gap-2 mt-0.5 flex-wrap">
81
+ {opp.Amount !== undefined && (
82
+ <span className="text-xs font-medium text-foreground">
83
+ {new Intl.NumberFormat(undefined, { style: "currency", currency: "USD" }).format(opp.Amount)}
84
+ </span>
85
+ )}
86
+ {opp.StageName && (
87
+ <span className="rounded-full bg-muted px-2 py-0.5 text-xs text-muted-foreground">
88
+ {opp.StageName}
89
+ </span>
90
+ )}
91
+ {opp.CloseDate && (
92
+ <span className="flex items-center gap-1 text-xs text-muted-foreground">
93
+ <Calendar className="h-3 w-3" />
94
+ {opp.CloseDate}
95
+ </span>
96
+ )}
97
+ </div>
98
+ </div>
99
+ </div>
100
+ {opp.Probability !== undefined && (
101
+ <div className="pl-6 space-y-0.5">
102
+ <div className="flex items-center justify-between">
103
+ <span className="text-xs text-muted-foreground">Probability</span>
104
+ <span className="text-xs font-medium text-foreground">{opp.Probability}%</span>
105
+ </div>
106
+ <div className="h-1.5 rounded-full bg-muted overflow-hidden">
107
+ <div
108
+ className="h-full rounded-full bg-primary transition-all"
109
+ style={{ width: `${Math.min(100, opp.Probability)}%` }}
110
+ />
111
+ </div>
112
+ </div>
113
+ )}
114
+ </div>
115
+ );
116
+ }
117
+
118
+ export function SalesforceRenderer({ data }: SalesforceRendererProps) {
119
+ // {records: [...]}
120
+ if (typeof data === "object" && data !== null && "records" in data) {
121
+ const records = (data as any).records;
122
+ if (Array.isArray(records)) {
123
+ const contacts = records.filter(isContact);
124
+ const opps = records.filter(isOpportunity);
125
+ if (opps.length > 0 && contacts.length === 0) {
126
+ return (
127
+ <div className="space-y-1.5">
128
+ {opps.map((o, i) => <OpportunityCard key={o.Id || i} opp={o} />)}
129
+ </div>
130
+ );
131
+ }
132
+ if (contacts.length > 0) {
133
+ return (
134
+ <div className="space-y-1.5">
135
+ {contacts.map((c, i) => <ContactCard key={c.Id || i} contact={c} />)}
136
+ </div>
137
+ );
138
+ }
139
+ }
140
+ }
141
+
142
+ // Single
143
+ if (isOpportunity(data)) return <OpportunityCard opp={data} />;
144
+ if (isContact(data)) return <ContactCard contact={data} />;
145
+
146
+ // Array
147
+ if (Array.isArray(data)) {
148
+ const contacts = data.filter(isContact);
149
+ if (contacts.length > 0) {
150
+ return <div className="space-y-1.5">{contacts.map((c, i) => <ContactCard key={c.Id || i} contact={c} />)}</div>;
151
+ }
152
+ const opps = data.filter(isOpportunity);
153
+ if (opps.length > 0) {
154
+ return <div className="space-y-1.5">{opps.map((o, i) => <OpportunityCard key={o.Id || i} opp={o} />)}</div>;
155
+ }
156
+ }
157
+
158
+ return (
159
+ <pre className="rounded-lg border border-border bg-background p-3 text-xs text-foreground overflow-x-auto max-h-96 overflow-y-auto">
160
+ {JSON.stringify(data, null, 2)}
161
+ </pre>
162
+ );
163
+ }
@@ -0,0 +1,141 @@
1
+ import React from "react";
2
+ import { ExternalLink, Search } from "lucide-react";
3
+
4
+ interface SearchResult {
5
+ title?: string;
6
+ link?: string;
7
+ url?: string;
8
+ snippet?: string;
9
+ description?: string;
10
+ position?: number;
11
+ }
12
+
13
+ interface SearchData {
14
+ results?: SearchResult[];
15
+ organic?: SearchResult[];
16
+ answer?: string;
17
+ answerBox?: { answer?: string; snippet?: string; title?: string };
18
+ relatedSearches?: Array<{ query?: string }>;
19
+ related_searches?: Array<{ query?: string }>;
20
+ }
21
+
22
+ interface SearchRendererProps {
23
+ data: unknown;
24
+ }
25
+
26
+ function isSearchData(data: unknown): data is SearchData {
27
+ if (typeof data !== "object" || data === null) return false;
28
+ return "results" in data || "organic" in data || "answer" in data || "answerBox" in data;
29
+ }
30
+
31
+ function isSearchResult(data: unknown): data is SearchResult {
32
+ return typeof data === "object" && data !== null && ("title" in data || "link" in data || "url" in data);
33
+ }
34
+
35
+ function getDomain(url: string): string {
36
+ try {
37
+ return new URL(url).hostname.replace("www.", "");
38
+ } catch {
39
+ return url;
40
+ }
41
+ }
42
+
43
+ function ResultCard({ result }: { result: SearchResult }) {
44
+ const href = result.link || result.url;
45
+ const snippet = result.snippet || result.description;
46
+
47
+ return (
48
+ <div className="rounded-lg border border-border bg-card p-3 space-y-1">
49
+ <div className="flex items-start gap-2">
50
+ <div className="min-w-0 flex-1">
51
+ {href ? (
52
+ <a
53
+ href={href}
54
+ target="_blank"
55
+ rel="noopener noreferrer"
56
+ className="text-sm font-medium text-primary hover:underline"
57
+ >
58
+ {result.title || href}
59
+ </a>
60
+ ) : (
61
+ <p className="text-sm font-medium text-foreground">{result.title}</p>
62
+ )}
63
+ {href && (
64
+ <p className="text-xs text-muted-foreground truncate">{getDomain(href)}</p>
65
+ )}
66
+ </div>
67
+ {href && (
68
+ <a href={href} target="_blank" rel="noopener noreferrer" className="shrink-0 text-muted-foreground hover:text-foreground">
69
+ <ExternalLink className="h-3.5 w-3.5" />
70
+ </a>
71
+ )}
72
+ </div>
73
+ {snippet && (
74
+ <p className="text-xs text-muted-foreground line-clamp-2">{snippet}</p>
75
+ )}
76
+ </div>
77
+ );
78
+ }
79
+
80
+ export function SearchRenderer({ data }: SearchRendererProps) {
81
+ if (isSearchData(data)) {
82
+ const results = data.results || data.organic || [];
83
+ const answerText = data.answer || data.answerBox?.answer || data.answerBox?.snippet;
84
+ const related = data.relatedSearches || data.related_searches;
85
+
86
+ return (
87
+ <div className="space-y-3">
88
+ {answerText && (
89
+ <div className="rounded-lg border border-border bg-card p-3">
90
+ <p className="text-sm text-foreground">{answerText}</p>
91
+ {data.answerBox?.title && (
92
+ <p className="text-xs text-muted-foreground mt-1">{data.answerBox.title}</p>
93
+ )}
94
+ </div>
95
+ )}
96
+
97
+ {results.length > 0 && (
98
+ <div className="space-y-2">
99
+ {results.filter(isSearchResult).map((result, i) => (
100
+ <ResultCard key={result.link || result.url || i} result={result} />
101
+ ))}
102
+ </div>
103
+ )}
104
+
105
+ {related && related.length > 0 && (
106
+ <div className="flex flex-wrap gap-1.5">
107
+ {related.map((r, i) => (
108
+ <span
109
+ key={i}
110
+ className="inline-flex items-center gap-1 rounded-full bg-muted px-2.5 py-1 text-xs text-muted-foreground"
111
+ >
112
+ <Search className="h-3 w-3" />
113
+ {r.query}
114
+ </span>
115
+ ))}
116
+ </div>
117
+ )}
118
+ </div>
119
+ );
120
+ }
121
+
122
+ // Array of results
123
+ if (Array.isArray(data)) {
124
+ const results = data.filter(isSearchResult);
125
+ if (results.length > 0) {
126
+ return (
127
+ <div className="space-y-2">
128
+ {results.map((result, i) => (
129
+ <ResultCard key={result.link || result.url || i} result={result} />
130
+ ))}
131
+ </div>
132
+ );
133
+ }
134
+ }
135
+
136
+ return (
137
+ <pre className="rounded-lg border border-border bg-background p-3 text-xs text-foreground overflow-x-auto max-h-96 overflow-y-auto">
138
+ {JSON.stringify(data, null, 2)}
139
+ </pre>
140
+ );
141
+ }
@@ -0,0 +1,109 @@
1
+ import React from "react";
2
+ import { Table2, ExternalLink } from "lucide-react";
3
+
4
+ interface SheetsData {
5
+ spreadsheetId?: string;
6
+ title?: string;
7
+ url?: string;
8
+ values?: unknown[][];
9
+ range?: string;
10
+ updatedRange?: string;
11
+ updatedRows?: number;
12
+ updatedColumns?: number;
13
+ updatedCells?: number;
14
+ sheets?: Array<{ properties?: { title?: string; sheetId?: number } }>;
15
+ }
16
+
17
+ interface SheetsRendererProps {
18
+ data: unknown;
19
+ }
20
+
21
+ function isSheetsData(data: unknown): data is SheetsData {
22
+ return typeof data === "object" && data !== null && ("spreadsheetId" in data || "values" in data || "range" in data || "updatedRange" in data);
23
+ }
24
+
25
+ function SheetsTable({ values }: { values: unknown[][] }) {
26
+ if (values.length === 0) return null;
27
+
28
+ const headers = values[0] as string[];
29
+ const rows = values.slice(1);
30
+
31
+ return (
32
+ <div className="overflow-x-auto rounded-lg border border-border">
33
+ <table className="w-full text-xs">
34
+ <thead>
35
+ <tr className="border-b border-border bg-muted">
36
+ {headers.map((header, i) => (
37
+ <th key={i} className="px-3 py-2 text-left font-medium text-foreground whitespace-nowrap">
38
+ {String(header)}
39
+ </th>
40
+ ))}
41
+ </tr>
42
+ </thead>
43
+ <tbody>
44
+ {rows.map((row, rowIdx) => (
45
+ <tr key={rowIdx} className="border-b border-border last:border-0">
46
+ {headers.map((_, colIdx) => (
47
+ <td key={colIdx} className="px-3 py-1.5 text-muted-foreground whitespace-nowrap">
48
+ {String((row as unknown[])[colIdx] ?? "")}
49
+ </td>
50
+ ))}
51
+ </tr>
52
+ ))}
53
+ </tbody>
54
+ </table>
55
+ </div>
56
+ );
57
+ }
58
+
59
+ function SheetsCard({ sheet }: { sheet: SheetsData }) {
60
+ const sheetUrl = sheet.url || (sheet.spreadsheetId ? `https://docs.google.com/spreadsheets/d/${sheet.spreadsheetId}` : null);
61
+
62
+ return (
63
+ <div className="space-y-2">
64
+ <div className="rounded-lg border border-border bg-card p-3">
65
+ <div className="flex items-start gap-2">
66
+ <Table2 className="h-4 w-4 text-muted-foreground mt-0.5 shrink-0" />
67
+ <div className="min-w-0 flex-1">
68
+ <p className="text-sm font-medium text-foreground">
69
+ {sheetUrl ? (
70
+ <a href={sheetUrl} target="_blank" rel="noopener noreferrer" className="hover:underline">
71
+ {sheet.title || "Spreadsheet"}
72
+ </a>
73
+ ) : (
74
+ sheet.title || "Spreadsheet"
75
+ )}
76
+ </p>
77
+ {(sheet.updatedRange || sheet.range) && (
78
+ <p className="text-xs text-muted-foreground">
79
+ {sheet.updatedRange || sheet.range}
80
+ {sheet.updatedCells !== undefined && ` \u00B7 ${sheet.updatedCells} cells updated`}
81
+ </p>
82
+ )}
83
+ </div>
84
+ {sheetUrl && (
85
+ <a href={sheetUrl} target="_blank" rel="noopener noreferrer" className="shrink-0 text-muted-foreground hover:text-foreground">
86
+ <ExternalLink className="h-3.5 w-3.5" />
87
+ </a>
88
+ )}
89
+ </div>
90
+ </div>
91
+
92
+ {sheet.values && sheet.values.length > 0 && (
93
+ <SheetsTable values={sheet.values} />
94
+ )}
95
+ </div>
96
+ );
97
+ }
98
+
99
+ export function SheetsRenderer({ data }: SheetsRendererProps) {
100
+ if (isSheetsData(data)) {
101
+ return <SheetsCard sheet={data} />;
102
+ }
103
+
104
+ return (
105
+ <pre className="rounded-lg border border-border bg-background p-3 text-xs text-foreground overflow-x-auto max-h-96 overflow-y-auto">
106
+ {JSON.stringify(data, null, 2)}
107
+ </pre>
108
+ );
109
+ }