Compare commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
75bf09126f | ||
|
|
9d8c7f1f7d | ||
|
|
9dc8f5a637 | ||
|
|
48361f92b7 | ||
|
|
1659ae3754 | ||
|
|
88d44aff50 | ||
|
|
c63b3497bd | ||
|
|
74a21635e4 | ||
|
|
66b9bf3c6b | ||
|
|
cf22c23b45 | ||
|
|
170b020e17 | ||
|
|
8260af3a92 | ||
|
|
b9154cc9e7 | ||
|
|
08955f1588 | ||
|
|
756295241a | ||
|
|
249679481e | ||
|
|
a491de4d7c | ||
|
|
898efc56a9 | ||
|
|
c0e7ae95de | ||
|
|
1e8190a1f9 | ||
|
|
aac187a76c | ||
|
|
03390c1e25 | ||
|
|
8d3b9c6a33 | ||
|
|
ddef88866f | ||
|
|
8133fab9e4 | ||
|
|
37414eb54a | ||
|
|
22bf68a6f0 | ||
|
|
68dd4dc17e | ||
|
|
ecf6dbf2aa | ||
|
|
01daaceb21 | ||
|
|
a5345808ec | ||
|
|
bfa0a42219 | ||
|
|
426bb8bbb2 | ||
|
|
823af013ae | ||
|
|
98aea1e0b9 | ||
|
|
620e294f8c | ||
|
|
84eaded152 | ||
|
|
31d7173432 | ||
|
|
a48329b697 | ||
|
|
6e62a3aa5c | ||
|
|
746b0c9d8b | ||
|
|
0f38a1287a | ||
|
|
61bfebf5d8 | ||
|
|
170c377445 | ||
|
|
4cbf67181d | ||
|
|
6bf6108beb | ||
|
|
ce85be3346 | ||
|
|
e125e743a3 | ||
|
|
05da842b7a | ||
|
|
216b70475e | ||
|
|
9d259d0836 | ||
|
|
ca52cdd3bf | ||
|
|
cf68002922 | ||
|
|
495c7f925d | ||
|
|
32add5f602 | ||
|
|
5f74b3ca3e | ||
|
|
761cde6a32 | ||
|
|
9546b927d9 | ||
|
|
f82f885dad | ||
|
|
34d2fddf49 | ||
|
|
a7544f75ce | ||
|
|
bd3d645be6 | ||
|
|
5fedc2c040 | ||
|
|
6c8a7cf2ba | ||
|
|
4f02f6eeb7 | ||
|
|
72f75ea40b | ||
|
|
8b6a953933 | ||
|
|
33b0b6dcb4 | ||
|
|
cdc7d95db2 | ||
|
|
592bf6f1fd | ||
|
|
8973174ad5 | ||
|
|
c160e91c08 | ||
|
|
f33e31ad6e | ||
|
|
4111d0e638 | ||
|
|
ffb7b6487b | ||
|
|
77063e5c85 | ||
|
|
585eac2a6f | ||
|
|
c54db522b3 | ||
|
|
d62e9971ee | ||
|
|
7d796f5aa5 |
@@ -18,8 +18,8 @@
|
||||
## Features
|
||||
|
||||
- Support for React & React-Native
|
||||
- First party support for [Embedded Wallets](https://portal.thirdweb.com/connect/embedded-wallet/overview) (social/email login)
|
||||
- First party support for [Account Abstraction](https://portal.thirdweb.com/connect/account-abstraction/overview)
|
||||
- First party support for [Embedded Wallets](https://portal.thirdweb.com/wallets) (social/email login)
|
||||
- First party support for [Account Abstraction](https://portal.thirdweb.com/wallets/sponsor-gas)
|
||||
- Instant connection to any chain with RPC Edge integration
|
||||
- Integrated IPFS upload/download
|
||||
- UI Components for connection, transactions, nft rendering
|
||||
|
||||
@@ -9,7 +9,7 @@ module.exports = [
|
||||
// -- build category
|
||||
"/wallets",
|
||||
"/account-abstraction",
|
||||
"/universal-bridge",
|
||||
"/payments",
|
||||
"/auth",
|
||||
"/in-app-wallets",
|
||||
"/transactions",
|
||||
@@ -62,4 +62,6 @@ module.exports = [
|
||||
// -- universal bridge landing pages --
|
||||
"/universal-bridge-regions/:region_slug",
|
||||
"/enterprise",
|
||||
"/token",
|
||||
"/vault",
|
||||
];
|
||||
|
||||
@@ -1,2 +0,0 @@
|
||||
name,description,image,animation_url,external_url,background_color,youtube_url,additional,properties,can be,added
|
||||
Token 0 Name,Token 0 Description,ipfs://ipfsHash/0,ipfs://ipfsHash/0,https://thirdweb.com,#0098EE,,every,row,is a ,property
|
||||
|
@@ -1,4 +0,0 @@
|
||||
name,description,image,animation_url,external_url,background_color,youtube_url,additional,properties,can be,added
|
||||
Token 0 Name,Token 0 Description,0.png,0.mp4,https://thirdweb.com,#0098EE,,every,row,is a ,property
|
||||
Token 1 Name,Token 1 Description,0.png,0.mp4,https://thirdweb.com,#0098EE,,every,row,is a ,property
|
||||
Token 2 Name,Token 2 Description,0.png,0.mp4,https://thirdweb.com,#0098EE,,every,row,is a ,property
|
||||
|
@@ -1,2 +0,0 @@
|
||||
name,description,external_url,background_color,youtube_url,additional,properties,can be,added
|
||||
Token 0 Name,Token 0 Description,https://thirdweb.com,#0098EE,,every,row,is a ,property
|
||||
|
@@ -66,15 +66,20 @@ const projectRoute = "/team/:team_slug/:project_slug";
|
||||
|
||||
const projectPageRedirects = [
|
||||
{
|
||||
destination: `${projectRoute}/universal-bridge/:path*`,
|
||||
destination: `${projectRoute}/payments/:path*`,
|
||||
permanent: false,
|
||||
source: `${projectRoute}/connect/pay/:path*`,
|
||||
},
|
||||
{
|
||||
destination: `${projectRoute}/universal-bridge/:path*`,
|
||||
destination: `${projectRoute}/payments/:path*`,
|
||||
permanent: false,
|
||||
source: `${projectRoute}/connect/universal-bridge/:path*`,
|
||||
},
|
||||
{
|
||||
destination: `${projectRoute}/payments/:path*`,
|
||||
permanent: false,
|
||||
source: `${projectRoute}/universal-bridge/:path*`,
|
||||
},
|
||||
{
|
||||
destination: `${projectRoute}/account-abstraction/:path*`,
|
||||
permanent: false,
|
||||
@@ -375,12 +380,22 @@ async function redirects() {
|
||||
permanent: false,
|
||||
source: "/template/:slug",
|
||||
},
|
||||
// redirect /connect/pay to /universal-bridge
|
||||
// redirect /connect/pay to /payments
|
||||
{
|
||||
destination: "/universal-bridge",
|
||||
permanent: false,
|
||||
destination: "/payments",
|
||||
permanent: true,
|
||||
source: "/connect/pay",
|
||||
},
|
||||
{
|
||||
destination: "/payments",
|
||||
permanent: true,
|
||||
source: "/universal-bridge",
|
||||
},
|
||||
{
|
||||
destination: "/payments/:slug",
|
||||
permanent: true,
|
||||
source: "/universal-bridge/:slug",
|
||||
},
|
||||
// PREVIOUS CAMPAIGNS
|
||||
{
|
||||
destination: "/",
|
||||
@@ -416,7 +431,7 @@ async function redirects() {
|
||||
source: "/connect/account-abstraction",
|
||||
},
|
||||
{
|
||||
destination: "/universal-bridge",
|
||||
destination: "/payments",
|
||||
permanent: false,
|
||||
source: "/connect/universal-bridge",
|
||||
},
|
||||
@@ -440,9 +455,19 @@ async function redirects() {
|
||||
permanent: false,
|
||||
source: "/rpc-edge",
|
||||
},
|
||||
{
|
||||
destination: "/payments",
|
||||
permanent: false,
|
||||
source: "/universal-bridge",
|
||||
},
|
||||
...legacyDashboardToTeamRedirects,
|
||||
...projectPageRedirects,
|
||||
...teamPageRedirects,
|
||||
{
|
||||
source: "/support/:path*",
|
||||
destination: "/team/~/~/support",
|
||||
permanent: false,
|
||||
},
|
||||
];
|
||||
}
|
||||
|
||||
|
||||
+1
-1
@@ -12,7 +12,7 @@ import { COOKIE_ACTIVE_ACCOUNT, COOKIE_PREFIX_TOKEN } from "@/constants/cookie";
|
||||
import { NEXT_PUBLIC_THIRDWEB_API_HOST } from "@/constants/public-envs";
|
||||
import { API_SERVER_SECRET } from "@/constants/server-envs";
|
||||
import { isVercel } from "@/utils/vercel";
|
||||
import { verifyTurnstileToken } from "./verifyTurnstileToken";
|
||||
import { verifyTurnstileToken } from "../../app/login/verifyTurnstileToken";
|
||||
|
||||
export async function getLoginPayload(
|
||||
params: GenerateLoginPayloadParams,
|
||||
@@ -1,15 +1,16 @@
|
||||
import "server-only";
|
||||
import { unstable_cache } from "next/cache";
|
||||
import { PostHog } from "posthog-node";
|
||||
|
||||
let posthogServer: PostHog | null = null;
|
||||
let _posthogClient: PostHog | null = null;
|
||||
|
||||
function getPostHogServer(): PostHog | null {
|
||||
if (!posthogServer && process.env.NEXT_PUBLIC_POSTHOG_KEY) {
|
||||
posthogServer = new PostHog(process.env.NEXT_PUBLIC_POSTHOG_KEY, {
|
||||
if (!_posthogClient && process.env.NEXT_PUBLIC_POSTHOG_KEY) {
|
||||
_posthogClient = new PostHog(process.env.NEXT_PUBLIC_POSTHOG_KEY, {
|
||||
host: "https://us.i.posthog.com",
|
||||
});
|
||||
}
|
||||
return posthogServer;
|
||||
return _posthogClient;
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -17,29 +18,40 @@ function getPostHogServer(): PostHog | null {
|
||||
* @param flagKey - The feature flag key
|
||||
* @param userEmail - The user's email address for filtering
|
||||
*/
|
||||
export async function isFeatureFlagEnabled(
|
||||
flagKey: string,
|
||||
userEmail?: string,
|
||||
): Promise<boolean> {
|
||||
// For localdev environments where Posthog is not running, enable all feature flags.
|
||||
if (!posthogServer) {
|
||||
return true;
|
||||
}
|
||||
|
||||
try {
|
||||
const client = getPostHogServer();
|
||||
if (client && userEmail) {
|
||||
const isEnabled = await client.isFeatureEnabled(flagKey, userEmail, {
|
||||
personProperties: {
|
||||
email: userEmail,
|
||||
},
|
||||
});
|
||||
if (isEnabled !== undefined) {
|
||||
return isEnabled;
|
||||
}
|
||||
export const isFeatureFlagEnabled = unstable_cache(
|
||||
async (params: {
|
||||
flagKey: string;
|
||||
accountId: string;
|
||||
email: string | undefined;
|
||||
}): Promise<boolean> => {
|
||||
const posthogClient = getPostHogServer();
|
||||
if (!posthogClient) {
|
||||
console.warn("Posthog client not set");
|
||||
return true;
|
||||
}
|
||||
} catch (error) {
|
||||
console.error(`Error checking feature flag ${flagKey}:`, error);
|
||||
}
|
||||
return false;
|
||||
}
|
||||
|
||||
const { flagKey, accountId, email } = params;
|
||||
|
||||
try {
|
||||
if (posthogClient && accountId) {
|
||||
const isEnabled = await posthogClient.isFeatureEnabled(
|
||||
flagKey,
|
||||
accountId,
|
||||
{
|
||||
personProperties: email ? { email } : undefined,
|
||||
},
|
||||
);
|
||||
if (isEnabled !== undefined) {
|
||||
return isEnabled;
|
||||
}
|
||||
}
|
||||
} catch (error) {
|
||||
console.error(`Error checking feature flag ${flagKey}:`, error);
|
||||
}
|
||||
return false;
|
||||
},
|
||||
["is-feature-flag-enabled"],
|
||||
{
|
||||
revalidate: 3600, // 1 hour
|
||||
},
|
||||
);
|
||||
|
||||
@@ -413,3 +413,18 @@ export function reportUpsellShown(properties: UpsellParams) {
|
||||
export function reportUpsellClicked(properties: UpsellParams) {
|
||||
posthog.capture("upsell clicked", properties);
|
||||
}
|
||||
|
||||
// ----------------------------
|
||||
// PAYMENTS
|
||||
// ----------------------------
|
||||
|
||||
/**
|
||||
* ### Why do we need to report this event?
|
||||
* - To track conversions on payment overview page
|
||||
*
|
||||
* ### Who is responsible for this event?
|
||||
* @samina
|
||||
*/
|
||||
export function reportPaymentCardClick(properties: { id: string }) {
|
||||
posthog.capture("payment card clicked", properties);
|
||||
}
|
||||
|
||||
@@ -0,0 +1,143 @@
|
||||
"use server";
|
||||
import { NEXT_PUBLIC_THIRDWEB_API_HOST } from "@/constants/public-envs";
|
||||
import type { SupportTicket } from "../../app/(app)/team/[team_slug]/(team)/~/support/types/tickets";
|
||||
import { getAuthToken, getAuthTokenWalletAddress } from "./auth-token";
|
||||
|
||||
const ESCALATION_FEEDBACK_RATING = 9999;
|
||||
|
||||
export async function createSupportTicket(params: {
|
||||
message: string;
|
||||
teamSlug: string;
|
||||
teamId: string;
|
||||
title: string;
|
||||
conversationId?: string;
|
||||
}): Promise<{ data: SupportTicket } | { error: string }> {
|
||||
const token = await getAuthToken();
|
||||
if (!token) {
|
||||
return { error: "No auth token available" };
|
||||
}
|
||||
|
||||
try {
|
||||
const walletAddress = await getAuthTokenWalletAddress();
|
||||
|
||||
const encodedTeamSlug = encodeURIComponent(params.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 = params.message;
|
||||
if (!message.includes("Wallet address:")) {
|
||||
message = `Wallet address: ${String(walletAddress || "-")}\n${message}`;
|
||||
}
|
||||
|
||||
const payload = {
|
||||
markdown: message.trim(),
|
||||
title: params.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();
|
||||
return { error: `API Server error: ${response.status} - ${errorText}` };
|
||||
}
|
||||
|
||||
const createdConversation: SupportTicket = await response.json();
|
||||
|
||||
// Escalate to SIWA feedback endpoint if conversationId is provided
|
||||
if (params.conversationId) {
|
||||
try {
|
||||
const siwaUrl = process.env.NEXT_PUBLIC_SIWA_URL;
|
||||
if (siwaUrl) {
|
||||
await fetch(`${siwaUrl}/v1/chat/feedback`, {
|
||||
method: "POST",
|
||||
headers: {
|
||||
"Content-Type": "application/json",
|
||||
Authorization: `Bearer ${token}`,
|
||||
...(params.teamId ? { "x-team-id": params.teamId } : {}),
|
||||
},
|
||||
body: JSON.stringify({
|
||||
conversationId: params.conversationId,
|
||||
feedbackRating: ESCALATION_FEEDBACK_RATING,
|
||||
}),
|
||||
});
|
||||
}
|
||||
} catch (error) {
|
||||
// Log error but don't fail the ticket creation
|
||||
console.error("Failed to escalate to SIWA feedback:", error);
|
||||
}
|
||||
}
|
||||
|
||||
return { data: createdConversation };
|
||||
} catch (error) {
|
||||
return {
|
||||
error: `Failed to create support ticket: ${error instanceof Error ? error.message : "Unknown error"}`,
|
||||
};
|
||||
}
|
||||
}
|
||||
|
||||
export async function sendMessageToTicket(request: {
|
||||
ticketId: string;
|
||||
teamSlug: string;
|
||||
teamId: string;
|
||||
message: string;
|
||||
}): Promise<{ success: true } | { error: string }> {
|
||||
if (!request.ticketId || !request.teamSlug) {
|
||||
return { error: "Ticket ID and team slug are required" };
|
||||
}
|
||||
|
||||
const token = await getAuthToken();
|
||||
if (!token) {
|
||||
return { error: "No auth token available" };
|
||||
}
|
||||
|
||||
try {
|
||||
const encodedTeamSlug = encodeURIComponent(request.teamSlug);
|
||||
const encodedTicketId = encodeURIComponent(request.ticketId);
|
||||
const apiUrl = `${NEXT_PUBLIC_THIRDWEB_API_HOST}/v1/teams/${encodedTeamSlug}/support-conversations/${encodedTicketId}/messages`;
|
||||
|
||||
// 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",
|
||||
...(request.teamId ? { "x-team-id": request.teamId } : {}),
|
||||
};
|
||||
|
||||
const response = await fetch(apiUrl, {
|
||||
body,
|
||||
headers,
|
||||
method: "POST",
|
||||
});
|
||||
|
||||
if (!response.ok) {
|
||||
const errorText = await response.text();
|
||||
return { error: `API Server error: ${response.status} - ${errorText}` };
|
||||
}
|
||||
|
||||
return { success: true };
|
||||
} catch (error) {
|
||||
return {
|
||||
error: `Failed to send message: ${error instanceof Error ? error.message : "Unknown error"}`,
|
||||
};
|
||||
}
|
||||
}
|
||||
@@ -48,7 +48,7 @@ export async function service_getTeamBySlug(slug: string) {
|
||||
return null;
|
||||
}
|
||||
|
||||
export function getTeamById(id: string) {
|
||||
function getTeamById(id: string) {
|
||||
return getTeamBySlug(id);
|
||||
}
|
||||
|
||||
|
||||
@@ -37,7 +37,7 @@ type WebhookConfigsResponse =
|
||||
};
|
||||
|
||||
interface CreateWebhookConfigRequest {
|
||||
topics: { id: string; filters: object | null }[];
|
||||
topicIdsWithFilters: { id: string; filters: object | null }[];
|
||||
destinationUrl: string;
|
||||
description: string;
|
||||
isPaused?: boolean;
|
||||
@@ -76,7 +76,7 @@ type TopicsResponse =
|
||||
|
||||
interface UpdateWebhookConfigRequest {
|
||||
destinationUrl?: string;
|
||||
topics?: { id: string; filters: object | null }[];
|
||||
topicIdsWithFilters?: { id: string; filters: object | null }[];
|
||||
description?: string;
|
||||
isPaused?: boolean;
|
||||
}
|
||||
@@ -310,3 +310,53 @@ export async function deleteWebhookConfig(props: {
|
||||
status: "success",
|
||||
};
|
||||
}
|
||||
|
||||
type TestDestinationUrlResponse =
|
||||
| {
|
||||
result: {
|
||||
httpStatusCode: number;
|
||||
httpResponseBody: string;
|
||||
};
|
||||
status: "success";
|
||||
}
|
||||
| {
|
||||
body: string;
|
||||
reason: string;
|
||||
status: "error";
|
||||
};
|
||||
|
||||
export async function testDestinationUrl(props: {
|
||||
teamIdOrSlug: string;
|
||||
projectIdOrSlug: string;
|
||||
destinationUrl: string;
|
||||
}): Promise<TestDestinationUrlResponse> {
|
||||
const authToken = await getAuthToken();
|
||||
|
||||
if (!authToken) {
|
||||
return {
|
||||
body: "Authentication required",
|
||||
reason: "no_auth_token",
|
||||
status: "error",
|
||||
};
|
||||
}
|
||||
|
||||
const resp = await fetch(
|
||||
`${NEXT_PUBLIC_THIRDWEB_API_HOST}/v1/teams/${props.teamIdOrSlug}/projects/${props.projectIdOrSlug}/webhook-configs/test-destination-url`,
|
||||
{
|
||||
body: JSON.stringify({ destinationUrl: props.destinationUrl }),
|
||||
headers: {
|
||||
Authorization: `Bearer ${authToken}`,
|
||||
"Content-Type": "application/json",
|
||||
},
|
||||
method: "POST",
|
||||
},
|
||||
);
|
||||
if (!resp.ok) {
|
||||
return {
|
||||
body: await resp.text(),
|
||||
reason: "unknown",
|
||||
status: "error",
|
||||
};
|
||||
}
|
||||
return await resp.json();
|
||||
}
|
||||
|
||||
@@ -1,23 +1,18 @@
|
||||
"use client";
|
||||
|
||||
import {
|
||||
Alert,
|
||||
AlertIcon,
|
||||
Flex,
|
||||
FormControl,
|
||||
Input,
|
||||
InputGroup,
|
||||
InputRightElement,
|
||||
Textarea,
|
||||
} from "@chakra-ui/react";
|
||||
import { zodResolver } from "@hookform/resolvers/zod";
|
||||
import { Button } from "chakra/button";
|
||||
import { FormErrorMessage, FormHelperText, FormLabel } from "chakra/form";
|
||||
import { Heading } from "chakra/heading";
|
||||
import { Text } from "chakra/text";
|
||||
import { ChevronLeftIcon, EyeIcon, EyeOffIcon } from "lucide-react";
|
||||
import { useRef, useState } from "react";
|
||||
import { useDropzone } from "react-dropzone";
|
||||
import {
|
||||
ArrowLeftIcon,
|
||||
ArrowRightIcon,
|
||||
EyeIcon,
|
||||
EyeOffIcon,
|
||||
HelpCircleIcon,
|
||||
LockKeyholeIcon,
|
||||
RefreshCcwIcon,
|
||||
ShuffleIcon,
|
||||
} from "lucide-react";
|
||||
import Link from "next/link";
|
||||
import { useState } from "react";
|
||||
import { useForm } from "react-hook-form";
|
||||
import type { ThirdwebClient } from "thirdweb";
|
||||
import type { CreateDelayedRevealBatchParams } from "thirdweb/extensions/erc721";
|
||||
@@ -25,12 +20,14 @@ import type { NFTInput } from "thirdweb/utils";
|
||||
import { z } from "zod";
|
||||
import { FileInput } from "@/components/blocks/FileInput";
|
||||
import { TransactionButton } from "@/components/tx-button";
|
||||
import { Checkbox, CheckboxWithLabel } from "@/components/ui/checkbox";
|
||||
import { UnderlineLink } from "@/components/ui/UnderlineLink";
|
||||
import type { ComponentWithChildren } from "@/types/component-with-children";
|
||||
import { Button } from "@/components/ui/button";
|
||||
import { Form, FormItem, FormLabel, FormMessage } from "@/components/ui/form";
|
||||
import { Input } from "@/components/ui/input";
|
||||
import { Switch } from "@/components/ui/switch";
|
||||
import { Textarea } from "@/components/ui/textarea";
|
||||
import { processInputData, shuffleData } from "@/utils/batch";
|
||||
import { TabButtons } from "../ui/tabs";
|
||||
import { BatchTable } from "./batch-table";
|
||||
import { SelectOption } from "./lazy-mint-form/select-option";
|
||||
import { UploadStep } from "./upload-step";
|
||||
|
||||
type DelayedSubmit = {
|
||||
@@ -44,16 +41,6 @@ type InstantSubmit = {
|
||||
|
||||
type SubmitType = DelayedSubmit | InstantSubmit;
|
||||
|
||||
interface BatchLazyMintEVMProps {
|
||||
nextTokenIdToMint: bigint;
|
||||
canCreateDelayedRevealBatch: boolean;
|
||||
onSubmit: (formData: SubmitType) => Promise<unknown>;
|
||||
chainId: number;
|
||||
client: ThirdwebClient;
|
||||
}
|
||||
|
||||
type BatchLazyMintProps = BatchLazyMintEVMProps;
|
||||
|
||||
const BatchLazyMintFormSchema = z
|
||||
.object({
|
||||
confirmPassword: z
|
||||
@@ -73,7 +60,7 @@ const BatchLazyMintFormSchema = z
|
||||
name: z.string().min(1, "A name is required"),
|
||||
})
|
||||
.optional(),
|
||||
revealType: z.literal("instant").or(z.literal("delayed")).optional(),
|
||||
revealType: z.literal("instant").or(z.literal("delayed")),
|
||||
|
||||
// shared logic
|
||||
shuffle: z.boolean().default(false),
|
||||
@@ -91,18 +78,21 @@ function useBatchLazyMintForm() {
|
||||
return useForm<BatchLazyMintFormType>({
|
||||
defaultValues: {
|
||||
metadatas: [],
|
||||
revealType: undefined,
|
||||
revealType: "instant",
|
||||
shuffle: false,
|
||||
},
|
||||
resolver: zodResolver(BatchLazyMintFormSchema),
|
||||
});
|
||||
}
|
||||
|
||||
export const BatchLazyMint: ComponentWithChildren<
|
||||
BatchLazyMintProps & {
|
||||
isLoggedIn: boolean;
|
||||
}
|
||||
> = (props) => {
|
||||
export function BatchLazyMint(props: {
|
||||
nextTokenIdToMint: bigint;
|
||||
canCreateDelayedRevealBatch: boolean;
|
||||
onSubmit: (formData: SubmitType) => Promise<unknown>;
|
||||
chainId: number;
|
||||
client: ThirdwebClient;
|
||||
isLoggedIn: boolean;
|
||||
}) {
|
||||
const [step, setStep] = useState(0);
|
||||
|
||||
const form = useBatchLazyMintForm();
|
||||
@@ -110,369 +100,415 @@ export const BatchLazyMint: ComponentWithChildren<
|
||||
const nftMetadatas = form.watch("metadatas");
|
||||
const hasError = !!form.getFieldState("metadatas", form.formState).error;
|
||||
|
||||
const { getRootProps, getInputProps, isDragActive } = useDropzone({
|
||||
onDrop: async (acceptedFiles) => {
|
||||
try {
|
||||
await processInputData(acceptedFiles, (data) =>
|
||||
form.setValue("metadatas", data),
|
||||
);
|
||||
} catch {
|
||||
form.setError("metadatas", {
|
||||
message: "Invalid metadata files",
|
||||
type: "validate",
|
||||
});
|
||||
}
|
||||
|
||||
if (nftMetadatas.length === 0) {
|
||||
form.setError("metadatas", {
|
||||
message: "Invalid metadata files",
|
||||
type: "validate",
|
||||
});
|
||||
}
|
||||
},
|
||||
});
|
||||
|
||||
const paginationPortalRef = useRef<HTMLDivElement>(null);
|
||||
|
||||
return (
|
||||
<form
|
||||
className="mt-4 flex w-full flex-col"
|
||||
onSubmit={form.handleSubmit((data) => {
|
||||
// first shuffle
|
||||
const shuffledMetadatas = data.shuffle
|
||||
? shuffleData(data.metadatas)
|
||||
: data.metadatas;
|
||||
<Form {...form}>
|
||||
<form
|
||||
className="mt-4 flex w-full flex-col"
|
||||
onSubmit={form.handleSubmit(async (data) => {
|
||||
// first shuffle
|
||||
const shuffledMetadatas = data.shuffle
|
||||
? shuffleData(data.metadatas)
|
||||
: data.metadatas;
|
||||
|
||||
// check submit is instant
|
||||
if (data.revealType === "instant") {
|
||||
return props.onSubmit({
|
||||
data: { metadatas: shuffledMetadatas },
|
||||
revealType: "instant",
|
||||
});
|
||||
}
|
||||
// validate password
|
||||
if (!data.password) {
|
||||
form.setError("password", {
|
||||
message: "A password is required for delayed reveal.",
|
||||
type: "validate",
|
||||
});
|
||||
return;
|
||||
}
|
||||
// validate placeholder
|
||||
if (!data.placeHolder?.name) {
|
||||
form.setError("placeHolder.name", {
|
||||
message: "A name is required for delayed reveal.",
|
||||
type: "validate",
|
||||
});
|
||||
}
|
||||
// submit
|
||||
return props.onSubmit({
|
||||
data: {
|
||||
metadata: shuffledMetadatas,
|
||||
password: data.password,
|
||||
placeholderMetadata: {
|
||||
description: data.placeHolder?.description,
|
||||
image: data.placeHolder?.image,
|
||||
name: data.placeHolder?.name,
|
||||
// check submit is instant
|
||||
if (data.revealType === "instant") {
|
||||
return props.onSubmit({
|
||||
data: { metadatas: shuffledMetadatas },
|
||||
revealType: "instant",
|
||||
});
|
||||
}
|
||||
// validate password
|
||||
if (!data.password) {
|
||||
form.setError("password", {
|
||||
message: "A password is required for delayed reveal.",
|
||||
type: "validate",
|
||||
});
|
||||
return;
|
||||
}
|
||||
// validate placeholder
|
||||
if (!data.placeHolder?.name) {
|
||||
form.setError("placeHolder.name", {
|
||||
message: "A name is required for delayed reveal.",
|
||||
type: "validate",
|
||||
});
|
||||
}
|
||||
// submit
|
||||
await props.onSubmit({
|
||||
data: {
|
||||
metadata: shuffledMetadatas,
|
||||
password: data.password,
|
||||
placeholderMetadata: {
|
||||
description: data.placeHolder?.description,
|
||||
image: data.placeHolder?.image,
|
||||
name: data.placeHolder?.name,
|
||||
},
|
||||
},
|
||||
},
|
||||
revealType: "delayed",
|
||||
});
|
||||
})}
|
||||
>
|
||||
{step === 0 ? (
|
||||
<div className="flex flex-col gap-6">
|
||||
{nftMetadatas.length > 0 ? (
|
||||
<>
|
||||
<BatchTable
|
||||
client={props.client}
|
||||
data={nftMetadatas}
|
||||
nextTokenIdToMint={props.nextTokenIdToMint}
|
||||
portalRef={paginationPortalRef}
|
||||
/>
|
||||
<div className="border-border border-t">
|
||||
<div className="flex flex-col items-center justify-between p-0 md:flex-row md:p-4">
|
||||
<div ref={paginationPortalRef} />
|
||||
<div className="mt-4 flex w-full flex-row items-center gap-2 md:mt-0 md:w-auto">
|
||||
<Button
|
||||
borderRadius="md"
|
||||
isDisabled={!hasError}
|
||||
onClick={() => {
|
||||
form.reset();
|
||||
}}
|
||||
w={{ base: "100%", md: "auto" }}
|
||||
>
|
||||
Reset
|
||||
</Button>
|
||||
<Button
|
||||
borderRadius="md"
|
||||
colorScheme="primary"
|
||||
onClick={() => setStep(1)}
|
||||
w={{ base: "100%", md: "auto" }}
|
||||
>
|
||||
Next
|
||||
</Button>
|
||||
</div>
|
||||
revealType: "delayed",
|
||||
});
|
||||
})}
|
||||
>
|
||||
{step === 0 ? (
|
||||
<div>
|
||||
{nftMetadatas.length > 0 ? (
|
||||
<div className="pb-10">
|
||||
<h2 className="text-2xl font-semibold tracking-tight mb-4">
|
||||
Upload NFTs
|
||||
</h2>
|
||||
|
||||
<BatchTable
|
||||
client={props.client}
|
||||
data={nftMetadatas}
|
||||
nextTokenIdToMint={props.nextTokenIdToMint}
|
||||
/>
|
||||
<div className="mt-5 flex justify-end gap-4">
|
||||
<Button
|
||||
className="w-full md:w-auto gap-2"
|
||||
disabled={!hasError}
|
||||
onClick={() => {
|
||||
form.reset();
|
||||
}}
|
||||
variant="outline"
|
||||
>
|
||||
<RefreshCcwIcon className="size-4" />
|
||||
Reset
|
||||
</Button>
|
||||
<Button
|
||||
className="w-full md:w-auto gap-2"
|
||||
onClick={() => setStep(1)}
|
||||
>
|
||||
Next <ArrowRightIcon className="size-4" />
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
</>
|
||||
) : (
|
||||
<UploadStep
|
||||
getInputProps={getInputProps}
|
||||
getRootProps={getRootProps}
|
||||
hasFailed={hasError}
|
||||
isDragActive={isDragActive}
|
||||
/>
|
||||
)}
|
||||
</div>
|
||||
) : (
|
||||
<>
|
||||
<Flex align="center" justify="space-between" mb={2} py={4} w="100%">
|
||||
<div className="flex flex-row items-center gap-2">
|
||||
) : (
|
||||
<UploadStep
|
||||
hasFailed={hasError}
|
||||
reset={() => {
|
||||
form.reset();
|
||||
}}
|
||||
onDrop={async (acceptedFiles) => {
|
||||
try {
|
||||
await processInputData(acceptedFiles, (data) =>
|
||||
form.setValue("metadatas", data),
|
||||
);
|
||||
} catch {
|
||||
form.setError("metadatas", {
|
||||
message: "Invalid metadata files",
|
||||
type: "validate",
|
||||
});
|
||||
}
|
||||
|
||||
if (nftMetadatas.length === 0) {
|
||||
form.setError("metadatas", {
|
||||
message: "Invalid metadata files",
|
||||
type: "validate",
|
||||
});
|
||||
}
|
||||
}}
|
||||
/>
|
||||
)}
|
||||
</div>
|
||||
) : (
|
||||
<div className="container px-0 max-w-4xl pb-14">
|
||||
<div className="flex mb-3">
|
||||
<Button
|
||||
className="text-muted-foreground"
|
||||
className="gap-2 -translate-x-3 text-muted-foreground"
|
||||
size="sm"
|
||||
onClick={() => setStep(0)}
|
||||
variant="ghost"
|
||||
>
|
||||
<ChevronLeftIcon className="size-5 cursor-pointer" />
|
||||
<ArrowLeftIcon className="size-4" />
|
||||
Back
|
||||
</Button>
|
||||
<Heading className="my-auto" size="title.md">
|
||||
When will you reveal your NFTs?
|
||||
</Heading>
|
||||
</div>
|
||||
</Flex>
|
||||
<SelectReveal
|
||||
canCreateDelayedRevealBatch={props.canCreateDelayedRevealBatch}
|
||||
client={props.client}
|
||||
form={form}
|
||||
/>
|
||||
{form.watch("revealType") && (
|
||||
<>
|
||||
<CheckboxWithLabel>
|
||||
<Checkbox
|
||||
checked={form.watch("shuffle")}
|
||||
className="mt-3"
|
||||
onCheckedChange={(val) => form.setValue("shuffle", !!val)}
|
||||
/>
|
||||
<div className="flex flex-col items-center gap-1 md:flex-row">
|
||||
<p>Shuffle the order of the NFTs before uploading.</p>
|
||||
<em>This is an off-chain operation and is not provable.</em>
|
||||
</div>
|
||||
</CheckboxWithLabel>
|
||||
<div className="w-full md:w-[61%]">
|
||||
<TransactionButton
|
||||
className="mt-4 w-full"
|
||||
client={props.client}
|
||||
disabled={!nftMetadatas.length}
|
||||
isLoggedIn={props.isLoggedIn}
|
||||
isPending={form.formState.isSubmitting}
|
||||
transactionCount={1}
|
||||
txChainID={props.chainId}
|
||||
type="submit"
|
||||
>
|
||||
{form.formState.isSubmitting
|
||||
? `Uploading ${nftMetadatas.length} NFTs`
|
||||
: `Upload ${nftMetadatas.length} NFTs`}
|
||||
</TransactionButton>
|
||||
{props.children}
|
||||
</div>
|
||||
<Text mt={2} size="body.sm">
|
||||
<UnderlineLink
|
||||
className="text-primary-500"
|
||||
href="https://support.thirdweb.com/dashboard/n5evQ4EfEjEifczEQaZ1hL/batch-upload-troubleshooting/5WMQFqfaUTU1C8NM8FtJ2X"
|
||||
rel="noopener noreferrer"
|
||||
target="_blank"
|
||||
>
|
||||
Experiencing issues uploading your files?
|
||||
</UnderlineLink>
|
||||
</Text>
|
||||
</>
|
||||
)}
|
||||
</>
|
||||
)}
|
||||
</form>
|
||||
);
|
||||
};
|
||||
|
||||
interface SelectRevealProps {
|
||||
<h2 className="text-2xl font-semibold tracking-tight mb-4">
|
||||
Upload NFTs
|
||||
</h2>
|
||||
|
||||
<SelectReveal
|
||||
canCreateDelayedRevealBatch={props.canCreateDelayedRevealBatch}
|
||||
client={props.client}
|
||||
form={form}
|
||||
/>
|
||||
|
||||
<div className="h-4" />
|
||||
|
||||
<div className="space-y-6">
|
||||
{form.watch("revealType") === "delayed" && (
|
||||
<DelayedRevealConfiguration client={props.client} form={form} />
|
||||
)}
|
||||
|
||||
{/* shuffle */}
|
||||
<ShuffleNFTsCard
|
||||
isShuffleEnabled={form.watch("shuffle")}
|
||||
onShuffleChange={(val) => form.setValue("shuffle", val)}
|
||||
/>
|
||||
|
||||
<div className="space-y-3">
|
||||
<div className="flex justify-end">
|
||||
<TransactionButton
|
||||
client={props.client}
|
||||
disabled={!nftMetadatas.length}
|
||||
isLoggedIn={props.isLoggedIn}
|
||||
isPending={form.formState.isSubmitting}
|
||||
transactionCount={1}
|
||||
txChainID={props.chainId}
|
||||
type="submit"
|
||||
>
|
||||
{form.formState.isSubmitting
|
||||
? `Uploading ${nftMetadatas.length} NFTs`
|
||||
: `Upload ${nftMetadatas.length} NFTs`}
|
||||
</TransactionButton>
|
||||
</div>
|
||||
|
||||
<div className="flex justify-end">
|
||||
<Link
|
||||
className="text-sm flex items-center gap-2 text-muted-foreground hover:text-foreground"
|
||||
href="https://portal.thirdweb.com/knowledge-base/troubleshoot/contracts/batch-upload"
|
||||
rel="noopener noreferrer"
|
||||
target="_blank"
|
||||
>
|
||||
<HelpCircleIcon className="size-4" />
|
||||
Experiencing issues uploading your files?
|
||||
</Link>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</form>
|
||||
</Form>
|
||||
);
|
||||
}
|
||||
|
||||
function ShuffleNFTsCard(props: {
|
||||
isShuffleEnabled: boolean;
|
||||
onShuffleChange: (val: boolean) => void;
|
||||
}) {
|
||||
return (
|
||||
<div className="px-4 lg:px-6 py-8 border rounded-lg bg-card relative">
|
||||
<div className="flex mb-3">
|
||||
<div className="p-2 rounded-full border bg-card">
|
||||
<ShuffleIcon className="size-4 text-muted-foreground" />
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<h3 className="font-semibold text-lg mb-0.5 tracking-tight">
|
||||
Shuffle NFTs
|
||||
</h3>
|
||||
|
||||
<p className="text-sm text-muted-foreground">
|
||||
Shuffle the order of the NFTs before uploading. This is an off-chain
|
||||
operation and is not provable
|
||||
</p>
|
||||
|
||||
<Switch
|
||||
checked={props.isShuffleEnabled}
|
||||
onCheckedChange={(val) => props.onShuffleChange(!!val)}
|
||||
className="absolute right-4 top-4 lg:right-6 lg:top-8"
|
||||
/>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
function SelectReveal(props: {
|
||||
form: ReturnType<typeof useBatchLazyMintForm>;
|
||||
canCreateDelayedRevealBatch: boolean;
|
||||
client: ThirdwebClient;
|
||||
}) {
|
||||
const { form, canCreateDelayedRevealBatch } = props;
|
||||
|
||||
return (
|
||||
<div>
|
||||
{canCreateDelayedRevealBatch && (
|
||||
<TabButtons
|
||||
containerClassName="mb-8"
|
||||
tabClassName="!text-sm"
|
||||
tabs={[
|
||||
{
|
||||
name: "Reveal upon mint",
|
||||
onClick: () => {
|
||||
form.setValue("revealType", "instant");
|
||||
// reset all fields related to delayed reveal
|
||||
form.resetField("password");
|
||||
form.resetField("confirmPassword");
|
||||
form.resetField("placeHolder");
|
||||
},
|
||||
isActive: form.watch("revealType") === "instant",
|
||||
},
|
||||
{
|
||||
name: "Delayed Reveal",
|
||||
onClick: () => form.setValue("revealType", "delayed"),
|
||||
isActive: form.watch("revealType") === "delayed",
|
||||
},
|
||||
]}
|
||||
/>
|
||||
)}
|
||||
|
||||
{form.watch("revealType") === "instant" && (
|
||||
<div>
|
||||
<h2 className="font-semibold text-xl mb-1 tracking-tight leading-none">
|
||||
Reveal upon mint
|
||||
</h2>
|
||||
<p className="text-muted-foreground text-sm">
|
||||
Collectors will immediately see the final NFT when they complete the
|
||||
minting
|
||||
</p>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{form.watch("revealType") === "delayed" && (
|
||||
<div>
|
||||
<h2 className="font-semibold text-xl mb-1 tracking-tight leading-none">
|
||||
Delayed Reveal
|
||||
</h2>
|
||||
<p className="text-muted-foreground text-sm">
|
||||
Collectors will mint your placeholder image, then you reveal at a
|
||||
later time
|
||||
</p>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
const SelectReveal: React.FC<SelectRevealProps> = ({
|
||||
form,
|
||||
client,
|
||||
canCreateDelayedRevealBatch,
|
||||
}) => {
|
||||
function DelayedRevealConfiguration(props: {
|
||||
form: ReturnType<typeof useBatchLazyMintForm>;
|
||||
client: ThirdwebClient;
|
||||
}) {
|
||||
const { form, client } = props;
|
||||
const [show, setShow] = useState(false);
|
||||
|
||||
const imageUrl = form.watch("placeHolder.image");
|
||||
|
||||
return (
|
||||
<Flex flexDir="column">
|
||||
<Flex
|
||||
flexDir={{ base: "column", md: "row" }}
|
||||
gap={{ base: 3, md: 6 }}
|
||||
mb={6}
|
||||
>
|
||||
<SelectOption
|
||||
description="Collectors will immediately see the final NFT when they complete the minting"
|
||||
isActive={form.watch("revealType") === "instant"}
|
||||
name="Reveal upon mint"
|
||||
onClick={() => form.setValue("revealType", "instant")}
|
||||
/>
|
||||
<SelectOption
|
||||
description="Collectors will mint your placeholder image, then you reveal at a later time"
|
||||
disabled={!canCreateDelayedRevealBatch}
|
||||
disabledText="This contract doesn't implement Delayed Reveal"
|
||||
isActive={form.watch("revealType") === "delayed"}
|
||||
name="Delayed Reveal"
|
||||
onClick={() => form.setValue("revealType", "delayed")}
|
||||
/>
|
||||
</Flex>
|
||||
<div className="flex flex-col gap-3">
|
||||
{form.watch("revealType") === "delayed" && (
|
||||
<>
|
||||
<Heading size="title.sm">Let's set a password</Heading>
|
||||
<Alert borderRadius="lg" status="warning">
|
||||
<AlertIcon />
|
||||
You'll need this password to reveal your NFTs. Please save it
|
||||
somewhere safe.
|
||||
</Alert>
|
||||
<div className="space-y-6">
|
||||
{/* password */}
|
||||
<div className="border px-4 lg:px-6 py-8 rounded-lg bg-card">
|
||||
<div className="flex mb-3">
|
||||
<div className="p-2 rounded-full border bg-card">
|
||||
<LockKeyholeIcon className="size-4 text-muted-foreground" />
|
||||
</div>
|
||||
</div>
|
||||
<h3 className="font-semibold text-lg mb-0.5 tracking-tight">
|
||||
Set password
|
||||
</h3>
|
||||
<p className="text-sm text-muted-foreground mb-4">
|
||||
You'll need this password to reveal your NFTs. Please save it
|
||||
somewhere safe
|
||||
</p>
|
||||
|
||||
<Flex
|
||||
flexDir={{ base: "column", md: "row" }}
|
||||
gap={{ base: 4, md: 0 }}
|
||||
>
|
||||
<FormControl
|
||||
isInvalid={
|
||||
!!form.getFieldState("password", form.formState).error
|
||||
}
|
||||
isRequired
|
||||
mr={4}
|
||||
<div className="space-y-5">
|
||||
{/* select password */}
|
||||
<FormItem className="space-y-1">
|
||||
<FormLabel>Password</FormLabel>
|
||||
<div className="relative max-w-md">
|
||||
<Input
|
||||
{...form.register("password")}
|
||||
className="pr-10"
|
||||
type={show ? "text" : "password"}
|
||||
/>
|
||||
<Button
|
||||
variant="ghost"
|
||||
className="absolute right-3 top-1/2 -translate-y-1/2 p-2 h-auto text-muted-foreground"
|
||||
onClick={() => setShow(!show)}
|
||||
>
|
||||
<FormLabel>Password</FormLabel>
|
||||
<InputGroup>
|
||||
<Input
|
||||
{...form.register("password")}
|
||||
placeholder="Choose password"
|
||||
type={show ? "text" : "password"}
|
||||
/>
|
||||
<InputRightElement cursor="pointer">
|
||||
{show ? (
|
||||
<EyeIcon
|
||||
className="size-3"
|
||||
onClick={() => setShow(!show)}
|
||||
/>
|
||||
) : (
|
||||
<EyeOffIcon
|
||||
className="size-3"
|
||||
onClick={() => setShow(!show)}
|
||||
/>
|
||||
)}
|
||||
</InputRightElement>
|
||||
</InputGroup>
|
||||
|
||||
<FormErrorMessage>
|
||||
{
|
||||
form.getFieldState("password", form.formState).error
|
||||
?.message
|
||||
}
|
||||
</FormErrorMessage>
|
||||
</FormControl>
|
||||
<FormControl
|
||||
isInvalid={
|
||||
!!form.getFieldState("confirmPassword", form.formState).error
|
||||
}
|
||||
isRequired
|
||||
>
|
||||
<FormLabel>Confirm password</FormLabel>
|
||||
<Input
|
||||
{...form.register("confirmPassword")}
|
||||
placeholder="Confirm password"
|
||||
type="password"
|
||||
/>
|
||||
<FormErrorMessage>
|
||||
{
|
||||
form.getFieldState("confirmPassword", form.formState).error
|
||||
?.message
|
||||
}
|
||||
</FormErrorMessage>
|
||||
</FormControl>
|
||||
</Flex>
|
||||
<div className="flex flex-col gap-5">
|
||||
<Heading size="title.sm">Placeholder</Heading>
|
||||
<FormControl
|
||||
isInvalid={
|
||||
!!form.getFieldState("placeHolder.image", form.formState)
|
||||
.error
|
||||
}
|
||||
>
|
||||
<FormLabel>Image</FormLabel>
|
||||
<div className="w-auto md:w-[350px]">
|
||||
<FileInput
|
||||
accept={{ "image/*": [] }}
|
||||
className="rounded border border-border transition-all duration-200"
|
||||
client={client}
|
||||
setValue={(file) =>
|
||||
form.setValue("placeHolder.image", file)
|
||||
}
|
||||
showUploadButton
|
||||
value={imageUrl}
|
||||
/>
|
||||
</div>
|
||||
<FormHelperText>
|
||||
You can optionally upload an image as the placeholder.
|
||||
</FormHelperText>
|
||||
<FormErrorMessage>
|
||||
{
|
||||
form.getFieldState("placeHolder.image", form.formState)
|
||||
.error?.message
|
||||
}
|
||||
</FormErrorMessage>
|
||||
</FormControl>
|
||||
<FormControl
|
||||
isInvalid={
|
||||
!!form.getFieldState("placeHolder.name", form.formState).error
|
||||
}
|
||||
isRequired
|
||||
>
|
||||
<FormLabel>Name</FormLabel>
|
||||
<Input
|
||||
{...form.register("placeHolder.name")}
|
||||
placeholder="eg. My NFT (Coming soon)"
|
||||
/>
|
||||
<FormErrorMessage>
|
||||
{
|
||||
form.getFieldState("placeHolder.name", form.formState).error
|
||||
?.message
|
||||
}
|
||||
</FormErrorMessage>
|
||||
</FormControl>
|
||||
<FormControl
|
||||
isInvalid={
|
||||
!!form.getFieldState("placeHolder.name", form.formState).error
|
||||
}
|
||||
>
|
||||
<FormLabel>Description</FormLabel>
|
||||
<Textarea
|
||||
{...form.register("placeHolder.description")}
|
||||
placeholder="eg. Reveal on July 15th!"
|
||||
/>
|
||||
<FormErrorMessage>
|
||||
{
|
||||
form.getFieldState(
|
||||
"placeHolder.description",
|
||||
form.formState,
|
||||
).error?.message
|
||||
}
|
||||
</FormErrorMessage>
|
||||
</FormControl>
|
||||
{show ? (
|
||||
<EyeIcon className="size-4" />
|
||||
) : (
|
||||
<EyeOffIcon className="size-4" />
|
||||
)}
|
||||
</Button>
|
||||
</div>
|
||||
</>
|
||||
)}
|
||||
<FormMessage>
|
||||
{form.getFieldState("password", form.formState).error?.message}
|
||||
</FormMessage>
|
||||
</FormItem>
|
||||
|
||||
{/* confirm password */}
|
||||
<FormItem className="space-y-1">
|
||||
<FormLabel>Confirm password</FormLabel>
|
||||
<div className="relative max-w-md">
|
||||
<Input
|
||||
{...form.register("confirmPassword")}
|
||||
className="pr-10"
|
||||
type="password"
|
||||
/>
|
||||
</div>
|
||||
<FormMessage>
|
||||
{
|
||||
form.getFieldState("confirmPassword", form.formState).error
|
||||
?.message
|
||||
}
|
||||
</FormMessage>
|
||||
</FormItem>
|
||||
</div>
|
||||
</div>
|
||||
</Flex>
|
||||
|
||||
{/* placeholder */}
|
||||
<div className="border px-4 lg:px-6 py-8 rounded-lg bg-card">
|
||||
<div className="flex mb-3">
|
||||
<div className="p-2 rounded-full border bg-card">
|
||||
<EyeIcon className="size-4 text-muted-foreground" />
|
||||
</div>
|
||||
</div>
|
||||
<h3 className="font-semibold text-lg tracking-tight mb-0.5">
|
||||
Set placeholder metadata
|
||||
</h3>
|
||||
<p className="text-sm text-muted-foreground mb-4">
|
||||
The placeholder metadata will be displayed for all NFTs until you
|
||||
reveal your NFTs
|
||||
</p>
|
||||
<div className="flex flex-col lg:flex-row gap-5">
|
||||
<FormItem className="w-[200px]">
|
||||
<FormLabel>Image</FormLabel>
|
||||
<FileInput
|
||||
accept={{ "image/*": [] }}
|
||||
className="rounded border bg-background transition-all duration-200 "
|
||||
client={client}
|
||||
setValue={(file) => form.setValue("placeHolder.image", file)}
|
||||
value={imageUrl}
|
||||
/>
|
||||
|
||||
<FormMessage>
|
||||
{
|
||||
form.getFieldState("placeHolder.image", form.formState).error
|
||||
?.message
|
||||
}
|
||||
</FormMessage>
|
||||
</FormItem>
|
||||
<div className="grow space-y-5 flex flex-col">
|
||||
<FormItem>
|
||||
<FormLabel>Name</FormLabel>
|
||||
<Input
|
||||
className="max-w-sm"
|
||||
{...form.register("placeHolder.name")}
|
||||
placeholder="My NFT (Coming soon)"
|
||||
/>
|
||||
<FormMessage>
|
||||
{
|
||||
form.getFieldState("placeHolder.name", form.formState).error
|
||||
?.message
|
||||
}
|
||||
</FormMessage>
|
||||
</FormItem>
|
||||
<FormItem className="grow flex flex-col">
|
||||
<FormLabel>Description</FormLabel>
|
||||
<Textarea
|
||||
{...form.register("placeHolder.description")}
|
||||
className="grow"
|
||||
placeholder="Reveal on July 15th!"
|
||||
/>
|
||||
<FormMessage>
|
||||
{
|
||||
form.getFieldState("placeHolder.description", form.formState)
|
||||
.error?.message
|
||||
}
|
||||
</FormMessage>
|
||||
</FormItem>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
}
|
||||
|
||||
@@ -1,237 +1,183 @@
|
||||
/** biome-ignore-all lint/nursery/noNestedComponentDefinitions: FIXME */
|
||||
|
||||
import { FilePreview } from "@app/team/[team_slug]/[project_slug]/(sidebar)/tokens/create/_common/file-preview";
|
||||
import {
|
||||
Flex,
|
||||
IconButton,
|
||||
Portal,
|
||||
Select,
|
||||
Table,
|
||||
TableContainer,
|
||||
Tbody,
|
||||
Td,
|
||||
Th,
|
||||
Thead,
|
||||
Tr,
|
||||
} from "@chakra-ui/react";
|
||||
import {
|
||||
ChevronFirstIcon,
|
||||
ChevronLastIcon,
|
||||
ChevronLeftIcon,
|
||||
ChevronRightIcon,
|
||||
} from "lucide-react";
|
||||
import { useMemo } from "react";
|
||||
import { type Column, usePagination, useTable } from "react-table";
|
||||
import { useState } from "react";
|
||||
import type { ThirdwebClient } from "thirdweb";
|
||||
import type { NFTInput } from "thirdweb/utils";
|
||||
import { FilePreview } from "@/components/blocks/file-preview";
|
||||
import { PaginationButtons } from "@/components/blocks/pagination-buttons";
|
||||
import { CodeClient } from "@/components/ui/code/code.client";
|
||||
import {
|
||||
Table,
|
||||
TableBody,
|
||||
TableCell,
|
||||
TableContainer,
|
||||
TableHead,
|
||||
TableHeader,
|
||||
TableRow,
|
||||
} from "@/components/ui/table";
|
||||
import { ToolTipLabel } from "@/components/ui/tooltip";
|
||||
|
||||
interface BatchTableProps {
|
||||
type BatchTableProps = {
|
||||
data: NFTInput[];
|
||||
portalRef: React.RefObject<HTMLDivElement | null>;
|
||||
nextTokenIdToMint?: bigint;
|
||||
client: ThirdwebClient;
|
||||
}
|
||||
};
|
||||
|
||||
export const BatchTable: React.FC<BatchTableProps> = ({
|
||||
export function BatchTable({
|
||||
data,
|
||||
portalRef,
|
||||
nextTokenIdToMint,
|
||||
client,
|
||||
}) => {
|
||||
const columns = useMemo(() => {
|
||||
let cols: Column<NFTInput>[] = [];
|
||||
if (nextTokenIdToMint !== undefined) {
|
||||
cols = cols.concat({
|
||||
accessor: (_row, index) => String(nextTokenIdToMint + BigInt(index)),
|
||||
Header: "Token ID",
|
||||
});
|
||||
}
|
||||
}: BatchTableProps) {
|
||||
const [currentPage, setCurrentPage] = useState(1);
|
||||
const pageSize = 10;
|
||||
|
||||
cols = cols.concat([
|
||||
{
|
||||
accessor: (row) => row.image,
|
||||
Cell: ({ cell: { value } }: { cell: { value?: string } }) => (
|
||||
<FilePreview
|
||||
className="size-24 shrink-0 rounded-lg object-contain"
|
||||
client={client}
|
||||
srcOrFile={value}
|
||||
/>
|
||||
),
|
||||
Header: "Image",
|
||||
},
|
||||
{
|
||||
accessor: (row) => row.animation_url,
|
||||
Cell: ({ cell: { value } }: { cell: { value?: string } }) => (
|
||||
<FilePreview
|
||||
className="size-24 shrink-0 rounded-lg"
|
||||
client={client}
|
||||
srcOrFile={value}
|
||||
/>
|
||||
),
|
||||
Header: "Animation Url",
|
||||
},
|
||||
{ accessor: (row) => row.name, Header: "Name" },
|
||||
{
|
||||
accessor: (row) => (
|
||||
<ToolTipLabel label={row.description}>
|
||||
<p className="line-clamp-6 whitespace-pre-wrap">
|
||||
{row.description}
|
||||
</p>
|
||||
</ToolTipLabel>
|
||||
),
|
||||
Header: "Description",
|
||||
},
|
||||
{
|
||||
accessor: (row) => row.attributes || row.properties,
|
||||
// biome-ignore lint/suspicious/noExplicitAny: FIXME
|
||||
Cell: ({ cell }: { cell: any }) =>
|
||||
cell.value ? (
|
||||
<CodeClient
|
||||
code={JSON.stringify(cell.value || {}, null, 2)}
|
||||
lang="json"
|
||||
scrollableClassName="max-w-[300px]"
|
||||
/>
|
||||
) : null,
|
||||
Header: "Attributes",
|
||||
},
|
||||
{ accessor: (row) => row.external_url, Header: "External URL" },
|
||||
{ accessor: (row) => row.background_color, Header: "Background Color" },
|
||||
]);
|
||||
return cols;
|
||||
}, [nextTokenIdToMint, client]);
|
||||
const totalPages = Math.ceil(data.length / pageSize);
|
||||
const startIndex = (currentPage - 1) * pageSize;
|
||||
const endIndex = startIndex + pageSize;
|
||||
const currentData = data.slice(startIndex, endIndex);
|
||||
|
||||
const {
|
||||
getTableProps,
|
||||
getTableBodyProps,
|
||||
headerGroups,
|
||||
prepareRow,
|
||||
// Instead of using 'rows', we'll use page,
|
||||
page,
|
||||
// which has only the rows for the active page
|
||||
const handlePageChange = (page: number) => {
|
||||
setCurrentPage(page);
|
||||
};
|
||||
|
||||
// The rest of these things are super handy, too ;)
|
||||
canPreviousPage,
|
||||
canNextPage,
|
||||
pageOptions,
|
||||
pageCount,
|
||||
gotoPage,
|
||||
nextPage,
|
||||
previousPage,
|
||||
setPageSize,
|
||||
state: { pageIndex, pageSize },
|
||||
} = useTable(
|
||||
{
|
||||
columns,
|
||||
data,
|
||||
initialState: {
|
||||
pageIndex: 0,
|
||||
pageSize: 50,
|
||||
},
|
||||
},
|
||||
// will be fixed with @tanstack/react-table v8
|
||||
// eslint-disable-next-line react-compiler/react-compiler
|
||||
usePagination,
|
||||
);
|
||||
const showTokenId = nextTokenIdToMint !== undefined;
|
||||
const showExternalUrl = data.some((row) => row.external_url);
|
||||
const showBackgroundColor = data.some((row) => row.background_color);
|
||||
const showAnimationUrl = data.some((row) => row.animation_url);
|
||||
const showDescription = data.some((row) => row.description);
|
||||
const showAttributes = data.some((row) => row.attributes || row.properties);
|
||||
|
||||
const showPagination = totalPages > 1;
|
||||
|
||||
// Render the UI for your table
|
||||
return (
|
||||
<Flex flexGrow={1} overflow="auto">
|
||||
<TableContainer className="w-full" maxW="100%">
|
||||
<Table {...getTableProps()}>
|
||||
<Thead>
|
||||
{headerGroups.map((headerGroup, index) => (
|
||||
// biome-ignore lint/suspicious/noArrayIndexKey: FIXME
|
||||
<Tr {...headerGroup.getHeaderGroupProps()} key={index}>
|
||||
{headerGroup.headers.map((column, i) => (
|
||||
// biome-ignore lint/suspicious/noArrayIndexKey: FIXME
|
||||
<Th {...column.getHeaderProps()} border="none" key={i}>
|
||||
<p className="text-muted-foreground">
|
||||
{column.render("Header")}
|
||||
</p>
|
||||
</Th>
|
||||
))}
|
||||
</Tr>
|
||||
))}
|
||||
</Thead>
|
||||
<Tbody {...getTableBodyProps()}>
|
||||
{page.map((row, rowIndex) => {
|
||||
prepareRow(row);
|
||||
<div className="rounded-lg border bg-card">
|
||||
<TableContainer className="border-0">
|
||||
<Table>
|
||||
<TableHeader>
|
||||
<TableRow>
|
||||
{showTokenId && <TableHead>Token ID</TableHead>}
|
||||
<TableHead>Image</TableHead>
|
||||
{showAnimationUrl && <TableHead>Animation Url</TableHead>}
|
||||
<TableHead>Name</TableHead>
|
||||
{showDescription && (
|
||||
<TableHead className="min-w-[300px]">Description</TableHead>
|
||||
)}
|
||||
{showAttributes && <TableHead>Attributes</TableHead>}
|
||||
{showExternalUrl && <TableHead>External URL</TableHead>}
|
||||
{showBackgroundColor && <TableHead>Background Color</TableHead>}
|
||||
</TableRow>
|
||||
</TableHeader>
|
||||
<TableBody>
|
||||
{currentData.map((row, rowIndex) => {
|
||||
const actualIndex = startIndex + rowIndex;
|
||||
return (
|
||||
<Tr
|
||||
{...row.getRowProps()}
|
||||
_last={{ borderBottomWidth: 0 }}
|
||||
borderBottomWidth={1}
|
||||
// biome-ignore lint/suspicious/noArrayIndexKey: FIXME
|
||||
key={rowIndex}
|
||||
<TableRow
|
||||
className="border-b last:border-b-0"
|
||||
key={actualIndex}
|
||||
>
|
||||
{row.cells.map((cell, cellIndex) => {
|
||||
return (
|
||||
<Td
|
||||
{...cell.getCellProps()}
|
||||
borderBottomWidth="inherit"
|
||||
borderColor="borderColor"
|
||||
// biome-ignore lint/suspicious/noArrayIndexKey: FIXME
|
||||
key={cellIndex}
|
||||
>
|
||||
{cell.render("Cell")}
|
||||
</Td>
|
||||
);
|
||||
})}
|
||||
</Tr>
|
||||
{/* Token ID */}
|
||||
{showTokenId && (
|
||||
<TableCell>
|
||||
{String(nextTokenIdToMint + BigInt(actualIndex))}
|
||||
</TableCell>
|
||||
)}
|
||||
|
||||
{/* Image */}
|
||||
<TableCell className="min-w-36">
|
||||
<FilePreview
|
||||
className="size-36 shrink-0 rounded-lg object-contain"
|
||||
client={client}
|
||||
srcOrFile={
|
||||
typeof row.image === "string" ||
|
||||
row.image instanceof File
|
||||
? row.image
|
||||
: undefined
|
||||
}
|
||||
/>
|
||||
</TableCell>
|
||||
|
||||
{/* Animation Url */}
|
||||
{showAnimationUrl && (
|
||||
<TableCell className="min-w-36">
|
||||
<FilePreview
|
||||
className="size-36 shrink-0 rounded-lg"
|
||||
client={client}
|
||||
srcOrFile={
|
||||
typeof row.animation_url === "string" ||
|
||||
row.animation_url instanceof File
|
||||
? row.animation_url
|
||||
: undefined
|
||||
}
|
||||
/>
|
||||
</TableCell>
|
||||
)}
|
||||
|
||||
{/* Name */}
|
||||
<TableCell>{row.name}</TableCell>
|
||||
|
||||
{/* Description */}
|
||||
{showDescription && (
|
||||
<TableCell className="max-w-xs">
|
||||
<p className="whitespace-pre-wrap">{row.description}</p>
|
||||
</TableCell>
|
||||
)}
|
||||
|
||||
{/* Attributes */}
|
||||
{showAttributes && (
|
||||
<TableCell>
|
||||
{row.attributes || row.properties ? (
|
||||
<CodeClient
|
||||
code={JSON.stringify(
|
||||
row.attributes || row.properties || {},
|
||||
null,
|
||||
2,
|
||||
)}
|
||||
lang="json"
|
||||
scrollableClassName="max-w-[300px] max-h-[400px]"
|
||||
className="bg-background [&>*]:text-xs"
|
||||
/>
|
||||
) : null}
|
||||
</TableCell>
|
||||
)}
|
||||
|
||||
{/* External URL */}
|
||||
{showExternalUrl && (
|
||||
<TableCell>
|
||||
{typeof row.external_url === "string" ? (
|
||||
<ToolTipLabel label={row.external_url}>
|
||||
<span>
|
||||
{row.external_url.slice(0, 20) +
|
||||
(row.external_url.length > 20 ? "..." : "")}
|
||||
</span>
|
||||
</ToolTipLabel>
|
||||
) : row.external_url instanceof File ? (
|
||||
<FilePreview
|
||||
client={client}
|
||||
srcOrFile={row.external_url}
|
||||
/>
|
||||
) : null}
|
||||
</TableCell>
|
||||
)}
|
||||
|
||||
{/* Background Color */}
|
||||
{showBackgroundColor && (
|
||||
<TableCell>{row.background_color}</TableCell>
|
||||
)}
|
||||
</TableRow>
|
||||
);
|
||||
})}
|
||||
</Tbody>
|
||||
</TableBody>
|
||||
</Table>
|
||||
</TableContainer>
|
||||
<Portal containerRef={portalRef}>
|
||||
<div className="flex w-full items-center justify-center">
|
||||
<div className="flex flex-row items-center gap-2">
|
||||
<IconButton
|
||||
aria-label="first page"
|
||||
icon={<ChevronFirstIcon className="size-4" />}
|
||||
isDisabled={!canPreviousPage}
|
||||
onClick={() => gotoPage(0)}
|
||||
/>
|
||||
<IconButton
|
||||
aria-label="previous page"
|
||||
icon={<ChevronLeftIcon className="size-4" />}
|
||||
isDisabled={!canPreviousPage}
|
||||
onClick={() => previousPage()}
|
||||
/>
|
||||
<p className="whitespace-nowrap">
|
||||
Page <strong>{pageIndex + 1}</strong> of{" "}
|
||||
<strong>{pageOptions.length}</strong>
|
||||
</p>
|
||||
<IconButton
|
||||
aria-label="next page"
|
||||
icon={<ChevronRightIcon className="size-4" />}
|
||||
isDisabled={!canNextPage}
|
||||
onClick={() => nextPage()}
|
||||
/>
|
||||
<IconButton
|
||||
aria-label="last page"
|
||||
icon={<ChevronLastIcon className="size-4" />}
|
||||
isDisabled={!canNextPage}
|
||||
onClick={() => gotoPage(pageCount - 1)}
|
||||
/>
|
||||
|
||||
<Select
|
||||
onChange={(e) => {
|
||||
setPageSize(Number.parseInt(e.target.value as string, 10));
|
||||
}}
|
||||
value={pageSize}
|
||||
>
|
||||
<option value="25">25</option>
|
||||
<option value="50">50</option>
|
||||
<option value="100">100</option>
|
||||
<option value="250">250</option>
|
||||
<option value="500">500</option>
|
||||
</Select>
|
||||
</div>
|
||||
{showPagination && (
|
||||
<div className="border-t py-5">
|
||||
<PaginationButtons
|
||||
activePage={currentPage}
|
||||
totalPages={totalPages}
|
||||
onPageClick={handlePageChange}
|
||||
/>
|
||||
</div>
|
||||
</Portal>
|
||||
</Flex>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
};
|
||||
}
|
||||
|
||||
@@ -42,18 +42,18 @@ export const SelectOption: React.FC<SelectOptionProps> = ({
|
||||
disabled
|
||||
? "pointer-events-none cursor-not-allowed bg-muted"
|
||||
: "cursor-pointer",
|
||||
isActive && "border-primary",
|
||||
isActive && "bg-muted/50 border-active-border",
|
||||
className,
|
||||
)}
|
||||
onClick={onClick}
|
||||
{...divProps}
|
||||
>
|
||||
<div className="flex flex-row justify-between">
|
||||
<div className="flex flex-row items-start">
|
||||
<div className="mt-0.5 mr-2.5 flex h-4 w-4 items-center justify-center rounded-full border border-inverted text-inverted">
|
||||
<div className="flex flex-row items-start gap-4">
|
||||
<div className="flex size-5 shrink-0 items-center justify-center rounded-full border border-inverted text-inverted">
|
||||
{isActive && <div className="h-2 w-2 rounded-full bg-inverted" />}
|
||||
</div>
|
||||
<div className="ml-4 flex flex-col gap-2 self-start">
|
||||
<div>
|
||||
<h4
|
||||
className={cn(
|
||||
"font-semibold text-sm",
|
||||
@@ -62,7 +62,11 @@ export const SelectOption: React.FC<SelectOptionProps> = ({
|
||||
>
|
||||
{name}
|
||||
</h4>
|
||||
{description && <p className="mt-1 text-sm">{description}</p>}
|
||||
{description && (
|
||||
<p className="mt-1 text-sm text-muted-foreground">
|
||||
{description}
|
||||
</p>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
{infoText && (
|
||||
|
||||
@@ -1,137 +1,333 @@
|
||||
import { UploadIcon } from "lucide-react";
|
||||
import { ArrowDownToLineIcon } from "lucide-react";
|
||||
import { useState } from "react";
|
||||
import { Button } from "@/components/ui/button";
|
||||
import { CodeClient } from "@/components/ui/code/code.client";
|
||||
import { InlineCode } from "@/components/ui/inline-code";
|
||||
import { UnorderedList } from "@/components/ui/List/List";
|
||||
import { cn } from "@/lib/utils";
|
||||
import { TabButtons } from "@/components/ui/tabs";
|
||||
import { handleDownload } from "../../../app/(app)/team/[team_slug]/[project_slug]/(sidebar)/tokens/create/_common/download-file-button";
|
||||
import { DropZone } from "../blocks/drop-zone/drop-zone";
|
||||
|
||||
interface UploadStepProps {
|
||||
// biome-ignore lint/suspicious/noExplicitAny: FIXME
|
||||
getRootProps: any;
|
||||
// biome-ignore lint/suspicious/noExplicitAny: FIXME
|
||||
getInputProps: any;
|
||||
hasFailed: boolean;
|
||||
isDragActive: boolean;
|
||||
onDrop: (acceptedFiles: File[]) => void;
|
||||
reset: () => void;
|
||||
}
|
||||
|
||||
export const UploadStep: React.FC<UploadStepProps> = ({
|
||||
getRootProps,
|
||||
hasFailed,
|
||||
isDragActive,
|
||||
getInputProps,
|
||||
}) => {
|
||||
export function UploadStep(props: UploadStepProps) {
|
||||
const [tab, setTab] = useState<"csv" | "json">("csv");
|
||||
|
||||
return (
|
||||
<div className="flex flex-col items-center gap-8 md:flex-row">
|
||||
<div className="relative aspect-square w-full md:w-1/2">
|
||||
<div
|
||||
className={cn(
|
||||
"flex h-full cursor-pointer rounded-md border border-border hover:border-blue-500",
|
||||
hasFailed ? "bg-red-200" : "bg-background",
|
||||
)}
|
||||
{...getRootProps()}
|
||||
>
|
||||
<input {...getInputProps()} />
|
||||
<div className="m-auto flex flex-col p-6">
|
||||
<UploadIcon
|
||||
className={cn(
|
||||
"mx-auto mb-2 size-8",
|
||||
hasFailed ? "text-red-500" : "text-gray-600",
|
||||
)}
|
||||
<div className="container px-0 max-w-3xl">
|
||||
<h2 className="text-2xl font-semibold tracking-tight mb-4">
|
||||
Upload NFTs
|
||||
</h2>
|
||||
|
||||
<DropZone
|
||||
className="w-full py-24"
|
||||
onDrop={props.onDrop}
|
||||
isError={props.hasFailed}
|
||||
title={props.hasFailed ? "Invalid files" : "Upload files"}
|
||||
description={
|
||||
props.hasFailed
|
||||
? "Make sure the uploaded files follow the requirements mentioned below"
|
||||
: "Drag and drop the files or folder"
|
||||
}
|
||||
resetButton={{
|
||||
label: "Reset",
|
||||
onClick: props.reset,
|
||||
}}
|
||||
accept={undefined}
|
||||
/>
|
||||
|
||||
<div className="h-8" />
|
||||
|
||||
<div className="w-full">
|
||||
<TabButtons
|
||||
tabs={[
|
||||
{
|
||||
isActive: tab === "csv",
|
||||
name: "CSV",
|
||||
onClick: () => setTab("csv"),
|
||||
},
|
||||
{
|
||||
isActive: tab === "json",
|
||||
name: "JSON",
|
||||
onClick: () => setTab("json"),
|
||||
},
|
||||
]}
|
||||
/>
|
||||
|
||||
<div className="h-6" />
|
||||
|
||||
<div className="space-y-8">
|
||||
<Section title="Uploading Files">
|
||||
<p>
|
||||
Make sure to drag and drop the {tab === "csv" ? "CSV" : "JSON"}{" "}
|
||||
and the assets at the same time or upload a folder with all the
|
||||
files in it.
|
||||
</p>
|
||||
|
||||
<p>
|
||||
Asset names <em>must</em> be sequential 0,1,2,3...n.[extension].
|
||||
It doesn't matter at what number you begin. Example: 131.png,
|
||||
132.png etc.
|
||||
</p>
|
||||
|
||||
<p>
|
||||
Images and other file types can be used in combination. They both
|
||||
have to follow the asset naming convention above. Example: 0.png
|
||||
and 0.mp4, 1.png and 1.glb etc.
|
||||
</p>
|
||||
</Section>
|
||||
|
||||
<Section title={`${tab === "csv" ? "CSV" : "JSON"} File Format`}>
|
||||
<p>
|
||||
The {tab === "csv" ? "CSV" : "JSON"} file must have a{" "}
|
||||
<InlineCode code="name" /> {tab === "csv" ? "column" : "property"}
|
||||
, which defines the name of the NFT
|
||||
</p>
|
||||
|
||||
<p>
|
||||
Other optional {tab === "csv" ? "columns" : "properties"} are{" "}
|
||||
<InlineCode code="description" />,{" "}
|
||||
<InlineCode code="background_color" />,{" "}
|
||||
<InlineCode code="external_url" />,{" "}
|
||||
<InlineCode code="animation_url" />,{" "}
|
||||
{tab === "json" && (
|
||||
<>
|
||||
,
|
||||
<InlineCode code="attributes" />
|
||||
</>
|
||||
)}{" "}
|
||||
and <InlineCode code="supply" />
|
||||
{". "}
|
||||
{tab === "csv" &&
|
||||
"All other columns will be treated as Attributes. For example: See 'eyes', 'nose' below."}
|
||||
</p>
|
||||
|
||||
<ExampleCode
|
||||
code={tab === "csv" ? csv_example_basic : json_example_basic}
|
||||
fileNameWithExtension={
|
||||
tab === "csv" ? "example.csv" : "example.json"
|
||||
}
|
||||
lang={tab === "csv" ? "csv" : "json"}
|
||||
/>
|
||||
{isDragActive ? (
|
||||
<p className="text-center text-muted-foreground">
|
||||
Drop the files here
|
||||
</p>
|
||||
) : (
|
||||
<p
|
||||
className={cn(
|
||||
"text-center leading-[1.2]",
|
||||
hasFailed ? "text-red-500" : "text-muted-foreground",
|
||||
)}
|
||||
>
|
||||
{hasFailed
|
||||
? `No valid CSV or JSON file found. Please make sure your NFT metadata includes at least a "name" field and try again.`
|
||||
: "Drag & Drop files or folders here, or click to select files"}
|
||||
</p>
|
||||
)}
|
||||
</div>
|
||||
</Section>
|
||||
|
||||
<Section title="Map your assets to NFTs">
|
||||
<p>
|
||||
If you want to map your media files to your NFTs, you can add the
|
||||
name of your files to the <InlineCode code="image" /> and{" "}
|
||||
<InlineCode code="animation_url" />{" "}
|
||||
{tab === "csv" ? "columns" : "properties"}.{" "}
|
||||
<ExampleCode
|
||||
code={
|
||||
tab === "csv"
|
||||
? csv_with_image_number_example
|
||||
: json_with_image_number_example
|
||||
}
|
||||
fileNameWithExtension={
|
||||
tab === "csv"
|
||||
? "example-with-maps.csv"
|
||||
: "example-with-maps.json"
|
||||
}
|
||||
lang={tab === "csv" ? "csv" : "json"}
|
||||
/>
|
||||
</p>
|
||||
</Section>
|
||||
|
||||
<Section title="Using asset links instead of uploading assets">
|
||||
<p>
|
||||
When uploading files, assets will be uploaded and pinned to IPFS
|
||||
automatically. If you already have the files uploaded elsewhere,
|
||||
you can specify the IPFS or HTTP(s) links in the{" "}
|
||||
<InlineCode code="image" /> and/or{" "}
|
||||
<InlineCode code="animation_url" />{" "}
|
||||
{tab === "csv" ? "columns" : "properties"}. instead of uploading
|
||||
the assets and just upload a single{" "}
|
||||
{tab === "csv" ? "CSV" : "JSON"} file.
|
||||
<ExampleCode
|
||||
code={
|
||||
tab === "csv"
|
||||
? csv_with_image_link_example
|
||||
: json_with_image_link_example
|
||||
}
|
||||
fileNameWithExtension={
|
||||
tab === "csv"
|
||||
? "example-with-ipfs.csv"
|
||||
: "example-with-ipfs.json"
|
||||
}
|
||||
lang={tab === "csv" ? "csv" : "json"}
|
||||
/>
|
||||
</p>
|
||||
</Section>
|
||||
</div>
|
||||
</div>
|
||||
<div className="flex w-full flex-col gap-2 md:w-1/2">
|
||||
<p className="text-lg">Requirements</p>
|
||||
<UnorderedList>
|
||||
<li>
|
||||
Files <em>must</em> contain one .csv or .json file with metadata. -{" "}
|
||||
<a
|
||||
className="text-link-foreground hover:text-foreground"
|
||||
download
|
||||
href="/assets/examples/example.csv"
|
||||
>
|
||||
Download example.csv
|
||||
</a>
|
||||
.{" "}
|
||||
<a
|
||||
className="text-link-foreground hover:text-foreground"
|
||||
download
|
||||
href="/assets/examples/example.json"
|
||||
>
|
||||
Download example.json
|
||||
</a>
|
||||
.
|
||||
</li>
|
||||
<li>
|
||||
The csv <em>must</em> have a <InlineCode code="name" /> column,
|
||||
which defines the name of the NFT.
|
||||
</li>
|
||||
<li>
|
||||
Asset names <em>must</em> be sequential 0,1,2,3...n.[extension]. It
|
||||
doesn't matter at what number you begin. (Example:{" "}
|
||||
<InlineCode code="131.png" />, <InlineCode code="132.png" />
|
||||
).
|
||||
</li>
|
||||
<li>
|
||||
Make sure to drag and drop the CSV/JSON and the images{" "}
|
||||
<strong>at the same time</strong>.
|
||||
</li>
|
||||
</UnorderedList>
|
||||
<p className="mt-4 text-lg">Options</p>
|
||||
<UnorderedList>
|
||||
<li>
|
||||
Images and other file types can be used in combination.
|
||||
<br />
|
||||
<small>
|
||||
They both have to follow the asset naming convention above.
|
||||
(Example: <InlineCode code="0.png" /> and{" "}
|
||||
<InlineCode code="0.mp4" />,<InlineCode code="1.png" /> and{" "}
|
||||
<InlineCode code="1.glb" />, etc.)
|
||||
</small>
|
||||
</li>
|
||||
<li>
|
||||
When uploading files, we will upload them and pin them to IPFS
|
||||
automatically for you. If you already have the files uploaded, you
|
||||
can add an <InlineCode code="image" /> and/or
|
||||
<InlineCode code="animation_url" /> column and add the IPFS hashes
|
||||
there.{" "}
|
||||
<a
|
||||
className="text-link-foreground hover:text-foreground"
|
||||
download
|
||||
href="/assets/examples/example-with-ipfs.csv"
|
||||
>
|
||||
Download example.csv
|
||||
</a>
|
||||
</li>
|
||||
<li>
|
||||
If you want to make your media files map to your NFTs, you can add
|
||||
the name of your files to the <InlineCode code="image" /> and
|
||||
<InlineCode code="animation_url" /> column.{" "}
|
||||
<a
|
||||
className="text-link-foreground hover:text-foreground"
|
||||
download
|
||||
href="/assets/examples/example-with-maps.csv"
|
||||
>
|
||||
Download example.csv
|
||||
</a>
|
||||
</li>
|
||||
</UnorderedList>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
}
|
||||
|
||||
function Section(props: { title: string; children: React.ReactNode }) {
|
||||
return (
|
||||
<section>
|
||||
<h2 className="mb-1 font-medium text-base text-foreground">
|
||||
{props.title}
|
||||
</h2>
|
||||
<div className="space-y-1.5 text-muted-foreground leading-relaxed">
|
||||
{props.children}
|
||||
</div>
|
||||
</section>
|
||||
);
|
||||
}
|
||||
|
||||
function ExampleCode(props: {
|
||||
code: string;
|
||||
lang: "csv" | "json";
|
||||
fileNameWithExtension: string;
|
||||
}) {
|
||||
return (
|
||||
<div className="!my-3 relative">
|
||||
<CodeClient
|
||||
code={props.code}
|
||||
lang={props.lang}
|
||||
scrollableClassName="max-h-[300px] bg-card"
|
||||
/>
|
||||
|
||||
<Button
|
||||
className="absolute top-3.5 right-14 mt-[1px] h-auto bg-background p-2"
|
||||
onClick={() => {
|
||||
handleDownload({
|
||||
fileContent: props.code,
|
||||
fileFormat: props.lang === "csv" ? "text/csv" : "application/json",
|
||||
fileNameWithExtension: props.fileNameWithExtension,
|
||||
});
|
||||
}}
|
||||
size="sm"
|
||||
variant="outline"
|
||||
>
|
||||
<ArrowDownToLineIcon className="size-3" />
|
||||
</Button>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
const csv_example_basic = `\
|
||||
name,description,background_color,eyes,nose
|
||||
Token 0 Name,Token 0 Description,#0098EE,red,green
|
||||
Token 1 Name,Token 1 Description,#0098EE,blue,yellow`;
|
||||
|
||||
const json_example_basic = `\
|
||||
[
|
||||
{
|
||||
"name": "Token 0 Name",
|
||||
"description": "Token 0 Description",
|
||||
"background_color": "#0098EE",
|
||||
"attributes": [
|
||||
{
|
||||
"trait_type": "eyes",
|
||||
"value": "red"
|
||||
},
|
||||
{
|
||||
"trait_type": "nose",
|
||||
"value": "green"
|
||||
}
|
||||
]
|
||||
},
|
||||
{
|
||||
"name": "Token 1 Name",
|
||||
"description": "Token 1 Description",
|
||||
"background_color": "#0098EE",
|
||||
"attributes": [
|
||||
{
|
||||
"trait_type": "eyes",
|
||||
"value": "blue"
|
||||
},
|
||||
{
|
||||
"trait_type": "nose",
|
||||
"value": "yellow"
|
||||
}
|
||||
]
|
||||
}
|
||||
]`;
|
||||
|
||||
const csv_with_image_link_example = `\
|
||||
name,description,image,animation_url,background_color,eyes,nose
|
||||
Token 0 Name,Token 0 Description,ipfs://ipfsHash/0,ipfs://ipfsHash/0,#0098EE,red,green
|
||||
Token 1 Name,Token 1 Description,ipfs://ipfsHash/1,ipfs://ipfsHash/1,#0098EE,blue,yellow`;
|
||||
|
||||
const json_with_image_link_example = `\
|
||||
[
|
||||
{
|
||||
"name": "Token 0 Name",
|
||||
"description": "Token 0 Description",
|
||||
"image": "ipfs://ipfsHash/0",
|
||||
"animation_url": "ipfs://ipfsHash/0",
|
||||
"attributes": [
|
||||
{
|
||||
"trait_type": "eyes",
|
||||
"value": "red"
|
||||
},
|
||||
{
|
||||
"trait_type": "nose",
|
||||
"value": "green"
|
||||
}
|
||||
]
|
||||
},
|
||||
{
|
||||
"name": "Token 1 Name",
|
||||
"description": "Token 1 Description",
|
||||
"image": "ipfs://ipfsHash/1",
|
||||
"animation_url": "ipfs://ipfsHash/1",
|
||||
"attributes": [
|
||||
{
|
||||
"trait_type": "eyes",
|
||||
"value": "blue"
|
||||
},
|
||||
{
|
||||
"trait_type": "nose",
|
||||
"value": "yellow"
|
||||
}
|
||||
]
|
||||
}
|
||||
]`;
|
||||
|
||||
const csv_with_image_number_example = `\
|
||||
name,description,image,animation_url,background_color,eyes,nose
|
||||
Token 0 Name,Token 0 Description,0.png,0.mp4,#0098EE,red,green
|
||||
Token 1 Name,Token 1 Description,1.png,1.mp4,#0098EE,blue,yellow`;
|
||||
|
||||
const json_with_image_number_example = `\
|
||||
[
|
||||
{
|
||||
"name": "Token 0 Name",
|
||||
"description": "Token 0 Description",
|
||||
"image": "0.png",
|
||||
"animation_url": "0.mp4",
|
||||
"attributes": [
|
||||
{
|
||||
"trait_type": "eyes",
|
||||
"value": "red"
|
||||
},
|
||||
{
|
||||
"trait_type": "nose",
|
||||
"value": "green"
|
||||
}
|
||||
]
|
||||
},
|
||||
{
|
||||
"name": "Token 1 Name",
|
||||
"description": "Token 1 Description",
|
||||
"image": "1.png",
|
||||
"animation_url": "1.mp4",
|
||||
"attributes": [
|
||||
{
|
||||
"trait_type": "eyes",
|
||||
"value": "blue"
|
||||
},
|
||||
{
|
||||
"trait_type": "nose",
|
||||
"value": "yellow"
|
||||
}
|
||||
]
|
||||
}
|
||||
]`;
|
||||
|
||||
@@ -1,4 +1,3 @@
|
||||
import { FilePreview } from "@app/team/[team_slug]/[project_slug]/(sidebar)/tokens/create/_common/file-preview";
|
||||
import { FilePlusIcon, UploadIcon } from "lucide-react";
|
||||
import { useCallback, useEffect, useRef } from "react";
|
||||
import {
|
||||
@@ -8,6 +7,7 @@ import {
|
||||
useDropzone,
|
||||
} from "react-dropzone";
|
||||
import type { ThirdwebClient } from "thirdweb";
|
||||
import { FilePreview } from "@/components/blocks/file-preview";
|
||||
import { Button } from "@/components/ui/button";
|
||||
import { cn } from "@/lib/utils";
|
||||
|
||||
|
||||
@@ -29,6 +29,7 @@ type ThirdwebAreaChartProps<TConfig extends ChartConfig> = {
|
||||
title: string;
|
||||
description?: string;
|
||||
titleClassName?: string;
|
||||
headerClassName?: string;
|
||||
};
|
||||
customHeader?: React.ReactNode;
|
||||
// chart config
|
||||
@@ -52,6 +53,12 @@ type ThirdwebAreaChartProps<TConfig extends ChartConfig> = {
|
||||
toolTipLabelFormatter?: (label: string, payload: unknown) => React.ReactNode;
|
||||
toolTipValueFormatter?: (value: unknown) => React.ReactNode;
|
||||
emptyChartState?: React.ReactElement;
|
||||
margin?: {
|
||||
top?: number;
|
||||
right?: number;
|
||||
bottom?: number;
|
||||
left?: number;
|
||||
};
|
||||
};
|
||||
|
||||
export function ThirdwebAreaChart<TConfig extends ChartConfig>(
|
||||
@@ -62,7 +69,7 @@ export function ThirdwebAreaChart<TConfig extends ChartConfig>(
|
||||
return (
|
||||
<Card className={props.className}>
|
||||
{props.header && (
|
||||
<CardHeader>
|
||||
<CardHeader className={props.header.headerClassName}>
|
||||
<CardTitle className={cn("mb-2", props.header.titleClassName)}>
|
||||
{props.header.title}
|
||||
</CardTitle>
|
||||
@@ -85,7 +92,16 @@ export function ThirdwebAreaChart<TConfig extends ChartConfig>(
|
||||
{props.emptyChartState}
|
||||
</EmptyChartState>
|
||||
) : (
|
||||
<AreaChart accessibilityLayer data={props.data}>
|
||||
<AreaChart
|
||||
accessibilityLayer
|
||||
data={props.data}
|
||||
margin={{
|
||||
right: props.margin?.right ?? 0,
|
||||
left: props.margin?.left ?? 0,
|
||||
bottom: props.margin?.bottom ?? 10,
|
||||
top: props.margin?.top ?? 0,
|
||||
}}
|
||||
>
|
||||
<CartesianGrid vertical={false} />
|
||||
{props.yAxis && <YAxis axisLine={false} tickLine={false} />}
|
||||
<XAxis
|
||||
|
||||
+10
-2
@@ -37,7 +37,12 @@ export function FilePreview(props: {
|
||||
|
||||
if (!objectUrl) {
|
||||
return (
|
||||
<div className={cn("flex items-center justify-center", props.className)}>
|
||||
<div
|
||||
className={cn(
|
||||
"flex items-center justify-center bg-accent",
|
||||
props.className,
|
||||
)}
|
||||
>
|
||||
<ImageOffIcon className="size-6 text-muted-foreground" />
|
||||
</div>
|
||||
);
|
||||
@@ -56,7 +61,10 @@ export function FilePreview(props: {
|
||||
|
||||
return (
|
||||
<MediaRenderer
|
||||
className={cn(props.className, "[&>div]:!bg-muted")}
|
||||
className={cn(
|
||||
props.className,
|
||||
"[&>div]:!bg-accent [&_a]:!text-muted-foreground [&_a]:!no-underline [&_svg]:!size-6 [&_svg]:!text-muted-foreground relative overflow-hidden",
|
||||
)}
|
||||
client={props.client}
|
||||
src={objectUrl}
|
||||
/>
|
||||
@@ -1,6 +1,6 @@
|
||||
"use client";
|
||||
|
||||
import { CrownIcon, LockIcon, SparklesIcon } from "lucide-react";
|
||||
import { CrownIcon, LockIcon } from "lucide-react";
|
||||
import Link from "next/link";
|
||||
import type React from "react";
|
||||
import type { Team } from "@/api/team";
|
||||
@@ -111,18 +111,15 @@ export function UpsellContent(props: {
|
||||
<CardContent className="space-y-6">
|
||||
{props.benefits && props.benefits.length > 0 && (
|
||||
<div className="space-y-3">
|
||||
<h4 className="font-semibold text-muted-foreground text-sm uppercase tracking-wide">
|
||||
What you'll get:
|
||||
<h4 className="font-semibold text-foreground text-sm capitalize text-center">
|
||||
What you'll get
|
||||
</h4>
|
||||
<div className="grid gap-2">
|
||||
<div className="grid gap-1.5">
|
||||
{props.benefits.map((benefit) => (
|
||||
<div
|
||||
className="flex items-center gap-3"
|
||||
className="flex items-center justify-center gap-3 text-center text-balance"
|
||||
key={benefit.description}
|
||||
>
|
||||
<div className="flex h-5 w-5 flex-shrink-0 items-center justify-center rounded-full bg-accent">
|
||||
<SparklesIcon className="h-3 w-3 text-success-text" />
|
||||
</div>
|
||||
<span className="text-sm">{benefit.description}</span>
|
||||
{benefit.status === "soon" && (
|
||||
<Badge className="text-xs" variant="secondary">
|
||||
|
||||
@@ -116,13 +116,12 @@ export function ChatBar(props: {
|
||||
) : (
|
||||
<Button
|
||||
aria-label="Send"
|
||||
className="!h-auto w-auto border border-nebula-pink-foreground p-2 disabled:opacity-100"
|
||||
className="!h-auto w-auto p-2 disabled:opacity-100"
|
||||
disabled={message.trim() === "" || props.isConnectingWallet}
|
||||
onClick={() => {
|
||||
if (message.trim() === "") return;
|
||||
handleSubmit(message);
|
||||
}}
|
||||
variant="pink"
|
||||
>
|
||||
<ArrowUpIcon className="size-4" />
|
||||
</Button>
|
||||
|
||||
@@ -1,8 +1,10 @@
|
||||
"use client";
|
||||
|
||||
import { MessageCircleIcon, XIcon } from "lucide-react";
|
||||
import { useSelectedLayoutSegments } from "next/navigation";
|
||||
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";
|
||||
@@ -14,22 +16,22 @@ const client = createThirdwebClient({
|
||||
});
|
||||
|
||||
export function CustomChatButton(props: {
|
||||
isLoggedIn: boolean;
|
||||
networks: "mainnet" | "testnet" | "all" | null;
|
||||
isFloating: boolean;
|
||||
pageType: "chain" | "contract" | "support";
|
||||
label: string;
|
||||
examplePrompts: string[];
|
||||
authToken: string | undefined;
|
||||
teamId: string | undefined;
|
||||
authToken: string;
|
||||
team: Team;
|
||||
clientId: string | undefined;
|
||||
requireLogin?: boolean;
|
||||
}) {
|
||||
const layoutSegments = useSelectedLayoutSegments();
|
||||
const [isOpen, setIsOpen] = useState(false);
|
||||
const [hasBeenOpened, setHasBeenOpened] = useState(false);
|
||||
const closeModal = useCallback(() => setIsOpen(false), []);
|
||||
const ref = useRef<HTMLDivElement>(null);
|
||||
|
||||
if (layoutSegments[0] === "~" && layoutSegments[1] === "support") {
|
||||
return null;
|
||||
}
|
||||
|
||||
return (
|
||||
<>
|
||||
{/* Inline Button (not floating) */}
|
||||
@@ -54,14 +56,14 @@ export function CustomChatButton(props: {
|
||||
ref={ref}
|
||||
>
|
||||
{/* Header with close button */}
|
||||
<div className="flex items-center justify-between border-b px-4 py-2">
|
||||
<div className="flex items-center justify-between border-b px-4 py-4">
|
||||
<div className="flex items-center gap-2 font-semibold text-lg">
|
||||
<MessageCircleIcon className="size-5 text-muted-foreground" />
|
||||
{props.label}
|
||||
</div>
|
||||
<Button
|
||||
aria-label="Close chat"
|
||||
className="h-auto w-auto p-1 text-muted-foreground"
|
||||
className="h-auto w-auto p-1 text-muted-foreground rounded-full"
|
||||
onClick={closeModal}
|
||||
size="icon"
|
||||
variant="ghost"
|
||||
@@ -80,9 +82,7 @@ export function CustomChatButton(props: {
|
||||
message: prompt,
|
||||
title: prompt,
|
||||
}))}
|
||||
networks={props.networks}
|
||||
requireLogin={props.requireLogin}
|
||||
teamId={props.teamId}
|
||||
team={props.team}
|
||||
/>
|
||||
)}
|
||||
</div>
|
||||
|
||||
@@ -1,44 +1,36 @@
|
||||
"use client";
|
||||
import { ArrowRightIcon } from "lucide-react";
|
||||
import Link from "next/link";
|
||||
import { usePathname } from "next/navigation";
|
||||
import { useCallback, useState } from "react";
|
||||
import type { ThirdwebClient } from "thirdweb";
|
||||
import { useActiveWalletConnectionStatus } from "thirdweb/react";
|
||||
import type { Team } from "@/api/team";
|
||||
import { Button } from "@/components/ui/button";
|
||||
import { NebulaIcon } from "@/icons/NebulaIcon";
|
||||
import { ThirdwebMiniLogo } from "../../../app/(app)/components/ThirdwebMiniLogo";
|
||||
import { ChatBar } from "./ChatBar";
|
||||
import type { UserMessage, UserMessageContent } from "./CustomChats";
|
||||
import { type CustomChatMessage, CustomChats } from "./CustomChats";
|
||||
import type { ExamplePrompt, NebulaContext } from "./types";
|
||||
import type { ExamplePrompt } from "./types";
|
||||
|
||||
export default function CustomChatContent(props: {
|
||||
authToken: string | undefined;
|
||||
teamId: string | undefined;
|
||||
team: Team;
|
||||
clientId: string | undefined;
|
||||
client: ThirdwebClient;
|
||||
examplePrompts: ExamplePrompt[];
|
||||
networks: NebulaContext["networks"];
|
||||
requireLogin?: boolean;
|
||||
}) {
|
||||
if (props.requireLogin !== false && !props.authToken) {
|
||||
return <LoggedOutStateChatContent />;
|
||||
}
|
||||
|
||||
return (
|
||||
<CustomChatContentLoggedIn
|
||||
authToken={props.authToken || ""}
|
||||
client={props.client}
|
||||
clientId={props.clientId}
|
||||
examplePrompts={props.examplePrompts}
|
||||
teamId={props.teamId}
|
||||
team={props.team}
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
||||
function CustomChatContentLoggedIn(props: {
|
||||
authToken: string;
|
||||
teamId: string | undefined;
|
||||
team: Team;
|
||||
clientId: string | undefined;
|
||||
client: ThirdwebClient;
|
||||
examplePrompts: ExamplePrompt[];
|
||||
@@ -55,6 +47,9 @@ function CustomChatContentLoggedIn(props: {
|
||||
const [enableAutoScroll, setEnableAutoScroll] = useState(false);
|
||||
const connectionStatus = useActiveWalletConnectionStatus();
|
||||
|
||||
const [showSupportForm, setShowSupportForm] = useState(false);
|
||||
const [productLabel, setProductLabel] = useState("");
|
||||
|
||||
const handleSendMessage = useCallback(
|
||||
async (userMessage: UserMessage) => {
|
||||
const abortController = new AbortController();
|
||||
@@ -96,7 +91,7 @@ function CustomChatContentLoggedIn(props: {
|
||||
headers: {
|
||||
Authorization: `Bearer ${props.authToken}`,
|
||||
"Content-Type": "application/json",
|
||||
...(props.teamId ? { "x-team-id": props.teamId } : {}),
|
||||
"x-team-id": props.team.id,
|
||||
...(props.clientId ? { "x-client-id": props.clientId } : {}),
|
||||
},
|
||||
method: "POST",
|
||||
@@ -132,7 +127,7 @@ function CustomChatContentLoggedIn(props: {
|
||||
setEnableAutoScroll(false);
|
||||
}
|
||||
},
|
||||
[props.authToken, props.clientId, props.teamId, sessionId],
|
||||
[props.authToken, props.clientId, props.team.id, sessionId],
|
||||
);
|
||||
|
||||
const handleFeedback = useCallback(
|
||||
@@ -165,7 +160,7 @@ function CustomChatContentLoggedIn(props: {
|
||||
headers: {
|
||||
Authorization: `Bearer ${props.authToken}`,
|
||||
"Content-Type": "application/json",
|
||||
...(props.teamId ? { "x-team-id": props.teamId } : {}),
|
||||
"x-team-id": props.team.id,
|
||||
},
|
||||
method: "POST",
|
||||
});
|
||||
@@ -188,9 +183,20 @@ function CustomChatContentLoggedIn(props: {
|
||||
// Consider implementing retry logic here
|
||||
}
|
||||
},
|
||||
[sessionId, props.authToken, props.teamId, messages],
|
||||
[sessionId, props.authToken, props.team.id, messages],
|
||||
);
|
||||
|
||||
const handleAddSuccessMessage = useCallback((message: string) => {
|
||||
setMessages((prev) => [
|
||||
...prev,
|
||||
{
|
||||
type: "assistant" as const,
|
||||
text: message,
|
||||
request_id: undefined,
|
||||
},
|
||||
]);
|
||||
}, []);
|
||||
|
||||
const showEmptyState = !userHasSubmittedMessage && messages.length === 0;
|
||||
return (
|
||||
<div className="flex grow flex-col overflow-hidden">
|
||||
@@ -212,6 +218,12 @@ function CustomChatContentLoggedIn(props: {
|
||||
sessionId={sessionId}
|
||||
setEnableAutoScroll={setEnableAutoScroll}
|
||||
useSmallText
|
||||
showSupportForm={showSupportForm}
|
||||
setShowSupportForm={setShowSupportForm}
|
||||
productLabel={productLabel}
|
||||
setProductLabel={setProductLabel}
|
||||
team={props.team}
|
||||
addSuccessMessage={handleAddSuccessMessage}
|
||||
/>
|
||||
)}
|
||||
<ChatBar
|
||||
@@ -245,42 +257,6 @@ function CustomChatContentLoggedIn(props: {
|
||||
);
|
||||
}
|
||||
|
||||
function LoggedOutStateChatContent() {
|
||||
const pathname = usePathname();
|
||||
return (
|
||||
<div className="flex grow flex-col items-center justify-center p-4">
|
||||
<div className="mb-4 flex justify-center">
|
||||
<div className="rounded-full border p-1">
|
||||
<div className="rounded-full border bg-card p-2">
|
||||
<NebulaIcon className="size-7 text-muted-foreground" />
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<h1 className="px-4 text-center font-semibold text-3xl tracking-tight md:text-4xl">
|
||||
How can I help you <br className="max-sm:hidden" />
|
||||
today?
|
||||
</h1>
|
||||
|
||||
<div className="h-3" />
|
||||
<p className="text-base text-muted-foreground">
|
||||
Sign in to use AI Assistant
|
||||
</p>
|
||||
<div className="h-5" />
|
||||
|
||||
<Button asChild>
|
||||
<Link
|
||||
className="w-full max-w-96 gap-2"
|
||||
href={`/login?next=${encodeURIComponent(pathname)}`}
|
||||
>
|
||||
Sign in
|
||||
<ArrowRightIcon className="size-4" />
|
||||
</Link>
|
||||
</Button>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
function EmptyStateChatPageContent(props: {
|
||||
sendMessage: (message: UserMessage) => void;
|
||||
examplePrompts: { title: string; message: string }[];
|
||||
@@ -288,9 +264,12 @@ function EmptyStateChatPageContent(props: {
|
||||
return (
|
||||
<div className="flex flex-1 flex-col items-center justify-center overflow-auto p-4 ">
|
||||
<div className="mb-4 flex justify-center">
|
||||
<div className="rounded-full border-[1.5px] border-nebula-pink-foreground/20 bg-[hsl(var(--nebula-pink-foreground)/5%)] p-1">
|
||||
<div className="rounded-full border-[1.5px] border-nebula-pink-foreground/40 bg-[hsl(var(--nebula-pink-foreground)/5%)] p-2">
|
||||
<NebulaIcon className="size-7 text-nebula-pink-foreground" />
|
||||
<div className="rounded-full border p-1 bg-muted/20">
|
||||
<div className="rounded-full border p-2 bg-inverted">
|
||||
<ThirdwebMiniLogo
|
||||
isMonoChrome
|
||||
className="size-7 text-inverted-foreground"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
@@ -301,11 +280,12 @@ function EmptyStateChatPageContent(props: {
|
||||
</h1>
|
||||
|
||||
<div className="h-6" />
|
||||
<div className="flex max-w-lg flex-col flex-wrap justify-center gap-2.5">
|
||||
<div className="flex flex-col items-center justify-center gap-2.5 overflow-hidden">
|
||||
{props.examplePrompts.map((prompt) => (
|
||||
<Button
|
||||
disabled={false}
|
||||
key={prompt.title}
|
||||
className="rounded-full text-xs sm:text-sm truncate bg-card w-fit h-auto py-1.5 whitespace-pre-wrap"
|
||||
onClick={() =>
|
||||
props.sendMessage({
|
||||
content: [
|
||||
|
||||
@@ -1,15 +1,26 @@
|
||||
import {
|
||||
AlertCircleIcon,
|
||||
MessageCircleIcon,
|
||||
ArrowRightIcon,
|
||||
ThumbsDownIcon,
|
||||
ThumbsUpIcon,
|
||||
} from "lucide-react";
|
||||
import { useEffect, useRef, useState } from "react";
|
||||
import type { ThirdwebClient } from "thirdweb";
|
||||
import type { Team } from "@/api/team";
|
||||
import { MarkdownRenderer } from "@/components/blocks/markdown-renderer";
|
||||
import { DynamicHeight } from "@/components/ui/DynamicHeight";
|
||||
import {
|
||||
Dialog,
|
||||
DialogContent,
|
||||
DialogHeader,
|
||||
DialogTitle,
|
||||
} from "@/components/ui/dialog";
|
||||
import { ScrollShadow } from "@/components/ui/ScrollShadow/ScrollShadow";
|
||||
import { cn } from "@/lib/utils";
|
||||
import { Reasoning } from "./Reasoning";
|
||||
import { ThirdwebMiniLogo } from "../../../app/(app)/components/ThirdwebMiniLogo";
|
||||
import { SupportTicketForm } from "../../../app/(app)/team/[team_slug]/(team)/~/support/_components/SupportTicketForm";
|
||||
import { Button } from "../ui/button";
|
||||
import { TextShimmer } from "../ui/text-shimmer";
|
||||
|
||||
// Define local types
|
||||
export type UserMessageContent = { type: "text"; text: string };
|
||||
@@ -46,11 +57,18 @@ export function CustomChats(props: {
|
||||
enableAutoScroll: boolean;
|
||||
useSmallText?: boolean;
|
||||
sendMessage: (message: UserMessage) => void;
|
||||
onFeedback?: (messageIndex: number, feedback: 1 | -1) => void;
|
||||
onFeedback?: (messageIndex: number, feedback: 1 | -1) => Promise<void>;
|
||||
showSupportForm: boolean;
|
||||
setShowSupportForm: (v: boolean) => void;
|
||||
productLabel: string;
|
||||
setProductLabel: (v: string) => void;
|
||||
team: Team;
|
||||
addSuccessMessage?: (message: string) => void;
|
||||
}) {
|
||||
const { messages, setEnableAutoScroll, enableAutoScroll } = props;
|
||||
const scrollAnchorRef = useRef<HTMLDivElement>(null);
|
||||
const chatContainerRef = useRef<HTMLDivElement>(null);
|
||||
const [supportTicketCreated, setSupportTicketCreated] = useState(false);
|
||||
|
||||
// auto scroll to bottom when messages change
|
||||
// eslint-disable-next-line no-restricted-syntax
|
||||
@@ -86,6 +104,8 @@ export function CustomChats(props: {
|
||||
chatScrollContainer.addEventListener("wheel", disableScroll);
|
||||
}, [setEnableAutoScroll, enableAutoScroll]);
|
||||
|
||||
const [showSupportFormDialog, setShowSupportFormDialog] = useState(false);
|
||||
|
||||
return (
|
||||
<div
|
||||
className="relative flex max-h-full flex-1 flex-col overflow-hidden"
|
||||
@@ -123,6 +143,66 @@ export function CustomChats(props: {
|
||||
sendMessage={props.sendMessage}
|
||||
sessionId={props.sessionId}
|
||||
/>
|
||||
{/* Support Case Button/Form in last assistant message */}
|
||||
{message.type === "assistant" &&
|
||||
index === props.messages.length - 1 && (
|
||||
<>
|
||||
{/* Only show button/form if ticket not created */}
|
||||
{!supportTicketCreated && (
|
||||
<div className="mt-3 pl-12">
|
||||
<Dialog
|
||||
open={showSupportFormDialog}
|
||||
onOpenChange={setShowSupportFormDialog}
|
||||
>
|
||||
<Button
|
||||
onClick={() => setShowSupportFormDialog(true)}
|
||||
size="sm"
|
||||
variant="outline"
|
||||
className="rounded-full bg-card"
|
||||
>
|
||||
Create Support Case
|
||||
<ArrowRightIcon className="w-4 h-4 ml-2" />
|
||||
</Button>
|
||||
|
||||
<DialogContent className="p-0 bg-card">
|
||||
<DynamicHeight>
|
||||
<DialogHeader className="p-4 lg:p-6 border-b border-dashed space-y-1">
|
||||
<DialogTitle className="text-xl font-semibold text-foreground tracking-tight">
|
||||
Create Support Case
|
||||
</DialogTitle>
|
||||
<p className="text-muted-foreground text-sm">
|
||||
Let's create a detailed support case for
|
||||
our technical team.
|
||||
</p>
|
||||
</DialogHeader>
|
||||
<SupportTicketForm
|
||||
team={props.team}
|
||||
productLabel={props.productLabel}
|
||||
setProductLabel={props.setProductLabel}
|
||||
conversationId={props.sessionId}
|
||||
closeForm={() => {
|
||||
setShowSupportFormDialog(false);
|
||||
}}
|
||||
onSuccess={() => {
|
||||
props.setShowSupportForm(false);
|
||||
props.setProductLabel("");
|
||||
setSupportTicketCreated(true);
|
||||
// Add success message as a regular assistant message
|
||||
if (props.addSuccessMessage) {
|
||||
const supportPortalUrl = `/team/${props.team.slug}/~/support`;
|
||||
props.addSuccessMessage(
|
||||
`Your support ticket has been created! Our team will get back to you soon. You can also visit the [support portal](${supportPortalUrl}) to track your case.`,
|
||||
);
|
||||
}
|
||||
}}
|
||||
/>
|
||||
</DynamicHeight>
|
||||
</DialogContent>
|
||||
</Dialog>
|
||||
</div>
|
||||
)}
|
||||
</>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
})}
|
||||
@@ -143,7 +223,7 @@ function RenderMessage(props: {
|
||||
nextMessage: CustomChatMessage | undefined;
|
||||
authToken: string;
|
||||
sessionId: string | undefined;
|
||||
onFeedback?: (messageIndex: number, feedback: 1 | -1) => void;
|
||||
onFeedback?: (messageIndex: number, feedback: 1 | -1) => Promise<void>;
|
||||
}) {
|
||||
const { message } = props;
|
||||
|
||||
@@ -179,13 +259,16 @@ function RenderMessage(props: {
|
||||
<div
|
||||
className={cn(
|
||||
"flex size-9 items-center justify-center rounded-full",
|
||||
message.type === "assistant" && "border bg-card",
|
||||
message.type === "assistant" && "border bg-inverted",
|
||||
message.type === "error" && "border",
|
||||
message.type === "presence" && "border bg-card",
|
||||
message.type === "presence" && "border bg-inverted",
|
||||
)}
|
||||
>
|
||||
{(message.type === "presence" || message.type === "assistant") && (
|
||||
<MessageCircleIcon className="size-5 text-muted-foreground" />
|
||||
<ThirdwebMiniLogo
|
||||
className="size-4 text-inverted-foreground"
|
||||
isMonoChrome
|
||||
/>
|
||||
)}
|
||||
|
||||
{message.type === "error" && (
|
||||
@@ -227,7 +310,7 @@ function RenderMessage(props: {
|
||||
function CustomFeedbackButtons(props: {
|
||||
message: CustomChatMessage & { type: "assistant" };
|
||||
messageIndex: number;
|
||||
onFeedback: (messageIndex: number, feedback: 1 | -1) => void;
|
||||
onFeedback: (messageIndex: number, feedback: 1 | -1) => Promise<void>;
|
||||
className?: string;
|
||||
}) {
|
||||
const [isSubmitting, setIsSubmitting] = useState(false);
|
||||
@@ -240,9 +323,9 @@ function CustomFeedbackButtons(props: {
|
||||
await props.onFeedback(props.messageIndex, feedback);
|
||||
} catch (_e) {
|
||||
// Handle error silently
|
||||
} finally {
|
||||
setIsSubmitting(false);
|
||||
}
|
||||
|
||||
setIsSubmitting(false);
|
||||
};
|
||||
|
||||
// Don't show buttons if feedback already given
|
||||
@@ -251,25 +334,26 @@ function CustomFeedbackButtons(props: {
|
||||
}
|
||||
|
||||
return (
|
||||
<div className={cn("flex gap-2", props.className)}>
|
||||
<button
|
||||
aria-label="Thumbs up"
|
||||
className="text-muted-foreground transition-colors hover:text-green-500 disabled:opacity-50"
|
||||
<div className={cn("flex gap-1.5", props.className)}>
|
||||
<Button
|
||||
aria-label="Upvote"
|
||||
variant="outline"
|
||||
disabled={isSubmitting}
|
||||
onClick={() => handleFeedback(1)}
|
||||
type="button"
|
||||
className="size-8 p-0 rounded-lg bg-card"
|
||||
>
|
||||
<ThumbsUpIcon className="size-5" />
|
||||
</button>
|
||||
<button
|
||||
aria-label="Thumbs down"
|
||||
className="text-muted-foreground transition-colors hover:text-red-500 disabled:opacity-50"
|
||||
<ThumbsUpIcon className="size-3.5" />
|
||||
</Button>
|
||||
<Button
|
||||
aria-label="Downvote"
|
||||
variant="outline"
|
||||
disabled={isSubmitting}
|
||||
onClick={() => handleFeedback(-1)}
|
||||
type="button"
|
||||
className="size-8 p-0 rounded-lg bg-card"
|
||||
>
|
||||
<ThumbsDownIcon className="size-5" />
|
||||
</button>
|
||||
<ThumbsDownIcon className="size-3.5" />
|
||||
</Button>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -296,7 +380,11 @@ function RenderResponse(props: {
|
||||
);
|
||||
|
||||
case "presence":
|
||||
return <Reasoning isPending={isMessagePending} texts={message.texts} />;
|
||||
return (
|
||||
<div className="h-8 flex items-center">
|
||||
<TextShimmer text="Reasoning..." />
|
||||
</div>
|
||||
);
|
||||
|
||||
case "error":
|
||||
return (
|
||||
@@ -325,19 +413,15 @@ function StyledMarkdownRenderer(props: {
|
||||
}) {
|
||||
return (
|
||||
<MarkdownRenderer
|
||||
className="text-foreground [&>*:first-child]:mt-0 [&>*:first-child]:border-none [&>*:first-child]:pb-0 [&>*:last-child]:mb-0"
|
||||
className="[&>*:first-child]:mt-0 [&>*:first-child]:border-none [&>*:first-child]:pb-0 [&>*:last-child]:mb-0"
|
||||
code={{
|
||||
className: "bg-transparent",
|
||||
className: "bg-card",
|
||||
ignoreFormattingErrors: true,
|
||||
}}
|
||||
inlineCode={{ className: "border-none" }}
|
||||
li={{ className: "text-foreground" }}
|
||||
markdownText={props.text}
|
||||
p={{
|
||||
className:
|
||||
props.type === "assistant"
|
||||
? "text-foreground"
|
||||
: "text-foreground leading-normal",
|
||||
className: props.type === "assistant" ? "" : "leading-normal",
|
||||
}}
|
||||
skipHtml
|
||||
/>
|
||||
|
||||
@@ -1,62 +0,0 @@
|
||||
import { ChevronDownIcon } from "lucide-react";
|
||||
import { useState } from "react";
|
||||
import { Button } from "@/components/ui/button";
|
||||
import { DynamicHeight } from "@/components/ui/DynamicHeight";
|
||||
import { TextShimmer } from "@/components/ui/text-shimmer";
|
||||
import { cn } from "@/lib/utils";
|
||||
|
||||
export function Reasoning(props: { isPending: boolean; texts: string[] }) {
|
||||
const [_isOpen, setIsOpen] = useState(false);
|
||||
const isOpen = props.isPending ? true : _isOpen;
|
||||
const showAll = !props.isPending;
|
||||
const lastText = props.texts[props.texts.length - 1];
|
||||
|
||||
return (
|
||||
<DynamicHeight>
|
||||
<Button
|
||||
className="h-auto w-auto items-center gap-1 px-0 py-1 pb-2 font-normal text-base text-muted-foreground hover:text-foreground hover:no-underline"
|
||||
onClick={() => setIsOpen(!isOpen)}
|
||||
size="sm"
|
||||
variant="link"
|
||||
>
|
||||
{props.isPending ? (
|
||||
<TextShimmer text="Reasoning..." />
|
||||
) : (
|
||||
<span className="text-foreground">Reasoning</span>
|
||||
)}
|
||||
|
||||
{!props.isPending && (
|
||||
<ChevronDownIcon
|
||||
className={cn(
|
||||
"size-4 transition-transform",
|
||||
isOpen && "rotate-180",
|
||||
)}
|
||||
/>
|
||||
)}
|
||||
</Button>
|
||||
|
||||
{isOpen && (
|
||||
<>
|
||||
{showAll && props.texts.length > 0 && (
|
||||
<ul className="list-none space-y-1.5">
|
||||
{props.texts.map((text) => (
|
||||
<li
|
||||
className="fade-in-0 animate-in text-muted-foreground text-sm leading-relaxed duration-300"
|
||||
key={text}
|
||||
>
|
||||
{text.trim()}
|
||||
</li>
|
||||
))}
|
||||
</ul>
|
||||
)}
|
||||
|
||||
{!showAll && lastText && (
|
||||
<div className="text-muted-foreground text-sm leading-relaxed">
|
||||
{lastText.trim()}
|
||||
</div>
|
||||
)}
|
||||
</>
|
||||
)}
|
||||
</DynamicHeight>
|
||||
);
|
||||
}
|
||||
@@ -6,12 +6,6 @@ export type ExamplePrompt = {
|
||||
|
||||
// TODO - remove "Nebula" wording and simplify types
|
||||
|
||||
export type NebulaContext = {
|
||||
chainIds: string[] | null;
|
||||
walletAddress: string | null;
|
||||
networks: "mainnet" | "testnet" | "all" | null;
|
||||
};
|
||||
|
||||
type NebulaUserMessageContentItem =
|
||||
| {
|
||||
type: "image";
|
||||
|
||||
@@ -1,7 +1,6 @@
|
||||
"use client";
|
||||
|
||||
import { useFavoriteChainIds } from "@app/(dashboard)/(chain)/components/client/star-button";
|
||||
import { doLogout } from "@app/login/auth-actions";
|
||||
import Image from "next/image";
|
||||
import Link from "next/link";
|
||||
import { usePathname } from "next/navigation";
|
||||
@@ -14,6 +13,7 @@ import {
|
||||
useActiveAccount,
|
||||
useConnectModal,
|
||||
} from "thirdweb/react";
|
||||
import { doLogout } from "@/actions/auth-actions";
|
||||
import { resetAnalytics } from "@/analytics/reset";
|
||||
import { CustomChainRenderer } from "@/components/misc/CustomChainRenderer";
|
||||
import { LazyConfigureNetworkModal } from "@/components/misc/configure-networks/LazyConfigureNetworkModal";
|
||||
|
||||
@@ -661,7 +661,7 @@ export const CodeOverview: React.FC<CodeOverviewProps> = ({
|
||||
rel="noopener noreferrer"
|
||||
target="_blank"
|
||||
>
|
||||
Connect SDK
|
||||
thirdweb SDK
|
||||
</UnderlineLink>{" "}
|
||||
in your applications. This will ensure account contracts are
|
||||
deployed for your users only when they need it.
|
||||
|
||||
@@ -1,15 +1,4 @@
|
||||
import {
|
||||
Flex,
|
||||
FormControl,
|
||||
IconButton,
|
||||
Input,
|
||||
InputGroup,
|
||||
InputRightElement,
|
||||
Tooltip,
|
||||
} from "@chakra-ui/react";
|
||||
import { Button } from "chakra/button";
|
||||
import { FormErrorMessage, FormLabel } from "chakra/form";
|
||||
import { BanIcon, PlusIcon, TrashIcon, UploadIcon, XIcon } from "lucide-react";
|
||||
import { PlusIcon, TrashIcon, XIcon } from "lucide-react";
|
||||
import { useEffect } from "react";
|
||||
import {
|
||||
type ArrayPath,
|
||||
@@ -25,7 +14,16 @@ import {
|
||||
type WatchObserver,
|
||||
} from "react-hook-form";
|
||||
import type { ThirdwebClient } from "thirdweb";
|
||||
import { FileInput } from "@/components/blocks/FileInput";
|
||||
import { Button } from "@/components/ui/button";
|
||||
import {
|
||||
FormControl,
|
||||
FormField,
|
||||
FormItem,
|
||||
FormLabel,
|
||||
FormMessage,
|
||||
} from "@/components/ui/form";
|
||||
import { Input } from "@/components/ui/input";
|
||||
import { cn } from "@/lib/utils";
|
||||
|
||||
type OptionalPropertiesInput = {
|
||||
[key: string]: string | number;
|
||||
@@ -46,17 +44,15 @@ interface IPropertiesFormControlProps<
|
||||
client: ThirdwebClient;
|
||||
}
|
||||
|
||||
export const PropertiesFormControl = <
|
||||
export function PropertiesFormControl<
|
||||
TFieldValues extends IPropertyFieldValues,
|
||||
>({
|
||||
control,
|
||||
register,
|
||||
watch,
|
||||
errors,
|
||||
setValue,
|
||||
client,
|
||||
}: React.PropsWithChildren<IPropertiesFormControlProps<TFieldValues>>) => {
|
||||
const { fields, append, remove, replace } = useFieldArray({
|
||||
}: React.PropsWithChildren<IPropertiesFormControlProps<TFieldValues>>) {
|
||||
const { fields, append, remove } = useFieldArray({
|
||||
control,
|
||||
name: "attributes" as ArrayPath<TFieldValues>,
|
||||
});
|
||||
@@ -72,19 +68,7 @@ export const PropertiesFormControl = <
|
||||
|
||||
return (
|
||||
<div className="flex flex-col gap-4">
|
||||
<Flex align="center" direction="row" justify="space-between">
|
||||
<FormLabel m={0}>Attributes</FormLabel>
|
||||
<Button
|
||||
colorScheme="red"
|
||||
// biome-ignore lint/suspicious/noExplicitAny: FIXME
|
||||
onClick={() => replace([{ trait_type: "", value: "" } as any])}
|
||||
rightIcon={<BanIcon className="size-4" />}
|
||||
size="xs"
|
||||
variant="outline"
|
||||
>
|
||||
Reset
|
||||
</Button>
|
||||
</Flex>
|
||||
<FormLabel>Attributes</FormLabel>
|
||||
{fields.map((field, index) => {
|
||||
// biome-ignore lint/suspicious/noExplicitAny: FIXME
|
||||
const keyError = (errors as any)?.attributes?.[index]?.trait_type
|
||||
@@ -92,102 +76,108 @@ export const PropertiesFormControl = <
|
||||
// biome-ignore lint/suspicious/noExplicitAny: FIXME
|
||||
const valueError = (errors as any)?.attributes?.[index]?.value
|
||||
?.message as string | undefined;
|
||||
const isInvalid = !!(keyError || valueError);
|
||||
const _isInvalid = !!(keyError || valueError);
|
||||
|
||||
return (
|
||||
<div className="flex flex-row items-center gap-2" key={field.id}>
|
||||
<FormControl
|
||||
className="flex flex-row items-start gap-3"
|
||||
isInvalid={isInvalid}
|
||||
>
|
||||
<FormControl isInvalid={!!keyError}>
|
||||
<Input
|
||||
{...register(
|
||||
`attributes.${index}.trait_type` as Path<TFieldValues>,
|
||||
)}
|
||||
placeholder="trait_type"
|
||||
/>
|
||||
<FormErrorMessage>{keyError}</FormErrorMessage>
|
||||
</FormControl>
|
||||
<FormControl isInvalid={!!valueError}>
|
||||
{watch(
|
||||
`attributes.${index}.value` as unknown as WatchObserver<TFieldValues>,
|
||||
) instanceof File ? (
|
||||
<InputGroup>
|
||||
<Input
|
||||
isDisabled
|
||||
value={
|
||||
watch(`attributes.${index}.value` as Path<TFieldValues>)
|
||||
.name
|
||||
}
|
||||
/>
|
||||
<InputRightElement>
|
||||
<TrashIcon
|
||||
className="size-4 cursor-pointer text-red-300 hover:text-red-200"
|
||||
onClick={() =>
|
||||
setValue(
|
||||
`attributes.${index}.value` as Path<TFieldValues>,
|
||||
"" as PathValue<TFieldValues, Path<TFieldValues>>,
|
||||
)
|
||||
}
|
||||
<div className="flex flex-row items-start gap-3 flex-1">
|
||||
<FormField
|
||||
control={control}
|
||||
name={`attributes.${index}.trait_type` as Path<TFieldValues>}
|
||||
render={({ field: traitField }) => (
|
||||
<FormItem className="flex-1">
|
||||
<FormControl>
|
||||
<Input
|
||||
{...traitField}
|
||||
placeholder="trait_type"
|
||||
className={cn(
|
||||
"bg-card",
|
||||
keyError && "border-destructive",
|
||||
)}
|
||||
/>
|
||||
</InputRightElement>
|
||||
</InputGroup>
|
||||
) : (
|
||||
<InputGroup>
|
||||
<Input
|
||||
{...register(
|
||||
`attributes.${index}.value` as Path<TFieldValues>,
|
||||
)}
|
||||
placeholder="value"
|
||||
/>
|
||||
<InputRightElement>
|
||||
<Tooltip label="Upload file" shouldWrapChildren>
|
||||
<FileInput
|
||||
client={client}
|
||||
setValue={(file) => {
|
||||
setValue(
|
||||
`attributes.${index}.value` as Path<TFieldValues>,
|
||||
file as PathValue<
|
||||
TFieldValues,
|
||||
Path<TFieldValues>
|
||||
>,
|
||||
);
|
||||
}}
|
||||
>
|
||||
<UploadIcon className="size-4 text-muted-foreground" />
|
||||
</FileInput>
|
||||
</Tooltip>
|
||||
</InputRightElement>
|
||||
</InputGroup>
|
||||
</FormControl>
|
||||
{keyError && <FormMessage>{keyError}</FormMessage>}
|
||||
</FormItem>
|
||||
)}
|
||||
<FormErrorMessage>{valueError}</FormErrorMessage>
|
||||
</FormControl>
|
||||
</FormControl>
|
||||
<IconButton
|
||||
aria-label="remove key value pair"
|
||||
colorScheme="red"
|
||||
icon={<XIcon />}
|
||||
/>
|
||||
|
||||
<FormField
|
||||
control={control}
|
||||
name={`attributes.${index}.value` as Path<TFieldValues>}
|
||||
render={({ field: valueField }) => (
|
||||
<FormItem className="flex-1">
|
||||
<FormControl>
|
||||
{watch(
|
||||
`attributes.${index}.value` as unknown as WatchObserver<TFieldValues>,
|
||||
) instanceof File ? (
|
||||
<div className="relative">
|
||||
<Input
|
||||
disabled
|
||||
value={
|
||||
watch(
|
||||
`attributes.${index}.value` as Path<TFieldValues>,
|
||||
).name
|
||||
}
|
||||
className="pr-10 bg-card"
|
||||
/>
|
||||
<Button
|
||||
type="button"
|
||||
variant="ghost"
|
||||
size="sm"
|
||||
className="absolute right-0 top-0 h-full px-3 hover:bg-transparent"
|
||||
onClick={() =>
|
||||
setValue(
|
||||
`attributes.${index}.value` as Path<TFieldValues>,
|
||||
"" as PathValue<
|
||||
TFieldValues,
|
||||
Path<TFieldValues>
|
||||
>,
|
||||
)
|
||||
}
|
||||
>
|
||||
<TrashIcon className="size-4 text-muted-foreground hover:text-destructive" />
|
||||
</Button>
|
||||
</div>
|
||||
) : (
|
||||
<div className="relative">
|
||||
<Input
|
||||
{...valueField}
|
||||
placeholder="value"
|
||||
className={`pr-10 bg-card ${valueError ? "border-destructive" : ""}`}
|
||||
/>
|
||||
</div>
|
||||
)}
|
||||
</FormControl>
|
||||
{valueError && <FormMessage>{valueError}</FormMessage>}
|
||||
</FormItem>
|
||||
)}
|
||||
/>
|
||||
</div>
|
||||
<Button
|
||||
type="button"
|
||||
variant="outline"
|
||||
size="sm"
|
||||
className="rounded-full p-0 size-10 bg-card"
|
||||
onClick={() => remove(index)}
|
||||
size="xs"
|
||||
variant="ghost"
|
||||
/>
|
||||
>
|
||||
<XIcon className="size-4" />
|
||||
</Button>
|
||||
</div>
|
||||
);
|
||||
})}
|
||||
<div className="flex flex-row gap-2">
|
||||
<Button
|
||||
colorScheme="purple"
|
||||
leftIcon={<PlusIcon className="size-5" />}
|
||||
className="rounded-full"
|
||||
onClick={() =>
|
||||
// biome-ignore lint/suspicious/noExplicitAny: FIXME
|
||||
append({ trait_type: undefined, value: undefined } as any)
|
||||
}
|
||||
size="sm"
|
||||
>
|
||||
<PlusIcon className="size-4 mr-2" />
|
||||
Add Row
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
}
|
||||
|
||||
@@ -47,7 +47,7 @@ export function AnnouncementBanner() {
|
||||
return (
|
||||
<AnnouncementBannerUI
|
||||
href="https://blog.thirdweb.com/the-fastest-way-to-build-web3-applications/"
|
||||
label="We have re-branded our Engine, Universal Bridge, and Connect products. Please read the full blog post for details on changes"
|
||||
label="We have re-branded our Engine, Payments, and Connect products. Please read the full blog post for details on changes"
|
||||
trackingLabel="product-rebrand"
|
||||
/>
|
||||
);
|
||||
|
||||
@@ -38,6 +38,7 @@ import { createProjectClient } from "@/hooks/useApi";
|
||||
import { useDashboardRouter } from "@/lib/DashboardRouter";
|
||||
import { projectDomainsSchema, projectNameSchema } from "@/schema/validations";
|
||||
import { toArrFromList } from "@/utils/string";
|
||||
import { createVaultAccountAndAccessToken } from "../../../../app/(app)/team/[team_slug]/[project_slug]/(sidebar)/transactions/lib/vault.client";
|
||||
|
||||
const ALL_PROJECT_SERVICES = SERVICES.filter(
|
||||
(srv) => srv.name !== "relayer" && srv.name !== "chainsaw",
|
||||
@@ -63,6 +64,16 @@ const CreateProjectDialog = (props: CreateProjectDialogProps) => {
|
||||
<CreateProjectDialogUI
|
||||
createProject={async (params) => {
|
||||
const res = await createProjectClient(props.teamId, params);
|
||||
await createVaultAccountAndAccessToken({
|
||||
project: res.project,
|
||||
projectSecretKey: res.secret,
|
||||
}).catch((error) => {
|
||||
console.error(
|
||||
"Failed to create vault account and access token",
|
||||
error,
|
||||
);
|
||||
throw error;
|
||||
});
|
||||
return {
|
||||
project: res.project,
|
||||
secret: res.secret,
|
||||
|
||||
@@ -7,6 +7,7 @@ export function CopyAddressButton(props: {
|
||||
address: string;
|
||||
className?: string;
|
||||
iconClassName?: string;
|
||||
tooltip?: string;
|
||||
variant?:
|
||||
| "primary"
|
||||
| "default"
|
||||
@@ -24,7 +25,7 @@ export function CopyAddressButton(props: {
|
||||
copyIconPosition={props.copyIconPosition}
|
||||
textToCopy={props.address}
|
||||
textToShow={shortenedAddress}
|
||||
tooltip="Copy Address"
|
||||
tooltip={props.tooltip || "Copy Address"}
|
||||
variant={props.variant}
|
||||
/>
|
||||
);
|
||||
|
||||
@@ -1,3 +1,4 @@
|
||||
import { useId } from "react";
|
||||
import { cn } from "@/lib/utils";
|
||||
|
||||
export function DotsBackgroundPattern(props: { className?: string }) {
|
||||
@@ -16,3 +17,70 @@ export function DotsBackgroundPattern(props: { className?: string }) {
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
||||
interface GridPatternProps extends React.SVGProps<SVGSVGElement> {
|
||||
width?: number;
|
||||
height?: number;
|
||||
x?: number;
|
||||
y?: number;
|
||||
squares?: Array<[x: number, y: number]>;
|
||||
strokeDasharray?: string;
|
||||
className?: string;
|
||||
[key: string]: unknown;
|
||||
}
|
||||
|
||||
export function GridPattern({
|
||||
width = 40,
|
||||
height = 40,
|
||||
x = -1,
|
||||
y = -1,
|
||||
strokeDasharray = "0",
|
||||
squares,
|
||||
className,
|
||||
...props
|
||||
}: GridPatternProps) {
|
||||
const id = useId();
|
||||
|
||||
return (
|
||||
<svg
|
||||
aria-hidden="true"
|
||||
className={cn(
|
||||
"pointer-events-none absolute inset-0 h-full w-full fill-current stroke-current",
|
||||
className,
|
||||
)}
|
||||
{...props}
|
||||
>
|
||||
<defs>
|
||||
<pattern
|
||||
id={id}
|
||||
width={width}
|
||||
height={height}
|
||||
patternUnits="userSpaceOnUse"
|
||||
x={x}
|
||||
y={y}
|
||||
>
|
||||
<path
|
||||
d={`M.5 ${height}V.5H${width}`}
|
||||
fill="none"
|
||||
strokeDasharray={strokeDasharray}
|
||||
/>
|
||||
</pattern>
|
||||
</defs>
|
||||
<rect width="100%" height="100%" strokeWidth={0} fill={`url(#${id})`} />
|
||||
{squares && (
|
||||
<svg x={x} y={y} className="overflow-visible" role="presentation">
|
||||
{squares.map(([x, y]) => (
|
||||
<rect
|
||||
strokeWidth="0"
|
||||
key={`${x}-${y}`}
|
||||
width={width - 1}
|
||||
height={height - 1}
|
||||
x={x * width + 1}
|
||||
y={y * height + 1}
|
||||
/>
|
||||
))}
|
||||
</svg>
|
||||
)}
|
||||
</svg>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -26,7 +26,6 @@ const buttonVariants = cva(
|
||||
link: "underline-offset-4 hover:underline",
|
||||
outline:
|
||||
"border border-input bg-transparent hover:bg-accent hover:text-accent-foreground",
|
||||
pink: "border border-nebula-pink-foreground !text-nebula-pink-foreground bg-[hsl(var(--nebula-pink-foreground)/5%)] hover:bg-nebula-pink-foreground/10 dark:!text-foreground dark:bg-nebula-pink-foreground/10 dark:hover:bg-nebula-pink-foreground/20",
|
||||
primary: "bg-primary hover:bg-primary/90 text-primary-foreground ",
|
||||
secondary:
|
||||
"bg-secondary hover:bg-secondary/80 text-secondary-foreground ",
|
||||
|
||||
@@ -8,7 +8,7 @@ const Card = React.forwardRef<
|
||||
>(({ className, ...props }, ref) => (
|
||||
<div
|
||||
className={cn(
|
||||
"rounded-lg border border-border bg-card text-card-foreground shadow-sm",
|
||||
"rounded-lg border border-border bg-card text-card-foreground",
|
||||
className,
|
||||
)}
|
||||
ref={ref}
|
||||
|
||||
@@ -150,9 +150,13 @@ export const ErrorProvider: ComponentWithChildren = ({ children }) => {
|
||||
/>
|
||||
)}
|
||||
|
||||
<Button asChild className="py-2 text-sm" variant="primary">
|
||||
<Link href="/support" rel="noopener noreferrer" target="_blank">
|
||||
Visit support site
|
||||
<Button asChild className="py-2 text-sm" variant="default">
|
||||
<Link
|
||||
href="/team/~/~/support"
|
||||
rel="noopener noreferrer"
|
||||
target="_blank"
|
||||
>
|
||||
Contact Support
|
||||
</Link>
|
||||
</Button>
|
||||
</div>
|
||||
|
||||
@@ -2,6 +2,7 @@ import { useMutation, useQuery, useQueryClient } from "@tanstack/react-query";
|
||||
import { useActiveAccount } from "thirdweb/react";
|
||||
import { apiServerProxy } from "@/actions/proxies";
|
||||
import type { Project } from "@/api/projects";
|
||||
import { rotateVaultAccountAndAccessToken } from "../../app/(app)/team/[team_slug]/[project_slug]/(sidebar)/transactions/lib/vault.client";
|
||||
import { accountKeys, authorizedWallets } from "../query-keys/cache-keys";
|
||||
|
||||
// FIXME: We keep repeating types, API server should provide them
|
||||
@@ -311,26 +312,40 @@ export type RotateSecretKeyAPIReturnType = {
|
||||
data: {
|
||||
secret: string;
|
||||
secretMasked: string;
|
||||
secretHash: string;
|
||||
};
|
||||
};
|
||||
|
||||
export async function rotateSecretKeyClient(params: {
|
||||
teamId: string;
|
||||
projectId: string;
|
||||
}) {
|
||||
export async function rotateSecretKeyClient(params: { project: Project }) {
|
||||
const res = await apiServerProxy<RotateSecretKeyAPIReturnType>({
|
||||
body: JSON.stringify({}),
|
||||
headers: {
|
||||
"Content-Type": "application/json",
|
||||
},
|
||||
method: "POST",
|
||||
pathname: `/v1/teams/${params.teamId}/projects/${params.projectId}/rotate-secret-key`,
|
||||
pathname: `/v1/teams/${params.project.teamId}/projects/${params.project.id}/rotate-secret-key`,
|
||||
});
|
||||
|
||||
if (!res.ok) {
|
||||
throw new Error(res.error);
|
||||
}
|
||||
|
||||
try {
|
||||
// if the project has an encrypted vault admin key, rotate it as well
|
||||
const service = params.project.services.find(
|
||||
(service) => service.name === "engineCloud",
|
||||
);
|
||||
if (service?.encryptedAdminKey) {
|
||||
await rotateVaultAccountAndAccessToken({
|
||||
project: params.project,
|
||||
projectSecretKey: res.data.data.secret,
|
||||
projectSecretHash: res.data.data.secretHash,
|
||||
});
|
||||
}
|
||||
} catch (error) {
|
||||
console.error("Failed to rotate vault admin key", error);
|
||||
}
|
||||
|
||||
return res.data;
|
||||
}
|
||||
|
||||
|
||||
@@ -3,7 +3,7 @@ import type { SVGProps } from "react";
|
||||
export function DiscordIcon(props: SVGProps<SVGSVGElement>) {
|
||||
return (
|
||||
<svg
|
||||
enable-background="new 0 0 100 100"
|
||||
enableBackground="new 0 0 100 100"
|
||||
viewBox="0 0 100 100"
|
||||
xmlns="http://www.w3.org/2000/svg"
|
||||
{...props}
|
||||
|
||||
@@ -57,7 +57,10 @@ export function teamStub(id: string, billingPlan: Team["billingPlan"]): Team {
|
||||
},
|
||||
nebula: {
|
||||
enabled: true,
|
||||
rateLimit: 1000,
|
||||
rateLimit: {
|
||||
perSecond: 1000,
|
||||
perMinute: 1000,
|
||||
},
|
||||
},
|
||||
pay: {
|
||||
enabled: true,
|
||||
@@ -82,6 +85,10 @@ export function teamStub(id: string, billingPlan: Team["billingPlan"]): Team {
|
||||
totalFileSizeBytesLimit: 1_000_000_000,
|
||||
},
|
||||
},
|
||||
mcp: {
|
||||
enabled: true,
|
||||
rateLimit: 10,
|
||||
},
|
||||
},
|
||||
createdAt: new Date().toISOString(),
|
||||
dedicatedSupportChannel: null,
|
||||
|
||||
@@ -4,14 +4,14 @@ import type { JSX } from "react";
|
||||
|
||||
const PLEASE_REACH_OUT_MESSAGE = (
|
||||
<span>
|
||||
If you believe this is incorrect or the error persists, please visit our{" "}
|
||||
If you believe this is incorrect or the error persists, please{" "}
|
||||
<Link
|
||||
className="font-semibold underline"
|
||||
href="/support"
|
||||
href="/team/~/~/support"
|
||||
rel="noopener noreferrer"
|
||||
target="_blank"
|
||||
>
|
||||
support site
|
||||
contact support
|
||||
</Link>
|
||||
.
|
||||
</span>
|
||||
|
||||
@@ -10,7 +10,7 @@ import {
|
||||
type SearchParams,
|
||||
} from "./components/server/routes-table";
|
||||
|
||||
const title = "Universal Bridge Routes: Swap, Bridge, and Onramp";
|
||||
const title = "Payments Routes: Swap, Bridge, and Onramp";
|
||||
const description =
|
||||
"A list of token routes for swapping, bridging, and on-ramping between EVM chains with thirdweb.";
|
||||
|
||||
@@ -66,7 +66,7 @@ export default async function RoutesPage(props: {
|
||||
<div className="relative flex flex-col gap-2 sm:flex-row sm:items-center sm:justify-between">
|
||||
<div className="flex flex-col gap-1">
|
||||
<h3 className="font-medium text-lg">
|
||||
Get Started with Universal Bridge
|
||||
Get Started with thirdweb Payments
|
||||
</h3>
|
||||
<p className="text-muted-foreground text-sm">
|
||||
Simple, instant, and secure payments across any token and chain.
|
||||
|
||||
@@ -1,6 +1,5 @@
|
||||
import "server-only";
|
||||
|
||||
import type { Address } from "thirdweb";
|
||||
import { NEXT_PUBLIC_THIRDWEB_BRIDGE_HOST } from "@/constants/public-envs";
|
||||
import { DASHBOARD_THIRDWEB_SECRET_KEY } from "@/constants/server-envs";
|
||||
import type { Route } from "./types/route";
|
||||
@@ -10,21 +9,13 @@ export async function getRoutes({
|
||||
offset,
|
||||
originQuery,
|
||||
destinationQuery,
|
||||
originChainId,
|
||||
destinationChainId,
|
||||
originTokenAddress,
|
||||
destinationTokenAddress,
|
||||
}: {
|
||||
limit?: number;
|
||||
offset?: number;
|
||||
originQuery?: string;
|
||||
destinationQuery?: string;
|
||||
originChainId?: number;
|
||||
destinationChainId?: number;
|
||||
originTokenAddress?: Address;
|
||||
destinationTokenAddress?: Address;
|
||||
} = {}) {
|
||||
const url = new URL(`${NEXT_PUBLIC_THIRDWEB_BRIDGE_HOST}/v1/routes`);
|
||||
const url = new URL(`${NEXT_PUBLIC_THIRDWEB_BRIDGE_HOST}/v1/routes/search`);
|
||||
if (limit) {
|
||||
url.searchParams.set("limit", limit.toString());
|
||||
}
|
||||
@@ -37,20 +28,7 @@ export async function getRoutes({
|
||||
if (destinationQuery) {
|
||||
url.searchParams.set("destinationQuery", destinationQuery);
|
||||
}
|
||||
if (originChainId) {
|
||||
url.searchParams.set("originChainId", originChainId.toString());
|
||||
}
|
||||
if (destinationChainId) {
|
||||
url.searchParams.set("destinationChainId", destinationChainId.toString());
|
||||
}
|
||||
if (originTokenAddress) {
|
||||
url.searchParams.set("originTokenAddress", originTokenAddress);
|
||||
}
|
||||
if (destinationTokenAddress) {
|
||||
url.searchParams.set("destinationTokenAddress", destinationTokenAddress);
|
||||
}
|
||||
url.searchParams.set("sortBy", "popularity");
|
||||
// It's faster to filter client side, doesn't seem to be a big performance boost to paginate/filter server side
|
||||
const routesResponse = await fetch(url, {
|
||||
headers: {
|
||||
"x-secret-key": DASHBOARD_THIRDWEB_SECRET_KEY,
|
||||
|
||||
+4
@@ -1,12 +1,15 @@
|
||||
"use client";
|
||||
import { useTheme } from "next-themes";
|
||||
import { defineChain, type ThirdwebClient } from "thirdweb";
|
||||
import type { ChainMetadata } from "thirdweb/chains";
|
||||
import { BuyWidget } from "thirdweb/react";
|
||||
import { getSDKTheme } from "@/utils/sdk-component-theme";
|
||||
|
||||
export function BuyFundsSection(props: {
|
||||
chain: ChainMetadata;
|
||||
client: ThirdwebClient;
|
||||
}) {
|
||||
const { theme } = useTheme();
|
||||
return (
|
||||
<section className="flex flex-col gap-4 items-center justify-center">
|
||||
<BuyWidget
|
||||
@@ -14,6 +17,7 @@ export function BuyFundsSection(props: {
|
||||
// eslint-disable-next-line no-restricted-syntax
|
||||
chain={defineChain(props.chain.chainId)}
|
||||
client={props.client}
|
||||
theme={getSDKTheme(theme === "dark" ? "dark" : "light")}
|
||||
/>
|
||||
</section>
|
||||
);
|
||||
|
||||
+1
-1
@@ -3,7 +3,7 @@ import type { ThirdwebClient } from "thirdweb";
|
||||
import type { ChainMetadata } from "thirdweb/chains";
|
||||
import { Button } from "@/components/ui/button";
|
||||
import { cn } from "@/lib/utils";
|
||||
import { mapV4ChainToV5Chain } from "../../../../../../../../@/utils/map-chains";
|
||||
import { mapV4ChainToV5Chain } from "@/utils/map-chains";
|
||||
import { ChainIcon } from "../../../../components/server/chain-icon";
|
||||
import { AddChainToWallet } from "../client/add-chain-to-wallet";
|
||||
|
||||
|
||||
@@ -19,7 +19,7 @@ import {
|
||||
DropdownMenuTrigger,
|
||||
} from "@/components/ui/dropdown-menu";
|
||||
import { getClientThirdwebClient } from "@/constants/thirdweb-client.client";
|
||||
import { mapV4ChainToV5Chain } from "../../../../../../@/utils/map-chains";
|
||||
import { mapV4ChainToV5Chain } from "@/utils/map-chains";
|
||||
import { TeamHeader } from "../../../../team/components/TeamHeader/team-header";
|
||||
import { StarButton } from "../../components/client/star-button";
|
||||
import { getChain, getChainMetadata } from "../../utils";
|
||||
|
||||
+2
-2
@@ -1,10 +1,10 @@
|
||||
import { getAddress, getContract, isAddress } from "thirdweb";
|
||||
import { localhost } from "thirdweb/chains";
|
||||
import { getUserThirdwebClient } from "@/api/auth-token";
|
||||
import { DASHBOARD_THIRDWEB_SECRET_KEY } from "@/constants/server-envs";
|
||||
import { getConfiguredThirdwebClient } from "@/constants/thirdweb.server";
|
||||
import { fetchChainWithLocalOverrides } from "@/utils/fetchChainWithLocalOverrides";
|
||||
import { mapV4ChainToV5Chain } from "@/utils/map-chains";
|
||||
import { getUserThirdwebClient } from "../../../../../../../@/api/auth-token";
|
||||
import { fetchChainWithLocalOverrides } from "../../../../../../../@/utils/fetchChainWithLocalOverrides";
|
||||
|
||||
export async function getContractPageParamsInfo(params: {
|
||||
contractAddress: string;
|
||||
|
||||
+311
-231
@@ -1,18 +1,5 @@
|
||||
"use client";
|
||||
|
||||
import {
|
||||
Accordion,
|
||||
AccordionButton,
|
||||
AccordionIcon,
|
||||
AccordionItem,
|
||||
AccordionPanel,
|
||||
FormControl,
|
||||
Input,
|
||||
Textarea,
|
||||
} from "@chakra-ui/react";
|
||||
import { Button } from "chakra/button";
|
||||
import { FormErrorMessage, FormHelperText, FormLabel } from "chakra/form";
|
||||
import { Heading } from "chakra/heading";
|
||||
import { type Dispatch, type SetStateAction, useMemo } from "react";
|
||||
import { useForm } from "react-hook-form";
|
||||
import { toast } from "sonner";
|
||||
@@ -31,6 +18,24 @@ import { OpenSeaPropertyBadge } from "@/components/badges/opensea";
|
||||
import { FileInput } from "@/components/blocks/FileInput";
|
||||
import { PropertiesFormControl } from "@/components/contracts/properties.shared";
|
||||
import { TransactionButton } from "@/components/tx-button";
|
||||
import {
|
||||
Accordion,
|
||||
AccordionContent,
|
||||
AccordionItem,
|
||||
AccordionTrigger,
|
||||
} from "@/components/ui/accordion";
|
||||
import { Button } from "@/components/ui/button";
|
||||
import {
|
||||
Form,
|
||||
FormControl,
|
||||
FormDescription,
|
||||
FormField,
|
||||
FormItem,
|
||||
FormLabel,
|
||||
FormMessage,
|
||||
} from "@/components/ui/form";
|
||||
import { Input } from "@/components/ui/input";
|
||||
import { Textarea } from "@/components/ui/textarea";
|
||||
import { useTxNotifications } from "@/hooks/useTxNotifications";
|
||||
import type { NFTMetadataInputLimited } from "@/types/modified-types";
|
||||
import { parseAttributes } from "@/utils/parseAttributes";
|
||||
@@ -53,13 +58,13 @@ type UpdateNftMetadataForm = {
|
||||
isLoggedIn: boolean;
|
||||
};
|
||||
|
||||
export const UpdateNftMetadata: React.FC<UpdateNftMetadataForm> = ({
|
||||
export function UpdateNftMetadata({
|
||||
contract,
|
||||
nft,
|
||||
useUpdateMetadata,
|
||||
setOpen,
|
||||
isLoggedIn,
|
||||
}) => {
|
||||
}: UpdateNftMetadataForm) {
|
||||
const address = useActiveAccount()?.address;
|
||||
|
||||
const transformedQueryData = useMemo(() => {
|
||||
@@ -90,7 +95,7 @@ export const UpdateNftMetadata: React.FC<UpdateNftMetadataForm> = ({
|
||||
register,
|
||||
watch,
|
||||
handleSubmit,
|
||||
formState: { errors, isDirty },
|
||||
formState: { errors },
|
||||
} = form;
|
||||
|
||||
const setFile = (file: File) => {
|
||||
@@ -112,229 +117,304 @@ export const UpdateNftMetadata: React.FC<UpdateNftMetadataForm> = ({
|
||||
);
|
||||
|
||||
return (
|
||||
<form
|
||||
className="flex flex-col gap-6"
|
||||
id={UPDATE_METADATA_FORM_ID}
|
||||
onSubmit={handleSubmit(async (data) => {
|
||||
if (!address) {
|
||||
toast.error("Please connect your wallet to update metadata.");
|
||||
return;
|
||||
}
|
||||
<Form {...form}>
|
||||
<form
|
||||
className="flex flex-col gap-6"
|
||||
id={UPDATE_METADATA_FORM_ID}
|
||||
onSubmit={handleSubmit(async (data) => {
|
||||
if (!address) {
|
||||
toast.error("Please connect your wallet to update metadata.");
|
||||
return;
|
||||
}
|
||||
|
||||
try {
|
||||
const newMetadata = parseAttributes({
|
||||
...data,
|
||||
animation_url: data.animation_url || nft.metadata.animation_url,
|
||||
image: data.image || nft.metadata.image,
|
||||
});
|
||||
try {
|
||||
const newMetadata = parseAttributes({
|
||||
...data,
|
||||
animation_url: data.animation_url || nft.metadata.animation_url,
|
||||
image: data.image || nft.metadata.image,
|
||||
});
|
||||
|
||||
const transaction = useUpdateMetadata
|
||||
? // For Drop contracts, we need to call the `updateBatchBaseURI` method
|
||||
nft.type === "ERC721"
|
||||
? updateMetadata721({
|
||||
contract,
|
||||
newMetadata,
|
||||
targetTokenId: BigInt(nft.id),
|
||||
})
|
||||
: updateMetadata1155({
|
||||
contract,
|
||||
newMetadata,
|
||||
targetTokenId: BigInt(nft.id),
|
||||
})
|
||||
: // For Collection contracts, we need to call the `setTokenURI` method
|
||||
nft.type === "ERC721"
|
||||
? updateTokenURI721({
|
||||
contract,
|
||||
newMetadata,
|
||||
tokenId: BigInt(nft.id),
|
||||
})
|
||||
: updateTokenURI1155({
|
||||
contract,
|
||||
newMetadata,
|
||||
tokenId: BigInt(nft.id),
|
||||
});
|
||||
await sendAndConfirmTx.mutateAsync(transaction, {
|
||||
onError: (error) => {
|
||||
console.error(error);
|
||||
},
|
||||
onSuccess: () => {
|
||||
setOpen(false);
|
||||
},
|
||||
});
|
||||
const transaction = useUpdateMetadata
|
||||
? // For Drop contracts, we need to call the `updateBatchBaseURI` method
|
||||
nft.type === "ERC721"
|
||||
? updateMetadata721({
|
||||
contract,
|
||||
newMetadata,
|
||||
targetTokenId: BigInt(nft.id),
|
||||
})
|
||||
: updateMetadata1155({
|
||||
contract,
|
||||
newMetadata,
|
||||
targetTokenId: BigInt(nft.id),
|
||||
})
|
||||
: // For Collection contracts, we need to call the `setTokenURI` method
|
||||
nft.type === "ERC721"
|
||||
? updateTokenURI721({
|
||||
contract,
|
||||
newMetadata,
|
||||
tokenId: BigInt(nft.id),
|
||||
})
|
||||
: updateTokenURI1155({
|
||||
contract,
|
||||
newMetadata,
|
||||
tokenId: BigInt(nft.id),
|
||||
});
|
||||
await sendAndConfirmTx.mutateAsync(transaction, {
|
||||
onError: (error) => {
|
||||
console.error(error);
|
||||
},
|
||||
onSuccess: () => {
|
||||
setOpen(false);
|
||||
},
|
||||
});
|
||||
|
||||
updateMetadataNotifications.onSuccess();
|
||||
} catch (err) {
|
||||
console.error(err);
|
||||
updateMetadataNotifications.onError(err);
|
||||
}
|
||||
})}
|
||||
>
|
||||
<FormControl isInvalid={!!errors.name} isRequired>
|
||||
<FormLabel>Name</FormLabel>
|
||||
<Input autoFocus {...register("name")} />
|
||||
<FormErrorMessage>{errors?.name?.message}</FormErrorMessage>
|
||||
</FormControl>
|
||||
|
||||
<FormControl isInvalid={!!mediaFileError}>
|
||||
<FormLabel>Media</FormLabel>
|
||||
<div className="flex flex-row flex-wrap gap-3">
|
||||
<FileInput
|
||||
className="shrink-0 rounded border border-border transition-all duration-200"
|
||||
client={contract.client}
|
||||
helperText={nft?.metadata ? "New Media" : "Media"}
|
||||
previewMaxWidth="200px"
|
||||
selectOrUpload="Upload"
|
||||
setValue={setFile}
|
||||
showPreview
|
||||
showUploadButton
|
||||
value={media}
|
||||
/>
|
||||
</div>
|
||||
|
||||
<FormHelperText>
|
||||
You can upload image, audio, video, html, text, pdf, and 3d model
|
||||
files here.
|
||||
</FormHelperText>
|
||||
<FormErrorMessage>
|
||||
{mediaFileError?.message as unknown as string}
|
||||
</FormErrorMessage>
|
||||
</FormControl>
|
||||
|
||||
{showCoverImageUpload && (
|
||||
<FormControl isInvalid={!!errors.image}>
|
||||
<FormLabel>Cover Image</FormLabel>
|
||||
<FileInput
|
||||
accept={{ "image/*": [] }}
|
||||
className="rounded border border-border transition-all"
|
||||
client={contract.client}
|
||||
previewMaxWidth="200px"
|
||||
setValue={(file) => setValue("image", file)}
|
||||
showUploadButton
|
||||
value={image}
|
||||
/>
|
||||
<FormHelperText>
|
||||
You can optionally upload an image as the cover of your NFT.
|
||||
</FormHelperText>
|
||||
<FormErrorMessage>
|
||||
{errors?.image?.message as unknown as string}
|
||||
</FormErrorMessage>
|
||||
</FormControl>
|
||||
)}
|
||||
<FormControl isInvalid={!!errors.description}>
|
||||
<FormLabel>Description</FormLabel>
|
||||
<Textarea {...register("description")} />
|
||||
<FormErrorMessage>{errors?.description?.message}</FormErrorMessage>
|
||||
</FormControl>
|
||||
<PropertiesFormControl
|
||||
client={contract.client}
|
||||
control={control}
|
||||
// biome-ignore lint/suspicious/noExplicitAny: FIXME
|
||||
errors={errors as any}
|
||||
register={register}
|
||||
setValue={setValue}
|
||||
watch={watch}
|
||||
/>
|
||||
|
||||
<Accordion
|
||||
allowToggle={!(errors.background_color || errors.external_url)}
|
||||
index={errors.background_color || errors.external_url ? [0] : undefined}
|
||||
updateMetadataNotifications.onSuccess();
|
||||
} catch (err) {
|
||||
console.error(err);
|
||||
updateMetadataNotifications.onError(err);
|
||||
}
|
||||
})}
|
||||
>
|
||||
<AccordionItem>
|
||||
<AccordionButton justifyContent="space-between" px={0}>
|
||||
<Heading size="subtitle.md">Advanced Options</Heading>
|
||||
<AccordionIcon />
|
||||
</AccordionButton>
|
||||
<AccordionPanel className="!px-0 flex flex-col gap-6">
|
||||
<FormControl isInvalid={!!errors.background_color}>
|
||||
<FormLabel>
|
||||
Background Color <OpenSeaPropertyBadge />
|
||||
</FormLabel>
|
||||
<Input max="6" {...register("background_color")} />
|
||||
<FormHelperText>
|
||||
Must be a six-character hexadecimal with a pre-pended #.
|
||||
</FormHelperText>
|
||||
<FormErrorMessage>
|
||||
{errors?.background_color?.message}
|
||||
</FormErrorMessage>
|
||||
</FormControl>
|
||||
|
||||
{!(external_url instanceof File) && (
|
||||
<FormControl isInvalid={!!errors.external_url}>
|
||||
<FormLabel>
|
||||
External URL <OpenSeaPropertyBadge />
|
||||
</FormLabel>
|
||||
<Input {...register("external_url")} />
|
||||
<FormHelperText>
|
||||
This is the URL that will appear below the asset's image
|
||||
on OpenSea and will allow users to leave OpenSea and view the
|
||||
item on your site.
|
||||
</FormHelperText>
|
||||
<FormErrorMessage>
|
||||
{errors?.external_url?.message as unknown as string}
|
||||
</FormErrorMessage>
|
||||
<FormField
|
||||
control={control}
|
||||
name="name"
|
||||
render={({ field }) => (
|
||||
<FormItem>
|
||||
<FormLabel>Name</FormLabel>
|
||||
<FormControl>
|
||||
<Input autoFocus {...field} className="bg-card" />
|
||||
</FormControl>
|
||||
)}
|
||||
<FormMessage />
|
||||
</FormItem>
|
||||
)}
|
||||
/>
|
||||
|
||||
{!(image instanceof File) && (
|
||||
<FormControl isInvalid={!!errors.image}>
|
||||
<FormLabel>Image URL</FormLabel>
|
||||
<Input
|
||||
onChange={(e) => {
|
||||
setValue("image", e.target.value);
|
||||
}}
|
||||
value={typeof image === "string" ? image : ""}
|
||||
/>
|
||||
<FormHelperText>
|
||||
If you already have your NFT image pre-uploaded to a URL, you
|
||||
can specify it here instead of uploading the media file
|
||||
</FormHelperText>
|
||||
<FormErrorMessage>{errors?.image?.message}</FormErrorMessage>
|
||||
</FormControl>
|
||||
)}
|
||||
<FormItem>
|
||||
<FormLabel>Media</FormLabel>
|
||||
<div className="flex flex-row flex-wrap gap-3">
|
||||
<FileInput
|
||||
className="shrink-0 rounded border border-border transition-all duration-200"
|
||||
client={contract.client}
|
||||
helperText={nft?.metadata ? "New Media" : "Media"}
|
||||
previewMaxWidth="200px"
|
||||
selectOrUpload="Upload"
|
||||
setValue={setFile}
|
||||
showPreview
|
||||
showUploadButton
|
||||
value={media}
|
||||
/>
|
||||
</div>
|
||||
<FormDescription>
|
||||
You can upload image, audio, video, html, text, pdf, and 3d model
|
||||
files here.
|
||||
</FormDescription>
|
||||
{mediaFileError && (
|
||||
<FormMessage>
|
||||
{mediaFileError.message as unknown as string}
|
||||
</FormMessage>
|
||||
)}
|
||||
</FormItem>
|
||||
|
||||
{!(animation_url instanceof File) && (
|
||||
<FormControl isInvalid={!!errors.animation_url}>
|
||||
<FormLabel>Animation URL</FormLabel>
|
||||
<Input
|
||||
onChange={(e) => {
|
||||
setValue("animation_url", e.target.value);
|
||||
}}
|
||||
value={typeof animation_url === "string" ? animation_url : ""}
|
||||
/>
|
||||
<FormHelperText>
|
||||
If you already have your NFT Animation URL pre-uploaded to a
|
||||
URL, you can specify it here instead of uploading the media
|
||||
file
|
||||
</FormHelperText>
|
||||
<FormErrorMessage>
|
||||
{errors?.animation_url?.message}
|
||||
</FormErrorMessage>
|
||||
</FormControl>
|
||||
{showCoverImageUpload && (
|
||||
<FormField
|
||||
control={control}
|
||||
name="image"
|
||||
render={() => (
|
||||
<FormItem>
|
||||
<FormLabel>Cover Image</FormLabel>
|
||||
<FormControl>
|
||||
<FileInput
|
||||
accept={{ "image/*": [] }}
|
||||
className="rounded border border-border transition-all"
|
||||
client={contract.client}
|
||||
previewMaxWidth="200px"
|
||||
setValue={(file) => {
|
||||
setValue("image", file);
|
||||
}}
|
||||
showUploadButton
|
||||
value={image}
|
||||
/>
|
||||
</FormControl>
|
||||
<FormDescription>
|
||||
You can optionally upload an image as the cover of your NFT.
|
||||
</FormDescription>
|
||||
<FormMessage />
|
||||
</FormItem>
|
||||
)}
|
||||
</AccordionPanel>
|
||||
</AccordionItem>
|
||||
</Accordion>
|
||||
<div className="mt-8 flex flex-row justify-end gap-3">
|
||||
<Button
|
||||
isDisabled={sendAndConfirmTx.isPending}
|
||||
onClick={() => setOpen(false)}
|
||||
variant="outline"
|
||||
>
|
||||
Cancel
|
||||
</Button>
|
||||
<TransactionButton
|
||||
/>
|
||||
)}
|
||||
|
||||
<FormField
|
||||
control={control}
|
||||
name="description"
|
||||
render={({ field }) => (
|
||||
<FormItem>
|
||||
<FormLabel>Description</FormLabel>
|
||||
<FormControl>
|
||||
<Textarea {...field} className="bg-card" />
|
||||
</FormControl>
|
||||
<FormMessage />
|
||||
</FormItem>
|
||||
)}
|
||||
/>
|
||||
|
||||
<PropertiesFormControl
|
||||
client={contract.client}
|
||||
disabled={!isDirty}
|
||||
form={UPDATE_METADATA_FORM_ID}
|
||||
isLoggedIn={isLoggedIn}
|
||||
isPending={sendAndConfirmTx.isPending}
|
||||
transactionCount={1}
|
||||
txChainID={contract.chain.id}
|
||||
type="submit"
|
||||
control={control}
|
||||
// biome-ignore lint/suspicious/noExplicitAny: FIXME
|
||||
errors={errors as any}
|
||||
register={register}
|
||||
setValue={setValue}
|
||||
watch={watch}
|
||||
/>
|
||||
|
||||
<Accordion
|
||||
type="single"
|
||||
className="-mx-1 border-t border-border"
|
||||
collapsible
|
||||
defaultValue={
|
||||
errors.background_color || errors.external_url
|
||||
? "advanced"
|
||||
: undefined
|
||||
}
|
||||
>
|
||||
Update NFT
|
||||
</TransactionButton>
|
||||
</div>
|
||||
</form>
|
||||
<AccordionItem value="advanced">
|
||||
<AccordionTrigger className="justify-between px-1">
|
||||
<h3 className="text-base font-medium">Advanced Options</h3>
|
||||
</AccordionTrigger>
|
||||
<AccordionContent className="flex flex-col gap-6 px-1">
|
||||
<FormField
|
||||
control={control}
|
||||
name="background_color"
|
||||
render={({ field }) => (
|
||||
<FormItem>
|
||||
<FormLabel>
|
||||
Background Color <OpenSeaPropertyBadge />
|
||||
</FormLabel>
|
||||
<FormControl>
|
||||
<Input max="6" {...field} className="bg-card" />
|
||||
</FormControl>
|
||||
<FormDescription>
|
||||
Must be a six-character hexadecimal with a pre-pended #.
|
||||
</FormDescription>
|
||||
<FormMessage />
|
||||
</FormItem>
|
||||
)}
|
||||
/>
|
||||
|
||||
{!(external_url instanceof File) && (
|
||||
<FormField
|
||||
control={control}
|
||||
name="external_url"
|
||||
render={({ field }) => (
|
||||
<FormItem>
|
||||
<FormLabel>
|
||||
External URL <OpenSeaPropertyBadge />
|
||||
</FormLabel>
|
||||
<FormControl>
|
||||
<Input
|
||||
{...field}
|
||||
className="bg-card"
|
||||
value={
|
||||
typeof field.value === "string" ? field.value : ""
|
||||
}
|
||||
/>
|
||||
</FormControl>
|
||||
<FormDescription>
|
||||
This is the URL that will appear below the asset's
|
||||
image on OpenSea and will allow users to leave OpenSea
|
||||
and view the item on your site.
|
||||
</FormDescription>
|
||||
<FormMessage />
|
||||
</FormItem>
|
||||
)}
|
||||
/>
|
||||
)}
|
||||
|
||||
{!(image instanceof File) && (
|
||||
<FormField
|
||||
control={control}
|
||||
name="image"
|
||||
render={({ field }) => (
|
||||
<FormItem>
|
||||
<FormLabel>Image URL</FormLabel>
|
||||
<FormControl>
|
||||
<Input
|
||||
{...field}
|
||||
onChange={(e) => {
|
||||
setValue("image", e.target.value);
|
||||
}}
|
||||
value={typeof image === "string" ? image : ""}
|
||||
className="bg-card"
|
||||
/>
|
||||
</FormControl>
|
||||
<FormDescription>
|
||||
If you already have your NFT image pre-uploaded to a
|
||||
URL, you can specify it here instead of uploading the
|
||||
media file
|
||||
</FormDescription>
|
||||
<FormMessage />
|
||||
</FormItem>
|
||||
)}
|
||||
/>
|
||||
)}
|
||||
|
||||
{!(animation_url instanceof File) && (
|
||||
<FormField
|
||||
control={control}
|
||||
name="animation_url"
|
||||
render={({ field }) => (
|
||||
<FormItem>
|
||||
<FormLabel>Animation URL</FormLabel>
|
||||
<FormControl>
|
||||
<Input
|
||||
{...field}
|
||||
onChange={(e) => {
|
||||
setValue("animation_url", e.target.value);
|
||||
}}
|
||||
value={
|
||||
typeof animation_url === "string"
|
||||
? animation_url
|
||||
: ""
|
||||
}
|
||||
className="bg-card"
|
||||
/>
|
||||
</FormControl>
|
||||
<FormDescription>
|
||||
If you already have your NFT Animation URL pre-uploaded
|
||||
to a URL, you can specify it here instead of uploading
|
||||
the media file
|
||||
</FormDescription>
|
||||
<FormMessage />
|
||||
</FormItem>
|
||||
)}
|
||||
/>
|
||||
)}
|
||||
</AccordionContent>
|
||||
</AccordionItem>
|
||||
</Accordion>
|
||||
|
||||
<div className="mt-8 flex flex-row justify-end gap-3">
|
||||
<Button
|
||||
disabled={sendAndConfirmTx.isPending}
|
||||
onClick={() => setOpen(false)}
|
||||
variant="outline"
|
||||
>
|
||||
Cancel
|
||||
</Button>
|
||||
<TransactionButton
|
||||
client={contract.client}
|
||||
form={UPDATE_METADATA_FORM_ID}
|
||||
isLoggedIn={isLoggedIn}
|
||||
isPending={sendAndConfirmTx.isPending}
|
||||
transactionCount={1}
|
||||
txChainID={contract.chain.id}
|
||||
type="submit"
|
||||
>
|
||||
Update NFT
|
||||
</TransactionButton>
|
||||
</div>
|
||||
</form>
|
||||
</Form>
|
||||
);
|
||||
};
|
||||
}
|
||||
|
||||
+1
-1
@@ -1,5 +1,5 @@
|
||||
import { notFound, redirect } from "next/navigation";
|
||||
import { getAuthTokenWalletAddress } from "../../../../../../../../@/api/auth-token";
|
||||
import { getAuthTokenWalletAddress } from "@/api/auth-token";
|
||||
import type { ProjectMeta } from "../../../../../../team/[team_slug]/[project_slug]/contract/[chainIdOrSlug]/[contractAddress]/types";
|
||||
import { redirectToContractLandingPage } from "../../../../../../team/[team_slug]/[project_slug]/contract/[chainIdOrSlug]/[contractAddress]/utils";
|
||||
import { buildContractPagePath } from "../../_utils/contract-page-path";
|
||||
|
||||
+51
-49
@@ -60,59 +60,61 @@ export const BatchLazyMintButton: React.FC<BatchLazyMintButtonProps> = ({
|
||||
Batch Upload
|
||||
</Button>
|
||||
</SheetTrigger>
|
||||
<SheetContent className="min-w-full overflow-y-auto">
|
||||
<SheetHeader>
|
||||
<SheetTitle className="text-left">Upload NFTs</SheetTitle>
|
||||
<SheetContent className="!w-full !max-w-full overflow-y-auto p-0">
|
||||
<SheetHeader className="sr-only">
|
||||
<SheetTitle>Upload NFTs</SheetTitle>
|
||||
</SheetHeader>
|
||||
<BatchLazyMint
|
||||
canCreateDelayedRevealBatch={canCreateDelayedRevealBatch}
|
||||
chainId={contract.chain.id}
|
||||
client={contract.client}
|
||||
isLoggedIn={isLoggedIn}
|
||||
nextTokenIdToMint={nextTokenIdToMintQuery.data || 0n}
|
||||
onSubmit={async ({ revealType, data }) => {
|
||||
try {
|
||||
const tx = (() => {
|
||||
switch (true) {
|
||||
// lazy mint erc721
|
||||
case revealType === "instant" && isErc721: {
|
||||
return ERC721Ext.lazyMint({
|
||||
contract,
|
||||
nfts: data.metadatas,
|
||||
});
|
||||
<div className="p-4 lg:p-6">
|
||||
<BatchLazyMint
|
||||
canCreateDelayedRevealBatch={canCreateDelayedRevealBatch}
|
||||
chainId={contract.chain.id}
|
||||
client={contract.client}
|
||||
isLoggedIn={isLoggedIn}
|
||||
nextTokenIdToMint={nextTokenIdToMintQuery.data || 0n}
|
||||
onSubmit={async ({ revealType, data }) => {
|
||||
try {
|
||||
const tx = (() => {
|
||||
switch (true) {
|
||||
// lazy mint erc721
|
||||
case revealType === "instant" && isErc721: {
|
||||
return ERC721Ext.lazyMint({
|
||||
contract,
|
||||
nfts: data.metadatas,
|
||||
});
|
||||
}
|
||||
// lazy mint erc1155
|
||||
case revealType === "instant" && !isErc721: {
|
||||
return ERC1155Ext.lazyMint({
|
||||
contract,
|
||||
nfts: data.metadatas,
|
||||
});
|
||||
}
|
||||
// delayed reveal erc721
|
||||
case revealType === "delayed": {
|
||||
return ERC721Ext.createDelayedRevealBatch({
|
||||
contract,
|
||||
metadata: data.metadata,
|
||||
password: data.password,
|
||||
placeholderMetadata: data.placeholderMetadata,
|
||||
});
|
||||
}
|
||||
default: {
|
||||
throw new Error("Invalid reveal type");
|
||||
}
|
||||
}
|
||||
// lazy mint erc1155
|
||||
case revealType === "instant" && !isErc721: {
|
||||
return ERC1155Ext.lazyMint({
|
||||
contract,
|
||||
nfts: data.metadatas,
|
||||
});
|
||||
}
|
||||
// delayed reveal erc721
|
||||
case revealType === "delayed": {
|
||||
return ERC721Ext.createDelayedRevealBatch({
|
||||
contract,
|
||||
metadata: data.metadata,
|
||||
password: data.password,
|
||||
placeholderMetadata: data.placeholderMetadata,
|
||||
});
|
||||
}
|
||||
default: {
|
||||
throw new Error("Invalid reveal type");
|
||||
}
|
||||
}
|
||||
})();
|
||||
})();
|
||||
|
||||
await sendTxMutation.mutateAsync(tx);
|
||||
await sendTxMutation.mutateAsync(tx);
|
||||
|
||||
txNotifications.onSuccess();
|
||||
setOpen(false);
|
||||
} catch (error) {
|
||||
console.error(error);
|
||||
txNotifications.onError(error);
|
||||
}
|
||||
}}
|
||||
/>
|
||||
txNotifications.onSuccess();
|
||||
setOpen(false);
|
||||
} catch (error) {
|
||||
console.error(error);
|
||||
txNotifications.onError(error);
|
||||
}
|
||||
}}
|
||||
/>
|
||||
</div>
|
||||
</SheetContent>
|
||||
</Sheet>
|
||||
</MinterOnly>
|
||||
|
||||
+220
-148
@@ -1,18 +1,5 @@
|
||||
"use client";
|
||||
|
||||
import {
|
||||
Accordion,
|
||||
AccordionButton,
|
||||
AccordionIcon,
|
||||
AccordionItem,
|
||||
AccordionPanel,
|
||||
Divider,
|
||||
FormControl,
|
||||
Input,
|
||||
Textarea,
|
||||
} from "@chakra-ui/react";
|
||||
import { FormErrorMessage, FormHelperText, FormLabel } from "chakra/form";
|
||||
import { Heading } from "chakra/heading";
|
||||
import type { Dispatch, SetStateAction } from "react";
|
||||
import { useForm } from "react-hook-form";
|
||||
import { toast } from "sonner";
|
||||
@@ -24,7 +11,24 @@ import { OpenSeaPropertyBadge } from "@/components/badges/opensea";
|
||||
import { FileInput } from "@/components/blocks/FileInput";
|
||||
import { PropertiesFormControl } from "@/components/contracts/properties.shared";
|
||||
import { TransactionButton } from "@/components/tx-button";
|
||||
import {
|
||||
Accordion,
|
||||
AccordionContent,
|
||||
AccordionItem,
|
||||
AccordionTrigger,
|
||||
} from "@/components/ui/accordion";
|
||||
import { Button } from "@/components/ui/button";
|
||||
import {
|
||||
Form,
|
||||
FormControl,
|
||||
FormDescription,
|
||||
FormField,
|
||||
FormItem,
|
||||
FormLabel,
|
||||
FormMessage,
|
||||
} from "@/components/ui/form";
|
||||
import { Input } from "@/components/ui/input";
|
||||
import { Textarea } from "@/components/ui/textarea";
|
||||
import { useTxNotifications } from "@/hooks/useTxNotifications";
|
||||
import type { NFTMetadataInputLimited } from "@/types/modified-types";
|
||||
import { parseAttributes } from "@/utils/parseAttributes";
|
||||
@@ -42,12 +46,12 @@ type LazyMintNftFormParams = {
|
||||
isLoggedIn: boolean;
|
||||
};
|
||||
|
||||
export const LazyMintNftForm: React.FC<LazyMintNftFormParams> = ({
|
||||
export function LazyMintNftForm({
|
||||
contract,
|
||||
isErc721,
|
||||
setOpen,
|
||||
isLoggedIn,
|
||||
}) => {
|
||||
}: LazyMintNftFormParams) {
|
||||
const address = useActiveAccount()?.address;
|
||||
const sendAndConfirmTx = useSendAndConfirmTransaction();
|
||||
|
||||
@@ -81,7 +85,7 @@ export const LazyMintNftForm: React.FC<LazyMintNftFormParams> = ({
|
||||
);
|
||||
|
||||
return (
|
||||
<>
|
||||
<Form {...form}>
|
||||
<form
|
||||
className="mt-6 flex flex-col gap-6"
|
||||
id={LAZY_MINT_FORM_ID}
|
||||
@@ -112,16 +116,21 @@ export const LazyMintNftForm: React.FC<LazyMintNftFormParams> = ({
|
||||
}
|
||||
})}
|
||||
>
|
||||
<div className="flex flex-col gap-2">
|
||||
<Heading size="subtitle.md">Metadata </Heading>
|
||||
<Divider />
|
||||
</div>
|
||||
<FormControl isInvalid={!!errors.name} isRequired>
|
||||
<FormLabel>Name</FormLabel>
|
||||
<Input autoFocus {...register("name")} />
|
||||
<FormErrorMessage>{errors?.name?.message}</FormErrorMessage>
|
||||
</FormControl>
|
||||
<FormControl isInvalid={!!mediaFileError}>
|
||||
<FormField
|
||||
control={control}
|
||||
name="name"
|
||||
render={({ field }) => (
|
||||
<FormItem>
|
||||
<FormLabel>Name</FormLabel>
|
||||
<FormControl>
|
||||
<Input autoFocus {...field} className="bg-card" />
|
||||
</FormControl>
|
||||
<FormMessage />
|
||||
</FormItem>
|
||||
)}
|
||||
/>
|
||||
|
||||
<FormItem>
|
||||
<FormLabel>Media</FormLabel>
|
||||
<div>
|
||||
<FileInput
|
||||
@@ -136,39 +145,58 @@ export const LazyMintNftForm: React.FC<LazyMintNftFormParams> = ({
|
||||
value={media}
|
||||
/>
|
||||
</div>
|
||||
<FormHelperText>
|
||||
<FormDescription>
|
||||
You can upload image, audio, video, html, text, pdf, and 3d model
|
||||
files here.
|
||||
</FormHelperText>
|
||||
<FormErrorMessage>
|
||||
{mediaFileError?.message as unknown as string}
|
||||
</FormErrorMessage>
|
||||
</FormControl>
|
||||
</FormDescription>
|
||||
{mediaFileError && (
|
||||
<FormMessage>
|
||||
{mediaFileError.message as unknown as string}
|
||||
</FormMessage>
|
||||
)}
|
||||
</FormItem>
|
||||
|
||||
{showCoverImageUpload && (
|
||||
<FormControl isInvalid={!!errors.image}>
|
||||
<FormLabel>Cover Image</FormLabel>
|
||||
<FileInput
|
||||
accept={{ "image/*": [] }}
|
||||
className="rounded border border-border transition-all"
|
||||
client={contract.client}
|
||||
previewMaxWidth="200px"
|
||||
setValue={(file) => setValue("image", file)}
|
||||
showUploadButton
|
||||
value={image}
|
||||
/>
|
||||
<FormHelperText>
|
||||
You can optionally upload an image as the cover of your NFT.
|
||||
</FormHelperText>
|
||||
<FormErrorMessage>
|
||||
{errors?.image?.message as unknown as string}
|
||||
</FormErrorMessage>
|
||||
</FormControl>
|
||||
<FormField
|
||||
control={control}
|
||||
name="image"
|
||||
render={({ field }) => (
|
||||
<FormItem>
|
||||
<FormLabel>Cover Image</FormLabel>
|
||||
<FormControl>
|
||||
<FileInput
|
||||
accept={{ "image/*": [] }}
|
||||
className="rounded-lg bg-card border border-border transition-all"
|
||||
client={contract.client}
|
||||
previewMaxWidth="200px"
|
||||
setValue={(file) => setValue("image", file)}
|
||||
showUploadButton
|
||||
value={image}
|
||||
/>
|
||||
</FormControl>
|
||||
<FormDescription>
|
||||
You can optionally upload an image as the cover of your NFT.
|
||||
</FormDescription>
|
||||
<FormMessage />
|
||||
</FormItem>
|
||||
)}
|
||||
/>
|
||||
)}
|
||||
<FormControl isInvalid={!!errors.description}>
|
||||
<FormLabel>Description</FormLabel>
|
||||
<Textarea {...register("description")} />
|
||||
<FormErrorMessage>{errors?.description?.message}</FormErrorMessage>
|
||||
</FormControl>
|
||||
|
||||
<FormField
|
||||
control={control}
|
||||
name="description"
|
||||
render={({ field }) => (
|
||||
<FormItem>
|
||||
<FormLabel>Description</FormLabel>
|
||||
<FormControl>
|
||||
<Textarea {...field} className="bg-card" />
|
||||
</FormControl>
|
||||
<FormMessage />
|
||||
</FormItem>
|
||||
)}
|
||||
/>
|
||||
|
||||
<PropertiesFormControl
|
||||
client={contract.client}
|
||||
control={control}
|
||||
@@ -178,112 +206,156 @@ export const LazyMintNftForm: React.FC<LazyMintNftFormParams> = ({
|
||||
setValue={setValue}
|
||||
watch={watch}
|
||||
/>
|
||||
|
||||
<Accordion
|
||||
allowToggle={!(errors.background_color || errors.external_url)}
|
||||
index={
|
||||
errors.background_color || errors.external_url ? [0] : undefined
|
||||
type="single"
|
||||
collapsible
|
||||
className="-mx-1 border-t border-border"
|
||||
defaultValue={
|
||||
errors.background_color || errors.external_url
|
||||
? "advanced"
|
||||
: undefined
|
||||
}
|
||||
>
|
||||
<AccordionItem>
|
||||
<AccordionButton justifyContent="space-between" px={0}>
|
||||
<Heading size="subtitle.md">Advanced Options</Heading>
|
||||
<AccordionIcon />
|
||||
</AccordionButton>
|
||||
<AccordionPanel className="!px-0 flex flex-col gap-6">
|
||||
<FormControl isInvalid={!!errors.background_color}>
|
||||
<FormLabel>
|
||||
Background Color <OpenSeaPropertyBadge />
|
||||
</FormLabel>
|
||||
<Input max="6" {...register("background_color")} />
|
||||
<FormHelperText>
|
||||
Must be a six-character hexadecimal with a pre-pended #.
|
||||
</FormHelperText>
|
||||
<FormErrorMessage>
|
||||
{errors?.background_color?.message}
|
||||
</FormErrorMessage>
|
||||
</FormControl>
|
||||
<AccordionItem value="advanced">
|
||||
<AccordionTrigger className="justify-between px-1">
|
||||
<h3 className="text-base font-medium">Advanced Options</h3>
|
||||
</AccordionTrigger>
|
||||
<AccordionContent className="flex flex-col gap-6 px-1">
|
||||
<FormField
|
||||
control={control}
|
||||
name="background_color"
|
||||
render={({ field }) => (
|
||||
<FormItem>
|
||||
<FormLabel>
|
||||
Background Color <OpenSeaPropertyBadge />
|
||||
</FormLabel>
|
||||
<FormControl>
|
||||
<Input max="6" {...field} className="bg-card" />
|
||||
</FormControl>
|
||||
<FormDescription>
|
||||
Must be a six-character hexadecimal with a pre-pended #.
|
||||
</FormDescription>
|
||||
<FormMessage />
|
||||
</FormItem>
|
||||
)}
|
||||
/>
|
||||
|
||||
{!(external_url instanceof File) && (
|
||||
<FormControl isInvalid={!!errors.external_url}>
|
||||
<FormLabel>
|
||||
External URL <OpenSeaPropertyBadge />
|
||||
</FormLabel>
|
||||
<Input {...register("external_url")} />
|
||||
<FormHelperText>
|
||||
This is the URL that will appear below the asset's
|
||||
image on OpenSea and will allow users to leave OpenSea and
|
||||
view the item on your site.
|
||||
</FormHelperText>
|
||||
<FormErrorMessage>
|
||||
{errors?.external_url?.message as unknown as string}
|
||||
</FormErrorMessage>
|
||||
</FormControl>
|
||||
<FormField
|
||||
control={control}
|
||||
name="external_url"
|
||||
render={({ field }) => (
|
||||
<FormItem>
|
||||
<FormLabel>
|
||||
External URL <OpenSeaPropertyBadge />
|
||||
</FormLabel>
|
||||
<FormControl>
|
||||
<Input
|
||||
{...field}
|
||||
className="bg-card"
|
||||
value={
|
||||
typeof field.value === "string" ? field.value : ""
|
||||
}
|
||||
/>
|
||||
</FormControl>
|
||||
<FormDescription>
|
||||
This is the URL that will appear below the asset's
|
||||
image on OpenSea and will allow users to leave OpenSea
|
||||
and view the item on your site.
|
||||
</FormDescription>
|
||||
<FormMessage />
|
||||
</FormItem>
|
||||
)}
|
||||
/>
|
||||
)}
|
||||
|
||||
{!(image instanceof File) && (
|
||||
<FormControl isInvalid={!!errors.image}>
|
||||
<FormLabel>Image URL</FormLabel>
|
||||
<Input
|
||||
{...register("image")}
|
||||
onChange={(e) => {
|
||||
setValue("image", e.target.value);
|
||||
}}
|
||||
value={image}
|
||||
/>
|
||||
<FormHelperText>
|
||||
If you already have your NFT image pre-uploaded to a URL,
|
||||
you can specify it here instead of uploading the media file
|
||||
</FormHelperText>
|
||||
<FormErrorMessage>{errors?.image?.message}</FormErrorMessage>
|
||||
</FormControl>
|
||||
<FormField
|
||||
control={control}
|
||||
name="image"
|
||||
render={({ field }) => (
|
||||
<FormItem>
|
||||
<FormLabel>Image URL</FormLabel>
|
||||
<FormControl>
|
||||
<Input
|
||||
{...field}
|
||||
onChange={(e) => {
|
||||
setValue("image", e.target.value);
|
||||
}}
|
||||
value={typeof image === "string" ? image : ""}
|
||||
className="bg-card"
|
||||
/>
|
||||
</FormControl>
|
||||
<FormDescription>
|
||||
If you already have your NFT image pre-uploaded to a
|
||||
URL, you can specify it here instead of uploading the
|
||||
media file
|
||||
</FormDescription>
|
||||
<FormMessage />
|
||||
</FormItem>
|
||||
)}
|
||||
/>
|
||||
)}
|
||||
|
||||
{!(animation_url instanceof File) && (
|
||||
<FormControl isInvalid={!!errors.animation_url}>
|
||||
<FormLabel>Animation URL</FormLabel>
|
||||
<Input
|
||||
{...register("animation_url")}
|
||||
onChange={(e) => {
|
||||
setValue("animation_url", e.target.value);
|
||||
}}
|
||||
value={animation_url}
|
||||
/>
|
||||
<FormHelperText>
|
||||
If you already have your NFT Animation URL pre-uploaded to a
|
||||
URL, you can specify it here instead of uploading the media
|
||||
file
|
||||
</FormHelperText>
|
||||
<FormErrorMessage>
|
||||
{errors?.animation_url?.message}
|
||||
</FormErrorMessage>
|
||||
</FormControl>
|
||||
<FormField
|
||||
control={control}
|
||||
name="animation_url"
|
||||
render={({ field }) => (
|
||||
<FormItem>
|
||||
<FormLabel>Animation URL</FormLabel>
|
||||
<FormControl>
|
||||
<Input
|
||||
{...field}
|
||||
onChange={(e) => {
|
||||
setValue("animation_url", e.target.value);
|
||||
}}
|
||||
value={
|
||||
typeof animation_url === "string"
|
||||
? animation_url
|
||||
: ""
|
||||
}
|
||||
className="bg-card"
|
||||
/>
|
||||
</FormControl>
|
||||
<FormDescription>
|
||||
If you already have your NFT Animation URL pre-uploaded
|
||||
to a URL, you can specify it here instead of uploading
|
||||
the media file
|
||||
</FormDescription>
|
||||
<FormMessage />
|
||||
</FormItem>
|
||||
)}
|
||||
/>
|
||||
)}
|
||||
</AccordionPanel>
|
||||
</AccordionContent>
|
||||
</AccordionItem>
|
||||
</Accordion>
|
||||
|
||||
<div className="mt-8 flex flex-row justify-end gap-3">
|
||||
<Button
|
||||
className="mr-3"
|
||||
disabled={sendAndConfirmTx.isPending}
|
||||
onClick={() => setOpen(false)}
|
||||
variant="outline"
|
||||
>
|
||||
Cancel
|
||||
</Button>
|
||||
<TransactionButton
|
||||
client={contract.client}
|
||||
disabled={!isDirty}
|
||||
form={LAZY_MINT_FORM_ID}
|
||||
isLoggedIn={isLoggedIn}
|
||||
isPending={sendAndConfirmTx.isPending}
|
||||
transactionCount={1}
|
||||
txChainID={contract.chain.id}
|
||||
type="submit"
|
||||
>
|
||||
Lazy Mint NFT
|
||||
</TransactionButton>
|
||||
</div>
|
||||
</form>
|
||||
<div className="mt-8 flex flex-row justify-end gap-3">
|
||||
<Button
|
||||
className="mr-3"
|
||||
disabled={sendAndConfirmTx.isPending}
|
||||
onClick={() => setOpen(false)}
|
||||
variant="outline"
|
||||
>
|
||||
Cancel
|
||||
</Button>
|
||||
<TransactionButton
|
||||
client={contract.client}
|
||||
disabled={!isDirty}
|
||||
form={LAZY_MINT_FORM_ID}
|
||||
isLoggedIn={isLoggedIn}
|
||||
isPending={sendAndConfirmTx.isPending}
|
||||
transactionCount={1}
|
||||
txChainID={contract.chain.id}
|
||||
type="submit"
|
||||
>
|
||||
Lazy Mint NFT
|
||||
</TransactionButton>
|
||||
</div>
|
||||
</>
|
||||
</Form>
|
||||
);
|
||||
};
|
||||
}
|
||||
|
||||
+244
-161
@@ -1,19 +1,5 @@
|
||||
"use client";
|
||||
|
||||
import {
|
||||
Accordion,
|
||||
AccordionButton,
|
||||
AccordionIcon,
|
||||
AccordionItem,
|
||||
AccordionPanel,
|
||||
Divider,
|
||||
FormControl,
|
||||
Input,
|
||||
Textarea,
|
||||
} from "@chakra-ui/react";
|
||||
import { Button } from "chakra/button";
|
||||
import { FormErrorMessage, FormHelperText, FormLabel } from "chakra/form";
|
||||
import { Heading } from "chakra/heading";
|
||||
import type { Dispatch, SetStateAction } from "react";
|
||||
import { useForm } from "react-hook-form";
|
||||
import { toast } from "sonner";
|
||||
@@ -25,6 +11,24 @@ import { OpenSeaPropertyBadge } from "@/components/badges/opensea";
|
||||
import { FileInput } from "@/components/blocks/FileInput";
|
||||
import { PropertiesFormControl } from "@/components/contracts/properties.shared";
|
||||
import { TransactionButton } from "@/components/tx-button";
|
||||
import {
|
||||
Accordion,
|
||||
AccordionContent,
|
||||
AccordionItem,
|
||||
AccordionTrigger,
|
||||
} from "@/components/ui/accordion";
|
||||
import { Button } from "@/components/ui/button";
|
||||
import {
|
||||
Form,
|
||||
FormControl,
|
||||
FormDescription,
|
||||
FormField,
|
||||
FormItem,
|
||||
FormLabel,
|
||||
FormMessage,
|
||||
} from "@/components/ui/form";
|
||||
import { Input } from "@/components/ui/input";
|
||||
import { Textarea } from "@/components/ui/textarea";
|
||||
import { useTxNotifications } from "@/hooks/useTxNotifications";
|
||||
import type { NFTMetadataInputLimited } from "@/types/modified-types";
|
||||
import { parseAttributes } from "@/utils/parseAttributes";
|
||||
@@ -42,12 +46,12 @@ type NFTMintForm = {
|
||||
isLoggedIn: boolean;
|
||||
};
|
||||
|
||||
export const NFTMintForm: React.FC<NFTMintForm> = ({
|
||||
export function NFTMintForm({
|
||||
contract,
|
||||
isErc721,
|
||||
setOpen,
|
||||
isLoggedIn,
|
||||
}) => {
|
||||
}: NFTMintForm) {
|
||||
const address = useActiveAccount()?.address;
|
||||
const form = useForm<
|
||||
NFTMetadataInputLimited & {
|
||||
@@ -84,7 +88,7 @@ export const NFTMintForm: React.FC<NFTMintForm> = ({
|
||||
);
|
||||
|
||||
return (
|
||||
<>
|
||||
<Form {...form}>
|
||||
<form
|
||||
className="mt-6 flex flex-col gap-6"
|
||||
id={MINT_FORM_ID}
|
||||
@@ -126,16 +130,21 @@ export const NFTMintForm: React.FC<NFTMintForm> = ({
|
||||
}
|
||||
})}
|
||||
>
|
||||
<div className="flex flex-col gap-2">
|
||||
<Heading size="subtitle.md">Metadata xx</Heading>
|
||||
<Divider />
|
||||
</div>
|
||||
<FormControl isInvalid={!!errors.name} isRequired>
|
||||
<FormLabel>Name</FormLabel>
|
||||
<Input autoFocus {...register("name")} />
|
||||
<FormErrorMessage>{errors?.name?.message}</FormErrorMessage>
|
||||
</FormControl>
|
||||
<FormControl isInvalid={!!mediaFileError}>
|
||||
<FormField
|
||||
control={control}
|
||||
name="name"
|
||||
render={({ field }) => (
|
||||
<FormItem>
|
||||
<FormLabel>Name</FormLabel>
|
||||
<FormControl>
|
||||
<Input autoFocus {...field} className="bg-card" />
|
||||
</FormControl>
|
||||
<FormMessage />
|
||||
</FormItem>
|
||||
)}
|
||||
/>
|
||||
|
||||
<FormItem>
|
||||
<FormLabel>Media</FormLabel>
|
||||
<div>
|
||||
<FileInput
|
||||
@@ -150,51 +159,80 @@ export const NFTMintForm: React.FC<NFTMintForm> = ({
|
||||
value={media}
|
||||
/>
|
||||
</div>
|
||||
<FormHelperText>
|
||||
<FormDescription>
|
||||
You can upload image, audio, video, html, text, pdf, and 3d model
|
||||
files here.
|
||||
</FormHelperText>
|
||||
<FormErrorMessage>
|
||||
{mediaFileError?.message as unknown as string}
|
||||
</FormErrorMessage>
|
||||
</FormControl>
|
||||
</FormDescription>
|
||||
{mediaFileError && (
|
||||
<FormMessage>
|
||||
{mediaFileError.message as unknown as string}
|
||||
</FormMessage>
|
||||
)}
|
||||
</FormItem>
|
||||
|
||||
{showCoverImageUpload && (
|
||||
<FormControl isInvalid={!!errors.image}>
|
||||
<FormLabel>Cover Image</FormLabel>
|
||||
<FileInput
|
||||
accept={{ "image/*": [] }}
|
||||
className="shrink-0 rounded border border-border transition-all"
|
||||
client={contract.client}
|
||||
previewMaxWidth="200px"
|
||||
setValue={(file) => setValue("image", file)}
|
||||
showUploadButton
|
||||
value={image}
|
||||
/>
|
||||
<FormHelperText>
|
||||
You can optionally upload an image as the cover of your NFT.
|
||||
</FormHelperText>
|
||||
<FormErrorMessage>
|
||||
{errors?.image?.message as unknown as string}
|
||||
</FormErrorMessage>
|
||||
</FormControl>
|
||||
<FormField
|
||||
control={control}
|
||||
name="image"
|
||||
render={() => (
|
||||
<FormItem>
|
||||
<FormLabel>Cover Image</FormLabel>
|
||||
<FormControl>
|
||||
<FileInput
|
||||
accept={{ "image/*": [] }}
|
||||
className="shrink-0 rounded border border-border transition-all"
|
||||
client={contract.client}
|
||||
previewMaxWidth="200px"
|
||||
setValue={(file) => setValue("image", file)}
|
||||
showUploadButton
|
||||
value={image}
|
||||
/>
|
||||
</FormControl>
|
||||
<FormDescription>
|
||||
You can optionally upload an image as the cover of your NFT.
|
||||
</FormDescription>
|
||||
<FormMessage />
|
||||
</FormItem>
|
||||
)}
|
||||
/>
|
||||
)}
|
||||
<FormControl isInvalid={!!errors.description}>
|
||||
<FormLabel>Description</FormLabel>
|
||||
<Textarea {...register("description")} />
|
||||
<FormErrorMessage>{errors?.description?.message}</FormErrorMessage>
|
||||
</FormControl>
|
||||
|
||||
<FormField
|
||||
control={control}
|
||||
name="description"
|
||||
render={({ field }) => (
|
||||
<FormItem>
|
||||
<FormLabel>Description</FormLabel>
|
||||
<FormControl>
|
||||
<Textarea {...field} className="bg-card" />
|
||||
</FormControl>
|
||||
<FormMessage />
|
||||
</FormItem>
|
||||
)}
|
||||
/>
|
||||
|
||||
{!isErc721 && (
|
||||
<FormControl isInvalid={!!errors.supply} isRequired>
|
||||
<FormLabel>Initial Supply</FormLabel>
|
||||
<Input
|
||||
pattern="[0-9]"
|
||||
step="1"
|
||||
type="number"
|
||||
{...register("supply")}
|
||||
/>
|
||||
<FormErrorMessage>{errors?.supply?.message}</FormErrorMessage>
|
||||
</FormControl>
|
||||
<FormField
|
||||
control={control}
|
||||
name="supply"
|
||||
render={({ field }) => (
|
||||
<FormItem>
|
||||
<FormLabel>Initial Supply</FormLabel>
|
||||
<FormControl>
|
||||
<Input
|
||||
pattern="[0-9]"
|
||||
step="1"
|
||||
type="number"
|
||||
{...field}
|
||||
className="bg-card"
|
||||
/>
|
||||
</FormControl>
|
||||
<FormMessage />
|
||||
</FormItem>
|
||||
)}
|
||||
/>
|
||||
)}
|
||||
|
||||
<PropertiesFormControl
|
||||
client={contract.client}
|
||||
control={control}
|
||||
@@ -204,109 +242,154 @@ export const NFTMintForm: React.FC<NFTMintForm> = ({
|
||||
setValue={setValue}
|
||||
watch={watch}
|
||||
/>
|
||||
|
||||
<Accordion
|
||||
allowToggle={!(errors.background_color || errors.external_url)}
|
||||
index={
|
||||
errors.background_color || errors.external_url ? [0] : undefined
|
||||
type="single"
|
||||
className="-mx-1 border-t border-border"
|
||||
collapsible
|
||||
defaultValue={
|
||||
errors.background_color || errors.external_url
|
||||
? "advanced"
|
||||
: undefined
|
||||
}
|
||||
>
|
||||
<AccordionItem>
|
||||
<AccordionButton justifyContent="space-between" px={0}>
|
||||
<Heading size="subtitle.md">Advanced Options</Heading>
|
||||
<AccordionIcon />
|
||||
</AccordionButton>
|
||||
<AccordionPanel className="!px-0 flex flex-col gap-6">
|
||||
<FormControl isInvalid={!!errors.background_color}>
|
||||
<FormLabel>
|
||||
Background Color <OpenSeaPropertyBadge />
|
||||
</FormLabel>
|
||||
<Input max="6" {...register("background_color")} />
|
||||
<FormHelperText>
|
||||
Must be a six-character hexadecimal with a pre-pended #.
|
||||
</FormHelperText>
|
||||
<FormErrorMessage>
|
||||
{errors?.background_color?.message}
|
||||
</FormErrorMessage>
|
||||
</FormControl>
|
||||
<AccordionItem value="advanced">
|
||||
<AccordionTrigger className="justify-between px-1">
|
||||
<h3 className="text-base font-medium">Advanced Options</h3>
|
||||
</AccordionTrigger>
|
||||
<AccordionContent>
|
||||
<div className="flex flex-col gap-6 px-1">
|
||||
<FormField
|
||||
control={control}
|
||||
name="background_color"
|
||||
render={({ field }) => (
|
||||
<FormItem>
|
||||
<FormLabel>
|
||||
Background Color <OpenSeaPropertyBadge />
|
||||
</FormLabel>
|
||||
<FormControl>
|
||||
<Input max="6" {...field} className="bg-card" />
|
||||
</FormControl>
|
||||
<FormDescription>
|
||||
Must be a six-character hexadecimal with a pre-pended #.
|
||||
</FormDescription>
|
||||
<FormMessage />
|
||||
</FormItem>
|
||||
)}
|
||||
/>
|
||||
|
||||
{!(external_url instanceof File) && (
|
||||
<FormControl isInvalid={!!errors.external_url}>
|
||||
<FormLabel>
|
||||
External URL <OpenSeaPropertyBadge />
|
||||
</FormLabel>
|
||||
<Input {...register("external_url")} />
|
||||
<FormHelperText>
|
||||
This is the URL that will appear below the asset's
|
||||
image on OpenSea and will allow users to leave OpenSea and
|
||||
view the item on your site.
|
||||
</FormHelperText>
|
||||
<FormErrorMessage>
|
||||
{errors?.external_url?.message as unknown as string}
|
||||
</FormErrorMessage>
|
||||
</FormControl>
|
||||
)}
|
||||
|
||||
{!(image instanceof File) && (
|
||||
<FormControl isInvalid={!!errors.image}>
|
||||
<FormLabel>Image URL</FormLabel>
|
||||
<Input
|
||||
onChange={(e) => {
|
||||
setValue("image", e.target.value);
|
||||
}}
|
||||
value={image}
|
||||
{!(external_url instanceof File) && (
|
||||
<FormField
|
||||
control={control}
|
||||
name="external_url"
|
||||
render={({ field }) => (
|
||||
<FormItem>
|
||||
<FormLabel>
|
||||
External URL <OpenSeaPropertyBadge />
|
||||
</FormLabel>
|
||||
<FormControl>
|
||||
<Input
|
||||
{...field}
|
||||
className="bg-card"
|
||||
value={
|
||||
typeof field.value === "string" ? field.value : ""
|
||||
}
|
||||
/>
|
||||
</FormControl>
|
||||
<FormDescription>
|
||||
This is the URL that will appear below the
|
||||
asset's image on OpenSea and will allow users to
|
||||
leave OpenSea and view the item on your site.
|
||||
</FormDescription>
|
||||
<FormMessage />
|
||||
</FormItem>
|
||||
)}
|
||||
/>
|
||||
<FormHelperText>
|
||||
If you already have your NFT image pre-uploaded to a URL,
|
||||
you can specify it here instead of uploading the asset
|
||||
</FormHelperText>
|
||||
<FormErrorMessage>{errors?.image?.message}</FormErrorMessage>
|
||||
</FormControl>
|
||||
)}
|
||||
)}
|
||||
|
||||
{!(animation_url instanceof File) && (
|
||||
<FormControl isInvalid={!!errors.animation_url}>
|
||||
<FormLabel>Animation URL</FormLabel>
|
||||
<Input
|
||||
onChange={(e) => {
|
||||
setValue("animation_url", e.target.value);
|
||||
}}
|
||||
value={animation_url}
|
||||
{!(image instanceof File) && (
|
||||
<FormField
|
||||
control={control}
|
||||
name="image"
|
||||
render={({ field }) => (
|
||||
<FormItem>
|
||||
<FormLabel>Image URL</FormLabel>
|
||||
<FormControl>
|
||||
<Input
|
||||
{...field}
|
||||
onChange={(e) => {
|
||||
setValue("image", e.target.value);
|
||||
}}
|
||||
value={image}
|
||||
className="bg-card"
|
||||
/>
|
||||
</FormControl>
|
||||
<FormDescription>
|
||||
If you already have your NFT image pre-uploaded to a
|
||||
URL, you can specify it here instead of uploading the
|
||||
asset
|
||||
</FormDescription>
|
||||
<FormMessage />
|
||||
</FormItem>
|
||||
)}
|
||||
/>
|
||||
<FormHelperText>
|
||||
If you already have your NFT Animation URL pre-uploaded to a
|
||||
URL, you can specify it here instead of uploading the asset
|
||||
</FormHelperText>
|
||||
<FormErrorMessage>
|
||||
{errors?.animation_url?.message}
|
||||
</FormErrorMessage>
|
||||
</FormControl>
|
||||
)}
|
||||
</AccordionPanel>
|
||||
)}
|
||||
|
||||
{!(animation_url instanceof File) && (
|
||||
<FormField
|
||||
control={control}
|
||||
name="animation_url"
|
||||
render={({ field }) => (
|
||||
<FormItem>
|
||||
<FormLabel>Animation URL</FormLabel>
|
||||
<FormControl>
|
||||
<Input
|
||||
{...field}
|
||||
onChange={(e) => {
|
||||
setValue("animation_url", e.target.value);
|
||||
}}
|
||||
value={animation_url}
|
||||
className="bg-card"
|
||||
/>
|
||||
</FormControl>
|
||||
<FormDescription>
|
||||
If you already have your NFT Animation URL
|
||||
pre-uploaded to a URL, you can specify it here instead
|
||||
of uploading the asset
|
||||
</FormDescription>
|
||||
<FormMessage />
|
||||
</FormItem>
|
||||
)}
|
||||
/>
|
||||
)}
|
||||
</div>
|
||||
</AccordionContent>
|
||||
</AccordionItem>
|
||||
</Accordion>
|
||||
|
||||
<div className="mt-8 flex flex-row justify-end gap-3">
|
||||
<Button
|
||||
disabled={sendAndConfirmTx.isPending}
|
||||
className="mr-3"
|
||||
onClick={() => setOpen(false)}
|
||||
variant="outline"
|
||||
>
|
||||
Cancel
|
||||
</Button>
|
||||
<TransactionButton
|
||||
client={contract.client}
|
||||
disabled={!isDirty}
|
||||
form={MINT_FORM_ID}
|
||||
isLoggedIn={isLoggedIn}
|
||||
isPending={sendAndConfirmTx.isPending}
|
||||
transactionCount={1}
|
||||
txChainID={contract.chain.id}
|
||||
type="submit"
|
||||
>
|
||||
Mint NFT
|
||||
</TransactionButton>
|
||||
</div>
|
||||
</form>
|
||||
<div className="mt-8 flex flex-row justify-end gap-3">
|
||||
<Button
|
||||
isDisabled={sendAndConfirmTx.isPending}
|
||||
mr={3}
|
||||
onClick={() => setOpen(false)}
|
||||
variant="outline"
|
||||
>
|
||||
Cancel
|
||||
</Button>
|
||||
<TransactionButton
|
||||
client={contract.client}
|
||||
disabled={!isDirty}
|
||||
form={MINT_FORM_ID}
|
||||
isLoggedIn={isLoggedIn}
|
||||
isPending={sendAndConfirmTx.isPending}
|
||||
transactionCount={1}
|
||||
txChainID={contract.chain.id}
|
||||
type="submit"
|
||||
>
|
||||
Mint NFT
|
||||
</TransactionButton>
|
||||
</div>
|
||||
</>
|
||||
</Form>
|
||||
);
|
||||
};
|
||||
}
|
||||
|
||||
+1
-1
@@ -1,5 +1,5 @@
|
||||
import type { ThirdwebContract } from "thirdweb";
|
||||
import { getAuthTokenWalletAddress } from "../../../../../../../../@/api/auth-token";
|
||||
import { getAuthTokenWalletAddress } from "@/api/auth-token";
|
||||
import { getPublishedByCardProps, PublishedByUI } from "./published-by-ui";
|
||||
|
||||
interface PublishedByProps {
|
||||
|
||||
+1
-1
@@ -6,7 +6,7 @@ import { PublicPageConnectButton } from "./PublicPageConnectButton";
|
||||
|
||||
export function PageHeader(props: { containerClassName?: string }) {
|
||||
return (
|
||||
<div className="border-b border-dashed">
|
||||
<div className="border-b bg-card">
|
||||
<header
|
||||
className={cn(
|
||||
"container flex max-w-8xl justify-between py-3",
|
||||
|
||||
+35
-6
@@ -17,28 +17,57 @@ export const Variants: Story = {
|
||||
function StoryVariants() {
|
||||
return (
|
||||
<div className="container max-w-md space-y-10 py-10">
|
||||
<BadgeContainer label="5.5 / Unlimited Supply">
|
||||
<SupplyClaimedProgress
|
||||
claimedSupplyTokens={5.5}
|
||||
totalSupplyTokens="unlimited"
|
||||
/>
|
||||
</BadgeContainer>
|
||||
|
||||
<BadgeContainer label="10 / Unlimited Supply">
|
||||
<SupplyClaimedProgress claimedSupply={10n} totalSupply="unlimited" />
|
||||
<SupplyClaimedProgress
|
||||
claimedSupplyTokens={10}
|
||||
totalSupplyTokens="unlimited"
|
||||
/>
|
||||
</BadgeContainer>
|
||||
|
||||
<BadgeContainer label="500/1000 Supply">
|
||||
<SupplyClaimedProgress claimedSupply={500n} totalSupply={1000n} />
|
||||
<SupplyClaimedProgress
|
||||
claimedSupplyTokens={500}
|
||||
totalSupplyTokens={1000}
|
||||
/>
|
||||
</BadgeContainer>
|
||||
|
||||
<BadgeContainer label="123.45/1000 Supply">
|
||||
<SupplyClaimedProgress
|
||||
claimedSupplyTokens={123.45}
|
||||
totalSupplyTokens={1000}
|
||||
/>
|
||||
</BadgeContainer>
|
||||
|
||||
<BadgeContainer label="10/1M Supply">
|
||||
<SupplyClaimedProgress claimedSupply={10n} totalSupply={1000000n} />
|
||||
<SupplyClaimedProgress
|
||||
claimedSupplyTokens={10}
|
||||
totalSupplyTokens={1000000}
|
||||
/>
|
||||
</BadgeContainer>
|
||||
|
||||
<BadgeContainer label="0/1M Supply">
|
||||
<SupplyClaimedProgress claimedSupply={0n} totalSupply={1000000n} />
|
||||
<SupplyClaimedProgress
|
||||
claimedSupplyTokens={0}
|
||||
totalSupplyTokens={1000000}
|
||||
/>
|
||||
</BadgeContainer>
|
||||
|
||||
<BadgeContainer label="10/10 Supply">
|
||||
<SupplyClaimedProgress claimedSupply={10n} totalSupply={10n} />
|
||||
<SupplyClaimedProgress
|
||||
claimedSupplyTokens={10}
|
||||
totalSupplyTokens={10}
|
||||
/>
|
||||
</BadgeContainer>
|
||||
|
||||
<BadgeContainer label="0 Supply - don't show anything">
|
||||
<SupplyClaimedProgress claimedSupply={0n} totalSupply={0n} />
|
||||
<SupplyClaimedProgress claimedSupplyTokens={0} totalSupplyTokens={0} />
|
||||
</BadgeContainer>
|
||||
</div>
|
||||
);
|
||||
|
||||
+12
-12
@@ -3,16 +3,16 @@ import { Progress } from "@/components/ui/progress";
|
||||
import { supplyFormatter } from "../nft/format";
|
||||
|
||||
export function SupplyClaimedProgress(props: {
|
||||
claimedSupply: bigint;
|
||||
totalSupply: bigint | "unlimited";
|
||||
claimedSupplyTokens: number;
|
||||
totalSupplyTokens: number | "unlimited";
|
||||
}) {
|
||||
// if total supply is unlimited
|
||||
if (props.totalSupply === "unlimited") {
|
||||
if (props.totalSupplyTokens === "unlimited") {
|
||||
return (
|
||||
<p className="flex items-center justify-between gap-2">
|
||||
<span className="font-medium text-sm">Claimed Supply </span>
|
||||
<span className="flex items-center gap-1 font-bold text-sm">
|
||||
{supplyFormatter.format(props.claimedSupply)} /{" "}
|
||||
{supplyFormatter.format(props.claimedSupplyTokens)} /{" "}
|
||||
<InfinityIcon aria-label="Unlimited" className="size-4" />
|
||||
</span>
|
||||
</p>
|
||||
@@ -20,29 +20,29 @@ export function SupplyClaimedProgress(props: {
|
||||
}
|
||||
|
||||
// if total supply is 0 - don't show anything
|
||||
if (props.totalSupply === 0n) {
|
||||
if (props.totalSupplyTokens === 0) {
|
||||
return null;
|
||||
}
|
||||
|
||||
// multiply by 10k to have precision up to 2 decimal places in percentage value
|
||||
const soldFractionTimes10KBigInt =
|
||||
(props.claimedSupply * 10000n) / props.totalSupply;
|
||||
|
||||
const soldPercentage = Number(soldFractionTimes10KBigInt) / 100;
|
||||
const soldPercentage =
|
||||
(props.claimedSupplyTokens / props.totalSupplyTokens) * 100;
|
||||
|
||||
const percentToShow = soldPercentage.toFixed(2);
|
||||
|
||||
return (
|
||||
<div className="space-y-2">
|
||||
<div className="flex items-center justify-between">
|
||||
<span className="font-medium text-sm">Supply Claimed</span>
|
||||
<span className="font-bold text-sm">
|
||||
{supplyFormatter.format(props.claimedSupply)} /{" "}
|
||||
{supplyFormatter.format(props.totalSupply)}
|
||||
{supplyFormatter.format(props.claimedSupplyTokens)} /{" "}
|
||||
{supplyFormatter.format(props.totalSupplyTokens)}
|
||||
</span>
|
||||
</div>
|
||||
<Progress className="h-2.5" value={soldPercentage} />
|
||||
<p className="font-medium text-muted-foreground text-xs">
|
||||
{soldPercentage === 0 && props.claimedSupply !== 0n && "~"}
|
||||
{soldPercentage.toFixed(2)}% Sold
|
||||
{percentToShow === "0.00" ? "~0.00%" : `${percentToShow}%`} Sold
|
||||
</p>
|
||||
</div>
|
||||
);
|
||||
|
||||
+51
@@ -0,0 +1,51 @@
|
||||
import "server-only";
|
||||
import { isProd } from "@/constants/env-utils";
|
||||
import { DASHBOARD_THIRDWEB_SECRET_KEY } from "@/constants/server-envs";
|
||||
|
||||
export type TokenPriceData = {
|
||||
price_usd: number;
|
||||
price_usd_cents: number;
|
||||
percent_change_24h: number;
|
||||
market_cap_usd: number;
|
||||
volume_24h_usd: number;
|
||||
volume_change_24h: number;
|
||||
holders: number;
|
||||
historical_prices: Array<{
|
||||
date: string;
|
||||
price_usd: number;
|
||||
price_usd_cents: number;
|
||||
}>;
|
||||
};
|
||||
|
||||
export async function getTokenPriceData(params: {
|
||||
chainId: number;
|
||||
contractAddress: string;
|
||||
}) {
|
||||
try {
|
||||
const url = new URL(
|
||||
`https://insight.${isProd ? "thirdweb" : "thirdweb-dev"}.com/v1/tokens/price`,
|
||||
);
|
||||
|
||||
url.searchParams.set("include_historical_prices", "true");
|
||||
url.searchParams.set("chain_id", params.chainId.toString());
|
||||
url.searchParams.set("address", params.contractAddress);
|
||||
url.searchParams.set("include_holders", "true");
|
||||
|
||||
const res = await fetch(url, {
|
||||
headers: {
|
||||
"x-secret-key": DASHBOARD_THIRDWEB_SECRET_KEY,
|
||||
},
|
||||
});
|
||||
if (!res.ok) {
|
||||
console.error("Failed to fetch token price data", await res.text());
|
||||
return undefined;
|
||||
}
|
||||
|
||||
const json = await res.json();
|
||||
const priceData = json.data[0] as TokenPriceData | undefined;
|
||||
|
||||
return priceData;
|
||||
} catch {
|
||||
return undefined;
|
||||
}
|
||||
}
|
||||
-19
@@ -1,19 +0,0 @@
|
||||
import type { ThirdwebContract } from "thirdweb";
|
||||
import { WalletAddress } from "@/components/blocks/wallet-address";
|
||||
|
||||
export function ContractCreatorBadge(props: {
|
||||
contractCreator: string;
|
||||
clientContract: ThirdwebContract;
|
||||
}) {
|
||||
return (
|
||||
<div className="flex items-center gap-1.5 bg-card rounded-full px-2.5 py-1.5 border hover:bg-accent">
|
||||
<span className="text-xs text-foreground">By</span>
|
||||
<WalletAddress
|
||||
address={props.contractCreator}
|
||||
className="py-0 text-xs h-auto !no-underline"
|
||||
client={props.clientContract.client}
|
||||
iconClassName="size-3.5 hidden"
|
||||
/>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
+65
-49
@@ -1,4 +1,9 @@
|
||||
import { ExternalLinkIcon, GlobeIcon, Settings2Icon } from "lucide-react";
|
||||
import {
|
||||
ExternalLinkIcon,
|
||||
GlobeIcon,
|
||||
Settings2Icon,
|
||||
UserIcon,
|
||||
} from "lucide-react";
|
||||
import Link from "next/link";
|
||||
import { useMemo } from "react";
|
||||
import { type ThirdwebContract, ZERO_ADDRESS } from "thirdweb";
|
||||
@@ -19,7 +24,6 @@ import { YoutubeIcon } from "@/icons/brand-icons/YoutubeIcon";
|
||||
import { ChainIconClient } from "@/icons/ChainIcon";
|
||||
import { cn } from "@/lib/utils";
|
||||
import { resolveSchemeWithErrorHandler } from "@/utils/resolveSchemeWithErrorHandler";
|
||||
import { ContractCreatorBadge } from "./ContractCreatorBadge";
|
||||
|
||||
const platformToIcons: Record<string, React.FC<{ className?: string }>> = {
|
||||
discord: DiscordIcon,
|
||||
@@ -44,6 +48,7 @@ export function ContractHeaderUI(props: {
|
||||
socialUrls: object;
|
||||
imageClassName?: string;
|
||||
contractCreator: string | null;
|
||||
className?: string;
|
||||
}) {
|
||||
const socialUrls = useMemo(() => {
|
||||
const socialUrlsValue: { name: string; href: string }[] = [];
|
||||
@@ -64,41 +69,45 @@ export function ContractHeaderUI(props: {
|
||||
?.replace("Mainnet", "")
|
||||
.trim();
|
||||
|
||||
const explorersToShow = getExplorersToShow(props.chainMetadata);
|
||||
const validBlockExplorer = getExplorerToShow(props.chainMetadata);
|
||||
|
||||
return (
|
||||
<div className="flex flex-col items-start gap-4 border-b border-dashed py-8 lg:flex-row lg:items-center">
|
||||
{props.image && (
|
||||
<Img
|
||||
className={cn(
|
||||
"size-20 shrink-0 rounded-full border bg-muted",
|
||||
props.imageClassName,
|
||||
)}
|
||||
fallback={
|
||||
<div className="flex items-center justify-center font-bold text-3xl text-muted-foreground/80">
|
||||
{props.name[0]}
|
||||
</div>
|
||||
}
|
||||
src={
|
||||
props.image
|
||||
? resolveSchemeWithErrorHandler({
|
||||
client: props.clientContract.client,
|
||||
uri: props.image,
|
||||
})
|
||||
: ""
|
||||
}
|
||||
/>
|
||||
<div
|
||||
className={cn(
|
||||
"flex flex-col lg:flex-row lg:items-center gap-5 py-6 relative",
|
||||
props.className,
|
||||
)}
|
||||
>
|
||||
<div className="flex">
|
||||
<div className="border p-1 rounded-full bg-card">
|
||||
<Img
|
||||
className={cn("size-28 shrink-0 rounded-full bg-muted")}
|
||||
fallback={
|
||||
<div className="flex items-center justify-center font-bold text-5xl text-muted-foreground/50 capitalize">
|
||||
{props.name[0]}
|
||||
</div>
|
||||
}
|
||||
src={
|
||||
props.image
|
||||
? resolveSchemeWithErrorHandler({
|
||||
client: props.clientContract.client,
|
||||
uri: props.image,
|
||||
})
|
||||
: ""
|
||||
}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="flex flex-col gap-3">
|
||||
<div className="flex flex-col gap-2.5 flex-1">
|
||||
{/* top row */}
|
||||
<div className="flex flex-col gap-3">
|
||||
<div className="flex flex-col flex-wrap gap-3 lg:flex-row lg:items-center">
|
||||
<h1 className="line-clamp-2 font-bold text-2xl tracking-tight lg:text-3xl">
|
||||
<h1 className="font-semibold text-3xl tracking-tighter lg:text-5xl">
|
||||
{props.name}
|
||||
</h1>
|
||||
|
||||
<div className="flex flex-wrap gap-2">
|
||||
<div className="flex flex-wrap gap-2 ">
|
||||
<Link
|
||||
className="flex w-fit shrink-0 items-center gap-2 rounded-3xl border border-border bg-card px-2.5 py-1.5 hover:bg-accent"
|
||||
href={`/${props.chainMetadata.slug}`}
|
||||
@@ -113,12 +122,15 @@ export function ContractHeaderUI(props: {
|
||||
)}
|
||||
</Link>
|
||||
|
||||
<CopyAddressButton
|
||||
address={props.clientContract.address}
|
||||
className="rounded-full bg-card w-[30px] h-[30px] p-0 [&>span]:hidden [&>svg]:text-foreground"
|
||||
copyIconPosition="left"
|
||||
variant="outline"
|
||||
/>
|
||||
{props.contractCreator &&
|
||||
validBlockExplorer &&
|
||||
props.contractCreator !== ZERO_ADDRESS && (
|
||||
<SocialLink
|
||||
href={`${validBlockExplorer.url}/address/${props.contractCreator}`}
|
||||
name="Contract Owner"
|
||||
icon={UserIcon}
|
||||
/>
|
||||
)}
|
||||
|
||||
{socialUrls
|
||||
.toSorted((a, b) => {
|
||||
@@ -148,13 +160,14 @@ export function ContractHeaderUI(props: {
|
||||
</div>
|
||||
|
||||
{/* bottom row */}
|
||||
<div className="flex flex-row flex-wrap items-center gap-2">
|
||||
{props.contractCreator && props.contractCreator !== ZERO_ADDRESS && (
|
||||
<ContractCreatorBadge
|
||||
clientContract={props.clientContract}
|
||||
contractCreator={props.contractCreator}
|
||||
/>
|
||||
)}
|
||||
<div className="flex flex-col items-end sm:flex-row flex-wrap sm:items-center gap-2 absolute top-6 right-0 sm:static w-1/2 sm:w-full">
|
||||
<CopyAddressButton
|
||||
address={props.clientContract.address}
|
||||
className="rounded-full bg-card px-2.5 py-1.5 text-xs"
|
||||
copyIconPosition="left"
|
||||
tooltip="Copy contract address"
|
||||
variant="outline"
|
||||
/>
|
||||
|
||||
<ToolTipLabel
|
||||
contentClassName="max-w-[300px]"
|
||||
@@ -180,13 +193,13 @@ export function ContractHeaderUI(props: {
|
||||
</Button>
|
||||
</ToolTipLabel>
|
||||
|
||||
{explorersToShow?.map((validBlockExplorer) => (
|
||||
{validBlockExplorer && (
|
||||
<BadgeLink
|
||||
href={`${validBlockExplorer.url.endsWith("/") ? validBlockExplorer.url : `${validBlockExplorer.url}/`}address/${props.clientContract.address}`}
|
||||
key={validBlockExplorer.url}
|
||||
name={validBlockExplorer.name}
|
||||
/>
|
||||
))}
|
||||
)}
|
||||
|
||||
{/* TODO - render social links here */}
|
||||
</div>
|
||||
@@ -204,19 +217,22 @@ function isValidUrl(url: string) {
|
||||
}
|
||||
}
|
||||
|
||||
function getExplorersToShow(chainMetadata: ChainMetadata) {
|
||||
const validBlockExplorers = chainMetadata.explorers
|
||||
?.filter((e) => e.standard === "EIP3091")
|
||||
?.slice(0, 2);
|
||||
function getExplorerToShow(chainMetadata: ChainMetadata) {
|
||||
const validBlockExplorers = chainMetadata.explorers?.filter(
|
||||
(e) => e.standard === "EIP3091",
|
||||
);
|
||||
|
||||
return validBlockExplorers?.slice(0, 1);
|
||||
return validBlockExplorers?.[0];
|
||||
}
|
||||
|
||||
function BadgeLink(props: { name: string; href: string }) {
|
||||
function BadgeLink(props: { name: string; href: string; className?: string }) {
|
||||
return (
|
||||
<Button
|
||||
asChild
|
||||
className="!h-auto gap-2 rounded-full bg-card px-3 py-1.5 text-xs capitalize"
|
||||
className={cn(
|
||||
"!h-auto gap-2 rounded-full bg-card px-3 py-1.5 text-xs capitalize",
|
||||
props.className,
|
||||
)}
|
||||
variant="outline"
|
||||
>
|
||||
<Link href={props.href} rel="noopener noreferrer" target="_blank">
|
||||
|
||||
+63
-112
@@ -6,13 +6,11 @@ import { useMemo, useState } from "react";
|
||||
import { ThirdwebAreaChart } from "@/components/blocks/charts/area-chart";
|
||||
import { Badge } from "@/components/ui/badge";
|
||||
import { Button } from "@/components/ui/button";
|
||||
import { SkeletonContainer } from "@/components/ui/skeleton";
|
||||
import { ToolTipLabel } from "@/components/ui/tooltip";
|
||||
import { cn } from "@/lib/utils";
|
||||
import { useTokenPriceData } from "../_hooks/useTokenPriceData";
|
||||
import type { TokenPriceData } from "../_apis/token-price-data";
|
||||
|
||||
function PriceChartUI(props: {
|
||||
isPending: boolean;
|
||||
showTimeOfDay: boolean;
|
||||
data: Array<{
|
||||
date: string;
|
||||
@@ -29,7 +27,7 @@ function PriceChartUI(props: {
|
||||
<ThirdwebAreaChart
|
||||
cardContentClassName="p-0"
|
||||
chartClassName="aspect-[1.5] lg:aspect-[3]"
|
||||
className="border-none bg-background p-0"
|
||||
className="border-none bg-transparent p-0"
|
||||
config={{
|
||||
price: {
|
||||
color: "hsl(var(--chart-1))",
|
||||
@@ -38,7 +36,7 @@ function PriceChartUI(props: {
|
||||
}}
|
||||
data={data}
|
||||
hideLabel={false}
|
||||
isPending={props.isPending}
|
||||
isPending={false}
|
||||
toolTipLabelFormatter={getTooltipLabelFormatter(props.showTimeOfDay)}
|
||||
toolTipValueFormatter={(value) => {
|
||||
return tokenPriceUSDFormatter.format(value as number);
|
||||
@@ -49,7 +47,7 @@ function PriceChartUI(props: {
|
||||
|
||||
const tokenPriceUSDFormatter = new Intl.NumberFormat("en-US", {
|
||||
currency: "USD",
|
||||
maximumFractionDigits: 10,
|
||||
maximumFractionDigits: 8,
|
||||
minimumFractionDigits: 0,
|
||||
notation: "compact",
|
||||
roundingMode: "halfEven",
|
||||
@@ -92,20 +90,14 @@ function getTooltipLabelFormatter(includeTimeOfDay: boolean) {
|
||||
export function TokenStats(params: {
|
||||
chainId: number;
|
||||
contractAddress: string;
|
||||
tokenPriceData: TokenPriceData;
|
||||
}) {
|
||||
const tokenPriceQuery = useTokenPriceData(params);
|
||||
const [interval, setInterval] = useState<Interval>("max");
|
||||
|
||||
const tokenPriceData = tokenPriceQuery.data;
|
||||
|
||||
const filteredHistoricalPrices = useMemo(() => {
|
||||
const currentDate = new Date();
|
||||
|
||||
if (tokenPriceData?.type === "no-data") {
|
||||
return [];
|
||||
}
|
||||
|
||||
return tokenPriceData?.data?.historical_prices.filter((item) => {
|
||||
return params.tokenPriceData.historical_prices.filter((item) => {
|
||||
const date = new Date(item.date);
|
||||
const maxDiff =
|
||||
interval === "24h"
|
||||
@@ -120,106 +112,75 @@ export function TokenStats(params: {
|
||||
|
||||
return differenceInCalendarDays(currentDate, date) <= maxDiff;
|
||||
});
|
||||
}, [tokenPriceData, interval]);
|
||||
}, [params.tokenPriceData, interval]);
|
||||
|
||||
const priceUsd =
|
||||
tokenPriceData?.type === "no-data" || tokenPriceQuery.isError
|
||||
? "N/A"
|
||||
: tokenPriceData?.data?.price_usd;
|
||||
const priceUsd = params.tokenPriceData.price_usd;
|
||||
const percentChange24h = params.tokenPriceData.percent_change_24h;
|
||||
const marketCap = params.tokenPriceData.market_cap_usd;
|
||||
const holders = params.tokenPriceData.holders;
|
||||
|
||||
const percentChange24h =
|
||||
tokenPriceData?.type === "no-data" || tokenPriceQuery.isError
|
||||
? "N/A"
|
||||
: tokenPriceData?.data?.percent_change_24h;
|
||||
const formattedAbsChange = percentChangeFormatter.format(
|
||||
Math.abs(percentChange24h),
|
||||
);
|
||||
|
||||
const marketCap =
|
||||
tokenPriceData?.type === "no-data" || tokenPriceQuery.isError
|
||||
? "N/A"
|
||||
: tokenPriceData?.data?.market_cap_usd;
|
||||
const isAlmostZeroChange =
|
||||
formattedAbsChange === percentChangeFormatter.format(0);
|
||||
|
||||
const holders =
|
||||
tokenPriceData?.type === "no-data" || tokenPriceQuery.isError
|
||||
? "N/A"
|
||||
: tokenPriceData?.data?.holders;
|
||||
const formattedPriceUSD = tokenPriceUSDFormatter.format(priceUsd);
|
||||
|
||||
return (
|
||||
<div>
|
||||
<div className="bg-card rounded-lg border">
|
||||
{/* price and change */}
|
||||
<div className="flex flex-col gap-4 lg:flex-row lg:items-center lg:justify-between">
|
||||
<div className="flex flex-col gap-4 lg:flex-row lg:items-center lg:justify-between p-4 lg:p-6">
|
||||
<div>
|
||||
<p className="mb-1 text-muted-foreground text-sm">Current Price</p>
|
||||
<div className="flex items-center gap-2">
|
||||
<SkeletonContainer
|
||||
loadedData={priceUsd}
|
||||
render={(v) => {
|
||||
return (
|
||||
<p className="font-bold text-4xl tracking-tight">
|
||||
{typeof v === "number"
|
||||
? tokenPriceUSDFormatter.format(v)
|
||||
: v}
|
||||
</p>
|
||||
);
|
||||
}}
|
||||
skeletonData={10.0001}
|
||||
/>
|
||||
<SkeletonContainer
|
||||
loadedData={percentChange24h}
|
||||
render={(data) => {
|
||||
if (typeof data === "string") {
|
||||
return null;
|
||||
}
|
||||
|
||||
const formattedAbsChange = percentChangeFormatter.format(
|
||||
Math.abs(data),
|
||||
);
|
||||
|
||||
const isAlmostZero =
|
||||
formattedAbsChange === percentChangeFormatter.format(0);
|
||||
|
||||
return (
|
||||
<Badge
|
||||
className="gap-2 text-sm"
|
||||
variant={
|
||||
isAlmostZero
|
||||
? "default"
|
||||
: data > 0
|
||||
? "success"
|
||||
: "destructive"
|
||||
}
|
||||
>
|
||||
<div className="flex items-center gap-0.5">
|
||||
{isAlmostZero ? null : data > 0 ? (
|
||||
<ArrowUpIcon className="size-3" />
|
||||
) : (
|
||||
<ArrowDownIcon className="size-3" />
|
||||
)}
|
||||
<span>
|
||||
{isAlmostZero ? "~0%" : `${formattedAbsChange}%`}
|
||||
</span>
|
||||
</div>
|
||||
<span>(1d)</span>
|
||||
</Badge>
|
||||
);
|
||||
}}
|
||||
skeletonData={0.001}
|
||||
/>
|
||||
<div className="flex items-center gap-3">
|
||||
<p
|
||||
className={cn(
|
||||
"font-bold text-3xl lg:text-4xl tracking-tight",
|
||||
formattedPriceUSD.length > 8 && "text-2xl lg:text-4xl",
|
||||
)}
|
||||
>
|
||||
{typeof priceUsd === "number" ? formattedPriceUSD : priceUsd}
|
||||
</p>
|
||||
<Badge
|
||||
className="gap-2 lg:text-sm text-xs px-1.5"
|
||||
variant={
|
||||
isAlmostZeroChange
|
||||
? "default"
|
||||
: percentChange24h > 0
|
||||
? "success"
|
||||
: "destructive"
|
||||
}
|
||||
>
|
||||
<div className="flex items-center gap-0.5">
|
||||
{isAlmostZeroChange ? null : percentChange24h > 0 ? (
|
||||
<ArrowUpIcon className="size-3" />
|
||||
) : (
|
||||
<ArrowDownIcon className="size-3" />
|
||||
)}
|
||||
<span>
|
||||
{isAlmostZeroChange ? "~0%" : `${formattedAbsChange}%`}
|
||||
</span>
|
||||
</div>
|
||||
<span>(1d)</span>
|
||||
</Badge>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<IntervalSelector interval={interval} setInterval={setInterval} />
|
||||
</div>
|
||||
|
||||
<div className="h-4" />
|
||||
<PriceChartUI
|
||||
data={filteredHistoricalPrices || []}
|
||||
isPending={tokenPriceQuery.isPending}
|
||||
showTimeOfDay={interval === "24h"}
|
||||
/>
|
||||
<div className="px-0">
|
||||
<PriceChartUI
|
||||
data={filteredHistoricalPrices || []}
|
||||
showTimeOfDay={interval === "24h"}
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div className="mt-8 flex gap-6 border-y border-dashed py-4 [&>*:not(:first-child)]:border-l [&>*:not(:first-child)]:border-dashed [&>*:not(:first-child)]:pl-5 [&>*]:grow">
|
||||
<div className="px-4 lg:px-6 py-4 mt-6 border-t border-dashed flex gap-6 [&>*:not(:first-child)]:border-l [&>*:not(:first-child)]:pl-5 [&>*]:grow">
|
||||
<TokenStat
|
||||
label="Market Cap"
|
||||
skeletonData={"1000000000"}
|
||||
tooltip="Market capitalization is the total value of all tokens in circulation, calculated by multiplying the current price by the circulating supply"
|
||||
value={
|
||||
typeof marketCap === "number"
|
||||
@@ -230,7 +191,6 @@ export function TokenStats(params: {
|
||||
|
||||
<TokenStat
|
||||
label="Number of Holders"
|
||||
skeletonData={"1000000000"}
|
||||
tooltip="The total number of unique wallet addresses that currently hold this token on the blockchain"
|
||||
value={
|
||||
// show 0 value as N/A
|
||||
@@ -246,9 +206,8 @@ export function TokenStats(params: {
|
||||
);
|
||||
}
|
||||
|
||||
function TokenStat<T extends string | number>(props: {
|
||||
value: T | undefined;
|
||||
skeletonData: T;
|
||||
function TokenStat(props: {
|
||||
value: string | number;
|
||||
label: string;
|
||||
tooltip: string;
|
||||
}) {
|
||||
@@ -261,17 +220,9 @@ function TokenStat<T extends string | number>(props: {
|
||||
</ToolTipLabel>
|
||||
</div>
|
||||
<div className="flex">
|
||||
<SkeletonContainer
|
||||
loadedData={props.value}
|
||||
render={(v) => {
|
||||
return (
|
||||
<p className="font-semibold text-foreground text-xl tracking-tight">
|
||||
{v}
|
||||
</p>
|
||||
);
|
||||
}}
|
||||
skeletonData={props.skeletonData}
|
||||
/>
|
||||
<p className="font-semibold text-foreground text-xl tracking-tight">
|
||||
{props.value}
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
@@ -296,7 +247,7 @@ function IntervalSelector(props: {
|
||||
{Object.entries(intervals).map(([key, value]) => (
|
||||
<Button
|
||||
className={cn(
|
||||
"rounded-full",
|
||||
"rounded-full px-2.5 py-1.5 h-auto bg-background text-xs lg:text-sm",
|
||||
props.interval === key && "border-active-border bg-accent",
|
||||
)}
|
||||
key={key}
|
||||
|
||||
+21
-23
@@ -47,8 +47,8 @@ function RecentTransfersUI(props: {
|
||||
}) {
|
||||
return (
|
||||
<div>
|
||||
<div className="mb-4">
|
||||
<h2 className="font-semibold text-xl tracking-tight">
|
||||
<div className="p-4 lg:p-6 bg-card border rounded-b-none border-b-0 rounded-lg">
|
||||
<h2 className="font-semibold text-2xl tracking-tight mb-0.5">
|
||||
Recent Transfers
|
||||
</h2>
|
||||
<p className="text-muted-foreground text-sm">
|
||||
@@ -57,7 +57,7 @@ function RecentTransfersUI(props: {
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<TableContainer className="rounded-b-none">
|
||||
<TableContainer className="rounded-b-none rounded-t-none">
|
||||
<Table>
|
||||
<TableHeader>
|
||||
<TableRow>
|
||||
@@ -159,7 +159,7 @@ function RecentTransfersUI(props: {
|
||||
|
||||
<div className="flex items-center justify-end gap-3 rounded-b-lg border-x border-b bg-card px-6 py-5">
|
||||
<Button
|
||||
className="gap-1.5 bg-background"
|
||||
className="gap-1.5 bg-background rounded-full"
|
||||
disabled={props.page === 0 || props.isPending}
|
||||
onClick={() => props.setPage(props.page - 1)}
|
||||
size="sm"
|
||||
@@ -169,7 +169,7 @@ function RecentTransfersUI(props: {
|
||||
Previous
|
||||
</Button>
|
||||
<Button
|
||||
className="gap-1.5 bg-background"
|
||||
className="gap-1.5 bg-background rounded-full"
|
||||
disabled={props.isPending || props.data.length < props.rowsPerPage}
|
||||
onClick={() => props.setPage(props.page + 1)}
|
||||
size="sm"
|
||||
@@ -222,23 +222,21 @@ export function RecentTransfers(props: {
|
||||
});
|
||||
|
||||
return (
|
||||
<div>
|
||||
<RecentTransfersUI
|
||||
client={props.clientContract.client}
|
||||
data={tokenQuery.data ?? []}
|
||||
explorerUrl={
|
||||
props.chainMetadata.explorers?.[0]?.url ||
|
||||
`https://thirdweb.com/${props.chainMetadata.slug}`
|
||||
}
|
||||
isPending={tokenQuery.isPending}
|
||||
page={page}
|
||||
rowsPerPage={rowsPerPage}
|
||||
setPage={setPage}
|
||||
tokenMetadata={{
|
||||
decimals: props.decimals,
|
||||
symbol: props.tokenSymbol,
|
||||
}}
|
||||
/>
|
||||
</div>
|
||||
<RecentTransfersUI
|
||||
client={props.clientContract.client}
|
||||
data={tokenQuery.data ?? []}
|
||||
explorerUrl={
|
||||
props.chainMetadata.explorers?.[0]?.url ||
|
||||
`https://thirdweb.com/${props.chainMetadata.slug}`
|
||||
}
|
||||
isPending={tokenQuery.isPending}
|
||||
page={page}
|
||||
rowsPerPage={rowsPerPage}
|
||||
setPage={setPage}
|
||||
tokenMetadata={{
|
||||
decimals: props.decimals,
|
||||
symbol: props.tokenSymbol,
|
||||
}}
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
||||
+4
-4
@@ -39,8 +39,8 @@ import { Spinner } from "@/components/ui/Spinner/Spinner";
|
||||
import { ToolTipLabel } from "@/components/ui/tooltip";
|
||||
import { cn } from "@/lib/utils";
|
||||
import { parseError } from "@/utils/errorParser";
|
||||
import { getSDKTheme } from "@/utils/sdk-component-theme";
|
||||
import { tryCatch } from "@/utils/try-catch";
|
||||
import { getSDKTheme } from "../../../../../../../../../../@/utils/sdk-component-theme";
|
||||
import { PublicPageConnectButton } from "../../../_components/PublicPageConnectButton";
|
||||
import { SupplyClaimedProgress } from "../../../_components/supply-claimed-progress";
|
||||
import { TokenPrice } from "../../../_components/token-price";
|
||||
@@ -325,13 +325,13 @@ export function TokenDropClaim(props: {
|
||||
<div className="h-4" />
|
||||
|
||||
<SupplyClaimedProgress
|
||||
claimedSupply={BigInt(
|
||||
claimedSupplyTokens={Number(
|
||||
toTokens(props.claimCondition.supplyClaimed, props.decimals),
|
||||
)}
|
||||
totalSupply={
|
||||
totalSupplyTokens={
|
||||
props.claimCondition.maxClaimableSupply === maxUint256
|
||||
? "unlimited"
|
||||
: BigInt(
|
||||
: Number(
|
||||
toTokens(
|
||||
props.claimCondition.maxClaimableSupply,
|
||||
props.decimals,
|
||||
|
||||
+33
-33
@@ -29,38 +29,38 @@ export function ContractAnalyticsOverview(props: {
|
||||
});
|
||||
|
||||
return (
|
||||
<div>
|
||||
<div className="flex flex-col">
|
||||
<h2 className="font-semibold text-xl tracking-tight">Analytics</h2>
|
||||
<p className="mb-3 text-muted-foreground text-sm">
|
||||
View trends in unique wallets, transactions, and events over time for
|
||||
this contract
|
||||
</p>
|
||||
</div>
|
||||
<ThirdwebAreaChart
|
||||
cardContentClassName="p-0"
|
||||
chartClassName="aspect-[1.2] lg:aspect-[2.6]"
|
||||
className="border-none bg-background p-0"
|
||||
config={{
|
||||
events: {
|
||||
color: "hsl(var(--chart-3))",
|
||||
label: "Events",
|
||||
},
|
||||
transactions: {
|
||||
color: "hsl(var(--chart-2))",
|
||||
label: "Transactions",
|
||||
},
|
||||
wallets: {
|
||||
color: "hsl(var(--chart-1))",
|
||||
label: "Unique Wallets",
|
||||
},
|
||||
}}
|
||||
data={data || []}
|
||||
hideLabel={false}
|
||||
isPending={isPending}
|
||||
showLegend
|
||||
toolTipLabelFormatter={toolTipLabelFormatterWithPrecision(precision)}
|
||||
/>
|
||||
</div>
|
||||
<ThirdwebAreaChart
|
||||
chartClassName="aspect-[1.2] lg:aspect-[3]"
|
||||
cardContentClassName="px-0 pb-0"
|
||||
margin={{
|
||||
bottom: 20,
|
||||
}}
|
||||
header={{
|
||||
headerClassName: "p-4 lg:p-6",
|
||||
title: "Analytics",
|
||||
titleClassName: "font-semibold text-2xl mb-0.5 tracking-tight",
|
||||
description:
|
||||
"View trends of transactions, events and unique wallets interacting with this contract over time",
|
||||
}}
|
||||
config={{
|
||||
events: {
|
||||
color: "hsl(var(--chart-5))",
|
||||
label: "Events",
|
||||
},
|
||||
transactions: {
|
||||
color: "hsl(var(--chart-8))",
|
||||
label: "Transactions",
|
||||
},
|
||||
wallets: {
|
||||
color: "hsl(var(--chart-1))",
|
||||
label: "Unique Wallets",
|
||||
},
|
||||
}}
|
||||
data={data || []}
|
||||
hideLabel={false}
|
||||
isPending={isPending}
|
||||
showLegend
|
||||
toolTipLabelFormatter={toolTipLabelFormatterWithPrecision(precision)}
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
||||
-56
@@ -1,56 +0,0 @@
|
||||
import { useQuery } from "@tanstack/react-query";
|
||||
import { isProd } from "@/constants/env-utils";
|
||||
import { NEXT_PUBLIC_DASHBOARD_CLIENT_ID } from "@/constants/public-envs";
|
||||
|
||||
type TokenPriceData = {
|
||||
price_usd: number;
|
||||
price_usd_cents: number;
|
||||
percent_change_24h: number;
|
||||
market_cap_usd: number;
|
||||
volume_24h_usd: number;
|
||||
volume_change_24h: number;
|
||||
holders: number;
|
||||
historical_prices: Array<{
|
||||
date: string;
|
||||
price_usd: number;
|
||||
price_usd_cents: number;
|
||||
}>;
|
||||
};
|
||||
|
||||
export function useTokenPriceData(params: {
|
||||
chainId: number;
|
||||
contractAddress: string;
|
||||
}) {
|
||||
return useQuery({
|
||||
queryFn: async () => {
|
||||
const url = new URL(
|
||||
`https://insight.${isProd ? "thirdweb" : "thirdweb-dev"}.com/v1/tokens/price`,
|
||||
);
|
||||
|
||||
url.searchParams.set("include_historical_prices", "true");
|
||||
url.searchParams.set("chain_id", params.chainId.toString());
|
||||
url.searchParams.set("address", params.contractAddress);
|
||||
url.searchParams.set("include_holders", "true");
|
||||
url.searchParams.set("clientId", NEXT_PUBLIC_DASHBOARD_CLIENT_ID);
|
||||
|
||||
const res = await fetch(url);
|
||||
if (!res.ok) {
|
||||
throw new Error(await res.text());
|
||||
}
|
||||
|
||||
const json = await res.json();
|
||||
const priceData = json.data[0] as TokenPriceData | undefined;
|
||||
return priceData
|
||||
? {
|
||||
data: priceData,
|
||||
type: "data-found" as const,
|
||||
}
|
||||
: { type: "no-data" as const };
|
||||
},
|
||||
queryKey: ["token-price-chart", params.chainId, params.contractAddress],
|
||||
refetchInterval: 5000,
|
||||
refetchOnWindowFocus: false,
|
||||
retry: false,
|
||||
retryOnMount: false,
|
||||
});
|
||||
}
|
||||
+3
-1
@@ -1,4 +1,6 @@
|
||||
import "server-only";
|
||||
import { isProd } from "@/constants/env-utils";
|
||||
import { DASHBOARD_THIRDWEB_SECRET_KEY } from "@/constants/server-envs";
|
||||
|
||||
export async function fetchTokenInfoFromBridge(params: {
|
||||
chainId: number;
|
||||
@@ -10,7 +12,7 @@ export async function fetchTokenInfoFromBridge(params: {
|
||||
`https://bridge.${isProd ? "thirdweb.com" : "thirdweb-dev.com"}/v1/tokens?chainId=${params.chainId}&tokenAddress=${params.tokenAddress}`,
|
||||
{
|
||||
headers: {
|
||||
"x-client-id": params.clientId,
|
||||
"x-secret-key": DASHBOARD_THIRDWEB_SECRET_KEY,
|
||||
},
|
||||
},
|
||||
);
|
||||
|
||||
+62
-44
@@ -2,9 +2,11 @@ import type { ThirdwebContract } from "thirdweb";
|
||||
import type { ChainMetadata } from "thirdweb/chains";
|
||||
import { getContractMetadata } from "thirdweb/extensions/common";
|
||||
import { decimals, getActiveClaimCondition } from "thirdweb/extensions/erc20";
|
||||
import { GridPattern } from "@/components/ui/background-patterns";
|
||||
import { resolveFunctionSelectors } from "@/lib/selectors";
|
||||
import { getContractCreator } from "../_components/getContractCreator";
|
||||
import { PageHeader } from "../_components/PageHeader";
|
||||
import { getTokenPriceData } from "./_apis/token-price-data";
|
||||
import { ContractHeaderUI } from "./_components/ContractHeader";
|
||||
import { TokenDropClaim } from "./_components/claim-tokens/claim-tokens-ui";
|
||||
import { ContractAnalyticsOverview } from "./_components/contract-analytics/contract-analytics";
|
||||
@@ -25,6 +27,7 @@ export async function ERC20PublicPage(props: {
|
||||
tokenDecimals,
|
||||
tokenInfo,
|
||||
functionSelectors,
|
||||
tokenPriceData,
|
||||
] = await Promise.all([
|
||||
getContractMetadata({
|
||||
contract: props.serverContract,
|
||||
@@ -39,6 +42,10 @@ export async function ERC20PublicPage(props: {
|
||||
tokenAddress: props.serverContract.address,
|
||||
}),
|
||||
resolveFunctionSelectors(props.serverContract),
|
||||
getTokenPriceData({
|
||||
chainId: props.serverContract.chain.id,
|
||||
contractAddress: props.serverContract.address,
|
||||
}),
|
||||
]);
|
||||
|
||||
if (!contractMetadata.image && tokenInfo) {
|
||||
@@ -78,62 +85,73 @@ export async function ERC20PublicPage(props: {
|
||||
|
||||
return (
|
||||
<div className="flex grow flex-col">
|
||||
<PageHeader />
|
||||
<div className="container flex max-w-8xl grow flex-col">
|
||||
<ContractHeaderUI
|
||||
chainMetadata={props.chainMetadata}
|
||||
clientContract={props.clientContract}
|
||||
contractCreator={contractCreator}
|
||||
image={contractMetadata.image}
|
||||
name={contractMetadata.name}
|
||||
socialUrls={
|
||||
typeof contractMetadata.social_urls === "object" &&
|
||||
contractMetadata.social_urls !== null
|
||||
? contractMetadata.social_urls
|
||||
: {}
|
||||
}
|
||||
symbol={contractMetadata.symbol}
|
||||
/>
|
||||
<PageHeader containerClassName="max-w-5xl" />
|
||||
|
||||
<div className="h-6" />
|
||||
<div className="border-b">
|
||||
<div className="container max-w-5xl">
|
||||
<ContractHeaderUI
|
||||
chainMetadata={props.chainMetadata}
|
||||
clientContract={props.clientContract}
|
||||
contractCreator={contractCreator}
|
||||
image={contractMetadata.image}
|
||||
name={contractMetadata.name}
|
||||
socialUrls={
|
||||
typeof contractMetadata.social_urls === "object" &&
|
||||
contractMetadata.social_urls !== null
|
||||
? contractMetadata.social_urls
|
||||
: {}
|
||||
}
|
||||
symbol={contractMetadata.symbol}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="flex flex-col gap-8 pb-20 xl:flex-row">
|
||||
<div className="flex grow flex-col gap-8 overflow-hidden">
|
||||
{activeClaimCondition ? (
|
||||
<div className="border-b border-dashed pb-6">
|
||||
<ContractAnalyticsOverview
|
||||
chainId={props.chainMetadata.chainId}
|
||||
chainSlug={props.chainMetadata.slug}
|
||||
contractAddress={props.clientContract.address}
|
||||
/>
|
||||
</div>
|
||||
) : (
|
||||
<div className="container flex max-w-5xl grow flex-col pt-8 pb-10">
|
||||
<div className="flex grow flex-col gap-8">
|
||||
<div className="sm:flex sm:justify-center w-full sm:border sm:border-dashed sm:bg-accent/20 sm:py-12 rounded-lg overflow-hidden relative">
|
||||
<GridPattern
|
||||
width={30}
|
||||
height={30}
|
||||
x={-1}
|
||||
y={-1}
|
||||
strokeDasharray={"4 2"}
|
||||
className="text-border dark:text-border/70"
|
||||
style={{
|
||||
maskImage:
|
||||
"linear-gradient(to bottom right,white,transparent,transparent)",
|
||||
}}
|
||||
/>
|
||||
<div className="sm:w-[420px] z-10">{buyEmbed}</div>
|
||||
</div>
|
||||
|
||||
{tokenPriceData ? (
|
||||
<>
|
||||
<TokenStats
|
||||
chainId={props.chainMetadata.chainId}
|
||||
contractAddress={props.clientContract.address}
|
||||
tokenPriceData={tokenPriceData}
|
||||
/>
|
||||
)}
|
||||
|
||||
<div className="xl:hidden">{buyEmbed}</div>
|
||||
|
||||
<RecentTransfers
|
||||
chainMetadata={props.chainMetadata}
|
||||
clientContract={props.clientContract}
|
||||
decimals={tokenDecimals}
|
||||
tokenSymbol={contractMetadata.symbol}
|
||||
/>
|
||||
|
||||
{!activeClaimCondition && (
|
||||
<ContractAnalyticsOverview
|
||||
chainId={props.chainMetadata.chainId}
|
||||
chainSlug={props.chainMetadata.slug}
|
||||
contractAddress={props.clientContract.address}
|
||||
/>
|
||||
)}
|
||||
</div>
|
||||
<div className="hidden xl:block xl:w-96">
|
||||
<div className="-mt-6 sticky top-0 pt-6">{buyEmbed}</div>
|
||||
</div>
|
||||
</>
|
||||
) : (
|
||||
<ContractAnalyticsOverview
|
||||
chainId={props.chainMetadata.chainId}
|
||||
chainSlug={props.chainMetadata.slug}
|
||||
contractAddress={props.clientContract.address}
|
||||
/>
|
||||
)}
|
||||
|
||||
<RecentTransfers
|
||||
chainMetadata={props.chainMetadata}
|
||||
clientContract={props.clientContract}
|
||||
decimals={tokenDecimals}
|
||||
tokenSymbol={contractMetadata.symbol}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
+22
-19
@@ -17,26 +17,29 @@ export function NFTPublicPageLayout(props: {
|
||||
return (
|
||||
<div className="flex grow flex-col">
|
||||
<PageHeader containerClassName="max-w-8xl" />
|
||||
<div className="border-b">
|
||||
<div className="container max-w-8xl">
|
||||
<ContractHeaderUI
|
||||
chainMetadata={props.chainMetadata}
|
||||
clientContract={props.clientContract}
|
||||
contractCreator={props.contractCreator}
|
||||
image={
|
||||
typeof props.contractMetadata.image === "string"
|
||||
? props.contractMetadata.image
|
||||
: undefined
|
||||
}
|
||||
name={props.contractMetadata.name}
|
||||
socialUrls={
|
||||
typeof props.contractMetadata.social_urls === "object" &&
|
||||
props.contractMetadata.social_urls !== null
|
||||
? props.contractMetadata.social_urls
|
||||
: {}
|
||||
}
|
||||
symbol={props.contractMetadata.symbol}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
<div className="container flex max-w-8xl grow flex-col">
|
||||
<ContractHeaderUI
|
||||
chainMetadata={props.chainMetadata}
|
||||
clientContract={props.clientContract}
|
||||
contractCreator={props.contractCreator}
|
||||
image={
|
||||
typeof props.contractMetadata.image === "string"
|
||||
? props.contractMetadata.image
|
||||
: undefined
|
||||
}
|
||||
imageClassName="rounded-lg"
|
||||
name={props.contractMetadata.name}
|
||||
socialUrls={
|
||||
typeof props.contractMetadata.social_urls === "object" &&
|
||||
props.contractMetadata.social_urls !== null
|
||||
? props.contractMetadata.social_urls
|
||||
: {}
|
||||
}
|
||||
symbol={props.contractMetadata.symbol}
|
||||
/>
|
||||
{props.children}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
+7
-2
@@ -10,6 +10,7 @@ import type { ChainMetadata } from "thirdweb/chains";
|
||||
import { getApprovalForTransaction } from "thirdweb/extensions/erc20";
|
||||
import { claimTo } from "thirdweb/extensions/erc1155";
|
||||
import { useActiveAccount, useSendAndConfirmTransaction } from "thirdweb/react";
|
||||
import { maxUint256 } from "thirdweb/utils";
|
||||
import * as z from "zod";
|
||||
import {
|
||||
reportAssetBuyFailed,
|
||||
@@ -225,8 +226,12 @@ export function BuyEditionDrop(props: BuyEditionDropProps) {
|
||||
|
||||
{claimCondition.data ? (
|
||||
<SupplyClaimedProgress
|
||||
claimedSupply={claimCondition.data.supplyClaimed}
|
||||
totalSupply={claimCondition.data.maxClaimableSupply}
|
||||
claimedSupplyTokens={Number(claimCondition.data.supplyClaimed)}
|
||||
totalSupplyTokens={
|
||||
claimCondition.data.maxClaimableSupply === maxUint256
|
||||
? "unlimited"
|
||||
: Number(claimCondition.data.maxClaimableSupply)
|
||||
}
|
||||
/>
|
||||
) : (
|
||||
<Skeleton className="h-[62px] w-full" />
|
||||
|
||||
+4
-2
@@ -231,8 +231,10 @@ export function BuyNFTDropUI(props: BuyNFTDropUIProps) {
|
||||
)}
|
||||
|
||||
<SupplyClaimedProgress
|
||||
claimedSupply={BigInt(props.totalNFTs) - props.totalUnclaimedSupply}
|
||||
totalSupply={BigInt(props.totalNFTs)}
|
||||
claimedSupplyTokens={Number(
|
||||
BigInt(props.totalNFTs) - props.totalUnclaimedSupply,
|
||||
)}
|
||||
totalSupplyTokens={Number(props.totalNFTs)}
|
||||
/>
|
||||
|
||||
<div className="flex flex-col gap-1.5 rounded-lg bg-muted/50 p-3">
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
import { ChevronDownIcon } from "lucide-react";
|
||||
import { headers } from "next/headers";
|
||||
import Link from "next/link";
|
||||
import { getAuthToken } from "@/api/auth-token";
|
||||
import {
|
||||
Breadcrumb,
|
||||
BreadcrumbItem,
|
||||
@@ -14,7 +15,6 @@ import {
|
||||
DropdownMenuItem,
|
||||
DropdownMenuTrigger,
|
||||
} from "@/components/ui/dropdown-menu";
|
||||
import { getAuthToken } from "../../../../../../@/api/auth-token";
|
||||
import {
|
||||
AllFilters,
|
||||
ChainOptionsFilter,
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
import type { Metadata } from "next";
|
||||
import { headers } from "next/headers";
|
||||
import { getAuthToken } from "../../../../../@/api/auth-token";
|
||||
import { getAuthToken } from "@/api/auth-token";
|
||||
import {
|
||||
AllFilters,
|
||||
ChainOptionsFilter,
|
||||
|
||||
@@ -3,8 +3,8 @@ import "server-only";
|
||||
import { DASHBOARD_THIRDWEB_SECRET_KEY } from "@/constants/server-envs";
|
||||
import { getConfiguredThirdwebClient } from "@/constants/thirdweb.server";
|
||||
import { cn } from "@/lib/utils";
|
||||
import { fallbackChainIcon } from "@/utils/chain-icons";
|
||||
import { resolveSchemeWithErrorHandler } from "@/utils/resolveSchemeWithErrorHandler";
|
||||
import { fallbackChainIcon } from "../../../../../../@/utils/chain-icons";
|
||||
|
||||
export async function ChainIcon(props: {
|
||||
iconUrl?: string;
|
||||
|
||||
@@ -2,9 +2,9 @@ import { ChakraProviderSetup } from "chakra/ChakraProviderSetup";
|
||||
import { revalidatePath } from "next/cache";
|
||||
import { notFound, redirect } from "next/navigation";
|
||||
import { fetchDeployMetadata } from "thirdweb/contract";
|
||||
import { getUserThirdwebClient } from "@/api/auth-token";
|
||||
import { ContractPublishForm } from "@/components/contract-components/contract-publish-form";
|
||||
import { getActiveAccountCookie, getJWTCookie } from "@/constants/cookie";
|
||||
import { getClientThirdwebClient } from "@/constants/thirdweb-client.client";
|
||||
import { serverThirdwebClient } from "@/constants/thirdweb-client.server";
|
||||
import { getLatestPublishedContractsWithPublisherMapping } from "../../../published-contract/[publisher]/[contract_id]/utils/getPublishedContractsWithPublisherMapping";
|
||||
|
||||
@@ -68,14 +68,15 @@ export default async function PublishContractPage(
|
||||
redirect(`/login?next=${encodeURIComponent(pathname)}`);
|
||||
}
|
||||
|
||||
const userThirdwebClient = await getUserThirdwebClient({
|
||||
teamId: undefined,
|
||||
});
|
||||
|
||||
return (
|
||||
<div className="container flex max-w-[1130px] flex-col gap-8 py-8">
|
||||
<ChakraProviderSetup>
|
||||
<ContractPublishForm
|
||||
client={getClientThirdwebClient({
|
||||
jwt: token,
|
||||
teamId: undefined,
|
||||
})}
|
||||
client={userThirdwebClient}
|
||||
isLoggedIn={!!token}
|
||||
onPublishSuccess={async () => {
|
||||
"use server";
|
||||
|
||||
@@ -90,144 +90,6 @@ const MARKETS = {
|
||||
name: "Marketplace",
|
||||
} satisfies ExploreCategory;
|
||||
|
||||
const MODULAR_CONTRACTS = {
|
||||
contracts: [
|
||||
// erc721 drop
|
||||
[
|
||||
"thirdweb.eth/ERC721CoreInitializable",
|
||||
[
|
||||
"deployer.thirdweb.eth/ClaimableERC721",
|
||||
"deployer.thirdweb.eth/BatchMetadataERC721",
|
||||
"deployer.thirdweb.eth/RoyaltyERC721",
|
||||
],
|
||||
{
|
||||
description: "ERC721 NFTs that anyone can mint.",
|
||||
title: "Modular NFT Drop",
|
||||
},
|
||||
],
|
||||
// erc721 token
|
||||
[
|
||||
"thirdweb.eth/ERC721CoreInitializable",
|
||||
[
|
||||
"deployer.thirdweb.eth/MintableERC721",
|
||||
"deployer.thirdweb.eth/BatchMetadataERC721",
|
||||
"deployer.thirdweb.eth/TransferableERC721",
|
||||
],
|
||||
{
|
||||
description: "ERC721 NFTs that only owners can mint.",
|
||||
title: "Modular NFT Collection",
|
||||
},
|
||||
],
|
||||
// open edition 721
|
||||
[
|
||||
"thirdweb.eth/ERC721CoreInitializable",
|
||||
[
|
||||
"deployer.thirdweb.eth/ClaimableERC721",
|
||||
"deployer.thirdweb.eth/OpenEditionMetadataERC721",
|
||||
"deployer.thirdweb.eth/RoyaltyERC721",
|
||||
],
|
||||
{
|
||||
description: "ERC721 NFTs with identical metadata.",
|
||||
title: "Modular Open Edition",
|
||||
},
|
||||
],
|
||||
// erc1155 drop
|
||||
[
|
||||
"thirdweb.eth/ERC1155CoreInitializable",
|
||||
[
|
||||
"deployer.thirdweb.eth/ClaimableERC1155",
|
||||
"deployer.thirdweb.eth/BatchMetadataERC1155",
|
||||
"deployer.thirdweb.eth/RoyaltyERC1155",
|
||||
"deployer.thirdweb.eth/SequentialTokenIdERC1155",
|
||||
],
|
||||
{
|
||||
description: "ERC1155 NFTs that others can mint.",
|
||||
title: "Modular Edition Drop",
|
||||
},
|
||||
],
|
||||
// erc1155 token
|
||||
[
|
||||
"thirdweb.eth/ERC1155CoreInitializable",
|
||||
[
|
||||
"deployer.thirdweb.eth/MintableERC1155",
|
||||
"deployer.thirdweb.eth/BatchMetadataERC1155",
|
||||
"deployer.thirdweb.eth/TransferableERC1155",
|
||||
"deployer.thirdweb.eth/SequentialTokenIdERC1155",
|
||||
],
|
||||
{
|
||||
description: "ERC1155 NFTs that only owners can mint.",
|
||||
title: "Modular Edition",
|
||||
},
|
||||
],
|
||||
// erc20 drop
|
||||
[
|
||||
"thirdweb.eth/ERC20CoreInitializable",
|
||||
[
|
||||
"deployer.thirdweb.eth/ClaimableERC20",
|
||||
"deployer.thirdweb.eth/TransferableERC20",
|
||||
],
|
||||
{
|
||||
description: "ERC20 Tokens that others can mint.",
|
||||
title: "Modular Token Drop",
|
||||
},
|
||||
],
|
||||
// erc20 token
|
||||
[
|
||||
"thirdweb.eth/ERC20CoreInitializable",
|
||||
[
|
||||
"deployer.thirdweb.eth/MintableERC20",
|
||||
"deployer.thirdweb.eth/TransferableERC20",
|
||||
],
|
||||
{
|
||||
description: "ERC20 Tokens that only owners can mint.",
|
||||
title: "Modular Token",
|
||||
},
|
||||
],
|
||||
],
|
||||
description:
|
||||
"Collection of highly customizable and upgradeable smart contracts built with the modular contracts framework.",
|
||||
displayName: "Modular Contracts",
|
||||
id: "modular-contracts",
|
||||
name: "modular",
|
||||
} satisfies ExploreCategory;
|
||||
|
||||
const SUPERCHAIN = {
|
||||
contracts: [
|
||||
// erc20 token + superchain
|
||||
[
|
||||
"thirdweb.eth/ERC20CoreInitializable",
|
||||
[
|
||||
"deployer.thirdweb.eth/MintableERC20",
|
||||
"deployer.thirdweb.eth/TransferableERC20",
|
||||
"deployer.thirdweb.eth/SuperChainInterop",
|
||||
],
|
||||
{
|
||||
description: "ERC20 Tokens that only owners can mint.",
|
||||
title: "Modular Superchain Token",
|
||||
},
|
||||
],
|
||||
// erc20 drop + superchain
|
||||
[
|
||||
"thirdweb.eth/ERC20CoreInitializable",
|
||||
[
|
||||
"deployer.thirdweb.eth/ClaimableERC20",
|
||||
"deployer.thirdweb.eth/TransferableERC20",
|
||||
"deployer.thirdweb.eth/SuperChainInterop",
|
||||
],
|
||||
{
|
||||
description: "ERC20 Tokens that others can mint.",
|
||||
title: "Modular Superchain Token Drop",
|
||||
},
|
||||
],
|
||||
],
|
||||
description: "Modular contracts with OP Superchain support",
|
||||
displayName: "Modular Superchain Contracts",
|
||||
id: "modular-superchain-contracts",
|
||||
isBeta: true,
|
||||
name: "Modular Superchain Contracts",
|
||||
showInExplore: true,
|
||||
} satisfies ExploreCategory;
|
||||
|
||||
const AIRDROP = {
|
||||
contracts: ["thirdweb.eth/Airdrop"],
|
||||
description:
|
||||
@@ -313,12 +175,26 @@ const SMART_WALLET = {
|
||||
showInExplore: true,
|
||||
} satisfies ExploreCategory;
|
||||
|
||||
const STYLUS = {
|
||||
contracts: [
|
||||
"0x6453a486d52e0EB6E79Ec4491038E2522a926936/StylusAirdropERC1155",
|
||||
"0x6453a486d52e0EB6E79Ec4491038E2522a926936/StylusAirdropERC721",
|
||||
"0x6453a486d52e0EB6E79Ec4491038E2522a926936/StylusAirdropERC20",
|
||||
],
|
||||
description:
|
||||
"Airdrop your NFTs or tokens to a large number of recipients. Built with Arbitrum Stylus.",
|
||||
displayName: "Arbitrum Stylus Contracts",
|
||||
id: "stylus",
|
||||
name: "Stylus",
|
||||
showInExplore: true,
|
||||
isBeta: true,
|
||||
} satisfies ExploreCategory;
|
||||
|
||||
const CATEGORIES: Record<string, ExploreCategory> = {
|
||||
[POPULAR.id]: POPULAR,
|
||||
[MODULAR_CONTRACTS.id]: MODULAR_CONTRACTS,
|
||||
[SUPERCHAIN.id]: SUPERCHAIN,
|
||||
[NFTS.id]: NFTS,
|
||||
[MARKETS.id]: MARKETS,
|
||||
[STYLUS.id]: STYLUS,
|
||||
[DROPS.id]: DROPS,
|
||||
[SMART_WALLET.id]: SMART_WALLET,
|
||||
[AIRDROP.id]: AIRDROP,
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
import { AppFooter } from "@/components/footers/app-footer";
|
||||
import { ErrorProvider } from "../../../@/contexts/error-handler";
|
||||
import { ErrorProvider } from "@/contexts/error-handler";
|
||||
|
||||
export default function DashboardLayout(props: { children: React.ReactNode }) {
|
||||
return (
|
||||
|
||||
+1
-1
@@ -1,10 +1,10 @@
|
||||
import { ChakraProviderSetup } from "chakra/ChakraProviderSetup";
|
||||
import { notFound } from "next/navigation";
|
||||
import { getAuthToken } from "@/api/auth-token";
|
||||
import { PublishedContract } from "@/components/contracts/published-contract";
|
||||
import { Separator } from "@/components/ui/separator";
|
||||
import { getClientThirdwebClient } from "@/constants/thirdweb-client.client";
|
||||
import { serverThirdwebClient } from "@/constants/thirdweb-client.server";
|
||||
import { getAuthToken } from "../../../../../../@/api/auth-token";
|
||||
import { getRawAccount } from "../../../../account/settings/getAccount";
|
||||
import { PublishedActions } from "../../components/contract-actions-published.client";
|
||||
import { DeployContractHeader } from "../../components/contract-header";
|
||||
|
||||
+1
-1
@@ -4,7 +4,7 @@ import { getUserThirdwebClient } from "@/api/auth-token";
|
||||
import { getProjects } from "@/api/projects";
|
||||
import { getTeams } from "@/api/team";
|
||||
import { CustomContractForm } from "@/components/contract-components/contract-deploy-form/custom-contract";
|
||||
import { loginRedirect } from "../../../login/loginRedirect";
|
||||
import { loginRedirect } from "@/utils/redirects";
|
||||
|
||||
type DeployFormForUriProps = {
|
||||
contractMetadata: FetchDeployMetadataResult | null;
|
||||
|
||||
-67
@@ -1,67 +0,0 @@
|
||||
import { type ReactElement, useState } from "react";
|
||||
import { AttachmentForm } from "../shared/SupportForm_AttachmentUploader";
|
||||
import { DescriptionInput } from "../shared/SupportForm_DescriptionInput";
|
||||
import { SupportForm_SelectInput } from "../shared/SupportForm_SelectInput";
|
||||
|
||||
type ProblemAreaItem = {
|
||||
label: string;
|
||||
component: ReactElement;
|
||||
};
|
||||
|
||||
const ACCOUNT_PROBLEM_AREAS: ProblemAreaItem[] = [
|
||||
{
|
||||
component: (
|
||||
<>
|
||||
<DescriptionInput />
|
||||
<AttachmentForm />
|
||||
</>
|
||||
),
|
||||
label: "Pricing inquiry",
|
||||
},
|
||||
{
|
||||
component: (
|
||||
<>
|
||||
<DescriptionInput />
|
||||
<AttachmentForm />
|
||||
</>
|
||||
),
|
||||
label: "Billing inquiry",
|
||||
},
|
||||
{
|
||||
component: (
|
||||
<>
|
||||
<DescriptionInput />
|
||||
<AttachmentForm />
|
||||
</>
|
||||
),
|
||||
label: "Usage inquiry",
|
||||
},
|
||||
{
|
||||
component: (
|
||||
<>
|
||||
<DescriptionInput />
|
||||
<AttachmentForm />
|
||||
</>
|
||||
),
|
||||
label: "Other",
|
||||
},
|
||||
];
|
||||
|
||||
export default function AccountSupportForm() {
|
||||
const [problemArea, setProblemArea] = useState<string>("");
|
||||
|
||||
return (
|
||||
<>
|
||||
<SupportForm_SelectInput
|
||||
formLabel="Problem area"
|
||||
name="extraInfo_Problem_Area"
|
||||
onValueChange={setProblemArea}
|
||||
options={ACCOUNT_PROBLEM_AREAS.map((o) => o.label)}
|
||||
promptText="Select a problem area"
|
||||
required={true}
|
||||
value={problemArea}
|
||||
/>
|
||||
{ACCOUNT_PROBLEM_AREAS.find((o) => o.label === problemArea)?.component}
|
||||
</>
|
||||
);
|
||||
}
|
||||
-66
@@ -1,66 +0,0 @@
|
||||
import { type ReactElement, useState } from "react";
|
||||
import { AttachmentForm } from "../shared/SupportForm_AttachmentUploader";
|
||||
import { DescriptionInput } from "../shared/SupportForm_DescriptionInput";
|
||||
import { SupportForm_SelectInput } from "../shared/SupportForm_SelectInput";
|
||||
|
||||
type ProblemAreaItem = {
|
||||
label: string;
|
||||
component: ReactElement;
|
||||
};
|
||||
|
||||
const OTHER_PROBLEM_AREAS: ProblemAreaItem[] = [
|
||||
{
|
||||
component: (
|
||||
<>
|
||||
<DescriptionInput />
|
||||
<AttachmentForm />
|
||||
</>
|
||||
),
|
||||
label: "General inquiry",
|
||||
},
|
||||
{
|
||||
component: (
|
||||
<>
|
||||
<DescriptionInput />
|
||||
<AttachmentForm />
|
||||
</>
|
||||
),
|
||||
label: "Security",
|
||||
},
|
||||
{
|
||||
component: (
|
||||
<>
|
||||
<DescriptionInput />
|
||||
<AttachmentForm />
|
||||
</>
|
||||
),
|
||||
label: "Feedback",
|
||||
},
|
||||
{
|
||||
component: (
|
||||
<>
|
||||
<DescriptionInput />
|
||||
<AttachmentForm />
|
||||
</>
|
||||
),
|
||||
label: "Other",
|
||||
},
|
||||
];
|
||||
|
||||
export default function OtherSupportForm() {
|
||||
const [problemArea, setProblemArea] = useState<string>("");
|
||||
return (
|
||||
<>
|
||||
<SupportForm_SelectInput
|
||||
formLabel="Problem area"
|
||||
name="extraInfo_Problem_Area"
|
||||
onValueChange={setProblemArea}
|
||||
options={OTHER_PROBLEM_AREAS.map((o) => o.label)}
|
||||
promptText="Select a problem area"
|
||||
required={true}
|
||||
value={problemArea}
|
||||
/>
|
||||
{OTHER_PROBLEM_AREAS.find((o) => o.label === problemArea)?.component}
|
||||
</>
|
||||
);
|
||||
}
|
||||
-17
@@ -1,17 +0,0 @@
|
||||
import { Label } from "@/components/ui/label";
|
||||
|
||||
export const AttachmentForm = () => {
|
||||
return (
|
||||
<div className="flex flex-col items-start gap-2">
|
||||
<Label className="relative" htmlFor="files">
|
||||
Attachments
|
||||
</Label>
|
||||
<input
|
||||
accept="image/*,video/*,"
|
||||
multiple
|
||||
name="attachments"
|
||||
type="file"
|
||||
/>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
-58
@@ -1,58 +0,0 @@
|
||||
import { useId } from "react";
|
||||
import { Label } from "@/components/ui/label";
|
||||
import {
|
||||
Select,
|
||||
SelectContent,
|
||||
SelectItem,
|
||||
SelectTrigger,
|
||||
SelectValue,
|
||||
} from "@/components/ui/select";
|
||||
|
||||
type MinimalTeam = {
|
||||
name: string;
|
||||
id: string;
|
||||
};
|
||||
|
||||
type Props = {
|
||||
teams: MinimalTeam[];
|
||||
selectedTeamId: string | undefined;
|
||||
onChange: (teamId: string) => void;
|
||||
};
|
||||
|
||||
export const SupportForm_TeamSelection = (props: Props) => {
|
||||
const selectedTeamName = props.teams.find(
|
||||
(t) => t.id === props.selectedTeamId,
|
||||
)?.name;
|
||||
|
||||
const teamId = useId();
|
||||
|
||||
return (
|
||||
<div className="flex flex-col items-start gap-2">
|
||||
<Label className="relative" htmlFor={teamId}>
|
||||
Select Team
|
||||
<span className="-top-1.5 -right-2 absolute text-destructive">•</span>
|
||||
</Label>
|
||||
|
||||
<Select
|
||||
name="teamId"
|
||||
onValueChange={(selectedId) => {
|
||||
props.onChange(selectedId);
|
||||
}}
|
||||
value={props.selectedTeamId}
|
||||
>
|
||||
<SelectTrigger id={teamId}>
|
||||
<SelectValue placeholder="Select a Team">
|
||||
{selectedTeamName}
|
||||
</SelectValue>
|
||||
</SelectTrigger>
|
||||
<SelectContent>
|
||||
{props.teams.map((team) => (
|
||||
<SelectItem key={team.id} value={team.id}>
|
||||
{team.name}
|
||||
</SelectItem>
|
||||
))}
|
||||
</SelectContent>
|
||||
</Select>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
-27
@@ -1,27 +0,0 @@
|
||||
import { Label } from "@/components/ui/label";
|
||||
|
||||
type Props = {
|
||||
placeholder?: string;
|
||||
};
|
||||
|
||||
const defaultDescription = "@YourHandle";
|
||||
|
||||
export const SupportForm_TelegramInput = (props: Props) => {
|
||||
return (
|
||||
<div className="flex flex-col items-start gap-2 bg-[#0a0a0a]">
|
||||
<Label className="relative" htmlFor="telegram">
|
||||
Telegram
|
||||
<span className="-top-1.5 -right-2 absolute text-destructive">•</span>
|
||||
</Label>
|
||||
|
||||
<input
|
||||
autoComplete="off"
|
||||
className="flex h-10 w-full items-center justify-between gap-2 rounded-md border border-input bg-background px-3 py-2 text-sm ring-offset-background placeholder:text-muted-foreground focus:outline-none focus:ring-2 focus:ring-ring focus:ring-offset-2 disabled:cursor-not-allowed disabled:opacity-50 [&>span]:line-clamp-1"
|
||||
maxLength={50}
|
||||
name="telegram"
|
||||
placeholder={props.placeholder ?? defaultDescription}
|
||||
type="text"
|
||||
/>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
-208
@@ -1,208 +0,0 @@
|
||||
"use server";
|
||||
import "server-only";
|
||||
|
||||
import { getTeamById } from "@/api/team";
|
||||
import { getAuthTokenWalletAddress } from "../../../../../../@/api/auth-token";
|
||||
import { getRawAccount } from "../../../../account/settings/getAccount";
|
||||
import { loginRedirect } from "../../../../login/loginRedirect";
|
||||
|
||||
type State = {
|
||||
success: boolean;
|
||||
message: string;
|
||||
};
|
||||
|
||||
const UNTHREAD_API_KEY = process.env.UNTHREAD_API_KEY || "";
|
||||
|
||||
const planToCustomerId = {
|
||||
accelerate: process.env.UNTHREAD_ACCELERATE_TIER_ID as string,
|
||||
free: process.env.UNTHREAD_FREE_TIER_ID as string,
|
||||
growth: process.env.UNTHREAD_GROWTH_TIER_ID as string,
|
||||
pro: process.env.UNTHREAD_PRO_TIER_ID as string,
|
||||
scale: process.env.UNTHREAD_SCALE_TIER_ID as string,
|
||||
// treat starter as free
|
||||
starter: process.env.UNTHREAD_FREE_TIER_ID as string,
|
||||
} as const;
|
||||
|
||||
function isValidPlan(plan: string): plan is keyof typeof planToCustomerId {
|
||||
return plan in planToCustomerId;
|
||||
}
|
||||
|
||||
function prepareEmailTitle(
|
||||
product: string,
|
||||
problemArea: string,
|
||||
email: string,
|
||||
name: string,
|
||||
) {
|
||||
const title =
|
||||
product && problemArea
|
||||
? `${product}: ${problemArea} (${email})`
|
||||
: `New ticket from ${name} (${email})`;
|
||||
return title;
|
||||
}
|
||||
|
||||
function prepareEmailBody(props: {
|
||||
product: string;
|
||||
markdownInput: string;
|
||||
email: string;
|
||||
name: string;
|
||||
extraInfoInput: Record<string, string>;
|
||||
walletAddress: string;
|
||||
telegramHandle: string;
|
||||
}) {
|
||||
const {
|
||||
extraInfoInput,
|
||||
email,
|
||||
walletAddress,
|
||||
product,
|
||||
name,
|
||||
markdownInput,
|
||||
telegramHandle,
|
||||
} = props;
|
||||
// Update `markdown` to include the infos from the form
|
||||
const extraInfo = Object.keys(extraInfoInput)
|
||||
.filter((key) => key.startsWith("extraInfo_"))
|
||||
.map((key) => {
|
||||
const prettifiedKey = `# ${key
|
||||
.replace("extraInfo_", "")
|
||||
.replaceAll("_", " ")}`;
|
||||
return `${prettifiedKey}: ${extraInfoInput[key] ?? "N/A"}\n`;
|
||||
})
|
||||
.join("");
|
||||
const markdown = `# Email: ${email}
|
||||
# Name: ${name}
|
||||
# Telegram: ${telegramHandle}
|
||||
# Wallet address: ${walletAddress}
|
||||
# Product: ${product}
|
||||
${extraInfo}
|
||||
# Message:
|
||||
${markdownInput}
|
||||
`;
|
||||
return markdown;
|
||||
}
|
||||
|
||||
export async function createTicketAction(
|
||||
_previousState: State,
|
||||
formData: FormData,
|
||||
) {
|
||||
const teamId = formData.get("teamId")?.toString();
|
||||
|
||||
if (!teamId) {
|
||||
return {
|
||||
message: "teamId is required",
|
||||
success: false,
|
||||
};
|
||||
}
|
||||
|
||||
const team = await getTeamById(teamId);
|
||||
|
||||
if (!team) {
|
||||
return {
|
||||
message: `Team with id "${teamId}" not found`,
|
||||
success: false,
|
||||
};
|
||||
}
|
||||
|
||||
const [walletAddress, account] = await Promise.all([
|
||||
getAuthTokenWalletAddress(),
|
||||
getRawAccount(),
|
||||
]);
|
||||
|
||||
if (!walletAddress || !account) {
|
||||
loginRedirect("/support");
|
||||
}
|
||||
|
||||
const customerId = isValidPlan(team.supportPlan)
|
||||
? // fall back to "free" tier
|
||||
planToCustomerId[team.supportPlan] || planToCustomerId.free
|
||||
: // fallback to "free" tier
|
||||
planToCustomerId.free;
|
||||
|
||||
const product = formData.get("product")?.toString() || "";
|
||||
const problemArea = formData.get("extraInfo_Problem_Area")?.toString() || "";
|
||||
const telegramHandle = formData.get("telegram")?.toString() || "";
|
||||
|
||||
const title = prepareEmailTitle(
|
||||
product,
|
||||
problemArea,
|
||||
account.email || "",
|
||||
account.name || "",
|
||||
);
|
||||
|
||||
const keyVal: Record<string, string> = {};
|
||||
for (const key of formData.keys()) {
|
||||
keyVal[key] = formData.get(key)?.toString() || "";
|
||||
}
|
||||
|
||||
const markdown = prepareEmailBody({
|
||||
email: account.email || "",
|
||||
extraInfoInput: keyVal,
|
||||
markdownInput: keyVal.markdown || "",
|
||||
name: account.name || "",
|
||||
product,
|
||||
telegramHandle: telegramHandle,
|
||||
walletAddress: walletAddress,
|
||||
});
|
||||
|
||||
const content = {
|
||||
customerId,
|
||||
emailInboxId: process.env.UNTHREAD_EMAIL_INBOX_ID,
|
||||
markdown,
|
||||
onBehalfOf: {
|
||||
email: account.email,
|
||||
id: account.id,
|
||||
name: account.name,
|
||||
},
|
||||
status: "open",
|
||||
title,
|
||||
triageChannelId: process.env.UNTHREAD_TRIAGE_CHANNEL_ID,
|
||||
type: "email",
|
||||
};
|
||||
|
||||
// check files
|
||||
const files = formData.getAll("files") as File[];
|
||||
|
||||
if (files.length > 10) {
|
||||
return { message: "You can only attach 10 files at once.", success: false };
|
||||
}
|
||||
if (files.some((file) => file.size > 10 * 1024 * 1024)) {
|
||||
return { message: "The max file size is 20MB.", success: false };
|
||||
}
|
||||
|
||||
// add the content
|
||||
formData.append("json", JSON.stringify(content));
|
||||
|
||||
const KEEP_FIELDS = ["attachments", "json"];
|
||||
const keys = [...formData.keys()];
|
||||
// delete everything except attachments off of the form data
|
||||
for (const key of keys) {
|
||||
if (!KEEP_FIELDS.includes(key)) {
|
||||
formData.delete(key);
|
||||
}
|
||||
}
|
||||
|
||||
// actually create the ticket
|
||||
const res = await fetch("https://api.unthread.io/api/conversations", {
|
||||
body: formData,
|
||||
headers: {
|
||||
"X-Api-Key": UNTHREAD_API_KEY,
|
||||
},
|
||||
method: "POST",
|
||||
});
|
||||
if (!res.ok) {
|
||||
console.error(
|
||||
"Failed to create ticket",
|
||||
res.status,
|
||||
res.statusText,
|
||||
await res.text(),
|
||||
);
|
||||
return {
|
||||
message: "Failed to create ticket, please try again later.",
|
||||
success: false,
|
||||
};
|
||||
}
|
||||
|
||||
return {
|
||||
message: "Ticket created successfully",
|
||||
success: true,
|
||||
};
|
||||
}
|
||||
-183
@@ -1,183 +0,0 @@
|
||||
"use client";
|
||||
|
||||
import { SupportForm_SelectInput } from "@app/(dashboard)/support/create-ticket/components/contact-forms/shared/SupportForm_SelectInput";
|
||||
import { SupportForm_TeamSelection } from "@app/(dashboard)/support/create-ticket/components/contact-forms/shared/SupportForm_TeamSelection";
|
||||
import { SupportForm_TelegramInput } from "@app/(dashboard)/support/create-ticket/components/contact-forms/shared/SupportForm_TelegramInput";
|
||||
import dynamic from "next/dynamic";
|
||||
import {
|
||||
type ReactElement,
|
||||
useActionState,
|
||||
useEffect,
|
||||
useRef,
|
||||
useState,
|
||||
} from "react";
|
||||
import { useFormStatus } from "react-dom";
|
||||
import { toast } from "sonner";
|
||||
import { Button } from "@/components/ui/button";
|
||||
import { Spinner } from "@/components/ui/Spinner/Spinner";
|
||||
import { Skeleton } from "@/components/ui/skeleton";
|
||||
import { cn } from "@/lib/utils";
|
||||
import { createTicketAction } from "./create-ticket.action";
|
||||
|
||||
const ConnectSupportForm = dynamic(() => import("./contact-forms/connect"), {
|
||||
loading: () => <Skeleton className="h-12" />,
|
||||
ssr: false,
|
||||
});
|
||||
const EngineSupportForm = dynamic(() => import("./contact-forms/engine"), {
|
||||
loading: () => <Skeleton className="h-12" />,
|
||||
ssr: false,
|
||||
});
|
||||
const ContractSupportForm = dynamic(() => import("./contact-forms/contracts"), {
|
||||
loading: () => <Skeleton className="h-12" />,
|
||||
ssr: false,
|
||||
});
|
||||
const AccountSupportForm = dynamic(() => import("./contact-forms/account"), {
|
||||
loading: () => <Skeleton className="h-12" />,
|
||||
ssr: false,
|
||||
});
|
||||
const OtherSupportForm = dynamic(() => import("./contact-forms/other"), {
|
||||
loading: () => <Skeleton className="h-12" />,
|
||||
ssr: false,
|
||||
});
|
||||
|
||||
const productOptions: { label: string; component: ReactElement }[] = [
|
||||
{
|
||||
component: <ConnectSupportForm />,
|
||||
label: "Connect",
|
||||
},
|
||||
{
|
||||
component: <EngineSupportForm />,
|
||||
label: "Engine",
|
||||
},
|
||||
{
|
||||
component: <ContractSupportForm />,
|
||||
label: "Contracts",
|
||||
},
|
||||
{
|
||||
component: <AccountSupportForm />,
|
||||
label: "Account",
|
||||
},
|
||||
{
|
||||
component: <OtherSupportForm />,
|
||||
label: "Other",
|
||||
},
|
||||
];
|
||||
|
||||
function ProductAreaSelection(props: {
|
||||
productLabel: string;
|
||||
setProductLabel: (val: string) => void;
|
||||
}) {
|
||||
const { productLabel, setProductLabel } = props;
|
||||
|
||||
return (
|
||||
<div className="flex flex-col gap-6">
|
||||
<SupportForm_SelectInput
|
||||
formLabel="What do you need help with?"
|
||||
name="product"
|
||||
onValueChange={setProductLabel}
|
||||
options={productOptions.map((o) => o.label)}
|
||||
promptText="Select a product"
|
||||
required={true}
|
||||
value={productLabel}
|
||||
/>
|
||||
{productOptions.find((o) => o.label === productLabel)?.component}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
export function CreateTicket(props: {
|
||||
teams: {
|
||||
name: string;
|
||||
id: string;
|
||||
}[];
|
||||
}) {
|
||||
const formRef = useRef<HTMLFormElement>(null);
|
||||
const [selectedTeamId, setSelectedTeamId] = useState<string | undefined>(
|
||||
props.teams[0]?.id,
|
||||
);
|
||||
|
||||
const [productLabel, setProductLabel] = useState("");
|
||||
|
||||
const [state, formAction] = useActionState(createTicketAction, {
|
||||
message: "",
|
||||
success: false,
|
||||
});
|
||||
|
||||
// needed here
|
||||
// eslint-disable-next-line no-restricted-syntax
|
||||
useEffect(() => {
|
||||
if (!state.message) {
|
||||
return;
|
||||
}
|
||||
if (state.success) {
|
||||
toast.success(state.message);
|
||||
} else {
|
||||
toast.error(state.message);
|
||||
}
|
||||
}, [state.success, state.message]);
|
||||
|
||||
return (
|
||||
<form
|
||||
action={formAction}
|
||||
className="rounded-lg border bg-card"
|
||||
ref={formRef}
|
||||
>
|
||||
<div className="px-4 py-6 lg:px-6">
|
||||
<h2 className="font-semibold text-2xl tracking-tight">Get Support</h2>
|
||||
<p className="text-muted-foreground">
|
||||
We are here to help. Ask product questions, report problems, or leave
|
||||
feedback.
|
||||
</p>
|
||||
|
||||
<div className="h-7" />
|
||||
|
||||
<div className="flex flex-col gap-6">
|
||||
{/* Don't conditionally render this - it has be rendered to submit the input values */}
|
||||
<div className={cn(props.teams.length === 1 && "hidden")}>
|
||||
<SupportForm_TeamSelection
|
||||
onChange={(teamId) => setSelectedTeamId(teamId)}
|
||||
selectedTeamId={selectedTeamId}
|
||||
teams={props.teams}
|
||||
/>
|
||||
</div>
|
||||
|
||||
<SupportForm_TelegramInput />
|
||||
|
||||
<ProductAreaSelection
|
||||
productLabel={productLabel}
|
||||
setProductLabel={setProductLabel}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="h-7" />
|
||||
|
||||
<div className="flex justify-end gap-3 border-t px-4 py-6 lg:px-6">
|
||||
<Button
|
||||
onClick={() => {
|
||||
formRef.current?.reset();
|
||||
setProductLabel("");
|
||||
}}
|
||||
variant="outline"
|
||||
>
|
||||
Reset
|
||||
</Button>
|
||||
<SubmitButton />
|
||||
</div>
|
||||
</form>
|
||||
);
|
||||
}
|
||||
|
||||
function SubmitButton() {
|
||||
const { pending } = useFormStatus();
|
||||
return (
|
||||
<Button
|
||||
className="flex min-w-24 flex-row gap-2"
|
||||
disabled={pending}
|
||||
type="submit"
|
||||
>
|
||||
{pending && <Spinner className="size-4" />}
|
||||
{pending ? "Submitting" : "Submit"}
|
||||
</Button>
|
||||
);
|
||||
}
|
||||
@@ -1,48 +0,0 @@
|
||||
import Link from "next/link";
|
||||
import { getTeams } from "@/api/team";
|
||||
import {
|
||||
Breadcrumb,
|
||||
BreadcrumbItem,
|
||||
BreadcrumbLink,
|
||||
BreadcrumbList,
|
||||
BreadcrumbPage,
|
||||
BreadcrumbSeparator,
|
||||
} from "@/components/ui/breadcrumb";
|
||||
import { loginRedirect } from "../../../login/loginRedirect";
|
||||
import { CreateTicket } from "./components/create-ticket.client";
|
||||
|
||||
export default async function Page() {
|
||||
const teams = await getTeams();
|
||||
|
||||
const pagePath = "/support/create-ticket";
|
||||
|
||||
if (!teams || teams.length === 0) {
|
||||
loginRedirect(pagePath);
|
||||
}
|
||||
|
||||
return (
|
||||
<div>
|
||||
<Breadcrumb className="border-border border-b px-6 py-4">
|
||||
<BreadcrumbList>
|
||||
<BreadcrumbItem>
|
||||
<BreadcrumbLink asChild>
|
||||
<Link href="/support">Support</Link>
|
||||
</BreadcrumbLink>
|
||||
</BreadcrumbItem>
|
||||
<BreadcrumbSeparator />
|
||||
<BreadcrumbItem>
|
||||
<BreadcrumbPage>Get Support</BreadcrumbPage>
|
||||
</BreadcrumbItem>
|
||||
</BreadcrumbList>
|
||||
</Breadcrumb>
|
||||
<div className="container max-w-[750px] py-10">
|
||||
<CreateTicket
|
||||
teams={teams.map((t) => ({
|
||||
id: t.id,
|
||||
name: t.name,
|
||||
}))}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -1,12 +0,0 @@
|
||||
import { TeamHeader } from "../../team/components/TeamHeader/team-header";
|
||||
|
||||
export default function Layout({ children }: { children: React.ReactNode }) {
|
||||
return (
|
||||
<div className="flex grow flex-col">
|
||||
<div className="border-border border-b bg-card">
|
||||
<TeamHeader />
|
||||
</div>
|
||||
{children}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
Binary file not shown.
|
Before Width: | Height: | Size: 849 KiB |
@@ -1,155 +0,0 @@
|
||||
import {
|
||||
BookOpenIcon,
|
||||
ChevronRightIcon,
|
||||
HomeIcon,
|
||||
WalletIcon,
|
||||
} from "lucide-react";
|
||||
import type { Metadata } from "next";
|
||||
import Link from "next/link";
|
||||
import { getAuthToken, getAuthTokenWalletAddress } from "@/api/auth-token";
|
||||
import { getTeams } from "@/api/team";
|
||||
import { CustomChatButton } from "@/components/chat/CustomChatButton";
|
||||
import { Button } from "@/components/ui/button";
|
||||
import { Card, CardContent, CardHeader, CardTitle } from "@/components/ui/card";
|
||||
import { EngineIcon } from "@/icons/EngineIcon";
|
||||
import { InsightIcon } from "@/icons/InsightIcon";
|
||||
import { NebulaIcon } from "@/icons/NebulaIcon";
|
||||
import { PayIcon } from "@/icons/PayIcon";
|
||||
import { siwaExamplePrompts } from "./definitions";
|
||||
|
||||
export const metadata: Metadata = {
|
||||
openGraph: {
|
||||
title: "thirdweb Support",
|
||||
},
|
||||
title: "thirdweb Support",
|
||||
};
|
||||
|
||||
const HELP_PRODUCTS = [
|
||||
{
|
||||
description: "Try out our interactive playground to get started",
|
||||
icon: HomeIcon,
|
||||
title: "Playground",
|
||||
viewAllUrl:
|
||||
"https://playground.thirdweb.com/connect/sign-in/button?tab=code",
|
||||
},
|
||||
{
|
||||
description: "Create and manage crypto wallets",
|
||||
icon: WalletIcon,
|
||||
title: "Wallets",
|
||||
viewAllUrl: "https://portal.thirdweb.com/connect",
|
||||
},
|
||||
{
|
||||
description: "Enable payments on any tokens on any chain",
|
||||
icon: PayIcon,
|
||||
title: "Payments",
|
||||
viewAllUrl: "https://portal.thirdweb.com/pay/troubleshoot",
|
||||
},
|
||||
{
|
||||
description: "Perform read and write transactions onchain",
|
||||
icon: EngineIcon,
|
||||
title: "Transactions",
|
||||
viewAllUrl: "https://portal.thirdweb.com/engine/v3/troubleshoot",
|
||||
},
|
||||
{
|
||||
description: "Query and analyze blockchain data",
|
||||
icon: InsightIcon,
|
||||
title: "Insight",
|
||||
viewAllUrl: "https://portal.thirdweb.com/insight",
|
||||
},
|
||||
{
|
||||
description: "API interface for LLMs",
|
||||
icon: NebulaIcon,
|
||||
title: "Nebula",
|
||||
viewAllUrl: "https://portal.thirdweb.com/nebula",
|
||||
},
|
||||
] as const;
|
||||
|
||||
export default async function SupportPage() {
|
||||
const [authToken, accountAddress] = await Promise.all([
|
||||
getAuthToken(),
|
||||
getAuthTokenWalletAddress(),
|
||||
]);
|
||||
|
||||
const teams = await getTeams();
|
||||
const teamId = teams?.[0]?.id ?? undefined;
|
||||
|
||||
return (
|
||||
<main className="flex flex-col gap-12 pb-12">
|
||||
<div className="bg-gradient-to-b from-card/0 to-card py-20">
|
||||
<header className="container flex flex-col items-center gap-8">
|
||||
<div className="rounded-full bg-gradient-to-r from-[#F213A4] to-[#5204BF] p-2">
|
||||
<div className="rounded-full bg-background p-6 shadow-md">
|
||||
<BookOpenIcon className="size-8" />
|
||||
</div>
|
||||
</div>
|
||||
<div className="flex max-w-2xl flex-col items-center gap-2">
|
||||
<h1 className="text-center font-bold text-4xl md:text-balance md:text-5xl">
|
||||
How can we help?
|
||||
</h1>
|
||||
<p className="text-center text-lg text-muted-foreground">
|
||||
Get instant answers with Nebula AI, our onchain support assistant.
|
||||
Still need help? You can also create a support case to reach our
|
||||
team.
|
||||
</p>
|
||||
<div className="mt-6 flex w-full flex-col items-center gap-3">
|
||||
<CustomChatButton
|
||||
authToken={authToken || undefined}
|
||||
clientId={undefined}
|
||||
examplePrompts={siwaExamplePrompts}
|
||||
isFloating={false}
|
||||
isLoggedIn={!!accountAddress}
|
||||
label="Ask AI for support"
|
||||
networks="all"
|
||||
pageType="support"
|
||||
teamId={teamId}
|
||||
/>
|
||||
|
||||
<Link
|
||||
className="text-muted-foreground text-sm hover:underline"
|
||||
href="/support/create-ticket"
|
||||
rel="noopener noreferrer"
|
||||
target="_blank"
|
||||
>
|
||||
Open a support case
|
||||
</Link>
|
||||
</div>
|
||||
</div>
|
||||
</header>
|
||||
</div>
|
||||
<section className="container flex flex-col gap-6">
|
||||
<h2 className="font-bold text-3xl">Support Articles</h2>
|
||||
<div className="grid grid-cols-1 gap-4 md:grid-cols-2 md:gap-6 lg:grid-cols-3">
|
||||
{HELP_PRODUCTS.map((product) => (
|
||||
<Card className="relative col-span-1 bg-card" key={product.title}>
|
||||
<CardHeader className="flex flex-row items-center justify-between gap-2">
|
||||
<div className="flex flex-row items-center gap-2">
|
||||
{product.icon && <product.icon className="size-5" />}
|
||||
<CardTitle className="text-xl">{product.title}</CardTitle>
|
||||
</div>
|
||||
<Button
|
||||
asChild
|
||||
className="absolute top-4 right-2 flex flex-row gap-1 px-2 text-muted-foreground text-sm hover:text-foreground"
|
||||
size="sm"
|
||||
variant="ghost"
|
||||
>
|
||||
<Link href={product.viewAllUrl} target="_blank">
|
||||
<span>View</span>
|
||||
<ChevronRightIcon className="size-4" />
|
||||
</Link>
|
||||
</Button>
|
||||
</CardHeader>
|
||||
|
||||
<CardContent key={product.description}>
|
||||
{product.description && (
|
||||
<p className="mb-2 text-muted-foreground text-sm">
|
||||
{product.description}
|
||||
</p>
|
||||
)}
|
||||
</CardContent>
|
||||
</Card>
|
||||
))}
|
||||
</div>
|
||||
</section>
|
||||
</main>
|
||||
);
|
||||
}
|
||||
+2
-2
@@ -63,14 +63,14 @@ export const HexConverter = () => {
|
||||
|
||||
<Card className="flex flex-col gap-2 p-4">
|
||||
<p>
|
||||
Or use the
|
||||
Convert values using the{" "}
|
||||
<a
|
||||
className="underline"
|
||||
href="https://portal.thirdweb.com/typescript/v5"
|
||||
rel="noopener noreferrer"
|
||||
target="_blank"
|
||||
>
|
||||
Connect SDK
|
||||
thirdweb SDK
|
||||
</a>
|
||||
:
|
||||
</p>
|
||||
|
||||
+2
-2
@@ -49,14 +49,14 @@ export const Keccak256Converter = () => {
|
||||
|
||||
<Card className="flex flex-col gap-2 p-4">
|
||||
<p>
|
||||
Or use the{" "}
|
||||
Convert values using the{" "}
|
||||
<a
|
||||
className="underline"
|
||||
href="https://portal.thirdweb.com/typescript/v5"
|
||||
rel="noopener noreferrer"
|
||||
target="_blank"
|
||||
>
|
||||
Connect SDK
|
||||
thirdweb SDK
|
||||
</a>
|
||||
:
|
||||
</p>
|
||||
|
||||
+1
-1
@@ -308,7 +308,7 @@ ${Object.keys(populatedTransaction)
|
||||
rel="noopener noreferrer"
|
||||
target="_blank"
|
||||
>
|
||||
Connect SDK example
|
||||
TypeScript SDK example
|
||||
</a>
|
||||
<CodeClient code={state.codeExample} lang="ts" />
|
||||
</div>
|
||||
|
||||
+2
-2
@@ -79,14 +79,14 @@ export const WeiConverter = () => {
|
||||
|
||||
<Card className="flex flex-col gap-2 p-4">
|
||||
<p>
|
||||
Or use the{" "}
|
||||
Convert values using the{" "}
|
||||
<a
|
||||
className="underline"
|
||||
href="https://portal.thirdweb.com/typescript/v5"
|
||||
rel="noopener noreferrer"
|
||||
target="_blank"
|
||||
>
|
||||
Connect SDK
|
||||
thirdweb SDK
|
||||
</a>
|
||||
:
|
||||
</p>
|
||||
|
||||
@@ -4,6 +4,7 @@ import { useCallback, useState } from "react";
|
||||
import { toast } from "sonner";
|
||||
import type { ThirdwebClient } from "thirdweb";
|
||||
import { useActiveWallet, useDisconnect } from "thirdweb/react";
|
||||
import { doLogout } from "@/actions/auth-actions";
|
||||
import { createTeam } from "@/actions/createTeam";
|
||||
import { resetAnalytics } from "@/analytics/reset";
|
||||
import type { Project } from "@/api/projects";
|
||||
@@ -12,7 +13,6 @@ import { CustomConnectWallet } from "@/components/connect-wallet";
|
||||
import { LazyCreateProjectDialog } from "@/components/project/create-project-modal/LazyCreateAPIKeyDialog";
|
||||
import type { Account } from "@/hooks/useApi";
|
||||
import { useDashboardRouter } from "@/lib/DashboardRouter";
|
||||
import { doLogout } from "../../login/auth-actions";
|
||||
import {
|
||||
type AccountHeaderCompProps,
|
||||
AccountHeaderDesktopUI,
|
||||
|
||||
+2
-2
@@ -1,8 +1,8 @@
|
||||
import {
|
||||
getProjectContracts,
|
||||
type ProjectContract,
|
||||
} from "../../../../../@/api/getProjectContracts";
|
||||
import { fetchChainWithLocalOverrides } from "../../../../../@/utils/fetchChainWithLocalOverrides";
|
||||
} from "@/api/getProjectContracts";
|
||||
import { fetchChainWithLocalOverrides } from "@/utils/fetchChainWithLocalOverrides";
|
||||
|
||||
export async function getSortedDeployedContracts(params: {
|
||||
onlyMainnet?: boolean;
|
||||
|
||||
@@ -1,5 +1,6 @@
|
||||
import type React from "react";
|
||||
import type { ThirdwebClient } from "thirdweb";
|
||||
import { getAuthToken, getAuthTokenWalletAddress } from "@/api/auth-token";
|
||||
import { getProjects } from "@/api/projects";
|
||||
import { getTeams, type Team } from "@/api/team";
|
||||
import { AppFooter } from "@/components/footers/app-footer";
|
||||
@@ -7,12 +8,8 @@ import { AnnouncementBanner } from "@/components/misc/AnnouncementBanner";
|
||||
import { TabPathLinks } from "@/components/ui/tabs";
|
||||
import { getClientThirdwebClient } from "@/constants/thirdweb-client.client";
|
||||
import type { Account } from "@/hooks/useApi";
|
||||
import {
|
||||
getAuthToken,
|
||||
getAuthTokenWalletAddress,
|
||||
} from "../../../@/api/auth-token";
|
||||
import { loginRedirect } from "@/utils/redirects";
|
||||
import { TWAutoConnect } from "../components/autoconnect";
|
||||
import { loginRedirect } from "../login/loginRedirect";
|
||||
import { AccountHeader } from "./components/AccountHeader";
|
||||
import { getValidAccount } from "./settings/getAccount";
|
||||
|
||||
|
||||
@@ -1,9 +1,9 @@
|
||||
import { notFound } from "next/navigation";
|
||||
import { getAuthToken } from "@/api/auth-token";
|
||||
import { getTeams } from "@/api/team";
|
||||
import { getMemberByAccountId } from "@/api/team-members";
|
||||
import { getClientThirdwebClient } from "@/constants/thirdweb-client.client";
|
||||
import { getAuthToken } from "../../../@/api/auth-token";
|
||||
import { loginRedirect } from "../login/loginRedirect";
|
||||
import { loginRedirect } from "@/utils/redirects";
|
||||
import { AccountTeamsUI } from "./overview/AccountTeamsUI";
|
||||
import { getValidAccount } from "./settings/getAccount";
|
||||
|
||||
|
||||
@@ -2,13 +2,13 @@
|
||||
import type { ThirdwebClient } from "thirdweb";
|
||||
import { useActiveWallet, useDisconnect } from "thirdweb/react";
|
||||
import { upload } from "thirdweb/storage";
|
||||
import { doLogout } from "@/actions/auth-actions";
|
||||
import { confirmEmailWithOTP } from "@/actions/confirmEmail";
|
||||
import { apiServerProxy } from "@/actions/proxies";
|
||||
import { updateAccount } from "@/actions/updateAccount";
|
||||
import { resetAnalytics } from "@/analytics/reset";
|
||||
import type { Account } from "@/hooks/useApi";
|
||||
import { useDashboardRouter } from "@/lib/DashboardRouter";
|
||||
import { doLogout } from "../../login/auth-actions";
|
||||
import { AccountSettingsPageUI } from "./AccountSettingsPageUI";
|
||||
|
||||
export function AccountSettingsPage(props: {
|
||||
|
||||
@@ -285,7 +285,7 @@ function DeleteAccountCard(props: {
|
||||
variant="outline"
|
||||
>
|
||||
<Link
|
||||
href="/support/create-ticket"
|
||||
href="/team/~/~/support"
|
||||
rel="noopener noreferrer"
|
||||
target="_blank"
|
||||
>
|
||||
|
||||
@@ -1,8 +1,8 @@
|
||||
import { getAuthToken } from "@/api/auth-token";
|
||||
import { NEXT_PUBLIC_THIRDWEB_API_HOST } from "@/constants/public-envs";
|
||||
import type { Account } from "@/hooks/useApi";
|
||||
import { getAuthToken } from "../../../../@/api/auth-token";
|
||||
import { loginRedirect } from "../../login/loginRedirect";
|
||||
import { isAccountOnboardingComplete } from "../../login/onboarding/isOnboardingRequired";
|
||||
import { loginRedirect } from "@/utils/redirects";
|
||||
import { isAccountOnboardingComplete } from "../../../login/onboarding/isOnboardingRequired";
|
||||
|
||||
/**
|
||||
* Just get the account object without enforcing onboarding.
|
||||
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user