Skip to content

Commit 36e6b09

Browse files
committed
support-in-dashboard v1
1 parent 4f02f6e commit 36e6b09

File tree

19 files changed

+2391
-0
lines changed

19 files changed

+2391
-0
lines changed
Lines changed: 328 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,328 @@
1+
"use server";
2+
import "server-only";
3+
import { NEXT_PUBLIC_THIRDWEB_API_HOST } from "@/constants/public-envs";
4+
import { getAuthToken, getAuthTokenWalletAddress } from "./auth-token";
5+
6+
export interface SupportTicket {
7+
id: string;
8+
status: "needs_response" | "in_progress" | "on_hold" | "closed" | "resolved";
9+
createdAt: string;
10+
updatedAt: string;
11+
messages?: SupportMessage[];
12+
}
13+
14+
interface SupportMessage {
15+
id: string;
16+
content: string;
17+
createdAt: string;
18+
timestamp: string;
19+
author?: {
20+
name: string;
21+
email: string;
22+
type: "user" | "customer";
23+
};
24+
}
25+
26+
interface CreateSupportTicketRequest {
27+
message: string;
28+
teamSlug: string;
29+
title: string;
30+
}
31+
32+
interface SendMessageRequest {
33+
ticketId: string;
34+
teamSlug: string;
35+
message: string;
36+
}
37+
38+
export async function getSupportTicketsByTeam(
39+
teamSlug: string,
40+
authToken?: string,
41+
): Promise<SupportTicket[]> {
42+
if (!teamSlug) {
43+
throw new Error("Team slug is required to fetch support tickets");
44+
}
45+
46+
const token = authToken || (await getAuthToken());
47+
if (!token) {
48+
throw new Error("No auth token available");
49+
}
50+
51+
// URL encode the team slug to handle special characters like #
52+
const encodedTeamSlug = encodeURIComponent(teamSlug);
53+
const apiUrl = `${NEXT_PUBLIC_THIRDWEB_API_HOST}/v1/teams/${encodedTeamSlug}/support-conversations/list`;
54+
55+
// Build the POST payload according to API spec
56+
const payload = {
57+
limit: 50,
58+
descending: true,
59+
};
60+
61+
const response = await fetch(apiUrl, {
62+
body: JSON.stringify(payload),
63+
cache: "no-store",
64+
headers: {
65+
Accept: "application/json",
66+
"Accept-Encoding": "identity",
67+
Authorization: `Bearer ${token}`,
68+
"Content-Type": "application/json",
69+
},
70+
method: "POST",
71+
});
72+
73+
if (!response.ok) {
74+
const errorText = await response.text();
75+
throw new Error(`API Server error: ${response.status} - ${errorText}`);
76+
}
77+
const data: { data?: SupportTicket[] } = await response.json();
78+
const conversations = data.data || [];
79+
return conversations;
80+
}
81+
82+
interface RawSupportMessage {
83+
id: string;
84+
text?: string;
85+
timestamp?: string;
86+
createdAt?: string;
87+
isPrivateNote?: boolean;
88+
sentByUser?: {
89+
name: string;
90+
email: string;
91+
isExternal: boolean;
92+
};
93+
// Add any other fields you use from the API
94+
}
95+
96+
export async function getSupportTicket(
97+
ticketId: string,
98+
teamSlug: string,
99+
authToken?: string,
100+
): Promise<SupportTicket | null> {
101+
if (!ticketId || !teamSlug) {
102+
throw new Error("Ticket ID and team slug are required");
103+
}
104+
105+
const token = authToken || (await getAuthToken());
106+
if (!token) {
107+
throw new Error("No auth token available");
108+
}
109+
110+
// URL encode the team slug to handle special characters like #
111+
const encodedTeamSlug = encodeURIComponent(teamSlug);
112+
const encodedTicketId = encodeURIComponent(ticketId);
113+
114+
const messagesPayload = {
115+
limit: 100,
116+
descending: false,
117+
};
118+
119+
// Fetch conversation details and messages in parallel
120+
const [conversationResponse, messagesResponse] = await Promise.all([
121+
fetch(
122+
`${NEXT_PUBLIC_THIRDWEB_API_HOST}/v1/teams/${encodedTeamSlug}/support-conversations/${encodedTicketId}`,
123+
{
124+
cache: "no-store",
125+
headers: {
126+
Accept: "application/json",
127+
"Accept-Encoding": "identity",
128+
Authorization: `Bearer ${token}`,
129+
"Content-Type": "application/json",
130+
},
131+
method: "GET",
132+
},
133+
),
134+
fetch(
135+
`${NEXT_PUBLIC_THIRDWEB_API_HOST}/v1/teams/${encodedTeamSlug}/support-conversations/${encodedTicketId}/messages/list`,
136+
{
137+
body: JSON.stringify(messagesPayload),
138+
cache: "no-store",
139+
headers: {
140+
Accept: "application/json",
141+
"Accept-Encoding": "identity",
142+
Authorization: `Bearer ${token}`,
143+
"Content-Type": "application/json",
144+
},
145+
method: "POST",
146+
},
147+
),
148+
]);
149+
150+
if (!conversationResponse.ok) {
151+
if (conversationResponse.status === 404) {
152+
return null; // Ticket not found
153+
}
154+
const errorText = await conversationResponse.text();
155+
throw new Error(
156+
`API Server error: ${conversationResponse.status} - ${errorText}`,
157+
);
158+
}
159+
160+
const conversation: SupportTicket = await conversationResponse.json();
161+
162+
// Fetch and map messages if the messages request was successful
163+
if (messagesResponse.ok) {
164+
const messagesData: { data?: unknown[] } = await messagesResponse.json();
165+
const rawMessages = messagesData.data || [];
166+
console.log("rawMessages", rawMessages);
167+
// Transform the raw messages to match our interface
168+
const messages: SupportMessage[] = (rawMessages as RawSupportMessage[])
169+
.filter((msg) => {
170+
// Filter out messages without content - check both text and text fields
171+
const hasContent = msg.text && msg.text.length > 0;
172+
const hasText = msg.text && msg.text.trim().length > 0;
173+
// Filter out private notes - they should not be shown to customers
174+
const isNotPrivateNote = !msg.isPrivateNote;
175+
return (hasContent || hasText) && isNotPrivateNote;
176+
})
177+
.map((msg) => {
178+
// Use text if available and is a non-empty array, otherwise fall back to text
179+
let content = "";
180+
if (typeof msg.text === "string" && msg.text.trim().length > 0) {
181+
content = msg.text;
182+
}
183+
184+
// Clean up 'Email:' line to show only the plain email if it contains a mailto link
185+
if (content) {
186+
content = content
187+
.split("\n")
188+
.map((line) => {
189+
if (line.trim().toLowerCase().startsWith("email:")) {
190+
// Extract email from <mailto:...|...>
191+
const match = line.match(/<mailto:([^|>]+)\|[^>]+>/);
192+
if (match) {
193+
return `Email: ${match[1]}`;
194+
}
195+
}
196+
return line;
197+
})
198+
.join("\n");
199+
}
200+
201+
// Map the author information from sentByUser if available
202+
const author = msg.sentByUser
203+
? {
204+
name: msg.sentByUser.name,
205+
email: msg.sentByUser.email,
206+
type: (msg.sentByUser.isExternal ? "customer" : "user") as
207+
| "user"
208+
| "customer",
209+
}
210+
: undefined;
211+
212+
return {
213+
id: msg.id,
214+
content: content,
215+
createdAt: msg.timestamp || msg.createdAt || "",
216+
timestamp: msg.timestamp || msg.createdAt || "",
217+
author: author,
218+
};
219+
});
220+
221+
conversation.messages = messages;
222+
} else {
223+
// Don't throw error, just leave messages empty
224+
const _errorText = await messagesResponse.text();
225+
conversation.messages = [];
226+
}
227+
228+
return conversation;
229+
}
230+
231+
export async function createSupportTicket(
232+
request: CreateSupportTicketRequest,
233+
): Promise<SupportTicket> {
234+
if (!request.teamSlug) {
235+
throw new Error("Team slug is required to create support ticket");
236+
}
237+
238+
const token = await getAuthToken();
239+
if (!token) {
240+
throw new Error("No auth token available");
241+
}
242+
243+
// Fetch wallet address (server-side)
244+
const walletAddress = await getAuthTokenWalletAddress();
245+
246+
// URL encode the team slug to handle special characters like #
247+
const encodedTeamSlug = encodeURIComponent(request.teamSlug);
248+
const apiUrl = `${NEXT_PUBLIC_THIRDWEB_API_HOST}/v1/teams/${encodedTeamSlug}/support-conversations`;
249+
250+
// Build the payload for creating a conversation
251+
// If the message does not already include wallet address, prepend it
252+
let message = request.message;
253+
if (!message.includes("Wallet address:")) {
254+
message = `Wallet address: ${String(walletAddress || "-")}\n${message}`;
255+
}
256+
257+
const payload = {
258+
markdown: message.trim(),
259+
title: request.title,
260+
};
261+
262+
const body = JSON.stringify(payload);
263+
const headers: Record<string, string> = {
264+
Accept: "application/json",
265+
Authorization: `Bearer ${token}`,
266+
"Content-Type": "application/json",
267+
"Accept-Encoding": "identity",
268+
};
269+
270+
const response = await fetch(apiUrl, {
271+
body,
272+
headers,
273+
method: "POST",
274+
});
275+
276+
if (!response.ok) {
277+
const errorText = await response.text();
278+
throw new Error(`API Server error: ${response.status} - ${errorText}`);
279+
}
280+
281+
const createdConversation: SupportTicket = await response.json();
282+
return createdConversation;
283+
}
284+
285+
export async function sendMessageToTicket(
286+
request: SendMessageRequest,
287+
): Promise<void> {
288+
if (!request.ticketId || !request.teamSlug) {
289+
throw new Error("Ticket ID and team slug are required");
290+
}
291+
292+
const token = await getAuthToken();
293+
if (!token) {
294+
throw new Error("No auth token available");
295+
}
296+
297+
// URL encode the team slug and ticket ID to handle special characters like #
298+
const encodedTeamSlug = encodeURIComponent(request.teamSlug);
299+
const encodedTicketId = encodeURIComponent(request.ticketId);
300+
const apiUrl = `${NEXT_PUBLIC_THIRDWEB_API_HOST}/v1/teams/${encodedTeamSlug}/support-conversations/${encodedTicketId}/messages`;
301+
302+
// Build the payload for sending a message
303+
// Append /unthread send for customer messages to ensure proper routing
304+
const messageWithUnthread = `${request.message.trim()}\n/unthread send`;
305+
const payload = {
306+
markdown: messageWithUnthread,
307+
};
308+
309+
const body = JSON.stringify(payload);
310+
const headers: Record<string, string> = {
311+
Accept: "application/json",
312+
Authorization: `Bearer ${token}`,
313+
"Content-Type": "application/json",
314+
"Accept-Encoding": "identity",
315+
};
316+
317+
const response = await fetch(apiUrl, {
318+
body,
319+
headers,
320+
method: "POST",
321+
});
322+
323+
if (!response.ok) {
324+
const errorText = await response.text();
325+
throw new Error(`API Server error: ${response.status} - ${errorText}`);
326+
}
327+
// Message sent successfully, no need to return anything
328+
}
Lines changed: 24 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,24 @@
1+
"use client";
2+
3+
import { cn } from "@/lib/utils";
4+
5+
export function SupportLayout(props: { children: React.ReactNode }) {
6+
// If mobile navigation is needed in the future, add state and logic here.
7+
const showFullNavOnMobile = true;
8+
9+
return (
10+
<div className="flex grow flex-col">
11+
{/* Page content */}
12+
<div className="container flex grow gap-8 lg:min-h-[900px] [&>*]:py-8 lg:[&>*]:py-10">
13+
<div
14+
className={cn(
15+
"flex max-w-full grow flex-col",
16+
showFullNavOnMobile && "max-sm:hidden",
17+
)}
18+
>
19+
{props.children}
20+
</div>
21+
</div>
22+
</div>
23+
);
24+
}

0 commit comments

Comments
 (0)