Skip to content

[dashboard] - support in dashboard #7608

New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

Open
wants to merge 9 commits into
base: main
Choose a base branch
from
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
328 changes: 328 additions & 0 deletions apps/dashboard/src/@/api/support.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,328 @@
"use server";
import "server-only";
import { NEXT_PUBLIC_THIRDWEB_API_HOST } from "@/constants/public-envs";
import { getAuthToken, getAuthTokenWalletAddress } from "./auth-token";

export interface SupportTicket {
id: string;
status: "needs_response" | "in_progress" | "on_hold" | "closed" | "resolved";
createdAt: string;
updatedAt: string;
messages?: SupportMessage[];
}

interface SupportMessage {
id: string;
content: string;
createdAt: string;
timestamp: string;
author?: {
name: string;
email: string;
type: "user" | "customer";
};
}

interface CreateSupportTicketRequest {
message: string;
teamSlug: string;
title: string;
}

interface SendMessageRequest {
ticketId: string;
teamSlug: string;
message: string;
}

export async function getSupportTicketsByTeam(
teamSlug: string,
authToken?: string,
): Promise<SupportTicket[]> {
if (!teamSlug) {
throw new Error("Team slug is required to fetch support tickets");
}

const token = authToken || (await getAuthToken());
if (!token) {
throw new Error("No auth token available");
}

// URL encode the team slug to handle special characters like #
const encodedTeamSlug = encodeURIComponent(teamSlug);
const apiUrl = `${NEXT_PUBLIC_THIRDWEB_API_HOST}/v1/teams/${encodedTeamSlug}/support-conversations/list`;

// Build the POST payload according to API spec
const payload = {
limit: 50,
descending: true,
};

const response = await fetch(apiUrl, {
body: JSON.stringify(payload),
cache: "no-store",
headers: {
Accept: "application/json",
"Accept-Encoding": "identity",
Authorization: `Bearer ${token}`,
"Content-Type": "application/json",
},
method: "POST",
});

if (!response.ok) {
const errorText = await response.text();
throw new Error(`API Server error: ${response.status} - ${errorText}`);
}
const data: { data?: SupportTicket[] } = await response.json();
const conversations = data.data || [];
return conversations;
}

interface RawSupportMessage {
id: string;
text?: string;
timestamp?: string;
createdAt?: string;
isPrivateNote?: boolean;
sentByUser?: {
name: string;
email: string;
isExternal: boolean;
};
// Add any other fields you use from the API
}

export async function getSupportTicket(
ticketId: string,
teamSlug: string,
authToken?: string,
): Promise<SupportTicket | null> {
if (!ticketId || !teamSlug) {
throw new Error("Ticket ID and team slug are required");
}

const token = authToken || (await getAuthToken());
if (!token) {
throw new Error("No auth token available");
}

// URL encode the team slug to handle special characters like #
const encodedTeamSlug = encodeURIComponent(teamSlug);
const encodedTicketId = encodeURIComponent(ticketId);

const messagesPayload = {
limit: 100,
descending: false,
};

// Fetch conversation details and messages in parallel
const [conversationResponse, messagesResponse] = await Promise.all([
fetch(
`${NEXT_PUBLIC_THIRDWEB_API_HOST}/v1/teams/${encodedTeamSlug}/support-conversations/${encodedTicketId}`,
{
cache: "no-store",
headers: {
Accept: "application/json",
"Accept-Encoding": "identity",
Authorization: `Bearer ${token}`,
"Content-Type": "application/json",
},
method: "GET",
},
),
fetch(
`${NEXT_PUBLIC_THIRDWEB_API_HOST}/v1/teams/${encodedTeamSlug}/support-conversations/${encodedTicketId}/messages/list`,
{
body: JSON.stringify(messagesPayload),
cache: "no-store",
headers: {
Accept: "application/json",
"Accept-Encoding": "identity",
Authorization: `Bearer ${token}`,
"Content-Type": "application/json",
},
method: "POST",
},
),
]);

if (!conversationResponse.ok) {
if (conversationResponse.status === 404) {
return null; // Ticket not found
}
const errorText = await conversationResponse.text();
throw new Error(
`API Server error: ${conversationResponse.status} - ${errorText}`,
);
}

const conversation: SupportTicket = await conversationResponse.json();

// Fetch and map messages if the messages request was successful
if (messagesResponse.ok) {
const messagesData: { data?: unknown[] } = await messagesResponse.json();
const rawMessages = messagesData.data || [];
// Transform the raw messages to match our interface
const messages: SupportMessage[] = (rawMessages as RawSupportMessage[])
.filter((msg) => {
// Filter out messages without content - check both text and text fields
const hasContent = msg.text && msg.text.length > 0;
const hasText = msg.text && msg.text.trim().length > 0;
// Filter out private notes - they should not be shown to customers
const isNotPrivateNote = !msg.isPrivateNote;
return (hasContent || hasText) && isNotPrivateNote;
})
.map((msg) => {
// Use text if available and is a non-empty array, otherwise fall back to text
let content = "";
if (typeof msg.text === "string" && msg.text.trim().length > 0) {
content = msg.text;
}

// Clean up 'Email:' line to show only the plain email if it contains a mailto link
if (content) {
content = content
.split("\n")
.map((line) => {
if (line.trim().toLowerCase().startsWith("email:")) {
// Extract email from <mailto:...|...>
const match = line.match(/<mailto:([^|>]+)\|[^>]+>/);
if (match) {
return `Email: ${match[1]}`;
}
}
return line;
})
.join("\n");
}

// Map the author information from sentByUser if available
const author = msg.sentByUser
? {
name: msg.sentByUser.name,
email: msg.sentByUser.email,
type: (msg.sentByUser.isExternal ? "customer" : "user") as
| "user"
| "customer",
}
: undefined;

return {
id: msg.id,
content: content,
createdAt: msg.timestamp || msg.createdAt || "",
timestamp: msg.timestamp || msg.createdAt || "",
author: author,
};
});

conversation.messages = messages;
} else {
// Don't throw error, just leave messages empty
const errorText = await messagesResponse.text();
console.error("Failed to fetch messages:", errorText);
conversation.messages = [];
}

return conversation;
}

export async function createSupportTicket(
request: CreateSupportTicketRequest,
): Promise<SupportTicket> {
if (!request.teamSlug) {
throw new Error("Team slug is required to create support ticket");
}

const token = await getAuthToken();
if (!token) {
throw new Error("No auth token available");
}

// Fetch wallet address (server-side)
const walletAddress = await getAuthTokenWalletAddress();

// URL encode the team slug to handle special characters like #
const encodedTeamSlug = encodeURIComponent(request.teamSlug);
const apiUrl = `${NEXT_PUBLIC_THIRDWEB_API_HOST}/v1/teams/${encodedTeamSlug}/support-conversations`;

// Build the payload for creating a conversation
// If the message does not already include wallet address, prepend it
let message = request.message;
if (!message.includes("Wallet address:")) {
message = `Wallet address: ${String(walletAddress || "-")}\n${message}`;
}

const payload = {
markdown: message.trim(),
title: request.title,
};

const body = JSON.stringify(payload);
const headers: Record<string, string> = {
Accept: "application/json",
Authorization: `Bearer ${token}`,
"Content-Type": "application/json",
"Accept-Encoding": "identity",
};

const response = await fetch(apiUrl, {
body,
headers,
method: "POST",
});

if (!response.ok) {
const errorText = await response.text();
throw new Error(`API Server error: ${response.status} - ${errorText}`);
}

const createdConversation: SupportTicket = await response.json();
return createdConversation;
}

export async function sendMessageToTicket(
request: SendMessageRequest,
): Promise<void> {
if (!request.ticketId || !request.teamSlug) {
throw new Error("Ticket ID and team slug are required");
}

const token = await getAuthToken();
if (!token) {
throw new Error("No auth token available");
}

// URL encode the team slug and ticket ID to handle special characters like #
const encodedTeamSlug = encodeURIComponent(request.teamSlug);
const encodedTicketId = encodeURIComponent(request.ticketId);
const apiUrl = `${NEXT_PUBLIC_THIRDWEB_API_HOST}/v1/teams/${encodedTeamSlug}/support-conversations/${encodedTicketId}/messages`;

// Build the payload for sending a message
// Append /unthread send for customer messages to ensure proper routing
const messageWithUnthread = `${request.message.trim()}\n/unthread send`;
const payload = {
markdown: messageWithUnthread,
};

const body = JSON.stringify(payload);
const headers: Record<string, string> = {
Accept: "application/json",
Authorization: `Bearer ${token}`,
"Content-Type": "application/json",
"Accept-Encoding": "identity",
};

const response = await fetch(apiUrl, {
body,
headers,
method: "POST",
});

if (!response.ok) {
const errorText = await response.text();
throw new Error(`API Server error: ${response.status} - ${errorText}`);
}
// Message sent successfully, no need to return anything
}
6 changes: 1 addition & 5 deletions apps/dashboard/src/@/api/team.ts
Original file line number Diff line number Diff line change
Expand Up @@ -48,10 +48,6 @@ export async function service_getTeamBySlug(slug: string) {
return null;
}

export function getTeamById(id: string) {
return getTeamBySlug(id);
}

export async function getTeams() {
const token = await getAuthToken();
if (!token) {
Expand Down Expand Up @@ -97,7 +93,7 @@ export async function getLastVisitedTeam() {
const lastVisitedTeamId = cookiesStore.get(LAST_USED_TEAM_ID)?.value;

if (lastVisitedTeamId) {
const team = await getTeamById(lastVisitedTeamId);
const team = await getTeamBySlug(lastVisitedTeamId);
Copy link
Contributor

@coderabbitai coderabbitai bot Jul 15, 2025

Choose a reason for hiding this comment

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

💡 Verification agent

🧩 Analysis chain

Semantic mismatch: Variable name suggests ID but function expects slug.

The variable lastVisitedTeamId and the cookie constant LAST_USED_TEAM_ID suggest that a team ID is stored, but getTeamBySlug() expects a slug parameter. This creates confusion about what the cookie actually contains.

If the cookie now stores slugs (as part of the refactoring), consider renaming the variable and constant to reflect this:

-const lastVisitedTeamId = cookiesStore.get(LAST_USED_TEAM_ID)?.value;
+const lastVisitedTeamSlug = cookiesStore.get(LAST_USED_TEAM_SLUG)?.value;

-if (lastVisitedTeamId) {
+if (lastVisitedTeamSlug) {
-  const team = await getTeamBySlug(lastVisitedTeamId);
+  const team = await getTeamBySlug(lastVisitedTeamSlug);

Run the following script to verify what values are actually stored in the LAST_USED_TEAM_ID cookie across the codebase:


🏁 Script executed:

#!/bin/bash
# Description: Search for LAST_USED_TEAM_ID cookie usage to understand if it stores IDs or slugs

echo "=== Searching for LAST_USED_TEAM_ID constant usage ==="
rg "LAST_USED_TEAM_ID" -A 3 -B 3

echo -e "\n=== Searching for cookie set operations ==="
rg "LAST_USED_TEAM_ID.*set|set.*LAST_USED_TEAM_ID" -A 5 -B 5

echo -e "\n=== Searching for related cookie constants ==="
rg "LAST_USED_TEAM" 

Length of output: 15218


Rename LAST_USED_TEAM_ID and related variables to reflect that they store slugs, not IDs

We’ve confirmed that the cookie LAST_USED_TEAM_ID always holds the team slug (see its use in SaveLastUsedProject.tsx, custom-contract.tsx, etc.) and is passed into getTeamBySlug(). The current naming (ID) is misleading. Please update:

• constants/cookies.ts
– export const LAST_USED_TEAM_ID → LAST_USED_TEAM_SLUG
• apps/dashboard/src/@/api/team.ts
const lastVisitedTeamIdconst lastVisitedTeamSlug
– use cookiesStore.get(LAST_USED_TEAM_SLUG) and pass to getTeamBySlug(lastVisitedTeamSlug)
• apps/dashboard/src/@/api/auth-token.ts
const lastUsedTeamIdconst lastUsedTeamSlug
cookiesManager.get(LAST_USED_TEAM_SLUG)
• components that read/write this cookie (e.g. SaveLastUsedProject.tsx, SaveLastVisitedPage.tsx, custom-contract.tsx)
– prop/variable names from teamIdteamSlug
– calls to setCookie(LAST_USED_TEAM_ID, …) / getCookie(LAST_USED_TEAM_ID) → use LAST_USED_TEAM_SLUG

Example diff in apps/dashboard/src/@/api/team.ts:

-import { LAST_USED_TEAM_ID } from "@/constants/cookies";
+import { LAST_USED_TEAM_SLUG } from "@/constants/cookies";
 
-const lastVisitedTeamId = cookiesStore.get(LAST_USED_TEAM_ID)?.value;
+const lastVisitedTeamSlug = cookiesStore.get(LAST_USED_TEAM_SLUG)?.value;
 
-if (lastVisitedTeamId) {
-  const team = await getTeamBySlug(lastVisitedTeamId);
+if (lastVisitedTeamSlug) {
+  const team = await getTeamBySlug(lastVisitedTeamSlug);
 }

These renames will eliminate confusion between “ID” and “slug” throughout the codebase.

📝 Committable suggestion

‼️ IMPORTANT
Carefully review the code before committing. Ensure that it accurately replaces the highlighted code, contains no missing lines, and has no issues with indentation. Thoroughly test & benchmark the code to ensure it meets the requirements.

Suggested change
const team = await getTeamBySlug(lastVisitedTeamId);
--- a/apps/dashboard/src/@/api/team.ts
+++ b/apps/dashboard/src/@/api/team.ts
@@
-import { LAST_USED_TEAM_ID } from "@/constants/cookies";
+import { LAST_USED_TEAM_SLUG } from "@/constants/cookies";
@@
-const lastVisitedTeamId = cookiesStore.get(LAST_USED_TEAM_ID)?.value;
+const lastVisitedTeamSlug = cookiesStore.get(LAST_USED_TEAM_SLUG)?.value;
@@
-if (lastVisitedTeamId) {
- const team = await getTeamBySlug(lastVisitedTeamId);
-}
+if (lastVisitedTeamSlug) {
+ const team = await getTeamBySlug(lastVisitedTeamSlug);
+}
🤖 Prompt for AI Agents
In apps/dashboard/src/@/api/team.ts at line 96 and related files, rename all
instances of LAST_USED_TEAM_ID and variables like lastVisitedTeamId to
LAST_USED_TEAM_SLUG and lastVisitedTeamSlug respectively, reflecting that these
store slugs, not IDs. Update imports and usages to use
cookiesStore.get(LAST_USED_TEAM_SLUG) and pass lastVisitedTeamSlug to
getTeamBySlug. Apply similar renaming in constants/cookies.ts,
apps/dashboard/src/@/api/auth-token.ts, and components like
SaveLastUsedProject.tsx and custom-contract.tsx, changing variable names and
cookie get/set calls accordingly to maintain consistency and clarity.

Copy link
Member Author

Choose a reason for hiding this comment

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

getTeamById was calling the getTeamBySlug just as a wrapper!

Copy link
Contributor

Choose a reason for hiding this comment

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

@Yash094, thank you for that clarification! You're absolutely right - if getTeamById was just a wrapper around getTeamBySlug, then the functionality is unchanged by this refactoring. The change simply removes the unnecessary wrapper and calls getTeamBySlug directly.

This means the variable lastVisitedTeamId and cookie LAST_USED_TEAM_ID were actually storing slugs all along, and the misleading naming predated this change. The refactoring is a good cleanup that removes the wrapper layer.

While the functionality is correct, my suggestion about renaming LAST_USED_TEAM_ID to LAST_USED_TEAM_SLUG and lastVisitedTeamId to lastVisitedTeamSlug would still improve code clarity by making it explicit that these store slugs rather than IDs. But that's a separate improvement for better semantic naming, not a fix for this specific change.

Thanks for the context - it helps understand that this is purely a wrapper removal, not a functional change!


✏️ Learnings added
Learnt from: Yash094
PR: thirdweb-dev/js#7608
File: apps/dashboard/src/@/api/team.ts:96-96
Timestamp: 2025-07-15T08:25:44.527Z
Learning: In the thirdweb dashboard codebase, the `getTeamById` function was actually a wrapper around `getTeamBySlug`, so removing it and calling `getTeamBySlug` directly doesn't change functionality. The `LAST_USED_TEAM_ID` cookie and related variables were storing slugs all along, not IDs.

Note: Learnings are effective only in the context of similar code segments. To apply general coding guidelines, please configure review instructions. You can manage existing learnings in the app.


🧠 Learnings used
Learnt from: jnsdls
PR: thirdweb-dev/js#6929
File: apps/dashboard/src/app/(app)/team/[team_slug]/[project_slug]/insight/webhooks/page.tsx:14-19
Timestamp: 2025-05-21T05:17:31.283Z
Learning: In Next.js server components, the `params` object can sometimes be a Promise that needs to be awaited, despite type annotations suggesting otherwise. In apps/dashboard/src/app/(app)/team/[team_slug]/[project_slug]/insight/webhooks/page.tsx, it's necessary to await the params object before accessing its properties.

Learnt from: MananTank
PR: thirdweb-dev/js#7315
File: apps/dashboard/src/app/(app)/team/[team_slug]/[project_slug]/(sidebar)/assets/create/nft/launch-nft.tsx:155-160
Timestamp: 2025-06-10T00:46:58.580Z
Learning: In the dashboard application, the route structure for team and project navigation is `/team/[team_slug]/[project_slug]/...` without a `/project/` segment. Contract links should be formatted as `/team/${teamSlug}/${projectSlug}/contract/${chainId}/${contractAddress}`.

Learnt from: CR
PR: thirdweb-dev/js#0
File: .cursor/rules/dashboard.mdc:0-0
Timestamp: 2025-06-30T10:26:04.389Z
Learning: Applies to dashboard/**/api/**/*.{ts,tsx} : Reading cookies/headers with `next/headers` (`getAuthToken()`, `cookies()`).

Learnt from: CR
PR: thirdweb-dev/js#0
File: CLAUDE.md:0-0
Timestamp: 2025-06-30T10:25:29.489Z
Learning: Applies to apps/{dashboard,playground-web}/**/*.{tsx,ts} : Server Side Data Fetching: Always call `getAuthToken()` to retrieve JWT from cookies

Learnt from: CR
PR: thirdweb-dev/js#0
File: .cursor/rules/dashboard.mdc:0-0
Timestamp: 2025-06-30T10:26:04.389Z
Learning: Applies to dashboard/**/api/**/*.{ts,tsx} : Always call `getAuthToken()` to get the JWT from cookies.

Learnt from: MananTank
PR: thirdweb-dev/js#7152
File: apps/dashboard/src/app/(app)/team/[team_slug]/[project_slug]/contract/[chainIdOrSlug]/[contractAddress]/nfts/page.tsx:20-20
Timestamp: 2025-05-26T16:26:58.068Z
Learning: In team/project contract pages under routes like `/team/[team_slug]/[project_slug]/contract/[chainIdOrSlug]/[contractAddress]/*`, users are always logged in by design. The hardcoded `isLoggedIn={true}` prop in these pages is intentional and correct, not a bug to be fixed.

Learnt from: MananTank
PR: thirdweb-dev/js#7315
File: apps/dashboard/src/app/(app)/team/[team_slug]/[project_slug]/(sidebar)/assets/create/nft/launch-nft.tsx:153-226
Timestamp: 2025-06-10T00:50:20.795Z
Learning: In apps/dashboard/src/app/(app)/team/[team_slug]/[project_slug]/(sidebar)/assets/create/nft/launch-nft.tsx, the updateStatus function correctly expects a complete MultiStepState["status"] object. For pending states, { type: "pending" } is the entire status object. For error states, { type: "error", message: React.ReactNode } is the entire status object. The current code incorrectly spreads the entire step object instead of passing just the status object.

Learnt from: CR
PR: thirdweb-dev/js#0
File: CLAUDE.md:0-0
Timestamp: 2025-06-30T10:25:29.489Z
Learning: Applies to apps/{dashboard,playground-web}/**/*.{tsx,ts} : Server Side Data Fetching: Use `Authorization: Bearer` header – never embed tokens in URLs

Learnt from: CR
PR: thirdweb-dev/js#0
File: .cursor/rules/dashboard.mdc:0-0
Timestamp: 2025-06-30T10:26:04.389Z
Learning: Applies to dashboard/**/hooks/**/*.{ts,tsx} : Keep `queryKey` stable and descriptive for cache hits.

Learnt from: CR
PR: thirdweb-dev/js#0
File: CLAUDE.md:0-0
Timestamp: 2025-06-30T10:25:29.489Z
Learning: Applies to apps/{dashboard,playground-web}/**/*.{tsx,ts} : Client Side Data Fetching: Use descriptive, stable `queryKeys` for cache hits

Learnt from: CR
PR: thirdweb-dev/js#0
File: .cursor/rules/dashboard.mdc:0-0
Timestamp: 2025-06-30T10:26:04.389Z
Learning: Applies to dashboard/**/api/**/*.{ts,tsx} : Export default async functions without `'use client';` – they run on the Node edge.

Learnt from: CR
PR: thirdweb-dev/js#0
File: CLAUDE.md:0-0
Timestamp: 2025-06-30T10:25:29.488Z
Learning: Surface breaking changes prominently in PR descriptions

Learnt from: CR
PR: thirdweb-dev/js#0
File: .cursor/rules/dashboard.mdc:0-0
Timestamp: 2025-06-30T10:26:04.389Z
Learning: Applies to dashboard/**/components/*.client.tsx : Interactive UI that relies on hooks (`useState`, `useEffect`, React Query, wallet hooks).

Learnt from: arcoraven
PR: thirdweb-dev/js#7505
File: apps/dashboard/src/app/(app)/team/[team_slug]/[project_slug]/(sidebar)/webhooks/analytics/components/WebhookAnalyticsCharts.tsx:186-204
Timestamp: 2025-07-10T10:18:33.238Z
Learning: The ThirdwebBarChart component in apps/dashboard/src/app/(app)/team/[team_slug]/[project_slug]/(sidebar)/webhooks/analytics/components/WebhookAnalyticsCharts.tsx does not accept standard accessibility props like `aria-label` and `role` in its TypeScript interface, causing compilation errors when added.

Copy link
Member

Choose a reason for hiding this comment

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

@Yash094 Prefer using getTeamById still so its not confusing

Copy link
Contributor

Choose a reason for hiding this comment

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

Seems like the humans are having a chat. I'll hop back into my burrow for now. If you need me again, just tag @coderabbitai in a new comment, and I'll come hopping out!

if (team) {
return team;
}
Expand Down
5 changes: 3 additions & 2 deletions apps/dashboard/src/@/components/chat/CustomChatButton.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -3,6 +3,7 @@
import { MessageCircleIcon, XIcon } from "lucide-react";
import { useCallback, useRef, useState } from "react";
import { createThirdwebClient } from "thirdweb";
import type { Team } from "@/api/team";
import { Button } from "@/components/ui/button";
import { NEXT_PUBLIC_DASHBOARD_CLIENT_ID } from "@/constants/public-envs";
import { cn } from "@/lib/utils";
Expand All @@ -21,7 +22,7 @@ export function CustomChatButton(props: {
label: string;
examplePrompts: string[];
authToken: string | undefined;
teamId: string | undefined;
team: Team; // changed from teamId
clientId: string | undefined;
requireLogin?: boolean;
}) {
Expand Down Expand Up @@ -82,7 +83,7 @@ export function CustomChatButton(props: {
}))}
networks={props.networks}
requireLogin={props.requireLogin}
teamId={props.teamId}
team={props.team} // pass full team object
/>
)}
</div>
Expand Down
Loading
Loading