Compare commits
43
Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
557d42d4f4 | ||
|
|
0a662c1f0c | ||
|
|
f78b0a28da | ||
|
|
e0ab3d5f27 | ||
|
|
2c7d8411b9 | ||
|
|
16ab7da64f | ||
|
|
467ba50907 | ||
|
|
2f627e1483 | ||
|
|
6b80f75e3a | ||
|
|
c43c686eac | ||
|
|
92896ddf3f | ||
|
|
a08b62be9a | ||
|
|
7f116577ab | ||
|
|
d227bc389b | ||
|
|
6c711e8783 | ||
|
|
6f581c80b3 | ||
|
|
b1ac15c2ac | ||
|
|
98eefdf90b | ||
|
|
857d4dfc16 | ||
|
|
22aad59e7d | ||
|
|
a3b2f8f4b0 | ||
|
|
d617bf77b3 | ||
|
|
4be655dd08 | ||
|
|
74d4074f42 | ||
|
|
a36c850b86 | ||
|
|
59a8dad48e | ||
|
|
f7aa3e6239 | ||
|
|
827819b911 | ||
|
|
0d7e5e563d | ||
|
|
8665e8fe14 | ||
|
|
438027a40e | ||
|
|
66c7df98e4 | ||
|
|
409ae17c15 | ||
|
|
be28b0c26e | ||
|
|
c661a485d0 | ||
|
|
90f36bc414 | ||
|
|
a77e98a48a | ||
|
|
fde39eb883 | ||
|
|
c672a84fc3 | ||
|
|
d52855dbda | ||
|
|
a3909e1794 | ||
|
|
f92201d353 | ||
|
|
45ba811950 |
@@ -21,8 +21,8 @@ module.exports = [
|
||||
"/storage",
|
||||
// -- end scale category
|
||||
|
||||
// -- nebula
|
||||
"/nebula",
|
||||
// -- ai
|
||||
"/ai",
|
||||
// -- contracts
|
||||
"/contracts",
|
||||
"/contracts/modular-contracts",
|
||||
|
||||
@@ -10,8 +10,11 @@
|
||||
"ignoreDependencies": [
|
||||
"@thirdweb-dev/service-utils",
|
||||
"@thirdweb-dev/vault-sdk",
|
||||
"thirdweb",
|
||||
"@types/color",
|
||||
"fast-xml-parser"
|
||||
"fast-xml-parser",
|
||||
"@workspace/ui",
|
||||
"tailwindcss-animate"
|
||||
],
|
||||
"next": true,
|
||||
"project": ["src/**"]
|
||||
|
||||
@@ -115,6 +115,7 @@ const SENTRY_OPTIONS: SentryBuildOptions = {
|
||||
const FRAMER_ADDITIONAL_LANGUAGES = ["es"];
|
||||
|
||||
const baseNextConfig: NextConfig = {
|
||||
transpilePackages: ["@workspace/ui"],
|
||||
eslint: {
|
||||
ignoreDuringBuilds: true,
|
||||
},
|
||||
@@ -196,7 +197,6 @@ const baseNextConfig: NextConfig = {
|
||||
]),
|
||||
];
|
||||
},
|
||||
serverExternalPackages: ["pino-pretty"],
|
||||
};
|
||||
|
||||
function getConfig(): NextConfig {
|
||||
|
||||
@@ -28,6 +28,7 @@
|
||||
"@thirdweb-dev/vault-sdk": "workspace:*",
|
||||
"@vercel/functions": "2.2.2",
|
||||
"@vercel/og": "^0.6.8",
|
||||
"@workspace/ui": "workspace:*",
|
||||
"abitype": "1.0.8",
|
||||
"class-variance-authority": "^0.7.1",
|
||||
"clsx": "^2.1.1",
|
||||
@@ -43,7 +44,6 @@
|
||||
"next-themes": "^0.4.6",
|
||||
"nextjs-toploader": "^1.6.12",
|
||||
"nuqs": "^2.4.3",
|
||||
"p-limit": "^6.2.0",
|
||||
"papaparse": "^5.5.3",
|
||||
"pluralize": "^8.0.0",
|
||||
"posthog-js": "1.256.1",
|
||||
|
||||
@@ -1,6 +0,0 @@
|
||||
module.exports = {
|
||||
plugins: {
|
||||
autoprefixer: {},
|
||||
tailwindcss: {},
|
||||
},
|
||||
};
|
||||
@@ -0,0 +1 @@
|
||||
export { default } from "@workspace/ui/postcss.config";
|
||||
@@ -460,6 +460,12 @@ async function redirects() {
|
||||
permanent: false,
|
||||
source: "/universal-bridge",
|
||||
},
|
||||
// redirect /nebula to /ai
|
||||
{
|
||||
destination: "/ai",
|
||||
permanent: false,
|
||||
source: "/nebula",
|
||||
},
|
||||
...legacyDashboardToTeamRedirects,
|
||||
...projectPageRedirects,
|
||||
...teamPageRedirects,
|
||||
|
||||
@@ -224,7 +224,11 @@ export function reportChainConfigurationAdded(properties: {
|
||||
// ASSETS
|
||||
// ----------------------------
|
||||
|
||||
type AssetContractType = "DropERC20" | "DropERC1155" | "DropERC721";
|
||||
type AssetContractType =
|
||||
| "DropERC20"
|
||||
| "DropERC1155"
|
||||
| "DropERC721"
|
||||
| "ERC20Asset";
|
||||
|
||||
/**
|
||||
* ### Why do we need to report this event?
|
||||
@@ -334,6 +338,15 @@ export function reportAssetCreationSuccessful(properties: {
|
||||
});
|
||||
}
|
||||
|
||||
type CoinCreationStep =
|
||||
| "erc20-asset:deploy-contract"
|
||||
| "erc20-asset:airdrop-tokens"
|
||||
| "erc20-asset:approve-airdrop-tokens"
|
||||
| "drop-erc20:deploy-contract"
|
||||
| "drop-erc20:set-claim-conditions"
|
||||
| "drop-erc20:mint-tokens"
|
||||
| "drop-erc20:airdrop-tokens";
|
||||
|
||||
/**
|
||||
* ### Why do we need to report this event?
|
||||
* - To track number of failed asset creations
|
||||
@@ -355,11 +368,7 @@ export function reportAssetCreationFailed(
|
||||
}
|
||||
| {
|
||||
assetType: "coin";
|
||||
step:
|
||||
| "deploy-contract"
|
||||
| "set-claim-conditions"
|
||||
| "mint-tokens"
|
||||
| "airdrop-tokens";
|
||||
step: CoinCreationStep;
|
||||
}
|
||||
),
|
||||
) {
|
||||
@@ -517,3 +526,24 @@ export function reportTokenUpsellClicked(params: {
|
||||
}) {
|
||||
posthog.capture("token upsell clicked", params);
|
||||
}
|
||||
|
||||
// ----------------------------
|
||||
// CHAIN INFRASTRUCTURE CHECKOUT
|
||||
// ----------------------------
|
||||
/**
|
||||
* ### Why do we need to report this event?
|
||||
* - To record explicit user acknowledgement when proceeding to checkout without RPC
|
||||
* - To measure how often customers choose to omit RPC while purchasing Insight and/or Account Abstraction
|
||||
* - To correlate potential support issues arising from missing RPC
|
||||
*
|
||||
* ### Who is responsible for this event?
|
||||
* @jnsdls
|
||||
*/
|
||||
export function reportChainInfraRpcOmissionAgreed(properties: {
|
||||
chainId: number;
|
||||
frequency: "monthly" | "annual";
|
||||
includeInsight: boolean;
|
||||
includeAccountAbstraction: boolean;
|
||||
}) {
|
||||
posthog.capture("chain infra checkout rpc omission agreed", properties);
|
||||
}
|
||||
|
||||
@@ -12,6 +12,7 @@ export type AuthOption =
|
||||
| "google"
|
||||
| "facebook"
|
||||
| "x"
|
||||
| "tiktok"
|
||||
| "discord"
|
||||
| "farcaster"
|
||||
| "telegram"
|
||||
|
||||
@@ -29,16 +29,18 @@ export function MultiNetworkSelector(props: {
|
||||
client: ThirdwebClient;
|
||||
chainIds?: number[];
|
||||
}) {
|
||||
let { allChains, idToChain } = useAllChainsData();
|
||||
|
||||
if (props.chainIds && props.chainIds.length > 0) {
|
||||
allChains = allChains.filter((chain) =>
|
||||
props.chainIds?.includes(chain.chainId),
|
||||
);
|
||||
}
|
||||
const { allChains, idToChain } = useAllChainsData();
|
||||
|
||||
const options = useMemo(() => {
|
||||
let sortedChains = allChains;
|
||||
let chains = allChains.filter((chain) => chain.status !== "deprecated");
|
||||
|
||||
if (props.chainIds && props.chainIds.length > 0) {
|
||||
chains = allChains.filter((chain) =>
|
||||
props.chainIds?.includes(chain.chainId),
|
||||
);
|
||||
}
|
||||
|
||||
let sortedChains = chains;
|
||||
|
||||
if (props.priorityChains) {
|
||||
const priorityChainsSet = new Set();
|
||||
@@ -69,7 +71,13 @@ export function MultiNetworkSelector(props: {
|
||||
value: String(chain.chainId),
|
||||
};
|
||||
});
|
||||
}, [allChains, props.priorityChains, idToChain, props.hideTestnets]);
|
||||
}, [
|
||||
allChains,
|
||||
props.priorityChains,
|
||||
idToChain,
|
||||
props.hideTestnets,
|
||||
props.chainIds,
|
||||
]);
|
||||
|
||||
const searchFn = useCallback(
|
||||
(option: Option, searchValue: string) => {
|
||||
@@ -155,16 +163,38 @@ export function SingleNetworkSelector(props: {
|
||||
disableDeprecated?: boolean;
|
||||
placeholder?: string;
|
||||
client: ThirdwebClient;
|
||||
priorityChains?: number[];
|
||||
}) {
|
||||
const { allChains, idToChain } = useAllChainsData();
|
||||
|
||||
const chainsToShow = useMemo(() => {
|
||||
let chains = allChains;
|
||||
|
||||
chains = chains.filter((chain) => chain.status !== "deprecated");
|
||||
|
||||
if (props.disableTestnets) {
|
||||
chains = chains.filter((chain) => !chain.testnet);
|
||||
}
|
||||
|
||||
if (props.priorityChains) {
|
||||
const priorityChainsSet = new Set();
|
||||
for (const chainId of props.priorityChains || []) {
|
||||
priorityChainsSet.add(chainId);
|
||||
}
|
||||
|
||||
const priorityChains = (props.priorityChains || [])
|
||||
.map((chainId) => {
|
||||
return idToChain.get(chainId);
|
||||
})
|
||||
.filter((v) => !!v);
|
||||
|
||||
const otherChains = allChains.filter(
|
||||
(chain) => !priorityChainsSet.has(chain.chainId),
|
||||
);
|
||||
|
||||
chains = [...priorityChains, ...otherChains];
|
||||
}
|
||||
|
||||
if (props.chainIds) {
|
||||
const chainIdSet = new Set(props.chainIds);
|
||||
chains = chains.filter((chain) => chainIdSet.has(chain.chainId));
|
||||
@@ -180,6 +210,8 @@ export function SingleNetworkSelector(props: {
|
||||
props.chainIds,
|
||||
props.disableTestnets,
|
||||
props.disableDeprecated,
|
||||
props.priorityChains,
|
||||
idToChain,
|
||||
]);
|
||||
|
||||
const options = useMemo(() => {
|
||||
|
||||
@@ -10,13 +10,14 @@ interface SignatureOption {
|
||||
|
||||
interface SignatureSelectorProps {
|
||||
options: SignatureOption[];
|
||||
value: string;
|
||||
onChange: (val: string) => void;
|
||||
value: string | string[];
|
||||
onChange: (val: string | string[]) => void;
|
||||
setAbi?: (abi: string) => void;
|
||||
placeholder?: string;
|
||||
disabled?: boolean;
|
||||
secondaryTextFormatter?: (sig: SignatureOption) => string;
|
||||
className?: string;
|
||||
multiSelect?: boolean;
|
||||
}
|
||||
|
||||
export function SignatureSelector({
|
||||
@@ -28,6 +29,7 @@ export function SignatureSelector({
|
||||
disabled,
|
||||
secondaryTextFormatter,
|
||||
className,
|
||||
multiSelect = false,
|
||||
}: SignatureSelectorProps) {
|
||||
const [searchValue, setSearchValue] = useState("");
|
||||
const inputRef = useRef<HTMLInputElement>(null);
|
||||
@@ -42,38 +44,96 @@ export function SignatureSelector({
|
||||
}));
|
||||
}, [options, secondaryTextFormatter]);
|
||||
|
||||
// Check if the current value is a custom value (not in options)
|
||||
const isCustomValue = value && !options.some((opt) => opt.value === value);
|
||||
|
||||
// Add the custom value as an option if needed
|
||||
const allOptions = useMemo(() => {
|
||||
if (isCustomValue && value) {
|
||||
return [...formattedOptions, { label: value, value }];
|
||||
// Handle both single and multi-select values
|
||||
const currentValues = useMemo((): string[] => {
|
||||
if (multiSelect) {
|
||||
if (Array.isArray(value)) {
|
||||
return value.filter(
|
||||
(val): val is string =>
|
||||
val !== undefined && val !== null && val !== "",
|
||||
);
|
||||
} else {
|
||||
return value ? [value] : [];
|
||||
}
|
||||
} else {
|
||||
if (Array.isArray(value)) {
|
||||
return value.length > 0 && value[0] ? [value[0]] : [];
|
||||
} else {
|
||||
return value ? [value] : [];
|
||||
}
|
||||
}
|
||||
return formattedOptions;
|
||||
}, [formattedOptions, isCustomValue, value]);
|
||||
}, [value, multiSelect]);
|
||||
|
||||
// Single-select MultiSelect wrapper
|
||||
// Check if the current values include custom values (not in options)
|
||||
const customValues = useMemo((): string[] => {
|
||||
return currentValues.filter(
|
||||
(val): val is string =>
|
||||
val !== undefined &&
|
||||
val !== null &&
|
||||
val !== "" &&
|
||||
!options.some((opt) => opt.value === val),
|
||||
);
|
||||
}, [currentValues, options]);
|
||||
|
||||
// Add the custom values as options if needed
|
||||
const allOptions = useMemo(() => {
|
||||
const customOptions = customValues.map((val) => ({
|
||||
label: val,
|
||||
value: val,
|
||||
}));
|
||||
return [...formattedOptions, ...customOptions];
|
||||
}, [formattedOptions, customValues]);
|
||||
|
||||
// Multi-select or single-select MultiSelect wrapper
|
||||
const handleSelectedValuesChange = useCallback(
|
||||
(selected: string[]) => {
|
||||
// Always use the last selected value for single-select behavior
|
||||
const selectedValue =
|
||||
selected.length > 0 ? (selected[selected.length - 1] ?? "") : "";
|
||||
onChange(selectedValue);
|
||||
const found = options.find((opt) => opt.value === selectedValue);
|
||||
if (setAbi) {
|
||||
setAbi(found?.abi || "");
|
||||
if (multiSelect) {
|
||||
// Multi-select behavior
|
||||
onChange(selected);
|
||||
// For multi-select, we'll use the ABI from the first selected option that has one
|
||||
const firstOptionWithAbi = selected.find((selectedValue) => {
|
||||
const found = options.find((opt) => opt.value === selectedValue);
|
||||
return found?.abi;
|
||||
});
|
||||
if (setAbi && firstOptionWithAbi) {
|
||||
const found = options.find((opt) => opt.value === firstOptionWithAbi);
|
||||
setAbi(found?.abi || "");
|
||||
}
|
||||
} else {
|
||||
// Single-select behavior (maintain backward compatibility)
|
||||
const selectedValue =
|
||||
selected.length > 0 ? (selected[selected.length - 1] ?? "") : "";
|
||||
onChange(selectedValue);
|
||||
const found = options.find((opt) => opt.value === selectedValue);
|
||||
if (setAbi) {
|
||||
setAbi(found?.abi || "");
|
||||
}
|
||||
}
|
||||
setSearchValue("");
|
||||
},
|
||||
[onChange, setAbi, options],
|
||||
[onChange, setAbi, options, multiSelect],
|
||||
);
|
||||
|
||||
// Handle custom value entry
|
||||
const handleInputKeyDown = (event: React.KeyboardEvent<HTMLInputElement>) => {
|
||||
if (event.key === "Enter" && searchValue.trim()) {
|
||||
if (!options.some((opt) => opt.value === searchValue.trim())) {
|
||||
onChange(searchValue.trim());
|
||||
if (multiSelect) {
|
||||
// Add to existing values for multi-select
|
||||
const currentArray = Array.isArray(value)
|
||||
? value
|
||||
: value
|
||||
? [value]
|
||||
: [];
|
||||
const filteredArray = currentArray.filter(
|
||||
(val): val is string => val !== undefined && val !== null,
|
||||
);
|
||||
const newValues = [...filteredArray, searchValue.trim()];
|
||||
onChange(newValues);
|
||||
} else {
|
||||
// Replace value for single-select
|
||||
onChange(searchValue.trim());
|
||||
}
|
||||
if (setAbi) setAbi("");
|
||||
setSearchValue("");
|
||||
// Optionally blur input
|
||||
@@ -106,7 +166,7 @@ export function SignatureSelector({
|
||||
customSearchInput={customSearchInput}
|
||||
customTrigger={null}
|
||||
disabled={disabled}
|
||||
maxCount={1}
|
||||
maxCount={multiSelect ? 100 : 1}
|
||||
onSelectedValuesChange={handleSelectedValuesChange}
|
||||
options={allOptions}
|
||||
overrideSearchFn={(option, searchTerm) =>
|
||||
@@ -116,11 +176,13 @@ export function SignatureSelector({
|
||||
placeholder={placeholder}
|
||||
renderOption={(option) => <span>{option.label}</span>}
|
||||
searchPlaceholder={placeholder}
|
||||
selectedValues={value ? [value] : []}
|
||||
selectedValues={currentValues}
|
||||
/>
|
||||
{isCustomValue && (
|
||||
{customValues.length > 0 && (
|
||||
<div className="mt-2 rounded border border-warning-200 bg-warning-50 px-2 py-1 text-warning-700 text-xs">
|
||||
You entered a custom signature. Please provide the ABI below.
|
||||
{multiSelect
|
||||
? `You entered ${customValues.length} custom signature${customValues.length > 1 ? "s" : ""}. Please provide the ABI below.`
|
||||
: "You entered a custom signature. Please provide the ABI below."}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
|
||||
@@ -204,7 +204,7 @@ export function TokenSelector(props: {
|
||||
searchPlaceholder="Search by name or symbol"
|
||||
showCheck={props.showCheck}
|
||||
side={props.side}
|
||||
value={selectedValue}
|
||||
value={tokensQuery.isPending ? undefined : selectedValue}
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -3,12 +3,15 @@ import { cn } from "@/lib/utils";
|
||||
export type Segment = {
|
||||
label: string;
|
||||
percent: number;
|
||||
value: string;
|
||||
color: string;
|
||||
};
|
||||
|
||||
type DistributionBarChartProps = {
|
||||
segments: Segment[];
|
||||
title: string;
|
||||
title?: string;
|
||||
titleClassName?: string;
|
||||
barClassName?: string;
|
||||
};
|
||||
|
||||
export function DistributionBarChart(props: DistributionBarChartProps) {
|
||||
@@ -21,24 +24,36 @@ export function DistributionBarChart(props: DistributionBarChartProps) {
|
||||
|
||||
return (
|
||||
<div>
|
||||
<div className="mb-2 flex items-center justify-between">
|
||||
<h3 className="font-medium text-sm">{props.title}</h3>
|
||||
<div
|
||||
className={cn(
|
||||
"font-medium text-muted-foreground text-sm",
|
||||
invalidTotalPercentage && "text-red-500",
|
||||
)}
|
||||
>
|
||||
Total: {totalPercentage}%
|
||||
{props.title && (
|
||||
<div className="mb-2 flex items-center justify-between">
|
||||
<h3 className={cn("font-medium text-sm", props.titleClassName)}>
|
||||
{props.title}
|
||||
</h3>
|
||||
<div
|
||||
className={cn(
|
||||
"font-medium text-muted-foreground text-sm",
|
||||
invalidTotalPercentage && "text-red-500",
|
||||
)}
|
||||
>
|
||||
Total: {totalPercentage}%
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Bar */}
|
||||
<div className="flex h-3 overflow-hidden rounded-lg">
|
||||
<div
|
||||
className={cn(
|
||||
"flex h-3 overflow-hidden rounded-lg",
|
||||
props.barClassName,
|
||||
)}
|
||||
>
|
||||
{props.segments.map((segment) => {
|
||||
return (
|
||||
<div
|
||||
className="flex h-full items-center justify-center transition-all duration-200"
|
||||
className={cn(
|
||||
"flex h-full items-center justify-center transition-all duration-200",
|
||||
segment.percent > 0 && "border-r-2 border-background",
|
||||
)}
|
||||
key={segment.label}
|
||||
style={{
|
||||
backgroundColor: segment.color,
|
||||
@@ -67,7 +82,7 @@ export function DistributionBarChart(props: DistributionBarChartProps) {
|
||||
"text-destructive-text",
|
||||
)}
|
||||
>
|
||||
{segment.label}: {segment.percent}%
|
||||
{segment.label}: {segment.value}
|
||||
</p>
|
||||
</div>
|
||||
);
|
||||
|
||||
@@ -64,6 +64,7 @@ export const MultiSelect = forwardRef<HTMLButtonElement, MultiSelectProps>(
|
||||
popoverContentClassName,
|
||||
showSelectedValuesInModal = false,
|
||||
customSearchInput,
|
||||
customTrigger,
|
||||
...props
|
||||
},
|
||||
ref,
|
||||
@@ -144,13 +145,18 @@ export const MultiSelect = forwardRef<HTMLButtonElement, MultiSelectProps>(
|
||||
// scroll to top when options change
|
||||
const popoverElRef = useRef<HTMLDivElement>(null);
|
||||
|
||||
// Filter out customTrigger from props to avoid passing it to Button
|
||||
const buttonProps = Object.fromEntries(
|
||||
Object.entries(props).filter(([key]) => key !== "customTrigger"),
|
||||
) as React.ButtonHTMLAttributes<HTMLButtonElement>;
|
||||
|
||||
return (
|
||||
<Popover modal onOpenChange={setIsPopoverOpen} open={isPopoverOpen}>
|
||||
<PopoverTrigger asChild>
|
||||
{props.customTrigger || (
|
||||
{customTrigger || (
|
||||
<Button
|
||||
ref={ref}
|
||||
{...props}
|
||||
{...buttonProps}
|
||||
className={cn(
|
||||
"flex h-auto min-h-10 w-full items-center justify-between rounded-md border border-border bg-inherit p-3 hover:bg-inherit",
|
||||
className,
|
||||
|
||||
@@ -34,7 +34,7 @@ export function MultiStepStatus<T extends string>(props: {
|
||||
}) {
|
||||
return (
|
||||
<DynamicHeight>
|
||||
<div className="space-y-4">
|
||||
<div className="space-y-4 overflow-hidden">
|
||||
{props.steps.map((step) => (
|
||||
<div className="flex items-start space-x-3 " key={step.label}>
|
||||
{step.status.type === "completed" ? (
|
||||
@@ -46,7 +46,7 @@ export function MultiStepStatus<T extends string>(props: {
|
||||
) : (
|
||||
<CircleIcon className="mt-0.5 size-5 flex-shrink-0 text-muted-foreground/70" />
|
||||
)}
|
||||
<div className="flex-1">
|
||||
<div className="grow">
|
||||
<p
|
||||
className={`font-medium ${
|
||||
step.status.type === "pending"
|
||||
@@ -73,7 +73,7 @@ export function MultiStepStatus<T extends string>(props: {
|
||||
{step.status.type === "error"
|
||||
? props.renderError?.(step, step.status.message) || (
|
||||
<div className="mt-1 space-y-2">
|
||||
<p className="mb-1 text-red-500 text-sm">
|
||||
<p className="mb-1 text-red-500 text-sm whitespace-pre-wrap break-all">
|
||||
{step.status.message}
|
||||
</p>
|
||||
<Button
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
"use client";
|
||||
import { CheckIcon, CopyIcon, XIcon } from "lucide-react";
|
||||
import { CheckIcon, CircleSlashIcon, CopyIcon, XIcon } from "lucide-react";
|
||||
import { useMemo } from "react";
|
||||
import { isAddress, type ThirdwebClient, ZERO_ADDRESS } from "thirdweb";
|
||||
import { Blobbie, type SocialProfile, useSocialProfiles } from "thirdweb/react";
|
||||
@@ -23,6 +23,7 @@ export function WalletAddress(props: {
|
||||
className?: string;
|
||||
iconClassName?: string;
|
||||
client: ThirdwebClient;
|
||||
fallbackIcon?: React.ReactNode;
|
||||
}) {
|
||||
// default back to zero address if no address provided
|
||||
const address = useMemo(() => props.address || ZERO_ADDRESS, [props.address]);
|
||||
@@ -59,9 +60,16 @@ export function WalletAddress(props: {
|
||||
// special case for zero address
|
||||
if (address === ZERO_ADDRESS) {
|
||||
return (
|
||||
<span className={cn("cursor-pointer font-mono", props.className)}>
|
||||
{shortenedAddress}
|
||||
</span>
|
||||
<div className="flex items-center gap-2 py-2">
|
||||
<CircleSlashIcon
|
||||
className={cn("size-6 text-muted-foreground/70", props.iconClassName)}
|
||||
/>
|
||||
<span
|
||||
className={cn("cursor-pointer font-mono text-sm", props.className)}
|
||||
>
|
||||
{shortenedAddress}
|
||||
</span>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -82,6 +90,7 @@ export function WalletAddress(props: {
|
||||
iconClassName={props.iconClassName}
|
||||
profiles={profiles.data || []}
|
||||
thirdwebClient={props.client}
|
||||
fallbackIcon={props.fallbackIcon}
|
||||
/>
|
||||
)}
|
||||
<span className="cursor-pointer font-mono">
|
||||
@@ -173,6 +182,7 @@ function WalletAvatar(props: {
|
||||
profiles: SocialProfile[];
|
||||
thirdwebClient: ThirdwebClient;
|
||||
iconClassName?: string;
|
||||
fallbackIcon?: React.ReactNode;
|
||||
}) {
|
||||
const avatar = useMemo(() => {
|
||||
return props.profiles.find(
|
||||
@@ -199,6 +209,8 @@ function WalletAvatar(props: {
|
||||
className={cn("size-5 object-cover", props.iconClassName)}
|
||||
src={resolvedAvatarSrc}
|
||||
/>
|
||||
) : props.fallbackIcon ? (
|
||||
props.fallbackIcon
|
||||
) : (
|
||||
<Blobbie
|
||||
address={props.address}
|
||||
|
||||
@@ -300,6 +300,7 @@ const contractTypeToAssetTypeRecord: Record<string, string | undefined> = {
|
||||
DropERC20: "Coin",
|
||||
DropERC721: "NFT Collection",
|
||||
DropERC1155: "NFT Collection",
|
||||
ERC20Asset: "Coin",
|
||||
};
|
||||
|
||||
const NetworkFilterCell = React.memo(function NetworkFilterCell({
|
||||
|
||||
@@ -1,19 +1,20 @@
|
||||
import { moduleToBase64 } from "app/(app)/(dashboard)/published-contract/utils/module-base-64";
|
||||
import { RocketIcon, ShieldCheckIcon } from "lucide-react";
|
||||
import { unstable_cache } from "next/cache";
|
||||
import Link from "next/link";
|
||||
import { resolveAvatar } from "thirdweb/extensions/ens";
|
||||
import { fetchPublishedContractVersion } from "@/api/contract/fetch-contracts-with-versions";
|
||||
import { ClientOnly } from "@/components/blocks/client-only";
|
||||
import { Badge } from "@/components/ui/badge";
|
||||
import { Button } from "@/components/ui/button";
|
||||
import { Skeleton } from "@/components/ui/skeleton";
|
||||
import { getClientThirdwebClient } from "@/constants/thirdweb-client.client";
|
||||
import { serverThirdwebClient } from "@/constants/thirdweb-client.server";
|
||||
import { resolveEns } from "@/lib/ens";
|
||||
import { replaceDeployerAddress } from "@/lib/publisher-utils";
|
||||
import { cn } from "@/lib/utils";
|
||||
import { resolveSchemeWithErrorHandler } from "@/utils/resolveSchemeWithErrorHandler";
|
||||
import { ContractPublisher } from "./contract-publisher";
|
||||
|
||||
interface ContractCardProps {
|
||||
type ContractCardProps = {
|
||||
publisher: string;
|
||||
contractId: string;
|
||||
titleOverride?: string;
|
||||
@@ -29,7 +30,7 @@ interface ContractCardProps {
|
||||
moduleId: string;
|
||||
version?: string;
|
||||
}[];
|
||||
}
|
||||
};
|
||||
|
||||
function getContractUrl(
|
||||
{
|
||||
@@ -74,6 +75,72 @@ function getContractUrl(
|
||||
return replaceDeployerAddress(pathName);
|
||||
}
|
||||
|
||||
const cached_fetchPublishedContractVersion = unstable_cache(
|
||||
async (publisher: string, contractId: string, version: string = "latest") => {
|
||||
const result = await fetchPublishedContractVersion(
|
||||
publisher,
|
||||
contractId,
|
||||
serverThirdwebClient,
|
||||
version,
|
||||
).catch(() => null);
|
||||
|
||||
if (!result) {
|
||||
return null;
|
||||
}
|
||||
|
||||
const publisherEnsAndAvatar = result.publisher
|
||||
? await cached_resolvePublisherEnsAndAvatar(result.publisher)
|
||||
: undefined;
|
||||
|
||||
// Note: Do not return BigInt - it can't be serialized and cached by unstable_cache and will throw an error
|
||||
return {
|
||||
name: result.name,
|
||||
displayName: result.displayName,
|
||||
description: result.description,
|
||||
publisher: {
|
||||
address: result.publisher,
|
||||
ensName: publisherEnsAndAvatar?.ensName,
|
||||
ensAvatar: publisherEnsAndAvatar?.ensAvatar,
|
||||
},
|
||||
version: result.version,
|
||||
audit: result.audit,
|
||||
};
|
||||
},
|
||||
["fetchPublishedContractVersion"],
|
||||
{
|
||||
revalidate: 3600, // 1 hour
|
||||
},
|
||||
);
|
||||
|
||||
const cached_resolvePublisherEnsAndAvatar = unstable_cache(
|
||||
async (_addressOrEns: string) => {
|
||||
const addressOrEns = replaceDeployerAddress(_addressOrEns);
|
||||
const [ensNameInfo, ensAvatar] = await Promise.allSettled([
|
||||
resolveEns(addressOrEns, serverThirdwebClient),
|
||||
resolveAvatar({
|
||||
client: serverThirdwebClient,
|
||||
name: addressOrEns,
|
||||
}),
|
||||
]);
|
||||
|
||||
return {
|
||||
ensName:
|
||||
ensNameInfo.status === "fulfilled"
|
||||
? ensNameInfo.value?.ensName
|
||||
: undefined,
|
||||
address:
|
||||
ensNameInfo.status === "fulfilled"
|
||||
? ensNameInfo.value?.address
|
||||
: undefined,
|
||||
ensAvatar: ensAvatar.status === "fulfilled" ? ensAvatar.value : undefined,
|
||||
};
|
||||
},
|
||||
["resolvePublisherEnsAndAvatar"],
|
||||
{
|
||||
revalidate: 3600, // 1 hour
|
||||
},
|
||||
);
|
||||
|
||||
export async function ContractCard({
|
||||
publisher,
|
||||
contractId,
|
||||
@@ -83,12 +150,11 @@ export async function ContractCard({
|
||||
modules = [],
|
||||
isBeta,
|
||||
}: ContractCardProps) {
|
||||
const publishedContractResult = await fetchPublishedContractVersion(
|
||||
const publishedContractResult = await cached_fetchPublishedContractVersion(
|
||||
publisher,
|
||||
contractId,
|
||||
serverThirdwebClient,
|
||||
version,
|
||||
).catch(() => null);
|
||||
);
|
||||
|
||||
if (!publishedContractResult) {
|
||||
return null;
|
||||
@@ -186,13 +252,12 @@ export async function ContractCard({
|
||||
!modules?.length && "mt-auto",
|
||||
)}
|
||||
>
|
||||
{publishedContractResult.publisher && (
|
||||
<ClientOnly ssr={<Skeleton className="size-5 rounded-full" />}>
|
||||
<ContractPublisher
|
||||
addressOrEns={publishedContractResult.publisher}
|
||||
client={getClientThirdwebClient()}
|
||||
/>
|
||||
</ClientOnly>
|
||||
{publishedContractResult.publisher.address && (
|
||||
<ContractPublisher
|
||||
address={publishedContractResult.publisher.address}
|
||||
ensName={publishedContractResult.publisher.ensName || undefined}
|
||||
ensAvatar={publishedContractResult.publisher.ensAvatar || undefined}
|
||||
/>
|
||||
)}
|
||||
|
||||
<div className="flex items-center justify-between">
|
||||
|
||||
@@ -1,53 +1,36 @@
|
||||
"use client";
|
||||
|
||||
import Link from "next/link";
|
||||
import type { ThirdwebClient } from "thirdweb";
|
||||
import {
|
||||
AccountAddress,
|
||||
AccountAvatar,
|
||||
AccountBlobbie,
|
||||
AccountName,
|
||||
AccountProvider,
|
||||
} from "thirdweb/react";
|
||||
import { Skeleton } from "@/components/ui/skeleton";
|
||||
import { replaceDeployerAddress } from "@/lib/publisher-utils";
|
||||
import { Blobbie } from "thirdweb/react";
|
||||
import { Img } from "@/components/blocks/Img";
|
||||
import { getClientThirdwebClient } from "@/constants/thirdweb-client.client";
|
||||
import { resolveSchemeWithErrorHandler } from "@/utils/resolveSchemeWithErrorHandler";
|
||||
import { shortenIfAddress } from "@/utils/usedapp-external";
|
||||
|
||||
interface ContractPublisherProps {
|
||||
addressOrEns: string;
|
||||
client: ThirdwebClient;
|
||||
}
|
||||
|
||||
export const ContractPublisher: React.FC<ContractPublisherProps> = ({
|
||||
addressOrEns,
|
||||
client,
|
||||
}) => {
|
||||
export function ContractPublisher(props: {
|
||||
ensName: string | undefined;
|
||||
address: string;
|
||||
ensAvatar: string | undefined;
|
||||
}) {
|
||||
const displayName = props.ensName || props.address;
|
||||
return (
|
||||
<AccountProvider address={addressOrEns} client={client}>
|
||||
<Link
|
||||
className="flex shrink-0 items-center gap-1.5 hover:underline"
|
||||
href={replaceDeployerAddress(`/${addressOrEns}`)}
|
||||
>
|
||||
<AccountAvatar
|
||||
className="size-5 rounded-full object-cover"
|
||||
fallbackComponent={<AccountBlobbie className="size-5 rounded-full" />}
|
||||
loadingComponent={<Skeleton className="size-5 rounded-full" />}
|
||||
/>
|
||||
<Link
|
||||
className="flex shrink-0 items-center gap-1.5 hover:underline"
|
||||
href={`/${displayName}`}
|
||||
target="_blank"
|
||||
>
|
||||
<Img
|
||||
className="size-5 rounded-full object-cover"
|
||||
src={
|
||||
resolveSchemeWithErrorHandler({
|
||||
client: getClientThirdwebClient(),
|
||||
uri: props.ensAvatar,
|
||||
}) || ""
|
||||
}
|
||||
fallback={<Blobbie address={props.address} />}
|
||||
/>
|
||||
|
||||
<AccountName
|
||||
className="text-xs"
|
||||
fallbackComponent={
|
||||
<AccountAddress
|
||||
className="text-xs"
|
||||
formatFn={(addr) =>
|
||||
shortenIfAddress(replaceDeployerAddress(addr))
|
||||
}
|
||||
/>
|
||||
}
|
||||
formatFn={(name) => replaceDeployerAddress(name)}
|
||||
loadingComponent={<Skeleton className="h-4 w-40" />}
|
||||
/>
|
||||
</Link>
|
||||
</AccountProvider>
|
||||
<span className="text-xs"> {shortenIfAddress(displayName)}</span>
|
||||
</Link>
|
||||
);
|
||||
};
|
||||
}
|
||||
|
||||
@@ -1,5 +1,4 @@
|
||||
import { zodResolver } from "@hookform/resolvers/zod";
|
||||
import { DialogDescription } from "@radix-ui/react-dialog";
|
||||
import { useMutation } from "@tanstack/react-query";
|
||||
import type { ProjectService } from "@thirdweb-dev/service-utils";
|
||||
import { SERVICES } from "@thirdweb-dev/service-utils";
|
||||
@@ -18,6 +17,7 @@ import {
|
||||
Dialog,
|
||||
DialogClose,
|
||||
DialogContent,
|
||||
DialogDescription,
|
||||
DialogFooter,
|
||||
DialogHeader,
|
||||
DialogTitle,
|
||||
|
||||
+1
-1
@@ -1,7 +1,7 @@
|
||||
"use client";
|
||||
import { keepPreviousData, useQuery } from "@tanstack/react-query";
|
||||
import { useState } from "react";
|
||||
import type { ThirdwebClient } from "thirdweb/dist/types/client/client";
|
||||
import type { ThirdwebClient } from "thirdweb";
|
||||
import { analyticsServerProxy } from "@/actions/proxies";
|
||||
import {
|
||||
type SponsoredTransaction,
|
||||
|
||||
@@ -18,7 +18,7 @@ import {
|
||||
} from "thirdweb";
|
||||
import { type Chain, type ChainMetadata, localhost } from "thirdweb/chains";
|
||||
import {
|
||||
PayEmbed,
|
||||
BuyWidget,
|
||||
useActiveAccount,
|
||||
useActiveWallet,
|
||||
useActiveWalletChain,
|
||||
@@ -29,7 +29,6 @@ import {
|
||||
import { privateKeyToAccount, type Wallet } from "thirdweb/wallets";
|
||||
import { apiServerProxy } from "@/actions/proxies";
|
||||
import { Button } from "@/components/ui/button";
|
||||
import { DynamicHeight } from "@/components/ui/DynamicHeight";
|
||||
import {
|
||||
Dialog,
|
||||
DialogContent,
|
||||
@@ -251,39 +250,32 @@ export const MismatchButton = forwardRef<
|
||||
>
|
||||
<DialogContent
|
||||
className={cn(
|
||||
"gap-0 p-0",
|
||||
dialog === "no-funds" && "md:!max-w-[480px]",
|
||||
dialog === "pay" && "md:!max-w-[360px] border-none bg-transparent",
|
||||
"gap-0 p-0 md:!max-w-[480px]",
|
||||
dialog === "pay" && "border-none bg-transparent",
|
||||
)}
|
||||
dialogCloseClassName="focus:ring-0"
|
||||
>
|
||||
<DynamicHeight>
|
||||
{dialog === "no-funds" && (
|
||||
<NoFundsDialogContent
|
||||
chain={txChain}
|
||||
client={props.client}
|
||||
isLoggedIn={props.isLoggedIn}
|
||||
onCloseModal={() => setDialog(undefined)}
|
||||
openPayModal={() => {
|
||||
setDialog("pay");
|
||||
}}
|
||||
/>
|
||||
)}
|
||||
{dialog === "no-funds" && (
|
||||
<NoFundsDialogContent
|
||||
chain={txChain}
|
||||
client={props.client}
|
||||
isLoggedIn={props.isLoggedIn}
|
||||
onCloseModal={() => setDialog(undefined)}
|
||||
openPayModal={() => {
|
||||
setDialog("pay");
|
||||
}}
|
||||
/>
|
||||
)}
|
||||
|
||||
{dialog === "pay" && (
|
||||
<PayEmbed
|
||||
className="!w-auto"
|
||||
client={props.client}
|
||||
payOptions={{
|
||||
prefillBuy: {
|
||||
amount: "0.01",
|
||||
chain: txChain,
|
||||
},
|
||||
}}
|
||||
theme={getSDKTheme(theme === "dark" ? "dark" : "light")}
|
||||
/>
|
||||
)}
|
||||
</DynamicHeight>
|
||||
{dialog === "pay" && (
|
||||
<BuyWidget
|
||||
className="!w-full"
|
||||
client={props.client}
|
||||
chain={txChain}
|
||||
amount="0.01"
|
||||
theme={getSDKTheme(theme === "dark" ? "dark" : "light")}
|
||||
/>
|
||||
)}
|
||||
</DialogContent>
|
||||
</Dialog>
|
||||
</>
|
||||
@@ -362,11 +354,7 @@ function NoFundsDialogContent(props: {
|
||||
(x) => x.enabled && x.service === "pay",
|
||||
) ? (
|
||||
// pay case
|
||||
<Button
|
||||
className="w-full"
|
||||
onClick={props.openPayModal}
|
||||
variant="primary"
|
||||
>
|
||||
<Button className="w-full" onClick={props.openPayModal}>
|
||||
Buy Funds
|
||||
</Button>
|
||||
) : // no funds options available
|
||||
|
||||
@@ -25,6 +25,7 @@ export function CopyAddressButton(props: {
|
||||
copyIconPosition={props.copyIconPosition}
|
||||
textToCopy={props.address}
|
||||
textToShow={shortenedAddress}
|
||||
iconClassName={props.iconClassName}
|
||||
tooltip={props.tooltip || "Copy Address"}
|
||||
variant={props.variant}
|
||||
/>
|
||||
|
||||
@@ -17,9 +17,7 @@ export function DynamicHeight(props: {
|
||||
boxSizing: "border-box",
|
||||
height: height ? `${height}px` : "auto",
|
||||
overflow: "hidden",
|
||||
transition:
|
||||
props.transition ||
|
||||
"height 210ms cubic-bezier(0.175, 0.885, 0.32, 1.1)",
|
||||
transition: props.transition || "height 220ms ease",
|
||||
}}
|
||||
>
|
||||
<div
|
||||
|
||||
@@ -1,83 +1,3 @@
|
||||
import { Slot } from "@radix-ui/react-slot";
|
||||
import { cva, type VariantProps } from "class-variance-authority";
|
||||
import * as React from "react";
|
||||
|
||||
import { cn } from "@/lib/utils";
|
||||
|
||||
const buttonVariants = cva(
|
||||
"inline-flex items-center justify-center whitespace-nowrap rounded-md text-sm font-medium ring-offset-background transition-colors focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring focus-visible:ring-offset-2 disabled:pointer-events-none disabled:opacity-50",
|
||||
{
|
||||
defaultVariants: {
|
||||
size: "default",
|
||||
variant: "default",
|
||||
},
|
||||
variants: {
|
||||
size: {
|
||||
default: "h-10 px-4 py-2",
|
||||
icon: "h-10 w-10",
|
||||
lg: "h-11 rounded-md px-8",
|
||||
sm: "h-9 rounded-md px-3",
|
||||
},
|
||||
variant: {
|
||||
default: "bg-foreground text-background hover:bg-foreground/90",
|
||||
destructive:
|
||||
"bg-destructive hover:bg-destructive/90 text-destructive-foreground ",
|
||||
ghost: "hover:bg-accent hover:text-accent-foreground",
|
||||
link: "underline-offset-4 hover:underline",
|
||||
outline:
|
||||
"border border-input bg-transparent hover:bg-accent hover:text-accent-foreground",
|
||||
primary: "bg-primary hover:bg-primary/90 text-primary-foreground ",
|
||||
secondary:
|
||||
"bg-secondary hover:bg-secondary/80 text-secondary-foreground ",
|
||||
upsell:
|
||||
"bg-green-600 text-white hover:bg-green-700 shadow-lg hover:shadow-xl transform hover:-translate-y-0.5 transition-all duration-200",
|
||||
},
|
||||
},
|
||||
},
|
||||
);
|
||||
|
||||
export interface ButtonProps
|
||||
extends React.ButtonHTMLAttributes<HTMLButtonElement>,
|
||||
VariantProps<typeof buttonVariants> {
|
||||
asChild?: boolean;
|
||||
}
|
||||
|
||||
const Button = React.forwardRef<HTMLButtonElement, ButtonProps>(
|
||||
({ className, variant, size, asChild = false, disabled, ...props }, ref) => {
|
||||
const Comp = asChild ? Slot : "button";
|
||||
|
||||
// "button" elements automatically handle the `disabled` attribute.
|
||||
// For non-button elements rendered via `asChild` (e.g. <a>), we still want
|
||||
// to visually convey the disabled state and prevent user interaction.
|
||||
// We do that by conditionally adding the same utility classes that the
|
||||
// `disabled:` pseudo-variant would normally apply and by setting
|
||||
// `aria-disabled` for accessibility.
|
||||
const disabledClass = disabled ? "pointer-events-none opacity-50" : "";
|
||||
|
||||
const btnOnlyProps =
|
||||
Comp === "button"
|
||||
? {
|
||||
type:
|
||||
(props as React.ButtonHTMLAttributes<HTMLButtonElement>).type ||
|
||||
("button" as const),
|
||||
}
|
||||
: undefined;
|
||||
|
||||
return (
|
||||
<Comp
|
||||
aria-disabled={disabled ? true : undefined}
|
||||
className={cn(
|
||||
buttonVariants({ className, size, variant }),
|
||||
disabledClass,
|
||||
)}
|
||||
disabled={disabled}
|
||||
ref={ref}
|
||||
{...props}
|
||||
{...btnOnlyProps}
|
||||
/>
|
||||
);
|
||||
},
|
||||
);
|
||||
Button.displayName = "Button";
|
||||
|
||||
export { Button, buttonVariants };
|
||||
"use client";
|
||||
export type { ButtonProps } from "@workspace/ui/components/button";
|
||||
export { Button, buttonVariants } from "@workspace/ui/components/button";
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
"use client";
|
||||
|
||||
import * as CheckboxPrimitive from "@radix-ui/react-checkbox";
|
||||
import { CheckIcon } from "lucide-react";
|
||||
import { CheckIcon, MinusIcon } from "lucide-react";
|
||||
import * as React from "react";
|
||||
|
||||
import { cn } from "@/lib/utils";
|
||||
@@ -21,7 +21,11 @@ const Checkbox = React.forwardRef<
|
||||
<CheckboxPrimitive.Indicator
|
||||
className={cn("flex items-center justify-center text-current")}
|
||||
>
|
||||
<CheckIcon className="size-4" />
|
||||
{props.checked === "indeterminate" ? (
|
||||
<MinusIcon className="size-3" />
|
||||
) : (
|
||||
<CheckIcon className="size-4" />
|
||||
)}
|
||||
</CheckboxPrimitive.Indicator>
|
||||
</CheckboxPrimitive.Root>
|
||||
));
|
||||
|
||||
@@ -98,6 +98,7 @@ export function TabButtons(props: {
|
||||
shadowColor?: string;
|
||||
tabIconClassName?: string;
|
||||
hideBottomLine?: boolean;
|
||||
bottomLineClassName?: string;
|
||||
}) {
|
||||
const { containerRef, lineRef, activeTabRef } =
|
||||
useUnderline<HTMLButtonElement>();
|
||||
@@ -106,7 +107,12 @@ export function TabButtons(props: {
|
||||
<div className={cn("relative", props.containerClassName)}>
|
||||
{/* Bottom line */}
|
||||
{!props.hideBottomLine && (
|
||||
<div className="absolute right-0 bottom-0 left-0 h-[1px] bg-border" />
|
||||
<div
|
||||
className={cn(
|
||||
"absolute right-0 bottom-0 left-0 h-[1px] bg-border",
|
||||
props.bottomLineClassName,
|
||||
)}
|
||||
/>
|
||||
)}
|
||||
|
||||
<ScrollShadow
|
||||
|
||||
@@ -1,9 +1,6 @@
|
||||
export const THIRDWEB_DEPLOYER_ADDRESS =
|
||||
"0xdd99b75f095d0c4d5112aCe938e4e6ed962fb024";
|
||||
|
||||
export const THIRDWEB_PUBLISHER_ADDRESS =
|
||||
"0xdd99b75f095d0c4d5112aCe938e4e6ed962fb024";
|
||||
|
||||
export const DEFAULT_FEE_RECIPIENT =
|
||||
"0x1af20c6b23373350ad464700b5965ce4b0d2ad94";
|
||||
|
||||
|
||||
@@ -1,5 +1,4 @@
|
||||
"use client";
|
||||
|
||||
import { CircleAlertIcon, InfoIcon } from "lucide-react";
|
||||
import Link from "next/link";
|
||||
import { createContext, useCallback, useContext, useState } from "react";
|
||||
|
||||
@@ -11,7 +11,12 @@ export function useAddContractToProject() {
|
||||
contractAddress: string;
|
||||
chainId: string;
|
||||
deploymentType: "asset" | undefined;
|
||||
contractType: "DropERC20" | "DropERC721" | "DropERC1155" | undefined;
|
||||
contractType:
|
||||
| "ERC20Asset"
|
||||
| "DropERC721"
|
||||
| "DropERC1155"
|
||||
| "DropERC20"
|
||||
| undefined;
|
||||
}) => {
|
||||
const res = await apiServerProxy({
|
||||
body: JSON.stringify({
|
||||
|
||||
@@ -1,5 +1,4 @@
|
||||
import { useQuery } from "@tanstack/react-query";
|
||||
import pLimit from "p-limit";
|
||||
import { useQuery, useQueryClient } from "@tanstack/react-query";
|
||||
import Papa from "papaparse";
|
||||
import { useCallback, useState } from "react";
|
||||
import { isAddress, type ThirdwebClient, ZERO_ADDRESS } from "thirdweb";
|
||||
@@ -95,6 +94,7 @@ export function useCsvUpload<
|
||||
// Always gonna need the wallet address
|
||||
T extends { address: string },
|
||||
>(props: Props<T>) {
|
||||
const queryClient = useQueryClient();
|
||||
const [rawData, setRawData] = useState<
|
||||
T[] | Array<T & { [key in string]: unknown }>
|
||||
>(props.defaultRawData || []);
|
||||
@@ -132,16 +132,29 @@ export function useCsvUpload<
|
||||
[props.csvParser],
|
||||
);
|
||||
|
||||
const [normalizeProgress, setNormalizeProgress] = useState({
|
||||
total: 0,
|
||||
current: 0,
|
||||
});
|
||||
|
||||
const normalizeQuery = useQuery({
|
||||
queryFn: async () => {
|
||||
const limit = pLimit(50);
|
||||
const results = await Promise.all(
|
||||
rawData.map((item) => {
|
||||
return limit(() =>
|
||||
const batchSize = 50;
|
||||
const results = [];
|
||||
for (let i = 0; i < rawData.length; i += batchSize) {
|
||||
const batch = rawData.slice(i, i + batchSize);
|
||||
setNormalizeProgress({
|
||||
total: rawData.length,
|
||||
current: i,
|
||||
});
|
||||
const batchResults = await Promise.all(
|
||||
batch.map((item) =>
|
||||
checkIsAddress({ item: item, thirdwebClient: props.client }),
|
||||
);
|
||||
}),
|
||||
);
|
||||
),
|
||||
);
|
||||
results.push(...batchResults);
|
||||
}
|
||||
|
||||
return {
|
||||
invalidFound: !!results.find((item) => !item?.isValid),
|
||||
result: processAirdropData(results),
|
||||
@@ -153,12 +166,18 @@ export function useCsvUpload<
|
||||
|
||||
const removeInvalid = useCallback(() => {
|
||||
const filteredData = normalizeQuery.data?.result.filter(
|
||||
({ isValid }) => isValid,
|
||||
(d) => d.isValid && d.resolvedAddress !== ZERO_ADDRESS,
|
||||
);
|
||||
// double type assertion is save here because we don't really use this variable (only check for its length)
|
||||
// Also filteredData's type is the superset of T[]
|
||||
setRawData(filteredData as unknown as T[]);
|
||||
}, [normalizeQuery.data?.result]);
|
||||
|
||||
if (filteredData && normalizeQuery.data) {
|
||||
// Directly update the query result instead of setting new state to avoid triggering refetch
|
||||
queryClient.setQueryData(["snapshot-check-isAddress", rawData], {
|
||||
...normalizeQuery.data,
|
||||
result: filteredData,
|
||||
invalidFound: false, // Since we removed all invalid items
|
||||
});
|
||||
}
|
||||
}, [normalizeQuery.data, queryClient, rawData]);
|
||||
|
||||
const processData = useCallback(
|
||||
(data: T[]) => {
|
||||
@@ -181,5 +200,6 @@ export function useCsvUpload<
|
||||
removeInvalid,
|
||||
reset,
|
||||
setFiles,
|
||||
normalizeProgress,
|
||||
};
|
||||
}
|
||||
|
||||
@@ -7,15 +7,19 @@ import {
|
||||
import { toast } from "sonner";
|
||||
import {
|
||||
type Chain,
|
||||
NATIVE_TOKEN_ADDRESS,
|
||||
sendAndConfirmTransaction,
|
||||
type ThirdwebClient,
|
||||
type ThirdwebContract,
|
||||
} from "thirdweb";
|
||||
import type { GetBalanceResult } from "thirdweb/extensions/erc20";
|
||||
import { distribute, distributeByToken } from "thirdweb/extensions/split";
|
||||
import { getOwnedTokens } from "thirdweb/insight";
|
||||
import { useActiveAccount } from "thirdweb/react";
|
||||
import { getWalletBalance } from "thirdweb/wallets";
|
||||
import invariant from "tiny-invariant";
|
||||
import { parseError } from "../utils/errorParser";
|
||||
import { tryCatch } from "../utils/try-catch";
|
||||
|
||||
function getTokenBalancesQuery(params: {
|
||||
ownerAddress: string;
|
||||
@@ -24,7 +28,7 @@ function getTokenBalancesQuery(params: {
|
||||
}) {
|
||||
return queryOptions({
|
||||
queryFn: async () => {
|
||||
return getOwnedTokens({
|
||||
const ownedTokenBalancePromise = getOwnedTokens({
|
||||
client: params.client,
|
||||
chains: [params.chain],
|
||||
ownerAddress: params.ownerAddress,
|
||||
@@ -32,6 +36,31 @@ function getTokenBalancesQuery(params: {
|
||||
include_native: "true",
|
||||
},
|
||||
});
|
||||
|
||||
const result = await tryCatch(ownedTokenBalancePromise);
|
||||
|
||||
// fallback to fetch native token balance with rpc
|
||||
if (result.error) {
|
||||
const walletBalance = await getWalletBalance({
|
||||
address: params.ownerAddress,
|
||||
client: params.client,
|
||||
chain: params.chain,
|
||||
});
|
||||
|
||||
const nativeTokenBalance: GetBalanceResult = {
|
||||
name: walletBalance.name,
|
||||
value: walletBalance.value,
|
||||
decimals: walletBalance.decimals,
|
||||
displayValue: walletBalance.displayValue,
|
||||
symbol: walletBalance.symbol,
|
||||
chainId: params.chain.id,
|
||||
tokenAddress: NATIVE_TOKEN_ADDRESS,
|
||||
};
|
||||
|
||||
return [nativeTokenBalance];
|
||||
}
|
||||
|
||||
return result.data;
|
||||
},
|
||||
queryKey: ["getOwnedTokens", params.chain.id, params.ownerAddress],
|
||||
retry: false,
|
||||
|
||||
+1
-2
@@ -1,5 +1,4 @@
|
||||
// FIXME: export listing status type
|
||||
import type { ListingStatus } from "thirdweb/dist/types/extensions/marketplace/types";
|
||||
import type { ListingStatus } from "thirdweb/extensions/marketplace";
|
||||
|
||||
export const LISTING_STATUS: Record<ListingStatus, string> = {
|
||||
ACTIVE: "Active",
|
||||
|
||||
+10
-11
@@ -27,13 +27,15 @@ export const ClaimerSelection = () => {
|
||||
setOpenSnapshotIndex: setOpenIndex,
|
||||
isAdmin,
|
||||
claimConditionType,
|
||||
phaseSnapshots,
|
||||
setPhaseSnapshot,
|
||||
} = useClaimConditionsFormContext();
|
||||
|
||||
const handleClaimerChange = (value: string) => {
|
||||
const val = value as "any" | "specific" | "overrides";
|
||||
|
||||
if (val === "any") {
|
||||
form.setValue(`phases.${phaseIndex}.snapshot`, undefined);
|
||||
setPhaseSnapshot(phaseIndex, undefined);
|
||||
} else {
|
||||
if (val === "specific") {
|
||||
form.setValue(`phases.${phaseIndex}.maxClaimablePerWallet`, 0);
|
||||
@@ -41,7 +43,7 @@ export const ClaimerSelection = () => {
|
||||
if (val === "overrides" && field.maxClaimablePerWallet !== 1) {
|
||||
form.setValue(`phases.${phaseIndex}.maxClaimablePerWallet`, 1);
|
||||
}
|
||||
form.setValue(`phases.${phaseIndex}.snapshot`, []);
|
||||
setPhaseSnapshot(phaseIndex, []);
|
||||
setOpenIndex(phaseIndex);
|
||||
}
|
||||
};
|
||||
@@ -49,6 +51,7 @@ export const ClaimerSelection = () => {
|
||||
let helperText: React.ReactNode;
|
||||
|
||||
const disabledSnapshotButton = isAdmin && formDisabled;
|
||||
const snapshot = phaseSnapshots[phaseIndex];
|
||||
|
||||
if (dropType === "specific") {
|
||||
helperText = (
|
||||
@@ -87,10 +90,7 @@ export const ClaimerSelection = () => {
|
||||
|
||||
return (
|
||||
<FormFieldSetup
|
||||
errorMessage={
|
||||
form.getFieldState(`phases.${phaseIndex}.snapshot`, form.formState)
|
||||
?.error?.message
|
||||
}
|
||||
errorMessage={undefined}
|
||||
helperText={helperText}
|
||||
label={label}
|
||||
isRequired={false}
|
||||
@@ -117,7 +117,7 @@ export const ClaimerSelection = () => {
|
||||
)}
|
||||
|
||||
{/* Edit or See Snapshot */}
|
||||
{field.snapshot ? (
|
||||
{snapshot ? (
|
||||
<div className="flex items-center gap-3">
|
||||
{/* disable the "Edit" button when form is disabled, but not when it's a "See" button */}
|
||||
<Button
|
||||
@@ -133,7 +133,7 @@ export const ClaimerSelection = () => {
|
||||
<div
|
||||
className={cn(
|
||||
"flex gap-2 items-center",
|
||||
field.snapshot?.length === 0
|
||||
snapshot?.length === 0
|
||||
? "text-muted-foreground"
|
||||
: "text-green-600 dark:text-green-500",
|
||||
disabledSnapshotButton ? "opacity-50" : "",
|
||||
@@ -141,9 +141,8 @@ export const ClaimerSelection = () => {
|
||||
>
|
||||
<div className="size-2 bg-current rounded-full" />
|
||||
<span className="text-sm">
|
||||
{field.snapshot?.length}{" "}
|
||||
{field.snapshot?.length === 1 ? "address" : "addresses"} in
|
||||
snapshot
|
||||
{snapshot?.length}{" "}
|
||||
{snapshot?.length === 1 ? "address" : "addresses"} in snapshot
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
+10
-16
@@ -5,13 +5,12 @@ import {
|
||||
toUnits,
|
||||
} from "thirdweb";
|
||||
import { getContract } from "thirdweb/contract";
|
||||
import type { OverrideEntry } from "thirdweb/dist/types/utils/extensions/drops/types";
|
||||
import type { Prettify } from "thirdweb/dist/types/utils/type-utils";
|
||||
import { getContractMetadata } from "thirdweb/extensions/common";
|
||||
import * as ERC20Ext from "thirdweb/extensions/erc20";
|
||||
import * as ERC721Ext from "thirdweb/extensions/erc721";
|
||||
import * as ERC1155Ext from "thirdweb/extensions/erc1155";
|
||||
import { download } from "thirdweb/storage";
|
||||
import type { OverrideEntry } from "thirdweb/utils";
|
||||
import { maxUint256 } from "thirdweb/utils";
|
||||
import type { z } from "zod";
|
||||
import type {
|
||||
@@ -26,20 +25,15 @@ type SnapshotEntry = {
|
||||
currencyAddress?: string | undefined;
|
||||
};
|
||||
|
||||
type CombinedClaimCondition = Prettify<
|
||||
Omit<
|
||||
LegacyClaimCondition,
|
||||
"price" | "waitInSeconds" | "availableSupply" | "currencyMetadata"
|
||||
> & {
|
||||
price: bigint;
|
||||
currencyMetadata: Omit<
|
||||
LegacyClaimCondition["currencyMetadata"],
|
||||
"value"
|
||||
> & {
|
||||
value: bigint;
|
||||
};
|
||||
}
|
||||
>;
|
||||
type CombinedClaimCondition = Omit<
|
||||
LegacyClaimCondition,
|
||||
"price" | "waitInSeconds" | "availableSupply" | "currencyMetadata"
|
||||
> & {
|
||||
price: bigint;
|
||||
currencyMetadata: Omit<LegacyClaimCondition["currencyMetadata"], "value"> & {
|
||||
value: bigint;
|
||||
};
|
||||
};
|
||||
|
||||
type Options =
|
||||
| {
|
||||
|
||||
+116
-62
@@ -6,7 +6,15 @@ import {
|
||||
CircleHelpIcon,
|
||||
PlusIcon,
|
||||
} from "lucide-react";
|
||||
import { createContext, Fragment, useContext, useMemo, useState } from "react";
|
||||
import {
|
||||
createContext,
|
||||
Fragment,
|
||||
useContext,
|
||||
useEffect,
|
||||
useMemo,
|
||||
useRef,
|
||||
useState,
|
||||
} from "react";
|
||||
import {
|
||||
type UseFieldArrayReturn,
|
||||
type UseFormReturn,
|
||||
@@ -120,21 +128,19 @@ export const ClaimConditionTypeData: Record<
|
||||
});
|
||||
|
||||
const getClaimConditionTypeFromPhase = (
|
||||
phase: ClaimConditionInput,
|
||||
phase: Omit<ClaimConditionInput, "snapshot">,
|
||||
snapshot: SnapshotEntry[] | undefined,
|
||||
): ClaimConditionType => {
|
||||
if (!phase.snapshot) {
|
||||
if (!snapshot) {
|
||||
return "public";
|
||||
}
|
||||
|
||||
if (phase.snapshot) {
|
||||
if (snapshot) {
|
||||
if (
|
||||
phase.maxClaimablePerWallet?.toString() === "0" &&
|
||||
phase.price === "0" &&
|
||||
typeof phase.snapshot !== "string" &&
|
||||
phase.snapshot.length === 1 &&
|
||||
phase.snapshot.some(
|
||||
(a) => (a as SnapshotEntry).maxClaimable === "unlimited",
|
||||
)
|
||||
snapshot.length === 1 &&
|
||||
snapshot.some((a) => a.maxClaimable === "unlimited")
|
||||
) {
|
||||
return "creator";
|
||||
}
|
||||
@@ -165,6 +171,11 @@ interface ClaimsConditionFormContextData {
|
||||
isColumn?: boolean;
|
||||
isErc20: boolean;
|
||||
claimConditionType: ClaimConditionType;
|
||||
phaseSnapshots: Record<number, SnapshotEntry[] | undefined>;
|
||||
setPhaseSnapshot: (
|
||||
phaseIndex: number,
|
||||
snapshot: SnapshotEntry[] | undefined,
|
||||
) => void;
|
||||
}
|
||||
|
||||
// legacy, but we SHOULD remove this and instead pass down props!
|
||||
@@ -207,6 +218,10 @@ export const ClaimConditionsForm: React.FC<ClaimConditionsFormProps> = ({
|
||||
|
||||
const isAdmin = useIsAdmin(contract);
|
||||
const [openSnapshotIndex, setOpenSnapshotIndex] = useState(-1);
|
||||
const [phaseSnapshots, setPhaseSnapshots] = useState<
|
||||
Record<number, SnapshotEntry[] | undefined>
|
||||
>({});
|
||||
|
||||
const sendTx = useSendAndConfirmTransaction();
|
||||
|
||||
const tokenDecimals = useReadContract(decimals, {
|
||||
@@ -233,35 +248,56 @@ export const ClaimConditionsForm: React.FC<ClaimConditionsFormProps> = ({
|
||||
}),
|
||||
});
|
||||
|
||||
const isSnapshotInitialized = useRef(false);
|
||||
// save snapshots in a separate state instead of form state because it makes rendering very slow for large snapshots (10k, 100k rows etc)
|
||||
// eslint-disable-next-line no-restricted-syntax
|
||||
useEffect(() => {
|
||||
if (isSnapshotInitialized.current || !claimConditionsQuery.data) {
|
||||
return;
|
||||
}
|
||||
|
||||
const newPhaseSnapshots: Record<number, SnapshotEntry[] | undefined> = {};
|
||||
|
||||
claimConditionsQuery.data.forEach((phase, idx) => {
|
||||
const snapshot = phase.snapshot?.map(
|
||||
({ address, maxClaimable, price, currencyAddress }) => ({
|
||||
address,
|
||||
currencyAddress: currencyAddress || undefined,
|
||||
maxClaimable: maxClaimable || "0",
|
||||
price: price || undefined,
|
||||
}),
|
||||
);
|
||||
newPhaseSnapshots[idx] = snapshot;
|
||||
});
|
||||
|
||||
setPhaseSnapshots(newPhaseSnapshots);
|
||||
isSnapshotInitialized.current = true;
|
||||
}, [claimConditionsQuery.data]);
|
||||
|
||||
const transformedQueryData = useMemo(() => {
|
||||
return (claimConditionsQuery.data || [])
|
||||
.map((phase, idx) => ({
|
||||
...phase,
|
||||
currencyAddress: phase.currencyAddress?.toLowerCase() || "0",
|
||||
currencyMetadata: {
|
||||
...phase.currencyMetadata,
|
||||
value: phase.currencyMetadata.value?.toString() || "0",
|
||||
},
|
||||
fromSdk: true,
|
||||
isEditing: false,
|
||||
maxClaimablePerWallet: phase.maxClaimablePerWallet?.toString() || "0",
|
||||
maxClaimableSupply: phase.maxClaimableSupply?.toString() || "0",
|
||||
merkleRootHash: (phase.merkleRootHash || "") as string,
|
||||
metadata: {
|
||||
...phase.metadata,
|
||||
name: phase?.metadata?.name || `Phase ${idx + 1}`,
|
||||
},
|
||||
price: phase.currencyMetadata.displayValue,
|
||||
snapshot: phase.snapshot?.map(
|
||||
({ address, maxClaimable, price, currencyAddress }) => ({
|
||||
address,
|
||||
currencyAddress: currencyAddress || undefined,
|
||||
maxClaimable: maxClaimable || "0",
|
||||
price: price || undefined,
|
||||
}),
|
||||
),
|
||||
startTime: new Date(phase.startTime),
|
||||
}))
|
||||
.map((phase, idx) => {
|
||||
return {
|
||||
...phase,
|
||||
currencyAddress: phase.currencyAddress?.toLowerCase() || "0",
|
||||
currencyMetadata: {
|
||||
...phase.currencyMetadata,
|
||||
value: phase.currencyMetadata.value?.toString() || "0",
|
||||
},
|
||||
fromSdk: true,
|
||||
isEditing: false,
|
||||
maxClaimablePerWallet: phase.maxClaimablePerWallet?.toString() || "0",
|
||||
maxClaimableSupply: phase.maxClaimableSupply?.toString() || "0",
|
||||
merkleRootHash: (phase.merkleRootHash || "") as string,
|
||||
metadata: {
|
||||
...phase.metadata,
|
||||
name: phase?.metadata?.name || `Phase ${idx + 1}`,
|
||||
},
|
||||
price: phase.currencyMetadata.displayValue,
|
||||
snapshot: undefined, // Do not save snapshot in form data - its slow
|
||||
startTime: new Date(phase.startTime),
|
||||
};
|
||||
})
|
||||
.filter(
|
||||
(phase) => isMultiPhase || Number(phase.maxClaimableSupply) !== 0,
|
||||
);
|
||||
@@ -289,8 +325,19 @@ export const ClaimConditionsForm: React.FC<ClaimConditionsFormProps> = ({
|
||||
name: "phases",
|
||||
});
|
||||
|
||||
const setPhaseSnapshot = (
|
||||
phaseIndex: number,
|
||||
snapshot: SnapshotEntry[] | undefined,
|
||||
) => {
|
||||
setPhaseSnapshots((prev) => ({
|
||||
...prev,
|
||||
[phaseIndex]: snapshot,
|
||||
}));
|
||||
};
|
||||
|
||||
const addPhase = (type: ClaimConditionType) => {
|
||||
const name = `${ClaimConditionTypeData[type].name} phase`;
|
||||
const newPhaseIndex = formFields.fields.length;
|
||||
|
||||
switch (type) {
|
||||
case "public":
|
||||
@@ -305,8 +352,9 @@ export const ClaimConditionsForm: React.FC<ClaimConditionsFormProps> = ({
|
||||
...DEFAULT_PHASE,
|
||||
maxClaimablePerWallet: "0",
|
||||
metadata: { name },
|
||||
snapshot: [],
|
||||
snapshot: undefined,
|
||||
});
|
||||
setPhaseSnapshot(newPhaseIndex, []);
|
||||
break;
|
||||
|
||||
case "overrides":
|
||||
@@ -314,8 +362,9 @@ export const ClaimConditionsForm: React.FC<ClaimConditionsFormProps> = ({
|
||||
...DEFAULT_PHASE,
|
||||
maxClaimablePerWallet: "1",
|
||||
metadata: { name },
|
||||
snapshot: [],
|
||||
snapshot: undefined,
|
||||
});
|
||||
setPhaseSnapshot(newPhaseIndex, []);
|
||||
break;
|
||||
|
||||
case "creator":
|
||||
@@ -325,16 +374,17 @@ export const ClaimConditionsForm: React.FC<ClaimConditionsFormProps> = ({
|
||||
maxClaimableSupply: "unlimited",
|
||||
metadata: { name },
|
||||
price: "0",
|
||||
snapshot: walletAddress
|
||||
? [
|
||||
{
|
||||
address: walletAddress,
|
||||
maxClaimable: "unlimited",
|
||||
price: "0",
|
||||
},
|
||||
]
|
||||
: [],
|
||||
snapshot: undefined,
|
||||
});
|
||||
if (walletAddress) {
|
||||
setPhaseSnapshot(newPhaseIndex, [
|
||||
{
|
||||
address: walletAddress,
|
||||
maxClaimable: "unlimited",
|
||||
price: "0",
|
||||
},
|
||||
]);
|
||||
}
|
||||
break;
|
||||
|
||||
default:
|
||||
@@ -360,6 +410,12 @@ export const ClaimConditionsForm: React.FC<ClaimConditionsFormProps> = ({
|
||||
);
|
||||
}
|
||||
try {
|
||||
// Merge snapshots back into phases for submission
|
||||
const phasesWithSnapshots = d.phases.map((phase, index) => ({
|
||||
...phase,
|
||||
snapshot: phaseSnapshots[index] || undefined,
|
||||
}));
|
||||
|
||||
const tx = setClaimPhasesTx(
|
||||
{
|
||||
contract,
|
||||
@@ -370,19 +426,11 @@ export const ClaimConditionsForm: React.FC<ClaimConditionsFormProps> = ({
|
||||
? { type: "erc721" }
|
||||
: { tokenId: BigInt(tokenId || 0), type: "erc1155" }),
|
||||
},
|
||||
d.phases,
|
||||
phasesWithSnapshots,
|
||||
);
|
||||
await sendTx.mutateAsync(tx);
|
||||
|
||||
saveClaimPhaseNotification.onSuccess();
|
||||
|
||||
const newPhases = d.phases.map((phase) => ({
|
||||
...phase,
|
||||
fromSdk: true,
|
||||
isEditing: false,
|
||||
}));
|
||||
|
||||
form.setValue("phases", newPhases);
|
||||
} catch (error) {
|
||||
console.error(error);
|
||||
|
||||
@@ -446,17 +494,21 @@ export const ClaimConditionsForm: React.FC<ClaimConditionsFormProps> = ({
|
||||
{!isAdmin && <p>Connect with admin wallet to edit claim conditions.</p>}
|
||||
|
||||
{controlledFields.map((field, index) => {
|
||||
const dropType: DropType = field.snapshot
|
||||
const snapshot = phaseSnapshots[index];
|
||||
const dropType: DropType = snapshot
|
||||
? field.maxClaimablePerWallet?.toString() === "0"
|
||||
? "specific"
|
||||
: "overrides"
|
||||
: "any";
|
||||
|
||||
const claimConditionType = getClaimConditionTypeFromPhase(field);
|
||||
const claimConditionType = getClaimConditionTypeFromPhase(
|
||||
field,
|
||||
snapshot,
|
||||
);
|
||||
|
||||
const isActive = activePhaseId === field.id;
|
||||
|
||||
const snapshotValue = field.snapshot?.map((v) =>
|
||||
const snapshotValue = snapshot?.map((v) =>
|
||||
typeof v === "string"
|
||||
? {
|
||||
address: v,
|
||||
@@ -482,9 +534,7 @@ export const ClaimConditionsForm: React.FC<ClaimConditionsFormProps> = ({
|
||||
onClose={() => {
|
||||
setOpenSnapshotIndex(-1);
|
||||
}}
|
||||
setSnapshot={(snapshot) =>
|
||||
form.setValue(`phases.${index}.snapshot`, snapshot)
|
||||
}
|
||||
setSnapshot={(snapshot) => setPhaseSnapshot(index, snapshot)}
|
||||
value={snapshotValue}
|
||||
/>
|
||||
|
||||
@@ -503,6 +553,8 @@ export const ClaimConditionsForm: React.FC<ClaimConditionsFormProps> = ({
|
||||
phaseIndex: index,
|
||||
setOpenSnapshotIndex,
|
||||
tokenDecimals: tokenDecimals.data,
|
||||
phaseSnapshots,
|
||||
setPhaseSnapshot,
|
||||
}}
|
||||
>
|
||||
<ClaimConditionsPhase
|
||||
@@ -510,6 +562,8 @@ export const ClaimConditionsForm: React.FC<ClaimConditionsFormProps> = ({
|
||||
isPending={sendTx.isPending}
|
||||
onRemove={() => {
|
||||
formFields.remove(index);
|
||||
// Clean up snapshot when phase is removed
|
||||
setPhaseSnapshot(index, undefined);
|
||||
}}
|
||||
/>
|
||||
</ClaimsConditionFormContext.Provider>
|
||||
|
||||
+4
-3
@@ -35,12 +35,15 @@ export const ClaimConditionsPhase: React.FC<ClaimConditionsPhaseProps> = ({
|
||||
isActive,
|
||||
isMultiPhase,
|
||||
phaseIndex,
|
||||
phaseSnapshots,
|
||||
} = useClaimConditionsFormContext();
|
||||
|
||||
const toggleEditing = () => {
|
||||
form.setValue(`phases.${phaseIndex}.isEditing`, !field.isEditing);
|
||||
};
|
||||
|
||||
const snapshot = phaseSnapshots[phaseIndex];
|
||||
|
||||
return (
|
||||
<DynamicHeight transition="height 0.3s ease">
|
||||
<div className="border border-border rounded-lg bg-card relative">
|
||||
@@ -118,9 +121,7 @@ export const ClaimConditionsPhase: React.FC<ClaimConditionsPhaseProps> = ({
|
||||
<PhaseStartTimeInput />
|
||||
|
||||
<CreatorInput
|
||||
creatorAddress={
|
||||
(field.snapshot?.[0] as { address: string })?.address
|
||||
}
|
||||
creatorAddress={(snapshot?.[0] as { address: string })?.address}
|
||||
/>
|
||||
|
||||
<MaxClaimableSupplyInput />
|
||||
|
||||
+196
-105
@@ -4,9 +4,11 @@ import {
|
||||
CircleSlashIcon,
|
||||
RotateCcwIcon,
|
||||
} from "lucide-react";
|
||||
import { type ThirdwebClient, ZERO_ADDRESS } from "thirdweb";
|
||||
import { useState } from "react";
|
||||
import { isAddress, type ThirdwebClient, ZERO_ADDRESS } from "thirdweb";
|
||||
import { DownloadableCode } from "@/components/blocks/code/downloadable-code";
|
||||
import { DropZone } from "@/components/blocks/drop-zone/drop-zone";
|
||||
import { PaginationButtons } from "@/components/blocks/pagination-buttons";
|
||||
import { Button } from "@/components/ui/button";
|
||||
import { InlineCode } from "@/components/ui/inline-code";
|
||||
import { Spinner } from "@/components/ui/Spinner/Spinner";
|
||||
@@ -27,24 +29,18 @@ import {
|
||||
} from "@/components/ui/table";
|
||||
import { ToolTipLabel } from "@/components/ui/tooltip";
|
||||
import { useCsvUpload } from "@/hooks/useCsvUpload";
|
||||
import type { SnapshotEntry } from "./legacy-zod-schema";
|
||||
|
||||
interface SnapshotAddressInput {
|
||||
address: string;
|
||||
maxClaimable?: string;
|
||||
price?: string;
|
||||
currencyAddress?: string;
|
||||
isValid?: boolean;
|
||||
}
|
||||
interface SnapshotUploadProps {
|
||||
setSnapshot: (snapshot: SnapshotAddressInput[]) => void;
|
||||
setSnapshot: (snapshot: (SnapshotEntry & { ensName?: string })[]) => void;
|
||||
dropType: "specific" | "any" | "overrides";
|
||||
isDisabled: boolean;
|
||||
value?: SnapshotAddressInput[] | undefined;
|
||||
value?: (SnapshotEntry & { ensName?: string })[] | undefined;
|
||||
onClose: () => void;
|
||||
client: ThirdwebClient;
|
||||
}
|
||||
|
||||
const csvParser = (items: SnapshotAddressInput[]): SnapshotAddressInput[] => {
|
||||
const csvParser = (items: SnapshotEntry[]): SnapshotEntry[] => {
|
||||
return items
|
||||
.map(({ address, maxClaimable, price, currencyAddress }) => ({
|
||||
address: (address || "").trim(),
|
||||
@@ -55,92 +51,125 @@ const csvParser = (items: SnapshotAddressInput[]): SnapshotAddressInput[] => {
|
||||
.filter(({ address }) => address !== "");
|
||||
};
|
||||
|
||||
function SnapshotDataTable({ data }: { data: SnapshotAddressInput[] }) {
|
||||
function SnapshotDataTable({
|
||||
data,
|
||||
}: {
|
||||
data: (SnapshotEntry & {
|
||||
ensName?: string;
|
||||
})[];
|
||||
}) {
|
||||
const [currentPage, setCurrentPage] = useState(1);
|
||||
const itemsPerPage = 10;
|
||||
|
||||
const totalPages = Math.ceil(data.length / itemsPerPage);
|
||||
const startIndex = (currentPage - 1) * itemsPerPage;
|
||||
const endIndex = startIndex + itemsPerPage;
|
||||
const currentData = data.slice(startIndex, endIndex);
|
||||
|
||||
const handlePageChange = (page: number) => {
|
||||
setCurrentPage(page);
|
||||
};
|
||||
|
||||
return (
|
||||
<TableContainer>
|
||||
<Table>
|
||||
<TableHeader>
|
||||
<TableRow>
|
||||
<TableHead>Address</TableHead>
|
||||
<TableHead>Max claimable</TableHead>
|
||||
<TableHead>Price</TableHead>
|
||||
<TableHead>Currency Address</TableHead>
|
||||
</TableRow>
|
||||
</TableHeader>
|
||||
<TableBody>
|
||||
{data.map((item) => (
|
||||
<TableRow key={item.address}>
|
||||
<TableCell>
|
||||
{item.isValid ? (
|
||||
item.address
|
||||
) : (
|
||||
<ToolTipLabel
|
||||
label={
|
||||
item.address === ZERO_ADDRESS
|
||||
? "Cannot send tokens to ZERO_ADDRESS"
|
||||
: item.address.startsWith("0x")
|
||||
? "Address is not valid"
|
||||
: "Address couldn't be resolved"
|
||||
}
|
||||
>
|
||||
<div className="flex flex-row items-center gap-2">
|
||||
<CircleAlertIcon className="size-4 text-red-500" />
|
||||
<div className="cursor-default font-bold text-red-500">
|
||||
{item.address}
|
||||
</div>
|
||||
</div>
|
||||
</ToolTipLabel>
|
||||
)}
|
||||
</TableCell>
|
||||
<TableCell>
|
||||
{item.maxClaimable === "0" || !item.maxClaimable
|
||||
? "Default"
|
||||
: item.maxClaimable === "unlimited"
|
||||
? "Unlimited"
|
||||
: item.maxClaimable}
|
||||
</TableCell>
|
||||
<TableCell>
|
||||
{item.price === "0"
|
||||
? "Free"
|
||||
: !item.price || item.price === "unlimited"
|
||||
? "Default"
|
||||
: item.price}
|
||||
</TableCell>
|
||||
<TableCell>
|
||||
{item.currencyAddress ===
|
||||
"0x0000000000000000000000000000000000000000" ||
|
||||
!item.currencyAddress
|
||||
? "Default"
|
||||
: item.currencyAddress}
|
||||
</TableCell>
|
||||
<div className="border bg-card rounded-lg overflow-hidden">
|
||||
<TableContainer className="border-none rounded-none">
|
||||
<Table>
|
||||
<TableHeader>
|
||||
<TableRow>
|
||||
<TableHead>Address</TableHead>
|
||||
<TableHead>Max claimable</TableHead>
|
||||
<TableHead>Price</TableHead>
|
||||
<TableHead>Currency Address</TableHead>
|
||||
</TableRow>
|
||||
))}
|
||||
</TableBody>
|
||||
</Table>
|
||||
</TableContainer>
|
||||
</TableHeader>
|
||||
<TableBody>
|
||||
{currentData.map((item) => (
|
||||
<TableRow key={item.address}>
|
||||
<TableCell>
|
||||
{isAddress(item.address) && item.address !== ZERO_ADDRESS ? (
|
||||
item.ensName || item.address
|
||||
) : (
|
||||
<ToolTipLabel
|
||||
label={
|
||||
item.address === ZERO_ADDRESS
|
||||
? "Cannot send tokens to ZERO_ADDRESS"
|
||||
: item.address.startsWith("0x")
|
||||
? "Address is not valid"
|
||||
: "Address couldn't be resolved"
|
||||
}
|
||||
>
|
||||
<div className="flex flex-row items-center gap-2">
|
||||
<CircleAlertIcon className="size-4 text-red-500" />
|
||||
<div className="cursor-default font-bold text-red-500">
|
||||
{item.ensName || item.address}
|
||||
</div>
|
||||
</div>
|
||||
</ToolTipLabel>
|
||||
)}
|
||||
</TableCell>
|
||||
<TableCell>
|
||||
{item.maxClaimable === "0" || !item.maxClaimable
|
||||
? "Default"
|
||||
: item.maxClaimable === "unlimited"
|
||||
? "Unlimited"
|
||||
: item.maxClaimable}
|
||||
</TableCell>
|
||||
<TableCell>
|
||||
{item.price === "0"
|
||||
? "Free"
|
||||
: !item.price || item.price === "unlimited"
|
||||
? "Default"
|
||||
: item.price}
|
||||
</TableCell>
|
||||
<TableCell>
|
||||
{item.currencyAddress ===
|
||||
"0x0000000000000000000000000000000000000000" ||
|
||||
!item.currencyAddress
|
||||
? "Default"
|
||||
: item.currencyAddress}
|
||||
</TableCell>
|
||||
</TableRow>
|
||||
))}
|
||||
</TableBody>
|
||||
</Table>
|
||||
</TableContainer>
|
||||
|
||||
{totalPages > 1 && (
|
||||
<div className="border-t p-4 lg:px-6">
|
||||
<PaginationButtons
|
||||
activePage={currentPage}
|
||||
totalPages={totalPages}
|
||||
onPageClick={handlePageChange}
|
||||
/>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
const SnapshotViewerSheetContent: React.FC<SnapshotUploadProps> = ({
|
||||
const UploadSnapshot: React.FC<Omit<SnapshotUploadProps, "value">> = ({
|
||||
setSnapshot,
|
||||
dropType,
|
||||
isDisabled,
|
||||
value,
|
||||
onClose,
|
||||
client,
|
||||
}) => {
|
||||
const csvUpload = useCsvUpload<SnapshotAddressInput>({
|
||||
const csvUpload = useCsvUpload<SnapshotEntry>({
|
||||
client,
|
||||
csvParser,
|
||||
defaultRawData: value,
|
||||
});
|
||||
|
||||
const normalizeData = csvUpload.normalizeQuery.data;
|
||||
|
||||
if (!normalizeData) {
|
||||
return (
|
||||
<div className="flex min-h-[400px] w-full grow items-center justify-center">
|
||||
<div className="flex min-h-[400px] w-full flex-col grow items-center justify-center">
|
||||
<Spinner className="size-10" />
|
||||
<p className="text-base text-foreground mt-5">Resolving ENS</p>
|
||||
<p className="text-sm text-muted-foreground mt-2">
|
||||
{csvUpload.normalizeProgress.current} /{" "}
|
||||
{csvUpload.normalizeProgress.total}
|
||||
</p>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -154,6 +183,7 @@ const SnapshotViewerSheetContent: React.FC<SnapshotUploadProps> = ({
|
||||
currencyAddress: o.currencyAddress,
|
||||
maxClaimable: o.maxClaimable,
|
||||
price: o.price,
|
||||
ensName: o.address.startsWith("0x") ? undefined : o.address,
|
||||
})),
|
||||
);
|
||||
onClose();
|
||||
@@ -163,41 +193,58 @@ const SnapshotViewerSheetContent: React.FC<SnapshotUploadProps> = ({
|
||||
<div className="space-y-6">
|
||||
{csvUpload.rawData.length > 0 ? (
|
||||
<div>
|
||||
<SnapshotDataTable data={csvUpload.normalizeQuery.data.result} />
|
||||
<div className="flex justify-end gap-3 border-t py-6 border-dashed">
|
||||
<Button
|
||||
className="gap-2"
|
||||
disabled={isDisabled || csvUpload.rawData.length === 0}
|
||||
onClick={() => {
|
||||
csvUpload.reset();
|
||||
}}
|
||||
variant="outline"
|
||||
>
|
||||
<RotateCcwIcon className="size-4" />
|
||||
Reset
|
||||
</Button>
|
||||
<SnapshotDataTable
|
||||
data={csvUpload.normalizeQuery.data.result.map((x) => ({
|
||||
address: x.resolvedAddress,
|
||||
ensName: x.address.startsWith("0x") ? undefined : x.address,
|
||||
maxClaimable: x.maxClaimable,
|
||||
currencyAddress: x.currencyAddress,
|
||||
price: x.price,
|
||||
}))}
|
||||
/>
|
||||
|
||||
{csvUpload.normalizeQuery.data?.invalidFound ? (
|
||||
<div className="py-6">
|
||||
{csvUpload.normalizeQuery.data.invalidFound && (
|
||||
<p className="text-sm text-destructive-text mb-3">
|
||||
Invalid addresses found, please remove from the list to continue
|
||||
</p>
|
||||
)}
|
||||
|
||||
<div className="flex justify-start items-center gap-3">
|
||||
<Button
|
||||
className="gap-2"
|
||||
disabled={isDisabled || csvUpload.rawData.length === 0}
|
||||
onClick={() => {
|
||||
csvUpload.removeInvalid();
|
||||
csvUpload.reset();
|
||||
}}
|
||||
variant="outline"
|
||||
>
|
||||
<CircleSlashIcon className="size-4" />
|
||||
Remove invalid
|
||||
<RotateCcwIcon className="size-4" />
|
||||
Reset
|
||||
</Button>
|
||||
) : (
|
||||
<Button
|
||||
className="gap-2"
|
||||
disabled={isDisabled || csvUpload.rawData.length === 0}
|
||||
onClick={onSave}
|
||||
>
|
||||
Next
|
||||
<ArrowRightIcon className="size-4" />
|
||||
</Button>
|
||||
)}
|
||||
|
||||
{csvUpload.normalizeQuery.data?.invalidFound ? (
|
||||
<Button
|
||||
className="gap-2"
|
||||
disabled={isDisabled || csvUpload.rawData.length === 0}
|
||||
onClick={() => {
|
||||
csvUpload.removeInvalid();
|
||||
}}
|
||||
>
|
||||
<CircleSlashIcon className="size-4" />
|
||||
Remove invalid
|
||||
</Button>
|
||||
) : (
|
||||
<Button
|
||||
className="gap-2"
|
||||
disabled={isDisabled || csvUpload.rawData.length === 0}
|
||||
onClick={onSave}
|
||||
>
|
||||
Next
|
||||
<ArrowRightIcon className="size-4" />
|
||||
</Button>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
) : (
|
||||
@@ -308,6 +355,23 @@ const SnapshotViewerSheetContent: React.FC<SnapshotUploadProps> = ({
|
||||
);
|
||||
};
|
||||
|
||||
function ViewSnapshot(props: {
|
||||
data: (SnapshotEntry & { ensName?: string })[];
|
||||
onReset: () => void;
|
||||
}) {
|
||||
return (
|
||||
<div>
|
||||
<SnapshotDataTable data={props.data} />
|
||||
<div className="py-6">
|
||||
<Button className="gap-2" onClick={props.onReset} variant="outline">
|
||||
<RotateCcwIcon className="size-4" />
|
||||
Reset
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
export function SnapshotViewerSheet(
|
||||
props: SnapshotUploadProps & {
|
||||
isOpen: boolean;
|
||||
@@ -322,7 +386,7 @@ export function SnapshotViewerSheet(
|
||||
}}
|
||||
open={props.isOpen}
|
||||
>
|
||||
<SheetContent className="!w-full !max-w-4xl overflow-y-auto">
|
||||
<SheetContent className="!w-full !max-w-4xl overflow-y-auto flex flex-col">
|
||||
<SheetHeader className="mb-3">
|
||||
<SheetTitle className="text-left">Snapshot</SheetTitle>
|
||||
</SheetHeader>
|
||||
@@ -332,6 +396,33 @@ export function SnapshotViewerSheet(
|
||||
);
|
||||
}
|
||||
|
||||
function SnapshotViewerSheetContent(props: SnapshotUploadProps) {
|
||||
const [showEditMode, setShowEditMode] = useState(false);
|
||||
|
||||
if (showEditMode) {
|
||||
return (
|
||||
<UploadSnapshot
|
||||
setSnapshot={props.setSnapshot}
|
||||
dropType={props.dropType}
|
||||
isDisabled={props.isDisabled}
|
||||
onClose={props.onClose}
|
||||
client={props.client}
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
||||
if (props.value && props.value.length > 0) {
|
||||
return (
|
||||
<ViewSnapshot
|
||||
data={props.value || []}
|
||||
onReset={() => setShowEditMode(true)}
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
||||
return <UploadSnapshot {...props} />;
|
||||
}
|
||||
|
||||
const snapshotWithMaxClaimable = `\
|
||||
address,maxClaimable
|
||||
0x0000000000000000000000000000000000000000,2
|
||||
|
||||
+1
@@ -25,6 +25,7 @@ export type ContractPageMetadata = {
|
||||
isAccount: boolean;
|
||||
isAccountPermissionsSupported: boolean;
|
||||
functionSelectors: string[];
|
||||
showClaimRewards: boolean;
|
||||
};
|
||||
|
||||
export async function getContractPageMetadata(contract: ThirdwebContract) {
|
||||
|
||||
+37
@@ -1,6 +1,8 @@
|
||||
import type { ThirdwebContract } from "thirdweb";
|
||||
import { contractType as getContractType } from "thirdweb/extensions/thirdweb";
|
||||
import { getDeployedEntrypointERC20 } from "thirdweb/tokens";
|
||||
import { resolveFunctionSelectors } from "@/lib/selectors";
|
||||
import { getValidReward } from "../../../../../team/[team_slug]/[project_slug]/contract/[chainIdOrSlug]/[contractAddress]/rewards/utils/rewards";
|
||||
import {
|
||||
isERC20ClaimConditionsSupported,
|
||||
isERC721ClaimConditionsSupported,
|
||||
@@ -43,6 +45,7 @@ type ContractPageMetadata = {
|
||||
isAccount: boolean;
|
||||
isAccountPermissionsSupported: boolean;
|
||||
functionSelectors: string[];
|
||||
showClaimRewards: boolean;
|
||||
};
|
||||
|
||||
export async function getContractPageMetadataSetup(
|
||||
@@ -53,10 +56,14 @@ export async function getContractPageMetadataSetup(
|
||||
functionSelectorsResult,
|
||||
isInsightSupportedResult,
|
||||
contractTypeResult,
|
||||
claimRewardResult,
|
||||
] = await Promise.allSettled([
|
||||
resolveFunctionSelectors(contract),
|
||||
isAnalyticsSupportedFn(contract.chain.id),
|
||||
getContractType({ contract }),
|
||||
isClaimRewardsSupported({
|
||||
assetContract: contract,
|
||||
}),
|
||||
]);
|
||||
|
||||
const functionSelectors =
|
||||
@@ -72,6 +79,11 @@ export async function getContractPageMetadataSetup(
|
||||
const contractType =
|
||||
contractTypeResult.status === "fulfilled" ? contractTypeResult.value : null;
|
||||
|
||||
const showClaimRewards =
|
||||
claimRewardResult.status === "fulfilled"
|
||||
? !!claimRewardResult.value
|
||||
: false;
|
||||
|
||||
return {
|
||||
embedType: getEmbedTypeToShow(functionSelectors),
|
||||
functionSelectors,
|
||||
@@ -93,5 +105,30 @@ export async function getContractPageMetadataSetup(
|
||||
isSplitSupported: contractType === "Split",
|
||||
isVoteContract: contractType === "VoteERC20",
|
||||
supportedERCs: supportedERCs(functionSelectors),
|
||||
showClaimRewards,
|
||||
};
|
||||
}
|
||||
|
||||
async function isClaimRewardsSupported(params: {
|
||||
assetContract: ThirdwebContract;
|
||||
}): Promise<boolean> {
|
||||
try {
|
||||
const entrypointContract = await getDeployedEntrypointERC20({
|
||||
chain: params.assetContract.chain,
|
||||
client: params.assetContract.client,
|
||||
});
|
||||
|
||||
if (!entrypointContract) {
|
||||
return false;
|
||||
}
|
||||
|
||||
const reward = await getValidReward({
|
||||
assetContract: params.assetContract,
|
||||
entrypointContract,
|
||||
});
|
||||
|
||||
return !!reward;
|
||||
} catch {
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
+6
@@ -140,6 +140,12 @@ export function getContractPageSidebarLinks(data: {
|
||||
href: `${layoutPrefix}/permissions`,
|
||||
label: "Permissions",
|
||||
},
|
||||
{
|
||||
exactMatch: true,
|
||||
hide: !data.metadata.showClaimRewards,
|
||||
href: `${layoutPrefix}/rewards`,
|
||||
label: "Rewards",
|
||||
},
|
||||
];
|
||||
|
||||
const extensionsToShow = extensionsLinks.filter((l) => !l.hide);
|
||||
|
||||
+27
-21
@@ -1,3 +1,4 @@
|
||||
"use client";
|
||||
import {
|
||||
ExternalLinkIcon,
|
||||
GlobeIcon,
|
||||
@@ -8,6 +9,7 @@ import Link from "next/link";
|
||||
import { useMemo } from "react";
|
||||
import { type ThirdwebContract, ZERO_ADDRESS } from "thirdweb";
|
||||
import type { ChainMetadata } from "thirdweb/chains";
|
||||
import { useActiveAccount } from "thirdweb/react";
|
||||
import { Img } from "@/components/blocks/Img";
|
||||
import { Button } from "@/components/ui/button";
|
||||
import { CopyAddressButton } from "@/components/ui/CopyAddressButton";
|
||||
@@ -64,6 +66,7 @@ export function ContractHeaderUI(props: {
|
||||
|
||||
return socialUrlsValue;
|
||||
}, [props.socialUrls]);
|
||||
const activeAccount = useActiveAccount();
|
||||
|
||||
const cleanedChainName = props.chainMetadata?.name
|
||||
?.replace("Mainnet", "")
|
||||
@@ -169,29 +172,32 @@ export function ContractHeaderUI(props: {
|
||||
variant="outline"
|
||||
/>
|
||||
|
||||
<ToolTipLabel
|
||||
contentClassName="max-w-[300px]"
|
||||
label={
|
||||
<>
|
||||
View this contract in thirdweb dashboard to view contract
|
||||
management interface
|
||||
</>
|
||||
}
|
||||
>
|
||||
<Button
|
||||
asChild
|
||||
className="rounded-full bg-card gap-1.5 text-xs py-1.5 px-2.5 h-auto"
|
||||
size="sm"
|
||||
variant="outline"
|
||||
{props.contractCreator?.toLowerCase() ===
|
||||
activeAccount?.address?.toLowerCase() && (
|
||||
<ToolTipLabel
|
||||
contentClassName="max-w-[300px]"
|
||||
label={
|
||||
<>
|
||||
View this contract in thirdweb dashboard to view contract
|
||||
management interface
|
||||
</>
|
||||
}
|
||||
>
|
||||
<Link
|
||||
href={`/team/~/~/contract/${props.chainMetadata.slug}/${props.clientContract.address}`}
|
||||
<Button
|
||||
asChild
|
||||
className="rounded-full bg-card gap-1.5 text-xs py-1.5 px-2.5 h-auto"
|
||||
size="sm"
|
||||
variant="outline"
|
||||
>
|
||||
<Settings2Icon className="size-3.5 text-muted-foreground" />
|
||||
Manage Contract
|
||||
</Link>
|
||||
</Button>
|
||||
</ToolTipLabel>
|
||||
<Link
|
||||
href={`/team/~/~/contract/${props.chainMetadata.slug}/${props.clientContract.address}`}
|
||||
>
|
||||
<Settings2Icon className="size-3.5 text-muted-foreground" />
|
||||
Manage Contract
|
||||
</Link>
|
||||
</Button>
|
||||
</ToolTipLabel>
|
||||
)}
|
||||
|
||||
{validBlockExplorer && (
|
||||
<BadgeLink
|
||||
|
||||
+131
-64
@@ -1,11 +1,12 @@
|
||||
"use client";
|
||||
import { formatDistanceToNow } from "date-fns";
|
||||
import {
|
||||
ArrowRightIcon,
|
||||
ChevronLeftIcon,
|
||||
ChevronRightIcon,
|
||||
ExternalLinkIcon,
|
||||
} from "lucide-react";
|
||||
import { useState } from "react";
|
||||
import { useMemo, useState } from "react";
|
||||
import { type ThirdwebClient, type ThirdwebContract, toTokens } from "thirdweb";
|
||||
import type { ChainMetadata } from "thirdweb/chains";
|
||||
import { WalletAddress } from "@/components/blocks/wallet-address";
|
||||
@@ -20,7 +21,6 @@ import {
|
||||
TableHeader,
|
||||
TableRow,
|
||||
} from "@/components/ui/table";
|
||||
import { cn } from "@/lib/utils";
|
||||
import {
|
||||
type TokenTransfersData,
|
||||
useTokenTransfers,
|
||||
@@ -45,6 +45,32 @@ function RecentTransfersUI(props: {
|
||||
explorerUrl: string;
|
||||
client: ThirdwebClient;
|
||||
}) {
|
||||
const groupedData = useMemo(() => {
|
||||
const data: Array<{
|
||||
group: TokenTransfersData[];
|
||||
transactionHash: string;
|
||||
blockTimestamp: string;
|
||||
}> = [];
|
||||
|
||||
for (const transfer of props.data) {
|
||||
const existingGroup = data.find(
|
||||
(group) => group.transactionHash === transfer.transaction_hash,
|
||||
);
|
||||
|
||||
if (existingGroup) {
|
||||
existingGroup.group.push(transfer);
|
||||
} else {
|
||||
data.push({
|
||||
group: [transfer],
|
||||
transactionHash: transfer.transaction_hash,
|
||||
blockTimestamp: transfer.block_timestamp,
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
return data;
|
||||
}, [props.data]);
|
||||
|
||||
return (
|
||||
<div>
|
||||
<div className="p-4 lg:p-6 bg-card border rounded-b-none border-b-0 rounded-lg">
|
||||
@@ -74,76 +100,89 @@ function RecentTransfersUI(props: {
|
||||
// biome-ignore lint/suspicious/noArrayIndexKey: EXPECTED
|
||||
<SkeletonRow key={index} />
|
||||
))
|
||||
: props.data.map((transfer) => (
|
||||
: groupedData.map((group) => (
|
||||
<TableRow
|
||||
className="fade-in-0 animate-in duration-300"
|
||||
key={
|
||||
transfer.transaction_hash +
|
||||
transfer.amount +
|
||||
transfer.block_number +
|
||||
transfer.from_address
|
||||
}
|
||||
key={group.transactionHash}
|
||||
>
|
||||
<TableCell className="text-sm">
|
||||
<WalletAddress
|
||||
address={transfer.from_address}
|
||||
client={props.client}
|
||||
/>
|
||||
{/* From */}
|
||||
<TableCell className="relative space-y-1">
|
||||
{group.group.map((transfer) => (
|
||||
<div
|
||||
className="h-10 flex items-center gap-6 w-[150px]"
|
||||
key={transfer.log_index}
|
||||
>
|
||||
<WalletAddress
|
||||
address={transfer.from_address}
|
||||
client={props.client}
|
||||
iconClassName="size-4.5"
|
||||
/>
|
||||
<ArrowRightIcon className="size-4 text-muted-foreground/50 absolute -right-1 lg:right-3" />
|
||||
</div>
|
||||
))}
|
||||
</TableCell>
|
||||
<TableCell className="text-sm">
|
||||
<WalletAddress
|
||||
address={transfer.to_address}
|
||||
client={props.client}
|
||||
/>
|
||||
|
||||
{/* To */}
|
||||
<TableCell className="relative space-y-1">
|
||||
{group.group.map((transfer) => (
|
||||
<div
|
||||
className="h-10 flex items-center gap-6 w-[150px]"
|
||||
key={transfer.log_index}
|
||||
>
|
||||
<WalletAddress
|
||||
address={transfer.to_address}
|
||||
client={props.client}
|
||||
key={transfer.log_index}
|
||||
iconClassName="size-4.5"
|
||||
/>
|
||||
</div>
|
||||
))}
|
||||
</TableCell>
|
||||
<TableCell className="text-sm ">
|
||||
<div className="flex items-center gap-1.5">
|
||||
<span>
|
||||
{tokenAmountFormatter.format(
|
||||
Number(
|
||||
toTokens(
|
||||
BigInt(transfer.amount),
|
||||
props.tokenMetadata.decimals,
|
||||
),
|
||||
),
|
||||
)}
|
||||
</span>
|
||||
<span className="text-muted-foreground text-xs">
|
||||
{props.tokenMetadata.symbol}
|
||||
</span>
|
||||
|
||||
{/* Amount */}
|
||||
<TableCell className="space-y-1">
|
||||
{group.group.map((transfer) => (
|
||||
<div
|
||||
className="h-10 flex items-center"
|
||||
key={transfer.log_index}
|
||||
>
|
||||
<TokenAmount
|
||||
amount={transfer.amount}
|
||||
decimals={props.tokenMetadata.decimals}
|
||||
symbol={props.tokenMetadata.symbol}
|
||||
/>
|
||||
</div>
|
||||
))}
|
||||
</TableCell>
|
||||
|
||||
{/* timestamp */}
|
||||
<TableCell>
|
||||
<div
|
||||
key={group.blockTimestamp}
|
||||
className="capitalize text-muted-foreground text-sm"
|
||||
>
|
||||
{timestamp(group.blockTimestamp)}
|
||||
</div>
|
||||
</TableCell>
|
||||
<TableCell className="text-sm">
|
||||
{formatDistanceToNow(
|
||||
new Date(
|
||||
transfer.block_timestamp.endsWith("Z")
|
||||
? transfer.block_timestamp
|
||||
: `${transfer.block_timestamp}Z`,
|
||||
),
|
||||
{
|
||||
addSuffix: true,
|
||||
},
|
||||
)}
|
||||
</TableCell>
|
||||
|
||||
{/* transaction */}
|
||||
<TableCell>
|
||||
<Button
|
||||
asChild
|
||||
className="h-8 w-8 p-0"
|
||||
size="sm"
|
||||
variant="ghost"
|
||||
>
|
||||
<a
|
||||
className={cn(
|
||||
"flex items-center justify-center",
|
||||
"hover:bg-accent hover:text-accent-foreground",
|
||||
)}
|
||||
href={`${props.explorerUrl}/tx/${transfer.transaction_hash}`}
|
||||
rel="noopener noreferrer"
|
||||
target="_blank"
|
||||
<div className="flex items-center justify-center">
|
||||
<Button
|
||||
asChild
|
||||
size="sm"
|
||||
variant="outline"
|
||||
className="text-muted-foreground hover:text-foreground rounded-full size-9 p-0 flex items-center justify-center"
|
||||
>
|
||||
<ExternalLinkIcon className="h-4 w-4" />
|
||||
</a>
|
||||
</Button>
|
||||
<a
|
||||
href={`${props.explorerUrl}/tx/${group.transactionHash}`}
|
||||
rel="noopener noreferrer"
|
||||
target="_blank"
|
||||
>
|
||||
<ExternalLinkIcon className="size-3.5" />
|
||||
</a>
|
||||
</Button>
|
||||
</div>
|
||||
</TableCell>
|
||||
</TableRow>
|
||||
))}
|
||||
@@ -183,6 +222,34 @@ function RecentTransfersUI(props: {
|
||||
);
|
||||
}
|
||||
|
||||
function timestamp(block_timestamp: string) {
|
||||
return formatDistanceToNow(
|
||||
new Date(
|
||||
block_timestamp.endsWith("Z") ? block_timestamp : `${block_timestamp}Z`,
|
||||
),
|
||||
{
|
||||
addSuffix: true,
|
||||
},
|
||||
);
|
||||
}
|
||||
|
||||
function TokenAmount(props: {
|
||||
amount: string;
|
||||
decimals: number;
|
||||
symbol: string;
|
||||
}) {
|
||||
return (
|
||||
<div className="flex items-center gap-1.5">
|
||||
<span>
|
||||
{tokenAmountFormatter.format(
|
||||
Number(toTokens(BigInt(props.amount), props.decimals)),
|
||||
)}
|
||||
</span>
|
||||
<span className="text-muted-foreground text-xs">{props.symbol}</span>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
function SkeletonRow() {
|
||||
return (
|
||||
<TableRow className="fade-in-0 h-[73px] animate-in duration-300">
|
||||
@@ -199,7 +266,7 @@ function SkeletonRow() {
|
||||
<Skeleton className="h-6 w-32" />
|
||||
</TableCell>
|
||||
<TableCell>
|
||||
<Skeleton className="h-6 w-6" />
|
||||
<Skeleton className="size-9 rounded-full" />
|
||||
</TableCell>
|
||||
</TableRow>
|
||||
);
|
||||
|
||||
+140
-133
@@ -29,7 +29,7 @@ export function ContractSplitPage({
|
||||
}) {
|
||||
const address = useActiveAccount()?.address;
|
||||
|
||||
const { data: allRecipientsPercentages } = useReadContract(
|
||||
const allRecipientsPercentagesQuery = useReadContract(
|
||||
getAllRecipientsPercentages,
|
||||
{ contract },
|
||||
);
|
||||
@@ -39,156 +39,163 @@ export function ContractSplitPage({
|
||||
chain: contract.chain,
|
||||
});
|
||||
|
||||
const activeRecipient = (allRecipientsPercentages || []).find(
|
||||
const activeRecipient = (allRecipientsPercentagesQuery.data || []).find(
|
||||
(r) => r.address.toLowerCase() === address?.toLowerCase(),
|
||||
);
|
||||
|
||||
return (
|
||||
<div>
|
||||
{/* header */}
|
||||
<div className="flex flex-col gap-3 lg:flex-row lg:items-center justify-between mb-6 lg:mb-4">
|
||||
<div>
|
||||
<h2 className="text-2xl font-semibold tracking-tight mb-1">
|
||||
Balances
|
||||
</h2>
|
||||
<p className="text-muted-foreground">
|
||||
The Split can receive funds in the native token or in any ERC20
|
||||
</p>
|
||||
</div>
|
||||
<div className="mb-4">
|
||||
<h2 className="text-2xl font-semibold tracking-tight">Balances</h2>
|
||||
<p className="text-muted-foreground">
|
||||
The Split can receive funds in the native token or in any ERC20
|
||||
</p>
|
||||
</div>
|
||||
|
||||
{/* balances table */}
|
||||
<div className="mb-10">
|
||||
<div className="border rounded-lg overflow-hidden">
|
||||
<TableContainer className="border-none">
|
||||
<Table>
|
||||
<TableHeader>
|
||||
<TableRow>
|
||||
<TableHead>Token</TableHead>
|
||||
<TableHead>Balance</TableHead>
|
||||
{activeRecipient && <TableHead>Your Share</TableHead>}
|
||||
</TableRow>
|
||||
</TableHeader>
|
||||
<TableBody>
|
||||
{balanceQuery.isPending
|
||||
? new Array(3).fill(null).map((_, index) => (
|
||||
// biome-ignore lint/suspicious/noArrayIndexKey: ok
|
||||
<TableRow key={index}>
|
||||
<TableCell>
|
||||
<Skeleton className="h-5 w-52" />
|
||||
</TableCell>
|
||||
<TableCell>
|
||||
<Skeleton className="h-5 w-16" />
|
||||
</TableCell>
|
||||
{activeRecipient && (
|
||||
<TableCell>
|
||||
<Skeleton className="h-5 w-16" />
|
||||
</TableCell>
|
||||
)}
|
||||
</TableRow>
|
||||
))
|
||||
: balanceQuery.data?.map((tokenBalance) => (
|
||||
<TableRow key={tokenBalance.tokenAddress}>
|
||||
{/* token */}
|
||||
<TableCell className="font-medium">
|
||||
<Button
|
||||
asChild
|
||||
variant="ghost"
|
||||
className="flex items-center gap-2 w-fit h-auto py-1 px-2 -translate-x-2"
|
||||
>
|
||||
{tokenBalance.tokenAddress ===
|
||||
NATIVE_TOKEN_ADDRESS ? (
|
||||
<span>{tokenBalance.symbol}</span>
|
||||
) : (
|
||||
<Link
|
||||
href={`https://thirdweb.com/${tokenBalance.chainId}/${tokenBalance.tokenAddress}`}
|
||||
target="_blank"
|
||||
>
|
||||
{tokenBalance.name}
|
||||
<ExternalLinkIcon className="size-3.5 text-muted-foreground" />
|
||||
</Link>
|
||||
)}
|
||||
</Button>
|
||||
</TableCell>
|
||||
|
||||
{/* balance */}
|
||||
<TableCell>
|
||||
{tokenBalance.displayValue} {tokenBalance.symbol}
|
||||
</TableCell>
|
||||
|
||||
{/* your share percent */}
|
||||
{activeRecipient && (
|
||||
<TableCell>
|
||||
{activeRecipient.splitPercentage}%
|
||||
</TableCell>
|
||||
)}
|
||||
</TableRow>
|
||||
))}
|
||||
</TableBody>
|
||||
</Table>
|
||||
|
||||
{balanceQuery.isError && (
|
||||
<div className="text-red-500 px-4 flex justify-center items-center py-20 text-muted-foreground">
|
||||
{balanceQuery.error.message}
|
||||
</div>
|
||||
)}
|
||||
|
||||
{!balanceQuery.isPending && balanceQuery.data?.length === 0 && (
|
||||
<div className="px-4 flex justify-center items-center py-20 text-muted-foreground">
|
||||
No funds received yet
|
||||
</div>
|
||||
)}
|
||||
</TableContainer>
|
||||
|
||||
{balanceQuery.data && balanceQuery.data.length > 0 && (
|
||||
<div className="border-t p-4 lg:px-6 flex justify-end bg-card">
|
||||
<DistributeButton
|
||||
balances={balanceQuery.data || []}
|
||||
balancesIsError={balanceQuery.isError}
|
||||
balancesIsPending={balanceQuery.isPending}
|
||||
contract={contract}
|
||||
isLoggedIn={isLoggedIn}
|
||||
/>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* recipients table */}
|
||||
<div>
|
||||
<div className="mb-4">
|
||||
<h3 className="text-2xl font-semibold tracking-tight mb-1">
|
||||
Split Recipients
|
||||
</h3>
|
||||
<p className="text-muted-foreground">
|
||||
List of addresses that can receive funds from the Split and their
|
||||
percentage share.
|
||||
</p>
|
||||
</div>
|
||||
<TableContainer>
|
||||
<div className="bg-card border rounded-lg overflow-hidden">
|
||||
<TableContainer className="border-none rounded-none">
|
||||
<Table>
|
||||
<TableHeader>
|
||||
<TableRow>
|
||||
<TableHead>Address</TableHead>
|
||||
<TableHead>Percentage</TableHead>
|
||||
<TableHead className="w-[240px] lg:w-[320px]">Token</TableHead>
|
||||
<TableHead>Balance</TableHead>
|
||||
{activeRecipient && <TableHead>Your Share</TableHead>}
|
||||
</TableRow>
|
||||
</TableHeader>
|
||||
<TableBody>
|
||||
{(allRecipientsPercentages || []).map((split) => (
|
||||
<TableRow key={split.address}>
|
||||
<TableCell>
|
||||
<WalletAddress
|
||||
address={split.address}
|
||||
client={contract.client}
|
||||
/>
|
||||
</TableCell>
|
||||
<TableCell>{split.splitPercentage}%</TableCell>
|
||||
</TableRow>
|
||||
))}
|
||||
{balanceQuery.isPending
|
||||
? new Array(3).fill(null).map((_, index) => (
|
||||
// biome-ignore lint/suspicious/noArrayIndexKey: ok
|
||||
<TableRow key={index}>
|
||||
<TableCell>
|
||||
<Skeleton className="h-5 w-52" />
|
||||
</TableCell>
|
||||
<TableCell>
|
||||
<Skeleton className="h-5 w-16" />
|
||||
</TableCell>
|
||||
{activeRecipient && (
|
||||
<TableCell>
|
||||
<Skeleton className="h-5 w-16" />
|
||||
</TableCell>
|
||||
)}
|
||||
</TableRow>
|
||||
))
|
||||
: balanceQuery.data?.map((tokenBalance) => (
|
||||
<TableRow key={tokenBalance.tokenAddress}>
|
||||
{/* token */}
|
||||
<TableCell className="font-medium">
|
||||
<Button
|
||||
asChild
|
||||
variant="ghost"
|
||||
className="flex items-center gap-2 w-fit h-auto py-1 px-2 -translate-x-2"
|
||||
>
|
||||
{tokenBalance.tokenAddress ===
|
||||
NATIVE_TOKEN_ADDRESS ? (
|
||||
<span>{tokenBalance.symbol}</span>
|
||||
) : (
|
||||
<Link
|
||||
href={`https://thirdweb.com/${tokenBalance.chainId}/${tokenBalance.tokenAddress}`}
|
||||
target="_blank"
|
||||
>
|
||||
{tokenBalance.name}
|
||||
<ExternalLinkIcon className="size-3.5 text-muted-foreground" />
|
||||
</Link>
|
||||
)}
|
||||
</Button>
|
||||
</TableCell>
|
||||
|
||||
{/* balance */}
|
||||
<TableCell>
|
||||
{tokenBalance.displayValue} {tokenBalance.symbol}
|
||||
</TableCell>
|
||||
|
||||
{/* your share percent */}
|
||||
{activeRecipient && (
|
||||
<TableCell>
|
||||
{activeRecipient.splitPercentage}%
|
||||
</TableCell>
|
||||
)}
|
||||
</TableRow>
|
||||
))}
|
||||
</TableBody>
|
||||
</Table>
|
||||
|
||||
{balanceQuery.isError && (
|
||||
<div className="text-red-500 px-4 flex justify-center items-center py-20 text-muted-foreground">
|
||||
{balanceQuery.error.message}
|
||||
</div>
|
||||
)}
|
||||
|
||||
{!balanceQuery.isPending && balanceQuery.data?.length === 0 && (
|
||||
<div className="px-4 flex justify-center items-center py-20 text-muted-foreground">
|
||||
No funds received yet
|
||||
</div>
|
||||
)}
|
||||
</TableContainer>
|
||||
|
||||
{balanceQuery.data && balanceQuery.data.length > 0 && (
|
||||
<div className="border-t p-4 lg:py-5 lg:px-6 flex justify-end bg-card">
|
||||
<DistributeButton
|
||||
balances={balanceQuery.data || []}
|
||||
balancesIsError={balanceQuery.isError}
|
||||
balancesIsPending={balanceQuery.isPending}
|
||||
contract={contract}
|
||||
isLoggedIn={isLoggedIn}
|
||||
/>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
|
||||
<div className="h-10" />
|
||||
|
||||
<div className="mb-4">
|
||||
<h3 className="text-2xl font-semibold tracking-tight">
|
||||
Split Recipients
|
||||
</h3>
|
||||
<p className="text-muted-foreground">
|
||||
List of addresses that can receive funds from the Split and their
|
||||
percentage share.
|
||||
</p>
|
||||
</div>
|
||||
|
||||
{/* recipients table */}
|
||||
<TableContainer>
|
||||
<Table>
|
||||
<TableHeader>
|
||||
<TableRow>
|
||||
<TableHead className="w-[240px] lg:w-[320px]">Address</TableHead>
|
||||
<TableHead>Percentage</TableHead>
|
||||
</TableRow>
|
||||
</TableHeader>
|
||||
<TableBody>
|
||||
{allRecipientsPercentagesQuery.isPending
|
||||
? new Array(3).fill(null).map((_, index) => (
|
||||
// biome-ignore lint/suspicious/noArrayIndexKey: ok
|
||||
<TableRow key={index}>
|
||||
<TableCell>
|
||||
<Skeleton className="h-5 w-52" />
|
||||
</TableCell>
|
||||
<TableCell>
|
||||
<Skeleton className="h-5 w-16" />
|
||||
</TableCell>
|
||||
</TableRow>
|
||||
))
|
||||
: (allRecipientsPercentagesQuery.data || []).map((split) => (
|
||||
<TableRow key={split.address}>
|
||||
<TableCell>
|
||||
<WalletAddress
|
||||
address={split.address}
|
||||
client={contract.client}
|
||||
className="h-auto py-1"
|
||||
/>
|
||||
</TableCell>
|
||||
<TableCell>{split.splitPercentage}%</TableCell>
|
||||
</TableRow>
|
||||
))}
|
||||
</TableBody>
|
||||
</Table>
|
||||
</TableContainer>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
+2
-1
@@ -61,6 +61,7 @@ export const DistributeButton = ({
|
||||
isLoggedIn={isLoggedIn}
|
||||
isPending={mutation.isPending}
|
||||
onClick={distributeFunds}
|
||||
size="sm"
|
||||
transactionCount={undefined}
|
||||
// if we fail to get the balances, we can't know how many transactions there are going to be
|
||||
txChainID={contract.chain.id}
|
||||
@@ -87,7 +88,7 @@ export const DistributeButton = ({
|
||||
isLoggedIn={isLoggedIn}
|
||||
isPending={mutation.isPending}
|
||||
onClick={distributeFunds}
|
||||
transactionCount={numTransactions}
|
||||
transactionCount={numTransactions === 1 ? undefined : numTransactions}
|
||||
txChainID={contract.chain.id}
|
||||
variant="default"
|
||||
size="sm"
|
||||
|
||||
+19
-2
@@ -21,12 +21,29 @@ export function AirdropUpload(props: {
|
||||
client: props.client,
|
||||
csvParser,
|
||||
});
|
||||
|
||||
if (csvUpload.normalizeQuery.isPending) {
|
||||
return (
|
||||
<div className="flex min-h-[400px] w-full flex-col grow items-center justify-center">
|
||||
<Spinner className="size-10" />
|
||||
<p className="text-base text-foreground mt-5">Resolving ENS</p>
|
||||
<p className="text-sm text-muted-foreground mt-2">
|
||||
{csvUpload.normalizeProgress.current} /{" "}
|
||||
{csvUpload.normalizeProgress.total}
|
||||
</p>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
const normalizeData = csvUpload.normalizeQuery.data;
|
||||
|
||||
if (!normalizeData) {
|
||||
return (
|
||||
<div className="flex min-h-[400px] w-full grow items-center justify-center rounded-lg border border-border">
|
||||
<Spinner className="size-10" />
|
||||
<div className="flex min-h-[400px] w-full flex-col grow items-center justify-center text-center">
|
||||
<p className="text-base text-foreground">Failed to resolve ENS</p>
|
||||
<p className="text-sm text-muted-foreground mt-2">
|
||||
{String(csvUpload.normalizeQuery.error)}
|
||||
</p>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -7,11 +7,7 @@ import {
|
||||
} from "@/components/contracts/contract-card/contract-card";
|
||||
import type { ExploreCategory } from "../../data";
|
||||
|
||||
interface ContractRowProps {
|
||||
category: ExploreCategory;
|
||||
}
|
||||
|
||||
export function ContractRow({ category }: ContractRowProps) {
|
||||
export function ContractRow({ category }: { category: ExploreCategory }) {
|
||||
return (
|
||||
<section>
|
||||
{/* Title, Description + View all link */}
|
||||
|
||||
+1
-4
@@ -47,7 +47,6 @@ import {
|
||||
DEFAULT_FEE_BPS,
|
||||
DEFAULT_FEE_BPS_NEW,
|
||||
DEFAULT_FEE_RECIPIENT,
|
||||
THIRDWEB_PUBLISHER_ADDRESS,
|
||||
} from "@/constants/addresses";
|
||||
import { LAST_USED_PROJECT_ID, LAST_USED_TEAM_ID } from "@/constants/cookie";
|
||||
import { ZERO_FEE_CHAINS } from "@/constants/fee-config";
|
||||
@@ -218,9 +217,7 @@ export const CustomContractForm: React.FC<CustomContractFormProps> = ({
|
||||
const defaultFeeRecipientFunction = metadata.abi.find(
|
||||
(a) => a.type === "function" && a.name === "DEFAULT_FEE_RECIPIENT",
|
||||
);
|
||||
const hasInbuiltDefaultFeeConfig =
|
||||
defaultFeeRecipientFunction &&
|
||||
metadata.publisher === THIRDWEB_PUBLISHER_ADDRESS;
|
||||
const hasInbuiltDefaultFeeConfig = defaultFeeRecipientFunction;
|
||||
|
||||
const isFeeExempt =
|
||||
walletChain?.id && ZERO_FEE_CHAINS.includes(walletChain.id);
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
import "../../global.css";
|
||||
import "@workspace/ui/global.css";
|
||||
import type { Metadata } from "next";
|
||||
import { Inter } from "next/font/google";
|
||||
import NextTopLoader from "nextjs-toploader";
|
||||
|
||||
@@ -76,9 +76,6 @@ export function TeamHighlightsCard({
|
||||
: "activeUsers"
|
||||
}
|
||||
aggregateFn={(_data, key) => {
|
||||
if (key === "activeUsers") {
|
||||
return Math.max(...timeSeriesData.map((d) => d[key]));
|
||||
}
|
||||
return timeSeriesData.reduce((acc, curr) => acc + curr[key], 0);
|
||||
}}
|
||||
chartConfig={chartConfig}
|
||||
|
||||
+1
@@ -48,6 +48,7 @@ const authOptions = [
|
||||
"google",
|
||||
"facebook",
|
||||
"x",
|
||||
"tiktok",
|
||||
"discord",
|
||||
"farcaster",
|
||||
"telegram",
|
||||
|
||||
+95
-16
@@ -1,11 +1,20 @@
|
||||
"use client";
|
||||
|
||||
import { useQueryState } from "nuqs";
|
||||
import { useMemo, useTransition } from "react";
|
||||
import { useMemo, useState, useTransition } from "react";
|
||||
import { toast } from "sonner";
|
||||
import { getChainInfraCheckoutURL } from "@/actions/billing";
|
||||
import { reportChainInfraRpcOmissionAgreed } from "@/analytics/report";
|
||||
import { Badge } from "@/components/ui/badge";
|
||||
import { Button } from "@/components/ui/button";
|
||||
import {
|
||||
Dialog,
|
||||
DialogContent,
|
||||
DialogDescription,
|
||||
DialogFooter,
|
||||
DialogHeader,
|
||||
DialogTitle,
|
||||
} from "@/components/ui/dialog";
|
||||
import { Switch } from "@/components/ui/switch";
|
||||
import { InsightIcon } from "@/icons/InsightIcon";
|
||||
import { RPCIcon } from "@/icons/RPCIcon";
|
||||
@@ -44,7 +53,7 @@ const SERVICE_CONFIG = {
|
||||
icon: "RPCIcon",
|
||||
label: "RPC",
|
||||
monthlyPrice: 1500,
|
||||
required: true,
|
||||
required: false,
|
||||
sku: "chain:infra:rpc" as const,
|
||||
},
|
||||
} satisfies Record<
|
||||
@@ -80,19 +89,26 @@ export function DeployInfrastructureForm(props: {
|
||||
searchParams.addons.withOptions({ history: "replace", startTransition }),
|
||||
);
|
||||
|
||||
const [rpcParam, setRpcParam] = useQueryState(
|
||||
"rpc",
|
||||
searchParams.rpc.withOptions({ history: "replace", startTransition }),
|
||||
);
|
||||
|
||||
const addons = useMemo(() => {
|
||||
return addonsStr ? addonsStr.split(",").filter(Boolean) : [];
|
||||
}, [addonsStr]);
|
||||
|
||||
const includeInsight = addons.includes("insight");
|
||||
const includeAA = addons.includes("aa");
|
||||
const includeRPC = rpcParam === "on";
|
||||
|
||||
const selectedOrder = useMemo(() => {
|
||||
const arr: (keyof typeof SERVICE_CONFIG)[] = ["rpc"];
|
||||
const arr: (keyof typeof SERVICE_CONFIG)[] = [];
|
||||
if (includeRPC) arr.push("rpc");
|
||||
if (includeInsight) arr.push("insight");
|
||||
if (includeAA) arr.push("accountAbstraction");
|
||||
return arr;
|
||||
}, [includeInsight, includeAA]);
|
||||
}, [includeInsight, includeAA, includeRPC]);
|
||||
|
||||
// NEW: count selected services and prepare bundle discount hint
|
||||
const selectedCount = selectedOrder.length;
|
||||
@@ -112,9 +128,9 @@ export function DeployInfrastructureForm(props: {
|
||||
return {
|
||||
accountAbstraction: includeAA,
|
||||
insight: includeInsight,
|
||||
rpc: true,
|
||||
rpc: includeRPC,
|
||||
} as const;
|
||||
}, [includeInsight, includeAA]);
|
||||
}, [includeInsight, includeAA, includeRPC]);
|
||||
|
||||
const pricePerService = useMemo(() => {
|
||||
const isAnnual = frequency === "annual";
|
||||
@@ -172,10 +188,13 @@ export function DeployInfrastructureForm(props: {
|
||||
|
||||
const chainId = props.chain.chainId;
|
||||
|
||||
const checkout = () => {
|
||||
const [showRpcWarning, setShowRpcWarning] = useState(false);
|
||||
|
||||
const proceedToCheckout = () => {
|
||||
startTransition(async () => {
|
||||
try {
|
||||
const skus: ChainInfraSKU[] = [SERVICE_CONFIG.rpc.sku];
|
||||
const skus: ChainInfraSKU[] = [];
|
||||
if (includeRPC) skus.push(SERVICE_CONFIG.rpc.sku);
|
||||
if (includeInsight) skus.push(SERVICE_CONFIG.insight.sku);
|
||||
if (includeAA) skus.push(SERVICE_CONFIG.accountAbstraction.sku);
|
||||
|
||||
@@ -186,11 +205,9 @@ export function DeployInfrastructureForm(props: {
|
||||
teamSlug: props.teamSlug,
|
||||
});
|
||||
|
||||
// If the action returns, it means redirect did not happen and we have an error
|
||||
if (res.status === "error") {
|
||||
toast.error(res.error);
|
||||
} else if (res.status === "success") {
|
||||
// replace the current page with the checkout page (which will then redirect back to us)
|
||||
window.location.href = res.data;
|
||||
}
|
||||
} catch (err) {
|
||||
@@ -202,6 +219,15 @@ export function DeployInfrastructureForm(props: {
|
||||
});
|
||||
};
|
||||
|
||||
const checkout = () => {
|
||||
const hasAddons = includeInsight || includeAA;
|
||||
if (!includeRPC && hasAddons) {
|
||||
setShowRpcWarning(true);
|
||||
return;
|
||||
}
|
||||
proceedToCheckout();
|
||||
};
|
||||
|
||||
const periodLabel = frequency === "annual" ? "/yr" : "/mo";
|
||||
const isAnnual = frequency === "annual";
|
||||
|
||||
@@ -211,21 +237,22 @@ export function DeployInfrastructureForm(props: {
|
||||
<div className="flex flex-col gap-4">
|
||||
<h3 className="text-lg font-semibold">Select Services</h3>
|
||||
|
||||
{/* Required service */}
|
||||
{/* RPC (now optional) */}
|
||||
<div className="flex flex-col gap-2 mb-6">
|
||||
<ServiceCard
|
||||
description={SERVICE_CONFIG.rpc.description}
|
||||
disabled
|
||||
icon={SERVICE_CONFIG.rpc.icon}
|
||||
label={SERVICE_CONFIG.rpc.label}
|
||||
onToggle={() => {}}
|
||||
onToggle={() => {
|
||||
const newVal = !includeRPC;
|
||||
setRpcParam(newVal ? "on" : "off");
|
||||
}}
|
||||
originalPrice={
|
||||
isAnnual ? SERVICE_CONFIG.rpc.monthlyPrice * 12 : undefined
|
||||
}
|
||||
periodLabel={periodLabel}
|
||||
price={pricePerService.rpc}
|
||||
required
|
||||
selected
|
||||
selected={includeRPC}
|
||||
/>
|
||||
</div>
|
||||
|
||||
@@ -350,7 +377,11 @@ export function DeployInfrastructureForm(props: {
|
||||
|
||||
<Button
|
||||
className="w-full"
|
||||
disabled={isTransitionPending || !props.isOwner}
|
||||
disabled={
|
||||
isTransitionPending ||
|
||||
!props.isOwner ||
|
||||
selectedOrder.length === 0
|
||||
}
|
||||
onClick={checkout}
|
||||
>
|
||||
Proceed to Checkout
|
||||
@@ -362,6 +393,54 @@ export function DeployInfrastructureForm(props: {
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
{/* RPC Omission Warning Modal */}
|
||||
<Dialog open={showRpcWarning} onOpenChange={setShowRpcWarning}>
|
||||
<DialogContent>
|
||||
<DialogHeader>
|
||||
<DialogTitle>Proceed without RPC (not recommended)</DialogTitle>
|
||||
<DialogDescription className="space-y-3">
|
||||
<p>
|
||||
RPC powers core functionality used by <strong>Insight</strong>{" "}
|
||||
and <strong>Account Abstraction</strong>.
|
||||
</p>
|
||||
<div className="space-y-1">
|
||||
<p>Without RPC, you may experience:</p>
|
||||
<ul className="list-disc pl-5 space-y-1">
|
||||
<li>Delayed or missing data in Insight</li>
|
||||
<li>
|
||||
Transaction failures or degraded reliability for Account
|
||||
Abstraction
|
||||
</li>
|
||||
<li>Limited or unsupported features across both services</li>
|
||||
</ul>
|
||||
</div>
|
||||
<p>
|
||||
thirdweb <strong>cannot guarantee</strong> that Insight or
|
||||
Account Abstraction will work as expected without RPC. To ensure
|
||||
reliability, keep RPC enabled.
|
||||
</p>
|
||||
<p>If you still want to continue, confirm below.</p>
|
||||
</DialogDescription>
|
||||
</DialogHeader>
|
||||
<DialogFooter className="gap-2">
|
||||
<Button
|
||||
onClick={() => {
|
||||
reportChainInfraRpcOmissionAgreed({
|
||||
chainId,
|
||||
frequency: frequency === "annual" ? "annual" : "monthly",
|
||||
includeInsight,
|
||||
includeAccountAbstraction: includeAA,
|
||||
});
|
||||
setShowRpcWarning(false);
|
||||
proceedToCheckout();
|
||||
}}
|
||||
variant="destructive"
|
||||
>
|
||||
I understand — proceed without RPC
|
||||
</Button>
|
||||
</DialogFooter>
|
||||
</DialogContent>
|
||||
</Dialog>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
+1
@@ -3,4 +3,5 @@ import { parseAsString, parseAsStringEnum } from "nuqs/server";
|
||||
export const searchParams = {
|
||||
addons: parseAsString.withDefault(""),
|
||||
freq: parseAsStringEnum(["monthly", "annual"]).withDefault("monthly"),
|
||||
rpc: parseAsStringEnum(["on", "off"]).withDefault("on"),
|
||||
};
|
||||
|
||||
+1
-1
@@ -1,8 +1,8 @@
|
||||
import { DialogDescription } from "@radix-ui/react-dialog";
|
||||
import { Button } from "@/components/ui/button";
|
||||
import {
|
||||
Dialog,
|
||||
DialogContent,
|
||||
DialogDescription,
|
||||
DialogFooter,
|
||||
DialogHeader,
|
||||
DialogTitle,
|
||||
|
||||
-3
@@ -70,9 +70,6 @@ export function ProjectHighlightsCard(props: {
|
||||
: "activeUsers"
|
||||
}
|
||||
aggregateFn={(_data, key) => {
|
||||
if (key === "activeUsers") {
|
||||
return Math.max(...timeSeriesData.map((d) => d[key]));
|
||||
}
|
||||
return timeSeriesData.reduce((acc, curr) => acc + curr[key], 0);
|
||||
}}
|
||||
chartConfig={chartConfig}
|
||||
|
||||
+15
-1
@@ -1,6 +1,6 @@
|
||||
"use client";
|
||||
|
||||
import { CodeIcon, WebhookIcon } from "lucide-react";
|
||||
import { ArrowRightLeftIcon, CodeIcon, WebhookIcon } from "lucide-react";
|
||||
import { FeatureCard } from "./FeatureCard.client";
|
||||
|
||||
export function AdvancedSection({
|
||||
@@ -45,6 +45,20 @@ export function AdvancedSection({
|
||||
label: "Setup Webhooks",
|
||||
}}
|
||||
/>
|
||||
<FeatureCard
|
||||
title="Swap Tokens"
|
||||
icon={ArrowRightLeftIcon}
|
||||
setupTime={5}
|
||||
color="green"
|
||||
id="swap_tokens"
|
||||
features={["Swap any token", "Cross-chain swaps"]}
|
||||
description="Swap tokens cross-chain with dedicated swapping endpoints."
|
||||
link={{
|
||||
href: `https://portal.thirdweb.com/payments/swap`,
|
||||
label: "Setup Swaps",
|
||||
target: "_blank",
|
||||
}}
|
||||
/>
|
||||
</div>
|
||||
</section>
|
||||
);
|
||||
|
||||
+3
-3
@@ -20,8 +20,8 @@ export function QuickStartSection({
|
||||
</div>
|
||||
<div className="grid grid-cols-1 sm:grid-cols-2 lg:grid-cols-3 gap-6">
|
||||
<FeatureCard
|
||||
title="Create Payment Links"
|
||||
description="Create shareable URLs to receive any token in seconds."
|
||||
title="Create Payments"
|
||||
description="Create hosted payment UIs to receive any token in seconds."
|
||||
icon={LinkIcon}
|
||||
id="payment_links"
|
||||
color="violet"
|
||||
@@ -59,7 +59,7 @@ export function QuickStartSection({
|
||||
/>
|
||||
<FeatureCard
|
||||
title="UI Components"
|
||||
description="Instantly add payments to your React app with prebuild components."
|
||||
description="Instantly add payments to your React app with prebuilt components."
|
||||
icon={CodeIcon}
|
||||
id="components"
|
||||
color="violet"
|
||||
|
||||
+1
-1
@@ -100,7 +100,7 @@ export function RecentPaymentsSection(props: {
|
||||
<Link
|
||||
href={`/team/${props.teamSlug}/${props.projectSlug}/payments/links`}
|
||||
>
|
||||
Create Payment Link
|
||||
Create Payment
|
||||
<ArrowRightIcon className="size-4" />
|
||||
</Link>
|
||||
</Button>,
|
||||
|
||||
+1
-1
@@ -50,7 +50,7 @@ export default async function Layout(props: {
|
||||
},
|
||||
{
|
||||
exactMatch: true,
|
||||
name: "Links",
|
||||
name: "Create",
|
||||
path: `${payLayoutPath}/links`,
|
||||
},
|
||||
{
|
||||
|
||||
+2
-2
@@ -107,7 +107,7 @@ export function CreatePaymentLinkButton(
|
||||
return result;
|
||||
},
|
||||
onSuccess: () => {
|
||||
toast.success("Payment link created successfully.");
|
||||
toast.success("Payment created successfully.");
|
||||
return queryClient.invalidateQueries({
|
||||
queryKey: ["payment-links", props.clientId, props.teamId],
|
||||
});
|
||||
@@ -169,7 +169,7 @@ export function CreatePaymentLinkButton(
|
||||
)}
|
||||
>
|
||||
<DialogHeader>
|
||||
<DialogTitle>Create a Payment Link</DialogTitle>
|
||||
<DialogTitle>Create a Payment</DialogTitle>
|
||||
<DialogDescription>
|
||||
Get paid in any token on any chain.
|
||||
</DialogDescription>
|
||||
|
||||
+6
-6
@@ -101,8 +101,8 @@ export function PaymentLinksTable(props: { clientId: string; teamId: string }) {
|
||||
return (
|
||||
<EmptyState
|
||||
icon={LinkIcon}
|
||||
title="No payment links configured yet"
|
||||
description="Create a payment link to receive any token in seconds."
|
||||
title="No payments configured yet"
|
||||
description="Create a payment to receive any token in seconds."
|
||||
buttons={[
|
||||
<CreatePaymentLinkButton
|
||||
key="create-payment-link"
|
||||
@@ -111,7 +111,7 @@ export function PaymentLinksTable(props: { clientId: string; teamId: string }) {
|
||||
>
|
||||
<Button className="gap-1" variant="default" size="sm">
|
||||
<PlusIcon className="size-4" />
|
||||
Create Payment Link
|
||||
Create Payment
|
||||
</Button>
|
||||
</CreatePaymentLinkButton>,
|
||||
]}
|
||||
@@ -125,7 +125,7 @@ export function PaymentLinksTable(props: { clientId: string; teamId: string }) {
|
||||
<Table>
|
||||
<TableHeader>
|
||||
<TableRow>
|
||||
<TableHead>Payment Link</TableHead>
|
||||
<TableHead>Payment</TableHead>
|
||||
<TableHead>Recipient</TableHead>
|
||||
<TableHead>Amount</TableHead>
|
||||
<TableHead>Usages</TableHead>
|
||||
@@ -274,7 +274,7 @@ function DeletePaymentLinkButton(
|
||||
<DialogTitle>Are you sure?</DialogTitle>
|
||||
<DialogDescription>
|
||||
This action cannot be undone. This will permanently delete the
|
||||
payment link.
|
||||
payment.
|
||||
</DialogDescription>
|
||||
</DialogHeader>
|
||||
|
||||
@@ -290,7 +290,7 @@ function DeletePaymentLinkButton(
|
||||
});
|
||||
},
|
||||
onSuccess: () => {
|
||||
toast.success("Payment link deleted successfully.");
|
||||
toast.success("Payment deleted successfully.");
|
||||
setOpen(false);
|
||||
return queryClient.invalidateQueries({
|
||||
queryKey: ["payment-links", props.clientId, props.teamId],
|
||||
|
||||
+3
-3
@@ -23,10 +23,10 @@ export default async function Page(props: {
|
||||
<div className="w-full flex flex-col md:flex-row justify-between gap-4 items-start">
|
||||
<div>
|
||||
<h2 className="mb-0.5 font-semibold text-xl tracking-tight">
|
||||
Payment Links
|
||||
Create a Payment
|
||||
</h2>
|
||||
<p className="text-muted-foreground text-sm">
|
||||
Make money in any token instantly with hosted payment links
|
||||
Make money in any token instantly with a hosted payments UI.
|
||||
</p>
|
||||
</div>
|
||||
<CreatePaymentLinkButton
|
||||
@@ -36,7 +36,7 @@ export default async function Page(props: {
|
||||
>
|
||||
<Button className="gap-1" variant="default" size="sm">
|
||||
<PlusIcon className="size-4" />
|
||||
Create Payment Link
|
||||
Create Payment
|
||||
</Button>
|
||||
</CreatePaymentLinkButton>
|
||||
</div>
|
||||
|
||||
+23
@@ -0,0 +1,23 @@
|
||||
import type { ThirdwebClient } from "thirdweb";
|
||||
import { isProd } from "@/constants/env-utils";
|
||||
|
||||
export async function createTokenOnUniversalBridge(params: {
|
||||
chainId: number;
|
||||
tokenAddress: string;
|
||||
client: ThirdwebClient;
|
||||
}) {
|
||||
const domain = isProd ? "thirdweb.com" : "thirdweb-dev.com";
|
||||
const res = await fetch(`https://bridge.${domain}/v1/tokens`, {
|
||||
body: JSON.stringify({
|
||||
chainId: params.chainId.toString(),
|
||||
tokenAddress: params.tokenAddress,
|
||||
}),
|
||||
headers: {
|
||||
"Content-Type": "application/json",
|
||||
"x-client-id": params.client.clientId,
|
||||
},
|
||||
method: "POST",
|
||||
});
|
||||
|
||||
return res;
|
||||
}
|
||||
+1
-1
@@ -34,7 +34,7 @@ export function SocialUrlsFieldset<T extends WithSocialUrls>(props: {
|
||||
<h2 className="mb-2 font-medium text-sm">Social URLs</h2>
|
||||
|
||||
{fields.length > 0 && (
|
||||
<div className="mb-4 space-y-3">
|
||||
<div className="mb-4 space-y-4">
|
||||
{fields.map((field, index) => (
|
||||
<div
|
||||
className="flex gap-3 max-sm:mb-6 max-sm:border-b max-sm:border-dashed max-sm:pb-6"
|
||||
|
||||
+1
-1
@@ -1,4 +1,4 @@
|
||||
import type { ThirdwebClient } from "thirdweb/dist/types/client/client";
|
||||
import type { ThirdwebClient } from "thirdweb";
|
||||
import { useAllChainsData } from "@/hooks/chains/allChains";
|
||||
import { ChainIconClient } from "@/icons/ChainIcon";
|
||||
|
||||
|
||||
+1
-1
@@ -35,7 +35,7 @@ export function StepCard(props: {
|
||||
{props.children}
|
||||
|
||||
{(props.prevButton || props.nextButton) && (
|
||||
<div className="flex justify-end gap-3 border-t p-6">
|
||||
<div className="flex justify-end gap-3 border-t p-4 md:p-6">
|
||||
{props.prevButton && (
|
||||
<Button
|
||||
className="gap-2"
|
||||
|
||||
+2
-1
@@ -9,6 +9,7 @@ import { FormFieldSetup } from "@/components/blocks/FormFieldSetup";
|
||||
import { SingleNetworkSelector } from "@/components/blocks/NetworkSelectors";
|
||||
import { Form } from "@/components/ui/form";
|
||||
import { Input } from "@/components/ui/input";
|
||||
import { Skeleton } from "@/components/ui/skeleton";
|
||||
import { Textarea } from "@/components/ui/textarea";
|
||||
import { AdminAddressesFieldset } from "../../_common/admin-addresses-fieldset";
|
||||
import { SocialUrlsFieldset } from "../../_common/SocialUrls";
|
||||
@@ -95,7 +96,7 @@ export function NFTCollectionInfoFieldset(props: {
|
||||
isRequired
|
||||
label="Chain"
|
||||
>
|
||||
<ClientOnly ssr={null}>
|
||||
<ClientOnly ssr={<Skeleton className="h-10" />}>
|
||||
<SingleNetworkSelector
|
||||
chainId={Number(form.watch("chain"))}
|
||||
className="bg-background"
|
||||
|
||||
+74
-150
@@ -23,13 +23,9 @@ import { useActiveAccount } from "thirdweb/react";
|
||||
import { maxUint256 } from "thirdweb/utils";
|
||||
import { create7702MinimalAccount } from "thirdweb/wallets/smart";
|
||||
import { revalidatePathAction } from "@/actions/revalidate";
|
||||
import {
|
||||
reportAssetCreationFailed,
|
||||
reportContractDeployed,
|
||||
} from "@/analytics/report";
|
||||
import { reportContractDeployed } from "@/analytics/report";
|
||||
import type { Team } from "@/api/team/get-team";
|
||||
import { useAddContractToProject } from "@/hooks/project-contracts";
|
||||
import { parseError } from "@/utils/errorParser";
|
||||
import type { CreateNFTCollectionAllValues } from "./_common/form";
|
||||
import { CreateNFTPageUI } from "./create-nft-page-ui";
|
||||
|
||||
@@ -96,81 +92,67 @@ export function CreateNFTPage(props: {
|
||||
// eslint-disable-next-line no-restricted-syntax
|
||||
const chain = defineChain(Number(collectionInfo.chain));
|
||||
|
||||
try {
|
||||
let contractAddress: string;
|
||||
let contractAddress: string;
|
||||
|
||||
if (ercType === "erc721") {
|
||||
contractAddress = await deployERC721Contract({
|
||||
account,
|
||||
chain: chain,
|
||||
client: props.client,
|
||||
params: {
|
||||
description: collectionInfo.description,
|
||||
image: collectionInfo.image,
|
||||
name: collectionInfo.name,
|
||||
royaltyBps: BigInt(sales.royaltyBps),
|
||||
royaltyRecipient: sales.royaltyRecipient,
|
||||
saleRecipient: sales.primarySaleRecipient,
|
||||
social_urls: transformSocialUrls(collectionInfo.socialUrls),
|
||||
symbol: collectionInfo.symbol,
|
||||
},
|
||||
type: "DropERC721",
|
||||
});
|
||||
} else {
|
||||
contractAddress = await deployERC1155Contract({
|
||||
account,
|
||||
chain: chain,
|
||||
client: props.client,
|
||||
params: {
|
||||
description: collectionInfo.description,
|
||||
image: collectionInfo.image,
|
||||
name: collectionInfo.name,
|
||||
royaltyBps: BigInt(sales.royaltyBps),
|
||||
royaltyRecipient: sales.royaltyRecipient,
|
||||
saleRecipient: sales.primarySaleRecipient,
|
||||
social_urls: transformSocialUrls(collectionInfo.socialUrls),
|
||||
symbol: collectionInfo.symbol,
|
||||
},
|
||||
type: "DropERC1155",
|
||||
});
|
||||
}
|
||||
|
||||
reportContractDeployed({
|
||||
address: contractAddress,
|
||||
chainId: Number(collectionInfo.chain),
|
||||
contractName: contractType,
|
||||
deploymentType: "asset",
|
||||
publisher: "deployer.thirdweb.eth",
|
||||
if (ercType === "erc721") {
|
||||
contractAddress = await deployERC721Contract({
|
||||
account,
|
||||
chain: chain,
|
||||
client: props.client,
|
||||
params: {
|
||||
description: collectionInfo.description,
|
||||
image: collectionInfo.image,
|
||||
name: collectionInfo.name,
|
||||
royaltyBps: BigInt(sales.royaltyBps),
|
||||
royaltyRecipient: sales.royaltyRecipient,
|
||||
saleRecipient: sales.primarySaleRecipient,
|
||||
social_urls: transformSocialUrls(collectionInfo.socialUrls),
|
||||
symbol: collectionInfo.symbol,
|
||||
},
|
||||
type: "DropERC721",
|
||||
});
|
||||
|
||||
contractAddressRef.current = contractAddress;
|
||||
|
||||
// add contract to project in background
|
||||
addContractToProject.mutateAsync({
|
||||
chainId: collectionInfo.chain,
|
||||
contractAddress: contractAddress,
|
||||
contractType: ercType === "erc721" ? "DropERC721" : "DropERC1155",
|
||||
deploymentType: "asset",
|
||||
projectId: props.projectId,
|
||||
teamId: props.teamId,
|
||||
} else {
|
||||
contractAddress = await deployERC1155Contract({
|
||||
account,
|
||||
chain: chain,
|
||||
client: props.client,
|
||||
params: {
|
||||
description: collectionInfo.description,
|
||||
image: collectionInfo.image,
|
||||
name: collectionInfo.name,
|
||||
royaltyBps: BigInt(sales.royaltyBps),
|
||||
royaltyRecipient: sales.royaltyRecipient,
|
||||
saleRecipient: sales.primarySaleRecipient,
|
||||
social_urls: transformSocialUrls(collectionInfo.socialUrls),
|
||||
symbol: collectionInfo.symbol,
|
||||
},
|
||||
type: "DropERC1155",
|
||||
});
|
||||
|
||||
return {
|
||||
contractAddress,
|
||||
};
|
||||
} catch (error) {
|
||||
const errorMessage = parseError(error);
|
||||
console.error(errorMessage);
|
||||
|
||||
reportAssetCreationFailed({
|
||||
assetType: "nft",
|
||||
contractType,
|
||||
error: errorMessage,
|
||||
step: "deploy-contract",
|
||||
});
|
||||
|
||||
throw error;
|
||||
}
|
||||
|
||||
reportContractDeployed({
|
||||
address: contractAddress,
|
||||
chainId: Number(collectionInfo.chain),
|
||||
contractName: contractType,
|
||||
deploymentType: "asset",
|
||||
publisher: "deployer.thirdweb.eth",
|
||||
});
|
||||
|
||||
contractAddressRef.current = contractAddress;
|
||||
|
||||
// add contract to project in background
|
||||
addContractToProject.mutateAsync({
|
||||
chainId: collectionInfo.chain,
|
||||
contractAddress: contractAddress,
|
||||
contractType: ercType === "erc721" ? "DropERC721" : "DropERC1155",
|
||||
deploymentType: "asset",
|
||||
projectId: props.projectId,
|
||||
teamId: props.teamId,
|
||||
});
|
||||
|
||||
return {
|
||||
contractAddress,
|
||||
};
|
||||
}
|
||||
|
||||
async function handleLazyMintNFTs(params: {
|
||||
@@ -179,8 +161,6 @@ export function CreateNFTPage(props: {
|
||||
gasless: boolean;
|
||||
}) {
|
||||
const { values, ercType } = params;
|
||||
const contractType =
|
||||
ercType === "erc721" ? ("DropERC721" as const) : ("DropERC1155" as const);
|
||||
|
||||
const contract = getDeployedContract({
|
||||
chain: values.collectionInfo.chain,
|
||||
@@ -197,24 +177,10 @@ export function CreateNFTPage(props: {
|
||||
nfts: values.nfts,
|
||||
});
|
||||
|
||||
try {
|
||||
await sendAndConfirmTransaction({
|
||||
account,
|
||||
transaction,
|
||||
});
|
||||
} catch (error) {
|
||||
const errorMessage = parseError(error);
|
||||
console.error(error);
|
||||
|
||||
reportAssetCreationFailed({
|
||||
assetType: "nft",
|
||||
contractType,
|
||||
error: errorMessage,
|
||||
step: "mint-nfts",
|
||||
});
|
||||
|
||||
throw error;
|
||||
}
|
||||
await sendAndConfirmTransaction({
|
||||
account,
|
||||
transaction,
|
||||
});
|
||||
}
|
||||
|
||||
async function handleSetClaimConditionsERC721(params: {
|
||||
@@ -251,24 +217,10 @@ export function CreateNFTPage(props: {
|
||||
],
|
||||
});
|
||||
|
||||
try {
|
||||
await sendAndConfirmTransaction({
|
||||
account,
|
||||
transaction,
|
||||
});
|
||||
} catch (error) {
|
||||
const errorMessage = parseError(error);
|
||||
console.error(errorMessage);
|
||||
|
||||
reportAssetCreationFailed({
|
||||
assetType: "nft",
|
||||
contractType: "DropERC721",
|
||||
error: errorMessage,
|
||||
step: "set-claim-conditions",
|
||||
});
|
||||
|
||||
throw error;
|
||||
}
|
||||
await sendAndConfirmTransaction({
|
||||
account,
|
||||
transaction,
|
||||
});
|
||||
}
|
||||
|
||||
async function handleSetClaimConditionsERC1155(params: {
|
||||
@@ -336,24 +288,10 @@ export function CreateNFTPage(props: {
|
||||
data: encodedTransactions,
|
||||
});
|
||||
|
||||
try {
|
||||
await sendAndConfirmTransaction({
|
||||
account,
|
||||
transaction: tx,
|
||||
});
|
||||
} catch (error) {
|
||||
const errorMessage = parseError(error);
|
||||
console.error(errorMessage);
|
||||
|
||||
reportAssetCreationFailed({
|
||||
assetType: "nft",
|
||||
contractType: "DropERC1155",
|
||||
error: errorMessage,
|
||||
step: "set-claim-conditions",
|
||||
});
|
||||
|
||||
throw error;
|
||||
}
|
||||
await sendAndConfirmTransaction({
|
||||
account,
|
||||
transaction: tx,
|
||||
});
|
||||
}
|
||||
|
||||
async function handleSetAdmins(params: {
|
||||
@@ -395,24 +333,10 @@ export function CreateNFTPage(props: {
|
||||
data: encodedTxs,
|
||||
});
|
||||
|
||||
try {
|
||||
await sendAndConfirmTransaction({
|
||||
account,
|
||||
transaction: tx,
|
||||
});
|
||||
} catch (e) {
|
||||
const errorMessage = parseError(e);
|
||||
console.error(errorMessage);
|
||||
|
||||
reportAssetCreationFailed({
|
||||
assetType: "nft",
|
||||
contractType: params.contractType,
|
||||
error: errorMessage,
|
||||
step: "set-admins",
|
||||
});
|
||||
|
||||
throw e;
|
||||
}
|
||||
await sendAndConfirmTransaction({
|
||||
account,
|
||||
transaction: tx,
|
||||
});
|
||||
}
|
||||
|
||||
return (
|
||||
|
||||
+53
-21
@@ -1,6 +1,7 @@
|
||||
import type { UseFormReturn } from "react-hook-form";
|
||||
import * as z from "zod";
|
||||
import { addressSchema, socialUrlsSchema } from "../../_common/schema";
|
||||
import { getInitialTickValue, isValidTickValue } from "../utils/calculate-tick";
|
||||
|
||||
export const tokenInfoFormSchema = z.object({
|
||||
chain: z.string().min(1, "Chain is required"),
|
||||
@@ -15,38 +16,69 @@ export const tokenInfoFormSchema = z.object({
|
||||
.max(10, "Symbol must be 10 characters or less"),
|
||||
});
|
||||
|
||||
const priceAmountSchema = z.string().refine(
|
||||
(value) => {
|
||||
const number = Number(value);
|
||||
return !Number.isNaN(number) && number >= 0;
|
||||
},
|
||||
{
|
||||
message: "Amount must be number larger than or equal to 0",
|
||||
},
|
||||
);
|
||||
|
||||
export const tokenDistributionFormSchema = z.object({
|
||||
// airdrop
|
||||
airdropAddresses: z.array(
|
||||
z.object({
|
||||
address: addressSchema,
|
||||
quantity: z.string(),
|
||||
}),
|
||||
),
|
||||
// UI states
|
||||
airdropEnabled: z.boolean(),
|
||||
saleAllocationPercentage: z.string().refine(
|
||||
(value) => {
|
||||
const number = Number(value);
|
||||
if (Number.isNaN(number)) {
|
||||
// sales ---
|
||||
erc20Asset_poolMode: z.object({
|
||||
startingPricePerToken: priceAmountSchema.refine((value) => {
|
||||
const numValue = Number(value);
|
||||
if (numValue === 0) {
|
||||
return false;
|
||||
}
|
||||
return number >= 0 && number <= 100;
|
||||
},
|
||||
{
|
||||
message: "Must be a number between 0 and 100",
|
||||
},
|
||||
),
|
||||
const tick = getInitialTickValue({
|
||||
startingPricePerToken: Number(value),
|
||||
});
|
||||
|
||||
return isValidTickValue(tick);
|
||||
}, "Invalid price"),
|
||||
saleAllocationPercentage: z.string().refine(
|
||||
(value) => {
|
||||
const number = Number(value);
|
||||
if (Number.isNaN(number)) {
|
||||
return false;
|
||||
}
|
||||
return number >= 0 && number <= 100;
|
||||
},
|
||||
{
|
||||
message: "Must be a number between 0 and 100",
|
||||
},
|
||||
),
|
||||
}),
|
||||
dropERC20Mode: z.object({
|
||||
pricePerToken: priceAmountSchema,
|
||||
saleTokenAddress: addressSchema,
|
||||
saleAllocationPercentage: z.string().refine(
|
||||
(value) => {
|
||||
const number = Number(value);
|
||||
if (Number.isNaN(number)) {
|
||||
return false;
|
||||
}
|
||||
return number >= 0 && number <= 100;
|
||||
},
|
||||
{
|
||||
message: "Must be a number between 0 and 100",
|
||||
},
|
||||
),
|
||||
}),
|
||||
saleEnabled: z.boolean(),
|
||||
salePrice: z.string().refine(
|
||||
(value) => {
|
||||
const number = Number(value);
|
||||
return !Number.isNaN(number) && number >= 0;
|
||||
},
|
||||
{
|
||||
message: "Must be number larger than or equal to 0",
|
||||
},
|
||||
),
|
||||
saleTokenAddress: z.string(),
|
||||
saleMode: z.enum(["erc20-asset:pool", "drop-erc20:token-drop"]),
|
||||
supply: z.string().min(1, "Supply is required"),
|
||||
});
|
||||
|
||||
|
||||
+246
-147
@@ -8,34 +8,37 @@ import {
|
||||
sendAndConfirmTransaction,
|
||||
type ThirdwebClient,
|
||||
toUnits,
|
||||
toWei,
|
||||
} from "thirdweb";
|
||||
import { deployERC20Contract } from "thirdweb/deploys";
|
||||
import type { ClaimConditionsInput } from "thirdweb/dist/types/utils/extensions/drops/types";
|
||||
import {
|
||||
approve,
|
||||
claimTo,
|
||||
getActiveClaimCondition,
|
||||
setClaimConditions as setClaimConditionsExtension,
|
||||
transferBatch,
|
||||
} from "thirdweb/extensions/erc20";
|
||||
import { useActiveAccount } from "thirdweb/react";
|
||||
import {
|
||||
createToken,
|
||||
distributeToken,
|
||||
getDeployedEntrypointERC20,
|
||||
} from "thirdweb/tokens";
|
||||
import type { ClaimConditionsInput } from "thirdweb/utils";
|
||||
import { create7702MinimalAccount } from "thirdweb/wallets/smart";
|
||||
import { revalidatePathAction } from "@/actions/revalidate";
|
||||
import {
|
||||
reportAssetCreationFailed,
|
||||
reportContractDeployed,
|
||||
} from "@/analytics/report";
|
||||
import { reportContractDeployed } from "@/analytics/report";
|
||||
import type { Team } from "@/api/team/get-team";
|
||||
import {
|
||||
DEFAULT_FEE_BPS_NEW,
|
||||
DEFAULT_FEE_RECIPIENT,
|
||||
} from "@/constants/addresses";
|
||||
import { useAllChainsData } from "@/hooks/chains/allChains";
|
||||
import { useAddContractToProject } from "@/hooks/project-contracts";
|
||||
import { defineDashboardChain } from "@/lib/defineDashboardChain";
|
||||
import { parseError } from "@/utils/errorParser";
|
||||
import { pollWithTimeout } from "@/utils/pollWithTimeout";
|
||||
import { createTokenOnUniversalBridge } from "../_apis/create-token-on-bridge";
|
||||
import type { CreateAssetFormValues } from "./_common/form";
|
||||
import { CreateTokenAssetPageUI } from "./create-token-page.client";
|
||||
import { getInitialTickValue } from "./utils/calculate-tick";
|
||||
|
||||
export function CreateTokenAssetPage(props: {
|
||||
accountAddress: string;
|
||||
@@ -47,7 +50,6 @@ export function CreateTokenAssetPage(props: {
|
||||
teamPlan: Team["billingPlan"];
|
||||
}) {
|
||||
const activeAccount = useActiveAccount();
|
||||
const { idToChain } = useAllChainsData();
|
||||
const addContractToProject = useAddContractToProject();
|
||||
const contractAddressRef = useRef<string | undefined>(undefined);
|
||||
|
||||
@@ -83,7 +85,149 @@ export function CreateTokenAssetPage(props: {
|
||||
});
|
||||
}
|
||||
|
||||
async function deployContract(params: {
|
||||
// ERC20Asset ---
|
||||
|
||||
async function Erc20Asset_deployContract(params: {
|
||||
values: CreateAssetFormValues;
|
||||
gasless: boolean;
|
||||
}) {
|
||||
const account = getAccount(params.gasless);
|
||||
|
||||
const socialUrls = params.values.socialUrls.reduce(
|
||||
(acc, url) => {
|
||||
if (url.url && url.platform) {
|
||||
acc[url.platform] = url.url;
|
||||
}
|
||||
return acc;
|
||||
},
|
||||
{} as Record<string, string>,
|
||||
);
|
||||
|
||||
const salePercent = params.values.saleEnabled
|
||||
? Number(params.values.erc20Asset_poolMode.saleAllocationPercentage)
|
||||
: 0;
|
||||
|
||||
const saleAmount = Math.floor(
|
||||
Number(params.values.supply) * (salePercent / 100),
|
||||
);
|
||||
|
||||
const contractAddress = await createToken({
|
||||
account,
|
||||
// eslint-disable-next-line no-restricted-syntax
|
||||
chain: defineChain(Number(params.values.chain)),
|
||||
client: props.client,
|
||||
launchConfig:
|
||||
params.values.saleEnabled && saleAmount !== 0
|
||||
? {
|
||||
kind: "pool",
|
||||
config: {
|
||||
amount: BigInt(saleAmount),
|
||||
initialTick: getInitialTickValue({
|
||||
startingPricePerToken: Number(
|
||||
params.values.erc20Asset_poolMode.startingPricePerToken,
|
||||
),
|
||||
}),
|
||||
developerRewardBps: 1250, // 12.5%
|
||||
},
|
||||
}
|
||||
: undefined,
|
||||
params: {
|
||||
description: params.values.description,
|
||||
image: params.values.image,
|
||||
maxSupply: BigInt(params.values.supply),
|
||||
name: params.values.name,
|
||||
social_urls: socialUrls,
|
||||
symbol: params.values.symbol,
|
||||
},
|
||||
developerAddress: "0x1Af20C6B23373350aD464700B5965CE4B0D2aD94",
|
||||
});
|
||||
|
||||
// add contract to project in background
|
||||
addContractToProject.mutateAsync({
|
||||
chainId: params.values.chain,
|
||||
contractAddress: contractAddress,
|
||||
contractType: "ERC20Asset",
|
||||
deploymentType: "asset",
|
||||
projectId: props.projectId,
|
||||
teamId: props.teamId,
|
||||
});
|
||||
|
||||
reportContractDeployed({
|
||||
address: contractAddress,
|
||||
chainId: Number(params.values.chain),
|
||||
contractName: "DropERC20",
|
||||
deploymentType: "asset",
|
||||
publisher: account.address,
|
||||
});
|
||||
|
||||
contractAddressRef.current = contractAddress;
|
||||
|
||||
return {
|
||||
contractAddress: contractAddress,
|
||||
};
|
||||
}
|
||||
|
||||
async function ERC20Asset_airdropTokens(params: {
|
||||
values: CreateAssetFormValues;
|
||||
gasless: boolean;
|
||||
}) {
|
||||
const { values, gasless } = params;
|
||||
const account = getAccount(gasless);
|
||||
const contract = getDeployedContract({ chain: values.chain });
|
||||
|
||||
const airdropTx = await distributeToken({
|
||||
chain: contract.chain,
|
||||
client: props.client,
|
||||
contents: values.airdropAddresses.map((recipient) => ({
|
||||
amount: recipient.quantity,
|
||||
recipient: recipient.address,
|
||||
})),
|
||||
tokenAddress: contract.address,
|
||||
});
|
||||
|
||||
await sendAndConfirmTransaction({
|
||||
account,
|
||||
transaction: airdropTx,
|
||||
});
|
||||
}
|
||||
|
||||
async function ERC20Asset_approveAirdropTokens(params: {
|
||||
values: CreateAssetFormValues;
|
||||
gasless: boolean;
|
||||
}) {
|
||||
const { values, gasless } = params;
|
||||
const account = getAccount(gasless);
|
||||
const contract = getDeployedContract({ chain: values.chain });
|
||||
|
||||
const totalAmountToAirdrop = values.airdropAddresses.reduce(
|
||||
(acc, recipient) => acc + Number(recipient.quantity),
|
||||
0,
|
||||
);
|
||||
|
||||
const entrypoint = await getDeployedEntrypointERC20({
|
||||
chain: contract.chain,
|
||||
client: props.client,
|
||||
});
|
||||
|
||||
if (!entrypoint) {
|
||||
throw new Error("Entrypoint not found");
|
||||
}
|
||||
|
||||
const approvalTx = approve({
|
||||
amountWei: toWei(totalAmountToAirdrop.toString()),
|
||||
contract: contract,
|
||||
spender: entrypoint.address,
|
||||
});
|
||||
|
||||
await sendAndConfirmTransaction({
|
||||
account,
|
||||
transaction: approvalTx,
|
||||
});
|
||||
}
|
||||
|
||||
// DropERC20 ----
|
||||
|
||||
async function DropERC20_deployContract(params: {
|
||||
values: CreateAssetFormValues;
|
||||
gasless: boolean;
|
||||
}) {
|
||||
@@ -101,73 +245,53 @@ export function CreateTokenAssetPage(props: {
|
||||
{} as Record<string, string>,
|
||||
);
|
||||
|
||||
try {
|
||||
const contractAddress = await deployERC20Contract({
|
||||
account,
|
||||
// eslint-disable-next-line no-restricted-syntax
|
||||
chain: defineDashboardChain(
|
||||
Number(values.chain),
|
||||
idToChain.get(Number(values.chain)),
|
||||
),
|
||||
client: props.client,
|
||||
params: {
|
||||
description: values.description,
|
||||
image: values.image,
|
||||
// metadata
|
||||
name: values.name,
|
||||
// platform fees
|
||||
platformFeeBps: BigInt(DEFAULT_FEE_BPS_NEW),
|
||||
platformFeeRecipient: DEFAULT_FEE_RECIPIENT,
|
||||
// primary sale
|
||||
saleRecipient: account.address,
|
||||
social_urls: socialUrls,
|
||||
symbol: values.symbol,
|
||||
},
|
||||
type: "DropERC20",
|
||||
});
|
||||
const contractAddress = await deployERC20Contract({
|
||||
account,
|
||||
// eslint-disable-next-line no-restricted-syntax
|
||||
chain: defineChain(Number(values.chain)),
|
||||
client: props.client,
|
||||
params: {
|
||||
description: values.description,
|
||||
image: values.image,
|
||||
// metadata
|
||||
name: values.name,
|
||||
// platform fees
|
||||
platformFeeBps: BigInt(DEFAULT_FEE_BPS_NEW),
|
||||
platformFeeRecipient: DEFAULT_FEE_RECIPIENT,
|
||||
// primary sale
|
||||
saleRecipient: account.address,
|
||||
social_urls: socialUrls,
|
||||
symbol: values.symbol,
|
||||
},
|
||||
type: "DropERC20",
|
||||
});
|
||||
|
||||
contractAddressRef.current = contractAddress;
|
||||
contractAddressRef.current = contractAddress;
|
||||
|
||||
// add contract to project in background
|
||||
addContractToProject.mutateAsync({
|
||||
chainId: values.chain,
|
||||
contractAddress: contractAddress,
|
||||
contractType: "DropERC20",
|
||||
deploymentType: "asset",
|
||||
projectId: props.projectId,
|
||||
teamId: props.teamId,
|
||||
});
|
||||
// add contract to project in background
|
||||
addContractToProject.mutateAsync({
|
||||
chainId: values.chain,
|
||||
contractAddress: contractAddress,
|
||||
contractType: "DropERC20",
|
||||
deploymentType: "asset",
|
||||
projectId: props.projectId,
|
||||
teamId: props.teamId,
|
||||
});
|
||||
|
||||
reportContractDeployed({
|
||||
address: contractAddress,
|
||||
chainId: Number(values.chain),
|
||||
contractName: "DropERC20",
|
||||
deploymentType: "asset",
|
||||
publisher: "deployer.thirdweb.eth",
|
||||
});
|
||||
reportContractDeployed({
|
||||
address: contractAddress,
|
||||
chainId: Number(values.chain),
|
||||
contractName: "DropERC20",
|
||||
deploymentType: "asset",
|
||||
publisher: "deployer.thirdweb.eth",
|
||||
});
|
||||
|
||||
return {
|
||||
contractAddress: contractAddress,
|
||||
};
|
||||
} catch (e) {
|
||||
console.error(e);
|
||||
const parsedError = parseError(e);
|
||||
const errorMessage =
|
||||
typeof parsedError === "string" ? parsedError : "Unknown error";
|
||||
|
||||
reportAssetCreationFailed({
|
||||
assetType: "coin",
|
||||
contractType: "DropERC20",
|
||||
error: errorMessage,
|
||||
step: "deploy-contract",
|
||||
});
|
||||
|
||||
console.error(errorMessage);
|
||||
throw e;
|
||||
}
|
||||
return {
|
||||
contractAddress: contractAddress,
|
||||
};
|
||||
}
|
||||
|
||||
async function airdropTokens(params: {
|
||||
async function DropERC20_airdropTokens(params: {
|
||||
values: CreateAssetFormValues;
|
||||
gasless: boolean;
|
||||
}) {
|
||||
@@ -183,34 +307,21 @@ export function CreateTokenAssetPage(props: {
|
||||
throw new Error("No connected account");
|
||||
}
|
||||
|
||||
try {
|
||||
const airdropTx = transferBatch({
|
||||
batch: values.airdropAddresses.map((recipient) => ({
|
||||
amount: recipient.quantity,
|
||||
to: recipient.address,
|
||||
})),
|
||||
contract,
|
||||
});
|
||||
const airdropTx = transferBatch({
|
||||
batch: values.airdropAddresses.map((recipient) => ({
|
||||
amount: recipient.quantity,
|
||||
to: recipient.address,
|
||||
})),
|
||||
contract,
|
||||
});
|
||||
|
||||
await sendAndConfirmTransaction({
|
||||
account,
|
||||
transaction: airdropTx,
|
||||
});
|
||||
} catch (e) {
|
||||
console.error(e);
|
||||
const errorMessage = parseError(e);
|
||||
|
||||
reportAssetCreationFailed({
|
||||
assetType: "coin",
|
||||
contractType: "DropERC20",
|
||||
error: errorMessage,
|
||||
step: "airdrop-tokens",
|
||||
});
|
||||
throw e;
|
||||
}
|
||||
await sendAndConfirmTransaction({
|
||||
account,
|
||||
transaction: airdropTx,
|
||||
});
|
||||
}
|
||||
|
||||
async function mintTokens(params: {
|
||||
async function DropERC20_mintTokens(params: {
|
||||
values: CreateAssetFormValues;
|
||||
gasless: boolean;
|
||||
}) {
|
||||
@@ -235,39 +346,25 @@ export function CreateTokenAssetPage(props: {
|
||||
|
||||
const totalSupply = Number(values.supply);
|
||||
const salePercent = values.saleEnabled
|
||||
? Number(values.saleAllocationPercentage)
|
||||
? Number(values.dropERC20Mode.saleAllocationPercentage)
|
||||
: 0;
|
||||
|
||||
const ownerAndAirdropPercent = 100 - salePercent;
|
||||
const ownerSupplyTokens = (totalSupply * ownerAndAirdropPercent) / 100;
|
||||
|
||||
try {
|
||||
const claimTx = claimTo({
|
||||
contract,
|
||||
quantity: ownerSupplyTokens.toString(),
|
||||
to: account.address,
|
||||
});
|
||||
const claimTx = claimTo({
|
||||
contract,
|
||||
quantity: ownerSupplyTokens.toString(),
|
||||
to: account.address,
|
||||
});
|
||||
|
||||
await sendAndConfirmTransaction({
|
||||
account,
|
||||
transaction: claimTx,
|
||||
});
|
||||
} catch (e) {
|
||||
const errorMessage = parseError(e);
|
||||
console.error(e);
|
||||
|
||||
reportAssetCreationFailed({
|
||||
assetType: "coin",
|
||||
contractType: "DropERC20",
|
||||
error: errorMessage,
|
||||
step: "mint-tokens",
|
||||
});
|
||||
|
||||
throw e;
|
||||
}
|
||||
await sendAndConfirmTransaction({
|
||||
account,
|
||||
transaction: claimTx,
|
||||
});
|
||||
}
|
||||
|
||||
async function setClaimConditions(params: {
|
||||
async function DropERC20_setClaimConditions(params: {
|
||||
values: CreateAssetFormValues;
|
||||
gasless: boolean;
|
||||
}) {
|
||||
@@ -279,7 +376,7 @@ export function CreateTokenAssetPage(props: {
|
||||
const account = getAccount(gasless);
|
||||
|
||||
const salePercent = values.saleEnabled
|
||||
? Number(values.saleAllocationPercentage)
|
||||
? Number(values.dropERC20Mode.saleAllocationPercentage)
|
||||
: 0;
|
||||
|
||||
const totalSupply = Number(values.supply);
|
||||
@@ -288,10 +385,10 @@ export function CreateTokenAssetPage(props: {
|
||||
const phases: ClaimConditionsInput[] = [
|
||||
{
|
||||
currencyAddress:
|
||||
getAddress(values.saleTokenAddress) ===
|
||||
getAddress(values.dropERC20Mode.saleTokenAddress) ===
|
||||
getAddress(NATIVE_TOKEN_ADDRESS)
|
||||
? undefined
|
||||
: values.saleTokenAddress,
|
||||
: values.dropERC20Mode.saleTokenAddress,
|
||||
maxClaimablePerWallet: values.saleEnabled ? undefined : 0n,
|
||||
maxClaimableSupply: totalSupplyWei,
|
||||
metadata: {
|
||||
@@ -307,7 +404,10 @@ export function CreateTokenAssetPage(props: {
|
||||
price: "0",
|
||||
},
|
||||
],
|
||||
price: values.saleEnabled && salePercent > 0 ? values.salePrice : "0",
|
||||
price:
|
||||
values.saleEnabled && salePercent > 0
|
||||
? values.dropERC20Mode.pricePerToken
|
||||
: "0",
|
||||
startTime: new Date(),
|
||||
},
|
||||
];
|
||||
@@ -317,23 +417,10 @@ export function CreateTokenAssetPage(props: {
|
||||
phases,
|
||||
});
|
||||
|
||||
try {
|
||||
await sendAndConfirmTransaction({
|
||||
account,
|
||||
transaction: preparedTx,
|
||||
});
|
||||
} catch (e) {
|
||||
const errorMessage = parseError(e);
|
||||
console.error(e);
|
||||
|
||||
reportAssetCreationFailed({
|
||||
assetType: "coin",
|
||||
contractType: "DropERC20",
|
||||
error: errorMessage,
|
||||
step: "set-claim-conditions",
|
||||
});
|
||||
throw e;
|
||||
}
|
||||
await sendAndConfirmTransaction({
|
||||
account,
|
||||
transaction: preparedTx,
|
||||
});
|
||||
}
|
||||
|
||||
return (
|
||||
@@ -341,12 +428,24 @@ export function CreateTokenAssetPage(props: {
|
||||
accountAddress={props.accountAddress}
|
||||
client={props.client}
|
||||
createTokenFunctions={{
|
||||
airdropTokens,
|
||||
deployContract,
|
||||
mintTokens,
|
||||
setClaimConditions,
|
||||
ERC20Asset: {
|
||||
airdropTokens: ERC20Asset_airdropTokens,
|
||||
deployContract: Erc20Asset_deployContract,
|
||||
approveAirdropTokens: ERC20Asset_approveAirdropTokens,
|
||||
},
|
||||
DropERC20: {
|
||||
deployContract: DropERC20_deployContract,
|
||||
airdropTokens: DropERC20_airdropTokens,
|
||||
mintTokens: DropERC20_mintTokens,
|
||||
setClaimConditions: DropERC20_setClaimConditions,
|
||||
},
|
||||
}}
|
||||
onLaunchSuccess={() => {
|
||||
onLaunchSuccess={(params) => {
|
||||
createTokenOnUniversalBridge({
|
||||
chainId: params.chainId,
|
||||
client: props.client,
|
||||
tokenAddress: params.contractAddress,
|
||||
});
|
||||
revalidatePathAction(
|
||||
`/team/${props.teamSlug}/project/${props.projectId}/tokens`,
|
||||
"page",
|
||||
|
||||
+127
-40
@@ -1,16 +1,24 @@
|
||||
"use client";
|
||||
|
||||
import { zodResolver } from "@hookform/resolvers/zod";
|
||||
import { useQuery } from "@tanstack/react-query";
|
||||
import { useState } from "react";
|
||||
import { useForm } from "react-hook-form";
|
||||
import {
|
||||
defineChain,
|
||||
getAddress,
|
||||
NATIVE_TOKEN_ADDRESS,
|
||||
type ThirdwebClient,
|
||||
} from "thirdweb";
|
||||
import { useActiveWalletChain } from "thirdweb/react";
|
||||
import {
|
||||
getDeployedContractFactory,
|
||||
isPoolRouterEnabled,
|
||||
} from "thirdweb/tokens";
|
||||
import { reportAssetCreationStepConfigured } from "@/analytics/report";
|
||||
import type { Team } from "@/api/team/get-team";
|
||||
import { Spinner } from "@/components/ui/Spinner/Spinner";
|
||||
import { StepCard } from "../_common/step-card";
|
||||
import {
|
||||
type CreateAssetFormValues,
|
||||
type TokenDistributionFormValues,
|
||||
@@ -28,21 +36,33 @@ type CreateTokenFunctionsParams = {
|
||||
};
|
||||
|
||||
export type CreateTokenFunctions = {
|
||||
deployContract: (params: CreateTokenFunctionsParams) => Promise<{
|
||||
contractAddress: string;
|
||||
}>;
|
||||
setClaimConditions: (params: CreateTokenFunctionsParams) => Promise<void>;
|
||||
mintTokens: (params: CreateTokenFunctionsParams) => Promise<void>;
|
||||
airdropTokens: (params: CreateTokenFunctionsParams) => Promise<void>;
|
||||
ERC20Asset: {
|
||||
deployContract: (params: CreateTokenFunctionsParams) => Promise<{
|
||||
contractAddress: string;
|
||||
}>;
|
||||
airdropTokens: (values: CreateTokenFunctionsParams) => Promise<void>;
|
||||
approveAirdropTokens: (values: CreateTokenFunctionsParams) => Promise<void>;
|
||||
};
|
||||
DropERC20: {
|
||||
deployContract: (params: CreateTokenFunctionsParams) => Promise<{
|
||||
contractAddress: string;
|
||||
}>;
|
||||
setClaimConditions: (params: CreateTokenFunctionsParams) => Promise<void>;
|
||||
mintTokens: (params: CreateTokenFunctionsParams) => Promise<void>;
|
||||
airdropTokens: (params: CreateTokenFunctionsParams) => Promise<void>;
|
||||
};
|
||||
};
|
||||
|
||||
const checksummedNativeTokenAddress = getAddress(NATIVE_TOKEN_ADDRESS);
|
||||
const nativeTokenAddress = getAddress(NATIVE_TOKEN_ADDRESS);
|
||||
|
||||
export function CreateTokenAssetPageUI(props: {
|
||||
accountAddress: string;
|
||||
client: ThirdwebClient;
|
||||
createTokenFunctions: CreateTokenFunctions;
|
||||
onLaunchSuccess: () => void;
|
||||
onLaunchSuccess: (params: {
|
||||
chainId: number;
|
||||
contractAddress: string;
|
||||
}) => void;
|
||||
teamSlug: string;
|
||||
projectSlug: string;
|
||||
teamPlan: Team["billingPlan"];
|
||||
@@ -50,11 +70,11 @@ export function CreateTokenAssetPageUI(props: {
|
||||
const [step, setStep] = useState<"token-info" | "distribution" | "launch">(
|
||||
"token-info",
|
||||
);
|
||||
const activeWalletChain = useActiveWalletChain();
|
||||
const activeChain = useActiveWalletChain();
|
||||
|
||||
const tokenInfoForm = useForm<TokenInfoFormValues>({
|
||||
defaultValues: {
|
||||
chain: activeWalletChain?.id.toString() || "1",
|
||||
chain: activeChain?.id.toString() || "8453",
|
||||
description: "",
|
||||
image: undefined,
|
||||
name: "",
|
||||
@@ -74,22 +94,70 @@ export function CreateTokenAssetPageUI(props: {
|
||||
reValidateMode: "onChange",
|
||||
});
|
||||
|
||||
const isERC20AssetSupportedQuery = useQuery({
|
||||
queryKey: ["is-erc20-asset-supported", tokenInfoForm.watch("chain")],
|
||||
queryFn: async () => {
|
||||
try {
|
||||
const res = await getDeployedContractFactory({
|
||||
// eslint-disable-next-line no-restricted-syntax
|
||||
chain: defineChain(Number(tokenInfoForm.watch("chain"))),
|
||||
client: props.client,
|
||||
});
|
||||
return !!res;
|
||||
} catch {
|
||||
return false;
|
||||
}
|
||||
},
|
||||
});
|
||||
|
||||
const isAssetRouterEnabledQuery = useQuery({
|
||||
queryFn: async () => {
|
||||
try {
|
||||
return await isPoolRouterEnabled({
|
||||
// eslint-disable-next-line no-restricted-syntax
|
||||
chain: defineChain(Number(tokenInfoForm.watch("chain"))),
|
||||
client: props.client,
|
||||
});
|
||||
} catch {
|
||||
return false;
|
||||
}
|
||||
},
|
||||
queryKey: ["is-asset-router-enabled", tokenInfoForm.watch("chain")],
|
||||
});
|
||||
|
||||
const defaultSaleMode = isERC20AssetSupportedQuery.data
|
||||
? "erc20-asset:pool"
|
||||
: "drop-erc20:token-drop";
|
||||
|
||||
const tokenDistributionForm = useForm<TokenDistributionFormValues>({
|
||||
resolver: zodResolver(tokenDistributionFormSchema),
|
||||
reValidateMode: "onChange",
|
||||
values: {
|
||||
airdropAddresses: [],
|
||||
// airdrop
|
||||
airdropEnabled: false,
|
||||
// sale fieldset
|
||||
saleAllocationPercentage: "0",
|
||||
saleEnabled: false,
|
||||
salePrice: "0.1",
|
||||
saleTokenAddress: checksummedNativeTokenAddress,
|
||||
supply: "1000000",
|
||||
erc20Asset_poolMode: {
|
||||
startingPricePerToken: "0.000000001", // 1gwei per token
|
||||
saleAllocationPercentage: "100",
|
||||
},
|
||||
dropERC20Mode: {
|
||||
pricePerToken: "0.1",
|
||||
saleAllocationPercentage: "100",
|
||||
saleTokenAddress: nativeTokenAddress,
|
||||
},
|
||||
supply: "1000000000", // 1 billion
|
||||
saleEnabled: !(
|
||||
defaultSaleMode === "erc20-asset:pool" &&
|
||||
!isAssetRouterEnabledQuery.data
|
||||
),
|
||||
saleMode: defaultSaleMode,
|
||||
},
|
||||
mode: "onChange",
|
||||
resolver: zodResolver(tokenDistributionFormSchema),
|
||||
reValidateMode: "onChange",
|
||||
});
|
||||
|
||||
const distributionFieldIsPending =
|
||||
isERC20AssetSupportedQuery.isPending || isAssetRouterEnabledQuery.isPending;
|
||||
|
||||
return (
|
||||
<div>
|
||||
{step === "token-info" && (
|
||||
@@ -97,10 +165,10 @@ export function CreateTokenAssetPageUI(props: {
|
||||
client={props.client}
|
||||
form={tokenInfoForm}
|
||||
onChainUpdated={() => {
|
||||
// if the chain is updated, set the sale token address to the native token address
|
||||
// reset the token address to the native token address on chain change
|
||||
tokenDistributionForm.setValue(
|
||||
"saleTokenAddress",
|
||||
checksummedNativeTokenAddress,
|
||||
"dropERC20Mode.saleTokenAddress",
|
||||
nativeTokenAddress,
|
||||
);
|
||||
}}
|
||||
onNext={() => {
|
||||
@@ -113,25 +181,44 @@ export function CreateTokenAssetPageUI(props: {
|
||||
/>
|
||||
)}
|
||||
|
||||
{step === "distribution" && (
|
||||
<TokenDistributionFieldset
|
||||
accountAddress={props.accountAddress}
|
||||
chainId={tokenInfoForm.watch("chain")}
|
||||
client={props.client}
|
||||
form={tokenDistributionForm}
|
||||
onNext={() => {
|
||||
reportAssetCreationStepConfigured({
|
||||
assetType: "coin",
|
||||
step: "token-distribution",
|
||||
});
|
||||
setStep("launch");
|
||||
}}
|
||||
onPrevious={() => {
|
||||
setStep("token-info");
|
||||
}}
|
||||
tokenSymbol={tokenInfoForm.watch("symbol")}
|
||||
/>
|
||||
)}
|
||||
{step === "distribution" &&
|
||||
(distributionFieldIsPending ? (
|
||||
<StepCard
|
||||
nextButton={{
|
||||
disabled: true,
|
||||
type: "submit",
|
||||
}}
|
||||
prevButton={{
|
||||
onClick: () => {
|
||||
setStep("token-info");
|
||||
},
|
||||
}}
|
||||
title="Coin Distribution"
|
||||
>
|
||||
<div className="h-[400px] flex items-center justify-center">
|
||||
<Spinner className="size-10" />
|
||||
</div>
|
||||
</StepCard>
|
||||
) : (
|
||||
<TokenDistributionFieldset
|
||||
form={tokenDistributionForm}
|
||||
accountAddress={props.accountAddress}
|
||||
chainId={tokenInfoForm.watch("chain")}
|
||||
isRouterEnabled={isAssetRouterEnabledQuery.data === true}
|
||||
client={props.client}
|
||||
onNext={() => {
|
||||
reportAssetCreationStepConfigured({
|
||||
assetType: "coin",
|
||||
step: "token-distribution",
|
||||
});
|
||||
setStep("launch");
|
||||
}}
|
||||
onPrevious={() => {
|
||||
setStep("token-info");
|
||||
}}
|
||||
tokenSymbol={tokenInfoForm.watch("symbol")}
|
||||
/>
|
||||
))}
|
||||
|
||||
{step === "launch" && (
|
||||
<LaunchTokenStatus
|
||||
|
||||
+46
-23
@@ -1,7 +1,10 @@
|
||||
import type { Meta, StoryObj } from "@storybook/nextjs";
|
||||
import { ConnectButton, ThirdwebProvider } from "thirdweb/react";
|
||||
import { storybookThirdwebClient } from "@/storybook/utils";
|
||||
import { CreateTokenAssetPageUI } from "./create-token-page.client";
|
||||
import {
|
||||
CreateTokenAssetPageUI,
|
||||
type CreateTokenFunctions,
|
||||
} from "./create-token-page.client";
|
||||
|
||||
const meta = {
|
||||
component: CreateTokenAssetPageUI,
|
||||
@@ -9,9 +12,9 @@ const meta = {
|
||||
(Story) => (
|
||||
<ThirdwebProvider>
|
||||
<div className="container max-w-5xl py-10">
|
||||
<ConnectButton client={storybookThirdwebClient} />
|
||||
<div className="h-10" />
|
||||
<Story />
|
||||
<div className="h-10" />
|
||||
<ConnectButton client={storybookThirdwebClient} />
|
||||
</div>
|
||||
</ThirdwebProvider>
|
||||
),
|
||||
@@ -22,19 +25,33 @@ const meta = {
|
||||
export default meta;
|
||||
type Story = StoryObj<typeof meta>;
|
||||
|
||||
const mockCreateTokenFunctions = {
|
||||
airdropTokens: async () => {
|
||||
await new Promise((resolve) => setTimeout(resolve, 1000));
|
||||
const mockCreateTokenFunctions: CreateTokenFunctions = {
|
||||
ERC20Asset: {
|
||||
airdropTokens: async () => {
|
||||
await new Promise((resolve) => setTimeout(resolve, 1000));
|
||||
},
|
||||
approveAirdropTokens: async () => {
|
||||
await new Promise((resolve) => setTimeout(resolve, 1000));
|
||||
},
|
||||
deployContract: async () => {
|
||||
await new Promise((resolve) => setTimeout(resolve, 1000));
|
||||
return { contractAddress: "0x123" };
|
||||
},
|
||||
},
|
||||
deployContract: async () => {
|
||||
await new Promise((resolve) => setTimeout(resolve, 1000));
|
||||
return { contractAddress: "0x123" };
|
||||
},
|
||||
mintTokens: async () => {
|
||||
await new Promise((resolve) => setTimeout(resolve, 1000));
|
||||
},
|
||||
setClaimConditions: async () => {
|
||||
await new Promise((resolve) => setTimeout(resolve, 1000));
|
||||
DropERC20: {
|
||||
airdropTokens: async () => {
|
||||
await new Promise((resolve) => setTimeout(resolve, 1000));
|
||||
},
|
||||
deployContract: async () => {
|
||||
await new Promise((resolve) => setTimeout(resolve, 1000));
|
||||
return { contractAddress: "0xa0b86991c6218b36c1d19d4a2e9eb0ce3606eb48" };
|
||||
},
|
||||
setClaimConditions: async () => {
|
||||
await new Promise((resolve) => setTimeout(resolve, 1000));
|
||||
},
|
||||
mintTokens: async () => {
|
||||
await new Promise((resolve) => setTimeout(resolve, 1000));
|
||||
},
|
||||
},
|
||||
};
|
||||
|
||||
@@ -56,9 +73,12 @@ export const ErrorOnDeploy: Story = {
|
||||
client: storybookThirdwebClient,
|
||||
createTokenFunctions: {
|
||||
...mockCreateTokenFunctions,
|
||||
deployContract: async () => {
|
||||
await new Promise((resolve) => setTimeout(resolve, 1000));
|
||||
throw new Error("Failed to deploy contract");
|
||||
ERC20Asset: {
|
||||
...mockCreateTokenFunctions.ERC20Asset,
|
||||
deployContract: async () => {
|
||||
await new Promise((resolve) => setTimeout(resolve, 1000));
|
||||
throw new Error("Failed to deploy contract");
|
||||
},
|
||||
},
|
||||
},
|
||||
onLaunchSuccess: () => {},
|
||||
@@ -74,11 +94,14 @@ export const StorageErrorOnDeploy: Story = {
|
||||
client: storybookThirdwebClient,
|
||||
createTokenFunctions: {
|
||||
...mockCreateTokenFunctions,
|
||||
deployContract: async () => {
|
||||
await new Promise((resolve) => setTimeout(resolve, 1000));
|
||||
throw new Error(
|
||||
"You have reached your storage limit. Please add a valid payment method to continue using the service.",
|
||||
);
|
||||
ERC20Asset: {
|
||||
...mockCreateTokenFunctions.ERC20Asset,
|
||||
deployContract: async () => {
|
||||
await new Promise((resolve) => setTimeout(resolve, 1000));
|
||||
throw new Error(
|
||||
"You have reached your storage limit. Please add a valid payment method to continue using the service.",
|
||||
);
|
||||
},
|
||||
},
|
||||
},
|
||||
onLaunchSuccess: () => {},
|
||||
|
||||
+177
@@ -0,0 +1,177 @@
|
||||
"use client";
|
||||
|
||||
import type { ThirdwebClient } from "thirdweb";
|
||||
import { DistributionBarChart } from "@/components/blocks/distribution-chart";
|
||||
import { FormFieldSetup } from "@/components/blocks/FormFieldSetup";
|
||||
import { TokenSelector } from "@/components/blocks/TokenSelector";
|
||||
import { DynamicHeight } from "@/components/ui/DynamicHeight";
|
||||
import { DecimalInput } from "@/components/ui/decimal-input";
|
||||
import { Switch } from "@/components/ui/switch";
|
||||
import type { TokenDistributionForm } from "../_common/form";
|
||||
|
||||
export function DropERC20_TokenSaleSection(props: {
|
||||
form: TokenDistributionForm;
|
||||
chainId: string;
|
||||
client: ThirdwebClient;
|
||||
}) {
|
||||
const totalSupply = Number(props.form.watch("supply"));
|
||||
const sellSupply = Math.floor(
|
||||
(totalSupply *
|
||||
Number(props.form.watch("dropERC20Mode.saleAllocationPercentage"))) /
|
||||
100,
|
||||
);
|
||||
|
||||
const isEnabled = props.form.watch("saleEnabled");
|
||||
|
||||
const protocolFee = 2;
|
||||
const convenienceFee = 0.5;
|
||||
const recipientFee = 100 - protocolFee - convenienceFee;
|
||||
|
||||
return (
|
||||
<DynamicHeight>
|
||||
<div className="relative border-t border-dashed p-6">
|
||||
<div className="flex items-center justify-between gap-3">
|
||||
<div>
|
||||
<h2 className="font-semibold text-xl tracking-tight">Sale</h2>
|
||||
<p className="text-muted-foreground text-sm">
|
||||
Make your coin available for purchase by setting a price
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<Switch
|
||||
checked={isEnabled}
|
||||
onCheckedChange={(checked) => {
|
||||
props.form.setValue("saleEnabled", checked);
|
||||
if (!checked) {
|
||||
props.form.setValue(
|
||||
"dropERC20Mode.saleAllocationPercentage",
|
||||
"0",
|
||||
);
|
||||
props.form.setValue("dropERC20Mode.pricePerToken", "0");
|
||||
} else if (!props.form.getValues("airdropEnabled")) {
|
||||
props.form.setValue(
|
||||
"dropERC20Mode.saleAllocationPercentage",
|
||||
"100",
|
||||
{
|
||||
shouldValidate: true,
|
||||
},
|
||||
);
|
||||
}
|
||||
}}
|
||||
/>
|
||||
</div>
|
||||
|
||||
{isEnabled && (
|
||||
<div>
|
||||
<div className="mt-4 flex flex-col lg:flex-row gap-5 lg:max-w-2xl fade-in-0 duration-300 animate-in">
|
||||
<FormFieldSetup
|
||||
errorMessage={
|
||||
props.form.formState.errors.dropERC20Mode
|
||||
?.saleAllocationPercentage?.message
|
||||
}
|
||||
helperText={`${sellSupply} tokens`}
|
||||
isRequired
|
||||
label="Sell % of Total Supply"
|
||||
>
|
||||
<div className="relative lg:w-64">
|
||||
<DecimalInput
|
||||
maxValue={100}
|
||||
onChange={(value) => {
|
||||
props.form.setValue(
|
||||
"dropERC20Mode.saleAllocationPercentage",
|
||||
value,
|
||||
);
|
||||
}}
|
||||
value={props.form.watch(
|
||||
"dropERC20Mode.saleAllocationPercentage",
|
||||
)}
|
||||
/>
|
||||
<span className="-translate-y-1/2 absolute top-1/2 right-3 text-muted-foreground text-sm">
|
||||
%
|
||||
</span>
|
||||
</div>
|
||||
</FormFieldSetup>
|
||||
|
||||
<FormFieldSetup
|
||||
className="grow"
|
||||
errorMessage={
|
||||
props.form.formState.errors.dropERC20Mode?.pricePerToken
|
||||
?.message ||
|
||||
props.form.formState.errors.dropERC20Mode?.saleTokenAddress
|
||||
?.message
|
||||
}
|
||||
isRequired
|
||||
label="Price per Token"
|
||||
>
|
||||
<div className="relative flex items-center">
|
||||
<DecimalInput
|
||||
onChange={(value) => {
|
||||
props.form.setValue("dropERC20Mode.pricePerToken", value);
|
||||
}}
|
||||
value={props.form.watch("dropERC20Mode.pricePerToken")}
|
||||
className="rounded-r-none w-42"
|
||||
/>
|
||||
|
||||
<TokenSelector
|
||||
addNativeTokenIfMissing={true}
|
||||
chainId={Number(props.chainId)}
|
||||
className="bg-background border-l-0 rounded-l-none"
|
||||
client={props.client}
|
||||
disableAddress={true}
|
||||
popoverContentClassName="!w-[320px]"
|
||||
onChange={(value) => {
|
||||
props.form.setValue(
|
||||
"dropERC20Mode.saleTokenAddress",
|
||||
value.address,
|
||||
);
|
||||
}}
|
||||
selectedToken={{
|
||||
address: props.form.watch(
|
||||
"dropERC20Mode.saleTokenAddress",
|
||||
),
|
||||
chainId: Number(props.chainId),
|
||||
}}
|
||||
showCheck={true}
|
||||
/>
|
||||
</div>
|
||||
</FormFieldSetup>
|
||||
</div>
|
||||
|
||||
<div className="mt-5 relative border-t border-dashed pt-5">
|
||||
<p className="text-muted-foreground text-sm mb-3">
|
||||
All primary sales are subjected to{" "}
|
||||
<em className="text-foreground font-medium not-italic">2.5%</em>{" "}
|
||||
fee
|
||||
</p>
|
||||
|
||||
<div className="relative">
|
||||
<DistributionBarChart
|
||||
segments={[
|
||||
{
|
||||
label: "Your Wallet",
|
||||
percent: recipientFee,
|
||||
color: "hsl(var(--chart-1))",
|
||||
value: `${recipientFee}%`,
|
||||
},
|
||||
{
|
||||
label: "Protocol",
|
||||
percent: protocolFee,
|
||||
color: "hsl(var(--chart-2))",
|
||||
value: `${protocolFee}%`,
|
||||
},
|
||||
{
|
||||
label: "Convenience Fee",
|
||||
percent: convenienceFee,
|
||||
color: "hsl(var(--chart-3))",
|
||||
value: `${convenienceFee}%`,
|
||||
},
|
||||
]}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</DynamicHeight>
|
||||
);
|
||||
}
|
||||
+10
-5
@@ -66,10 +66,10 @@ export function TokenAirdropSection(props: {
|
||||
|
||||
return (
|
||||
<DynamicHeight>
|
||||
<div className="relative border-t border-dashed">
|
||||
<div className="flex items-center justify-between gap-3 px-6 py-5">
|
||||
<div className="relative border-t border-dashed px-4 py-8 md:px-6">
|
||||
<div className="flex items-center justify-between gap-3 ">
|
||||
<div>
|
||||
<h2 className="font-semibold text-lg">Airdrop</h2>
|
||||
<h2 className="font-semibold text-xl tracking-tight">Airdrop</h2>
|
||||
<p className="text-muted-foreground text-sm">
|
||||
Airdrop tokens to a list of addresses with each address receiving
|
||||
a specific quantity
|
||||
@@ -88,7 +88,7 @@ export function TokenAirdropSection(props: {
|
||||
</div>
|
||||
|
||||
{isEnabled && (
|
||||
<div className="flex justify-start px-6 pb-6">
|
||||
<div className="flex justify-start mt-4 fade-in-0 duration-300 animate-in">
|
||||
{airdropAddresses.length > 0 ? (
|
||||
<div className="flex w-full flex-col gap-4 rounded-lg border bg-background p-4 md:flex-row lg:items-center lg:justify-between">
|
||||
{/* left */}
|
||||
@@ -262,8 +262,13 @@ const AirdropUpload: React.FC<AirdropUploadProps> = ({
|
||||
|
||||
if (csvUpload.normalizeQuery.isPending) {
|
||||
return (
|
||||
<div className="flex h-[300px] w-full grow items-center justify-center rounded-lg border border-border">
|
||||
<div className="flex min-h-[400px] w-full flex-col grow items-center justify-center">
|
||||
<Spinner className="size-10" />
|
||||
<p className="text-base text-foreground mt-5">Resolving ENS</p>
|
||||
<p className="text-sm text-muted-foreground mt-2">
|
||||
{csvUpload.normalizeProgress.current} /{" "}
|
||||
{csvUpload.normalizeProgress.total}
|
||||
</p>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
+50
-24
@@ -14,21 +14,28 @@ import type {
|
||||
TokenDistributionForm,
|
||||
TokenDistributionFormValues,
|
||||
} from "../_common/form";
|
||||
import { DropERC20_TokenSaleSection } from "./drop-erc20-token-sale";
|
||||
import { TokenAirdropSection } from "./token-airdrop";
|
||||
import { TokenSaleSection } from "./token-sale";
|
||||
|
||||
const compactNumberFormatter = new Intl.NumberFormat("en-US", {
|
||||
maximumFractionDigits: 10,
|
||||
notation: "compact",
|
||||
});
|
||||
|
||||
export function TokenDistributionFieldset(props: {
|
||||
accountAddress: string;
|
||||
onNext: () => void;
|
||||
onPrevious: () => void;
|
||||
form: TokenDistributionForm;
|
||||
chainId: string;
|
||||
form: TokenDistributionForm;
|
||||
client: ThirdwebClient;
|
||||
tokenSymbol: string | undefined;
|
||||
isRouterEnabled: boolean;
|
||||
}) {
|
||||
const { form } = props;
|
||||
const distributionError = getDistributionError(form);
|
||||
|
||||
const distributionError = getDistributionError(form);
|
||||
const supplyId = useId();
|
||||
|
||||
return (
|
||||
@@ -45,40 +52,51 @@ export function TokenDistributionFieldset(props: {
|
||||
title="Coin Distribution"
|
||||
>
|
||||
<div>
|
||||
<div className="space-y-6 p-4 md:px-6 md:py-6">
|
||||
<div className="p-4 md:px-6 md:py-6">
|
||||
<FormFieldSetup
|
||||
errorMessage={form.formState.errors.supply?.message}
|
||||
htmlFor={supplyId}
|
||||
helperText={`${compactNumberFormatter.format(Number(form.watch("supply")))} tokens`}
|
||||
isRequired
|
||||
label="Total Supply"
|
||||
>
|
||||
<div className="relative">
|
||||
<div className="relative max-w-96">
|
||||
<Input id={supplyId} {...form.register("supply")} />
|
||||
<span className="-translate-y-1/2 absolute top-1/2 right-3 text-muted-foreground text-sm">
|
||||
{props.tokenSymbol || "Tokens"}
|
||||
</span>
|
||||
</div>
|
||||
</FormFieldSetup>
|
||||
|
||||
<div className="flex flex-col gap-3">
|
||||
<TokenDistributionBarChart
|
||||
distributionFormValues={form.watch()}
|
||||
/>
|
||||
|
||||
{distributionError && (
|
||||
<div className="text-destructive-text text-sm">
|
||||
{distributionError}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{form.watch("saleMode") === "drop-erc20:token-drop" ? (
|
||||
<DropERC20_TokenSaleSection
|
||||
chainId={props.chainId}
|
||||
client={props.client}
|
||||
form={form}
|
||||
/>
|
||||
) : (
|
||||
<TokenSaleSection
|
||||
chainId={props.chainId}
|
||||
isRouterEnabled={props.isRouterEnabled}
|
||||
client={props.client}
|
||||
form={form}
|
||||
/>
|
||||
)}
|
||||
|
||||
<TokenAirdropSection client={props.client} form={form} />
|
||||
<TokenSaleSection
|
||||
chainId={props.chainId}
|
||||
client={props.client}
|
||||
form={form}
|
||||
/>
|
||||
|
||||
<div className="flex flex-col gap-3 p-4 py-8 md:px-6 border-t border-dashed">
|
||||
<TokenDistributionBarChart
|
||||
distributionFormValues={form.watch()}
|
||||
/>
|
||||
|
||||
{distributionError && (
|
||||
<div className="text-destructive-text text-sm">
|
||||
{distributionError}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
</StepCard>
|
||||
</form>
|
||||
@@ -97,10 +115,12 @@ function getDistributionError(form: TokenDistributionForm) {
|
||||
}
|
||||
|
||||
const saleSupplyPercentage = SafeNumber(
|
||||
form.watch("saleAllocationPercentage"),
|
||||
form.watch("saleMode") === "erc20-asset:pool"
|
||||
? form.watch("erc20Asset_poolMode.saleAllocationPercentage")
|
||||
: form.watch("dropERC20Mode.saleAllocationPercentage"),
|
||||
);
|
||||
|
||||
const saleSupply = (saleSupplyPercentage / 100) * supply;
|
||||
const saleSupply = Math.round((saleSupplyPercentage / 100) * supply);
|
||||
const ownerSupply = Math.max(supply - totalAirdrop - saleSupply, 0);
|
||||
const totalSumOfSupply = totalAirdrop + saleSupply + ownerSupply;
|
||||
|
||||
@@ -127,7 +147,10 @@ export function TokenDistributionBarChart(props: {
|
||||
);
|
||||
const airdropPercentage = (totalAirdropSupply / totalSupply) * 100;
|
||||
const salePercentage = Number(
|
||||
props.distributionFormValues.saleAllocationPercentage,
|
||||
props.distributionFormValues.saleMode === "erc20-asset:pool"
|
||||
? props.distributionFormValues.erc20Asset_poolMode
|
||||
.saleAllocationPercentage
|
||||
: props.distributionFormValues.dropERC20Mode.saleAllocationPercentage,
|
||||
);
|
||||
|
||||
const ownerPercentage = Math.max(100 - airdropPercentage - salePercentage, 0);
|
||||
@@ -137,16 +160,19 @@ export function TokenDistributionBarChart(props: {
|
||||
color: "hsl(var(--chart-1))",
|
||||
label: "Owner",
|
||||
percent: ownerPercentage,
|
||||
value: `${ownerPercentage}%`,
|
||||
},
|
||||
{
|
||||
color: "hsl(var(--chart-3))",
|
||||
label: "Airdrop",
|
||||
percent: airdropPercentage,
|
||||
value: `${airdropPercentage}%`,
|
||||
},
|
||||
{
|
||||
color: "hsl(var(--chart-4))",
|
||||
label: "Sale",
|
||||
percent: salePercentage,
|
||||
value: `${salePercentage}%`,
|
||||
},
|
||||
];
|
||||
|
||||
|
||||
+231
-82
@@ -1,110 +1,139 @@
|
||||
"use client";
|
||||
|
||||
import { DollarSignIcon, XIcon } from "lucide-react";
|
||||
import type { ThirdwebClient } from "thirdweb";
|
||||
import { DistributionBarChart } from "@/components/blocks/distribution-chart";
|
||||
import { FormFieldSetup } from "@/components/blocks/FormFieldSetup";
|
||||
import { TokenSelector } from "@/components/blocks/TokenSelector";
|
||||
import { Badge } from "@/components/ui/badge";
|
||||
import { DynamicHeight } from "@/components/ui/DynamicHeight";
|
||||
import { DecimalInput } from "@/components/ui/decimal-input";
|
||||
import {
|
||||
Select,
|
||||
SelectContent,
|
||||
SelectItem,
|
||||
SelectTrigger,
|
||||
SelectValue,
|
||||
} from "@/components/ui/select";
|
||||
import { Switch } from "@/components/ui/switch";
|
||||
import { useAllChainsData } from "@/hooks/chains/allChains";
|
||||
import type { TokenDistributionForm } from "../_common/form";
|
||||
|
||||
export function TokenSaleSection(props: {
|
||||
form: TokenDistributionForm;
|
||||
chainId: string;
|
||||
client: ThirdwebClient;
|
||||
isRouterEnabled: boolean;
|
||||
}) {
|
||||
const totalSupply = Number(props.form.watch("supply"));
|
||||
const sellSupply = Math.floor(
|
||||
(totalSupply * Number(props.form.watch("saleAllocationPercentage"))) / 100,
|
||||
);
|
||||
const { idToChain } = useAllChainsData();
|
||||
const chainMeta = idToChain.get(Number(props.chainId));
|
||||
const isSaleEnabled = props.form.watch("saleEnabled");
|
||||
const protocolFee = 20;
|
||||
const leftOverFee = 100 - protocolFee;
|
||||
const convenienceFee = (12.5 * leftOverFee) / 100;
|
||||
const deployerFee = leftOverFee - convenienceFee;
|
||||
|
||||
const isEnabled = props.form.watch("saleEnabled");
|
||||
return (
|
||||
<DynamicHeight>
|
||||
<div className="relative border-t border-dashed p-6">
|
||||
<div className="flex items-center justify-between gap-3">
|
||||
<div>
|
||||
<h2 className="font-semibold text-lg">Sale</h2>
|
||||
<p className="text-muted-foreground text-sm">
|
||||
Make your coin available for purchase by setting a price
|
||||
</p>
|
||||
<div className="relative border-t border-dashed px-4 md:px-6 py-8">
|
||||
<div>
|
||||
<div className="flex items-center justify-between gap-3">
|
||||
<div>
|
||||
<h2 className="font-semibold text-xl tracking-tight">Sale</h2>
|
||||
<p className="text-muted-foreground text-sm">
|
||||
List your coin on a decentralized exchange and earn rewards on
|
||||
every trade
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<Switch
|
||||
checked={isSaleEnabled}
|
||||
disabled={!props.isRouterEnabled}
|
||||
onCheckedChange={(checked) => {
|
||||
if (!props.isRouterEnabled) {
|
||||
return;
|
||||
}
|
||||
|
||||
props.form.setValue("saleEnabled", checked);
|
||||
|
||||
if (checked && !props.form.getValues("airdropEnabled")) {
|
||||
props.form.setValue(
|
||||
"erc20Asset_poolMode.saleAllocationPercentage",
|
||||
"100",
|
||||
{
|
||||
shouldValidate: true,
|
||||
},
|
||||
);
|
||||
} else {
|
||||
props.form.setValue(
|
||||
"erc20Asset_poolMode.saleAllocationPercentage",
|
||||
"0",
|
||||
{
|
||||
shouldValidate: true,
|
||||
},
|
||||
);
|
||||
}
|
||||
}}
|
||||
/>
|
||||
</div>
|
||||
|
||||
<Switch
|
||||
checked={isEnabled}
|
||||
onCheckedChange={(checked) => {
|
||||
props.form.setValue("saleEnabled", checked);
|
||||
if (!checked) {
|
||||
props.form.setValue("saleAllocationPercentage", "0");
|
||||
props.form.setValue("salePrice", "0");
|
||||
}
|
||||
}}
|
||||
/>
|
||||
{props.isRouterEnabled === false && (
|
||||
<div className="mt-4 flex items-center gap-1.5 fade-in-0 duration-300 animate-in">
|
||||
<div className="rounded-full border bg-background p-1">
|
||||
<XIcon className="size-3 text-muted-foreground" />
|
||||
</div>
|
||||
<h2 className="text-muted-foreground text-sm">
|
||||
Not Available on {chainMeta?.name || props.chainId}
|
||||
</h2>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{isEnabled && (
|
||||
<div className="mt-4 flex flex-col gap-6">
|
||||
<FormFieldSetup
|
||||
errorMessage={
|
||||
props.form.formState.errors.saleAllocationPercentage?.message
|
||||
}
|
||||
helperText={`${sellSupply} tokens`}
|
||||
isRequired
|
||||
label="Sell % of Total Supply"
|
||||
>
|
||||
<div className="relative">
|
||||
<DecimalInput
|
||||
maxValue={100}
|
||||
onChange={(value) => {
|
||||
props.form.setValue("saleAllocationPercentage", value);
|
||||
}}
|
||||
value={props.form.watch("saleAllocationPercentage")}
|
||||
/>
|
||||
<span className="-translate-y-1/2 absolute top-1/2 right-3 text-muted-foreground text-sm">
|
||||
%
|
||||
</span>
|
||||
</div>
|
||||
</FormFieldSetup>
|
||||
{isSaleEnabled && props.isRouterEnabled === true && (
|
||||
<div className="pt-4 fade-in-0 duration-300 animate-in">
|
||||
<PoolConfig
|
||||
chainId={props.chainId}
|
||||
client={props.client}
|
||||
form={props.form}
|
||||
/>
|
||||
|
||||
<div className="grid grid-cols-1 gap-4 lg:grid-cols-2">
|
||||
<FormFieldSetup
|
||||
errorMessage={props.form.formState.errors.salePrice?.message}
|
||||
isRequired
|
||||
label="Price per Token"
|
||||
>
|
||||
<div className="relative">
|
||||
<DecimalInput
|
||||
onChange={(value) => {
|
||||
props.form.setValue("salePrice", value);
|
||||
}}
|
||||
value={props.form.watch("salePrice")}
|
||||
/>
|
||||
<div className="mt-5 border p-4 bg-background rounded-lg relative">
|
||||
<div className="flex mb-4">
|
||||
<div className="p-2 rounded-full border bg-card">
|
||||
<DollarSignIcon className="size-5 text-muted-foreground" />
|
||||
</div>
|
||||
</FormFieldSetup>
|
||||
</div>
|
||||
<h3 className="font-medium text-base mt-2"> Sale Rewards </h3>
|
||||
<p className="text-muted-foreground text-sm mb-4">
|
||||
All trades on the market are subjected to{" "}
|
||||
<em className="text-foreground font-medium not-italic"> 1% </em>{" "}
|
||||
fee distributed as:
|
||||
</p>
|
||||
|
||||
<FormFieldSetup
|
||||
errorMessage={
|
||||
props.form.formState.errors.saleTokenAddress?.message
|
||||
}
|
||||
isRequired
|
||||
label="Currency"
|
||||
>
|
||||
<TokenSelector
|
||||
addNativeTokenIfMissing={true}
|
||||
chainId={Number(props.chainId)}
|
||||
className="bg-background"
|
||||
client={props.client}
|
||||
disableAddress={true}
|
||||
onChange={(value) => {
|
||||
props.form.setValue("saleTokenAddress", value.address);
|
||||
}}
|
||||
selectedToken={{
|
||||
address: props.form.watch("saleTokenAddress"),
|
||||
chainId: Number(props.chainId),
|
||||
}}
|
||||
showCheck={true}
|
||||
<div className="relative">
|
||||
<div className="absolute -top-6 right-0 text-sm">Total: 1%</div>
|
||||
<DistributionBarChart
|
||||
segments={[
|
||||
{
|
||||
label: "Your Wallet",
|
||||
percent: deployerFee,
|
||||
color: "hsl(var(--chart-1))",
|
||||
value: `${deployerFee / 100}%`,
|
||||
},
|
||||
{
|
||||
label: "Protocol",
|
||||
percent: protocolFee,
|
||||
color: "hsl(var(--chart-2))",
|
||||
value: `${protocolFee / 100}%`,
|
||||
},
|
||||
{
|
||||
label: "Convenience fee",
|
||||
percent: convenienceFee,
|
||||
color: "hsl(var(--chart-3))",
|
||||
value: `${convenienceFee / 100}%`,
|
||||
},
|
||||
]}
|
||||
/>
|
||||
</FormFieldSetup>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
@@ -112,3 +141,123 @@ export function TokenSaleSection(props: {
|
||||
</DynamicHeight>
|
||||
);
|
||||
}
|
||||
|
||||
function PoolConfig(props: {
|
||||
form: TokenDistributionForm;
|
||||
chainId: string;
|
||||
client: ThirdwebClient;
|
||||
}) {
|
||||
const { idToChain } = useAllChainsData();
|
||||
const chainMeta = idToChain.get(Number(props.chainId));
|
||||
|
||||
const totalSupply = Number(props.form.watch("supply"));
|
||||
const sellSupply = Math.floor(
|
||||
(totalSupply *
|
||||
Number(
|
||||
props.form.watch("erc20Asset_poolMode.saleAllocationPercentage"),
|
||||
)) /
|
||||
100,
|
||||
);
|
||||
|
||||
return (
|
||||
<div className="space-y-6">
|
||||
{/* Pricing Strategy */}
|
||||
<FormFieldSetup
|
||||
isRequired
|
||||
label="Pricing Strategy"
|
||||
errorMessage={undefined}
|
||||
helperText="Bonding Curve pricing is a static formula for pricing tokens based on supply and demand"
|
||||
>
|
||||
<Select value="bonding-curve">
|
||||
<SelectTrigger>
|
||||
<SelectValue />
|
||||
</SelectTrigger>
|
||||
<SelectContent>
|
||||
<SelectItem value="bonding-curve">Bonding Curve</SelectItem>
|
||||
<SelectItem value="fixed-price" disabled>
|
||||
<span className="flex items-center gap-2">
|
||||
Dynamic Bonding Curve{" "}
|
||||
<Badge variant="secondary">Coming Soon</Badge>
|
||||
</span>
|
||||
</SelectItem>
|
||||
<SelectItem value="dutch-auction" disabled>
|
||||
<span className="flex items-center gap-2">
|
||||
Fixed Price <Badge variant="secondary">Coming Soon</Badge>
|
||||
</span>
|
||||
</SelectItem>
|
||||
</SelectContent>
|
||||
</Select>
|
||||
</FormFieldSetup>
|
||||
|
||||
<div className="grid grid-cols-1 lg:grid-cols-2 gap-4 lg:max-w-[536px]">
|
||||
{/* supply % */}
|
||||
<FormFieldSetup
|
||||
errorMessage={
|
||||
props.form.formState.errors.erc20Asset_poolMode
|
||||
?.saleAllocationPercentage?.message
|
||||
}
|
||||
helperText={`${compactNumberFormatter.format(sellSupply)} tokens`}
|
||||
isRequired
|
||||
label="Sell % of Total Supply"
|
||||
>
|
||||
<div className="relative">
|
||||
<DecimalInput
|
||||
maxValue={100}
|
||||
onChange={(value) => {
|
||||
props.form.setValue(
|
||||
"erc20Asset_poolMode.saleAllocationPercentage",
|
||||
value,
|
||||
{
|
||||
shouldValidate: true,
|
||||
},
|
||||
);
|
||||
}}
|
||||
value={props.form.watch(
|
||||
"erc20Asset_poolMode.saleAllocationPercentage",
|
||||
)}
|
||||
/>
|
||||
<span className="-translate-y-1/2 absolute top-1/2 right-3 text-sm text-muted-foreground">
|
||||
%
|
||||
</span>
|
||||
</div>
|
||||
</FormFieldSetup>
|
||||
|
||||
{/* starting price */}
|
||||
<FormFieldSetup
|
||||
errorMessage={
|
||||
props.form.formState.errors.erc20Asset_poolMode
|
||||
?.startingPricePerToken?.message
|
||||
}
|
||||
isRequired
|
||||
label="Starting price per token"
|
||||
>
|
||||
<div className="relative">
|
||||
<DecimalInput
|
||||
className="pr-10"
|
||||
onChange={(value) => {
|
||||
props.form.setValue(
|
||||
"erc20Asset_poolMode.startingPricePerToken",
|
||||
value,
|
||||
{
|
||||
shouldValidate: true,
|
||||
},
|
||||
);
|
||||
}}
|
||||
value={props.form.watch(
|
||||
"erc20Asset_poolMode.startingPricePerToken",
|
||||
)}
|
||||
/>
|
||||
<span className="-translate-y-1/2 absolute top-1/2 right-3 text-sm text-muted-foreground">
|
||||
{chainMeta?.nativeCurrency.symbol || "ETH"}
|
||||
</span>
|
||||
</div>
|
||||
</FormFieldSetup>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
const compactNumberFormatter = new Intl.NumberFormat("en-US", {
|
||||
maximumFractionDigits: 10,
|
||||
notation: "compact",
|
||||
});
|
||||
|
||||
+114
-48
@@ -38,10 +38,15 @@ import type { CreateTokenFunctions } from "../create-token-page.client";
|
||||
import { TokenDistributionBarChart } from "../distribution/token-distribution";
|
||||
|
||||
const stepIds = {
|
||||
"airdrop-tokens": "airdrop-tokens",
|
||||
"deploy-contract": "deploy-contract",
|
||||
"mint-tokens": "mint-tokens",
|
||||
"set-claim-conditions": "set-claim-conditions",
|
||||
// asset ---
|
||||
"erc20-asset:airdrop-tokens": "erc20-asset:airdrop-tokens",
|
||||
"erc20-asset:approve-airdrop-tokens": "erc20-asset:approve-airdrop-tokens",
|
||||
"erc20-asset:deploy-contract": "erc20-asset:deploy-contract",
|
||||
// fallback ---
|
||||
"drop-erc20:deploy-contract": "drop-erc20:deploy-contract",
|
||||
"drop-erc20:set-claim-conditions": "drop-erc20:set-claim-conditions",
|
||||
"drop-erc20:mint-tokens": "drop-erc20:mint-tokens",
|
||||
"drop-erc20:airdrop-tokens": "drop-erc20:airdrop-tokens",
|
||||
} as const;
|
||||
|
||||
type StepId = keyof typeof stepIds;
|
||||
@@ -51,7 +56,10 @@ export function LaunchTokenStatus(props: {
|
||||
values: CreateAssetFormValues;
|
||||
onPrevious: () => void;
|
||||
client: ThirdwebClient;
|
||||
onLaunchSuccess: () => void;
|
||||
onLaunchSuccess: (params: {
|
||||
chainId: number;
|
||||
contractAddress: string;
|
||||
}) => void;
|
||||
teamSlug: string;
|
||||
projectSlug: string;
|
||||
teamPlan: Team["billingPlan"];
|
||||
@@ -60,7 +68,7 @@ export function LaunchTokenStatus(props: {
|
||||
const { createTokenFunctions } = props;
|
||||
const [steps, setSteps] = useState<MultiStepState<StepId>[]>([]);
|
||||
const [isModalOpen, setIsModalOpen] = useState(false);
|
||||
const [contractLink, setContractLink] = useState<string | null>(null);
|
||||
const [contractAddress, setContractAddress] = useState<string | null>(null);
|
||||
const activeWallet = useActiveWallet();
|
||||
const walletRequiresApproval = activeWallet?.id !== "inApp";
|
||||
|
||||
@@ -85,35 +93,67 @@ export function LaunchTokenStatus(props: {
|
||||
}
|
||||
|
||||
async function handleSubmitClick() {
|
||||
const initialSteps: MultiStepState<StepId>[] = [
|
||||
{
|
||||
id: stepIds["deploy-contract"],
|
||||
label: "Deploy contract",
|
||||
status: { type: "idle" },
|
||||
},
|
||||
{
|
||||
id: stepIds["set-claim-conditions"],
|
||||
label: "Set claim conditions",
|
||||
status: { type: "idle" },
|
||||
},
|
||||
{
|
||||
id: stepIds["mint-tokens"],
|
||||
label: "Mint tokens",
|
||||
status: { type: "idle" },
|
||||
},
|
||||
];
|
||||
if (formValues.saleMode === "erc20-asset:pool") {
|
||||
const initialSteps: MultiStepState<StepId>[] = [
|
||||
{
|
||||
id: stepIds["erc20-asset:deploy-contract"],
|
||||
label: "Deploy contract",
|
||||
status: { type: "idle" },
|
||||
},
|
||||
];
|
||||
|
||||
if (formValues.airdropEnabled && formValues.airdropAddresses.length > 0) {
|
||||
initialSteps.push({
|
||||
id: stepIds["airdrop-tokens"],
|
||||
label: "Airdrop tokens",
|
||||
status: { type: "idle" },
|
||||
});
|
||||
if (formValues.airdropEnabled && formValues.airdropAddresses.length > 0) {
|
||||
initialSteps.push({
|
||||
id: stepIds["erc20-asset:approve-airdrop-tokens"],
|
||||
label: "Approve spending tokens for airdrop",
|
||||
status: { type: "idle" },
|
||||
});
|
||||
|
||||
initialSteps.push({
|
||||
id: stepIds["erc20-asset:airdrop-tokens"],
|
||||
label: "Airdrop tokens",
|
||||
status: { type: "idle" },
|
||||
});
|
||||
}
|
||||
|
||||
setSteps(initialSteps);
|
||||
setIsModalOpen(true);
|
||||
executeSteps(initialSteps, 0, isGasless);
|
||||
} else {
|
||||
const initialSteps: MultiStepState<StepId>[] = [
|
||||
{
|
||||
id: stepIds["drop-erc20:deploy-contract"],
|
||||
label: "Deploy contract",
|
||||
status: { type: "idle" },
|
||||
},
|
||||
{
|
||||
id: stepIds["drop-erc20:set-claim-conditions"],
|
||||
label: "Set claim conditions",
|
||||
status: { type: "idle" },
|
||||
},
|
||||
];
|
||||
|
||||
// if user is selling 100% of the tokens, owner share is 0% - so skip minting
|
||||
if (Number(formValues.dropERC20Mode.saleAllocationPercentage) !== 100) {
|
||||
initialSteps.push({
|
||||
id: stepIds["drop-erc20:mint-tokens"],
|
||||
label: "Mint tokens",
|
||||
status: { type: "idle" },
|
||||
});
|
||||
}
|
||||
|
||||
if (formValues.airdropEnabled && formValues.airdropAddresses.length > 0) {
|
||||
initialSteps.push({
|
||||
id: stepIds["drop-erc20:airdrop-tokens"],
|
||||
label: "Airdrop tokens",
|
||||
status: { type: "idle" },
|
||||
});
|
||||
}
|
||||
|
||||
setSteps(initialSteps);
|
||||
setIsModalOpen(true);
|
||||
executeSteps(initialSteps, 0, isGasless);
|
||||
}
|
||||
|
||||
setSteps(initialSteps);
|
||||
setIsModalOpen(true);
|
||||
executeSteps(initialSteps, 0, isGasless);
|
||||
}
|
||||
|
||||
const isComplete = steps.every((step) => step.status.type === "completed");
|
||||
@@ -125,17 +165,28 @@ export function LaunchTokenStatus(props: {
|
||||
values: formValues,
|
||||
};
|
||||
|
||||
if (stepId === "deploy-contract") {
|
||||
const result = await createTokenFunctions.deployContract(params);
|
||||
setContractLink(
|
||||
`/team/${props.teamSlug}/${props.projectSlug}/contract/${formValues.chain}/${result.contractAddress}`,
|
||||
);
|
||||
} else if (stepId === "set-claim-conditions") {
|
||||
await createTokenFunctions.setClaimConditions(params);
|
||||
} else if (stepId === "mint-tokens") {
|
||||
await createTokenFunctions.mintTokens(params);
|
||||
} else if (stepId === "airdrop-tokens") {
|
||||
await createTokenFunctions.airdropTokens(params);
|
||||
// erc20-asset
|
||||
if (stepId === "erc20-asset:deploy-contract") {
|
||||
const result =
|
||||
await createTokenFunctions.ERC20Asset.deployContract(params);
|
||||
setContractAddress(result.contractAddress);
|
||||
} else if (stepId === "erc20-asset:airdrop-tokens") {
|
||||
await createTokenFunctions.ERC20Asset.airdropTokens(params);
|
||||
} else if (stepId === "erc20-asset:approve-airdrop-tokens") {
|
||||
await createTokenFunctions.ERC20Asset.approveAirdropTokens(params);
|
||||
}
|
||||
|
||||
// drop-erc20
|
||||
else if (stepId === "drop-erc20:deploy-contract") {
|
||||
const result =
|
||||
await createTokenFunctions.DropERC20.deployContract(params);
|
||||
setContractAddress(result.contractAddress);
|
||||
} else if (stepId === "drop-erc20:set-claim-conditions") {
|
||||
await createTokenFunctions.DropERC20.setClaimConditions(params);
|
||||
} else if (stepId === "drop-erc20:mint-tokens") {
|
||||
await createTokenFunctions.DropERC20.mintTokens(params);
|
||||
} else if (stepId === "drop-erc20:airdrop-tokens") {
|
||||
await createTokenFunctions.DropERC20.airdropTokens(params);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -165,7 +216,10 @@ export function LaunchTokenStatus(props: {
|
||||
|
||||
reportAssetCreationFailed({
|
||||
assetType: "coin",
|
||||
contractType: "DropERC20",
|
||||
contractType:
|
||||
formValues.saleMode === "drop-erc20:token-drop"
|
||||
? "DropERC20"
|
||||
: "ERC20Asset",
|
||||
error: errorMessage,
|
||||
step: currentStep.id,
|
||||
});
|
||||
@@ -181,10 +235,18 @@ export function LaunchTokenStatus(props: {
|
||||
|
||||
reportAssetCreationSuccessful({
|
||||
assetType: "coin",
|
||||
contractType: "DropERC20",
|
||||
contractType:
|
||||
formValues.saleMode === "drop-erc20:token-drop"
|
||||
? "DropERC20"
|
||||
: "ERC20Asset",
|
||||
});
|
||||
|
||||
props.onLaunchSuccess();
|
||||
if (contractAddress) {
|
||||
props.onLaunchSuccess({
|
||||
chainId: Number(formValues.chain),
|
||||
contractAddress,
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
async function handleRetry(step: MultiStepState<StepId>, gasless: boolean) {
|
||||
@@ -196,6 +258,10 @@ export function LaunchTokenStatus(props: {
|
||||
await executeSteps(steps, startIndex, gasless);
|
||||
}
|
||||
|
||||
const contractLink = contractAddress
|
||||
? `/team/${props.teamSlug}/${props.projectSlug}/contract/${formValues.chain}/${contractAddress}`
|
||||
: null;
|
||||
|
||||
return (
|
||||
<StepCard
|
||||
nextButton={{
|
||||
@@ -302,7 +368,7 @@ export function LaunchTokenStatus(props: {
|
||||
className="gap-0 overflow-hidden p-0 md:max-w-[480px]"
|
||||
dialogCloseClassName="hidden"
|
||||
>
|
||||
<div className="flex flex-col gap-6 p-6">
|
||||
<div className="flex flex-col gap-6 p-6 overflow-hidden">
|
||||
<DialogHeader className="space-y-0.5">
|
||||
<DialogTitle className="font-semibold text-xl tracking-tight">
|
||||
Status
|
||||
|
||||
+20
-5
@@ -8,6 +8,7 @@ import { FormFieldSetup } from "@/components/blocks/FormFieldSetup";
|
||||
import { SingleNetworkSelector } from "@/components/blocks/NetworkSelectors";
|
||||
import { Form } from "@/components/ui/form";
|
||||
import { Input } from "@/components/ui/input";
|
||||
import { Skeleton } from "@/components/ui/skeleton";
|
||||
import { Textarea } from "@/components/ui/textarea";
|
||||
import { SocialUrlsFieldset } from "../../_common/SocialUrls";
|
||||
import { StepCard } from "../../_common/step-card";
|
||||
@@ -47,7 +48,7 @@ export function TokenInfoFieldset(props: {
|
||||
client={props.client}
|
||||
setValue={(file) =>
|
||||
form.setValue("image", file, {
|
||||
shouldTouch: true,
|
||||
shouldValidate: true,
|
||||
})
|
||||
}
|
||||
value={form.watch("image")}
|
||||
@@ -59,7 +60,7 @@ export function TokenInfoFieldset(props: {
|
||||
{/* name + symbol */}
|
||||
<div className="flex flex-col gap-6 lg:flex-row lg:gap-4">
|
||||
<FormFieldSetup
|
||||
className="grow"
|
||||
className="lg:max-w-[300px] grow"
|
||||
errorMessage={form.formState.errors.name?.message}
|
||||
htmlFor={nameId}
|
||||
isRequired
|
||||
@@ -73,7 +74,7 @@ export function TokenInfoFieldset(props: {
|
||||
</FormFieldSetup>
|
||||
|
||||
<FormFieldSetup
|
||||
className="lg:max-w-[200px]"
|
||||
className="lg:max-w-[150px]"
|
||||
errorMessage={form.formState.errors.symbol?.message}
|
||||
htmlFor={symbolId}
|
||||
isRequired
|
||||
@@ -94,14 +95,28 @@ export function TokenInfoFieldset(props: {
|
||||
isRequired
|
||||
label="Chain"
|
||||
>
|
||||
<ClientOnly ssr={null}>
|
||||
<ClientOnly ssr={<Skeleton className="h-10" />}>
|
||||
<SingleNetworkSelector
|
||||
priorityChains={[
|
||||
8453, // base
|
||||
84532, // base sepolia
|
||||
1, // ethereum
|
||||
56, // bnb smart chain mainnet (bsc)
|
||||
42161, // arbitrum one mainnet
|
||||
43114, // avalanche mainnet
|
||||
146, // sonic
|
||||
137, // polygon
|
||||
80094, // berachain mainnet
|
||||
10, // optimism
|
||||
]}
|
||||
chainId={Number(form.watch("chain"))}
|
||||
className="bg-background"
|
||||
client={props.client}
|
||||
disableChainId
|
||||
onChange={(chain) => {
|
||||
form.setValue("chain", chain.toString());
|
||||
form.setValue("chain", chain.toString(), {
|
||||
shouldValidate: true,
|
||||
});
|
||||
props.onChainUpdated();
|
||||
}}
|
||||
/>
|
||||
|
||||
+17
@@ -0,0 +1,17 @@
|
||||
const MIN_TICK = -887200;
|
||||
const MAX_TICK = 887200;
|
||||
const TICK_SPACING = 200;
|
||||
|
||||
export function getInitialTickValue(params: { startingPricePerToken: number }) {
|
||||
const calculatedTick =
|
||||
Math.log(params.startingPricePerToken) / Math.log(1.0001);
|
||||
|
||||
// Round to nearest tick spacing
|
||||
const tick = Math.round(calculatedTick / TICK_SPACING) * TICK_SPACING;
|
||||
|
||||
return tick;
|
||||
}
|
||||
|
||||
export function isValidTickValue(tick: number) {
|
||||
return tick >= MIN_TICK && tick <= MAX_TICK;
|
||||
}
|
||||
+1
@@ -86,6 +86,7 @@ const authMethodsToPickFrom: InAppWalletAuth[] = [
|
||||
"discord",
|
||||
"line",
|
||||
"x",
|
||||
"tiktok",
|
||||
"coinbase",
|
||||
"farcaster",
|
||||
"telegram",
|
||||
|
||||
+157
-92
@@ -3,7 +3,6 @@ import { zodResolver } from "@hookform/resolvers/zod";
|
||||
import { useMutation } from "@tanstack/react-query";
|
||||
import type { ProjectEmbeddedWalletsService } from "@thirdweb-dev/service-utils";
|
||||
import { CircleAlertIcon, PlusIcon, Trash2Icon } from "lucide-react";
|
||||
import Link from "next/link";
|
||||
import type React from "react";
|
||||
import { useState } from "react";
|
||||
import { type UseFormReturn, useFieldArray, useForm } from "react-hook-form";
|
||||
@@ -240,11 +239,7 @@ export const InAppWalletSettingsUI: React.FC<
|
||||
|
||||
return (
|
||||
<Form {...form}>
|
||||
<form
|
||||
autoComplete="off"
|
||||
className="flex flex-col gap-6"
|
||||
onSubmit={handleSubmit}
|
||||
>
|
||||
<form autoComplete="off" className="space-y-6" onSubmit={handleSubmit}>
|
||||
{/* Branding */}
|
||||
<BrandingFieldset
|
||||
client={props.client}
|
||||
@@ -252,12 +247,28 @@ export const InAppWalletSettingsUI: React.FC<
|
||||
requiredPlan={brandingRequiredPlan}
|
||||
teamPlan={props.teamPlan}
|
||||
teamSlug={props.teamSlug}
|
||||
isUpdating={props.isUpdating}
|
||||
/>
|
||||
|
||||
<NativeAppsFieldset form={form} />
|
||||
<NativeAppsFieldset form={form} isUpdating={props.isUpdating} />
|
||||
|
||||
{/* Authentication */}
|
||||
<Fieldset legend="Authentication">
|
||||
<Fieldset
|
||||
legend="Authentication"
|
||||
footer={
|
||||
<div className="flex justify-end p-4 md:px-6 border-t border-dashed">
|
||||
<Button
|
||||
className="gap-2"
|
||||
type="submit"
|
||||
size="sm"
|
||||
variant="outline"
|
||||
>
|
||||
{props.isUpdating && <Spinner className="size-4" />}
|
||||
Save
|
||||
</Button>
|
||||
</div>
|
||||
}
|
||||
>
|
||||
<JSONWebTokenFields
|
||||
form={form}
|
||||
requiredPlan={authRequiredPlan}
|
||||
@@ -265,7 +276,7 @@ export const InAppWalletSettingsUI: React.FC<
|
||||
teamSlug={props.teamSlug}
|
||||
/>
|
||||
|
||||
<div className="h-5" />
|
||||
<div className="my-5 border-t border-dashed" />
|
||||
|
||||
<AuthEndpointFields
|
||||
form={form}
|
||||
@@ -274,7 +285,7 @@ export const InAppWalletSettingsUI: React.FC<
|
||||
teamSlug={props.teamSlug}
|
||||
/>
|
||||
|
||||
<div className="h-5" />
|
||||
<div className="my-5 border-t border-dashed" />
|
||||
|
||||
<SMSCountryFields
|
||||
form={form}
|
||||
@@ -284,13 +295,6 @@ export const InAppWalletSettingsUI: React.FC<
|
||||
teamSlug={props.teamSlug}
|
||||
/>
|
||||
</Fieldset>
|
||||
|
||||
<div className="flex justify-end">
|
||||
<Button className="gap-2" type="submit" variant="primary">
|
||||
{props.isUpdating && <Spinner className="size-4" />}
|
||||
Save changes
|
||||
</Button>
|
||||
</div>
|
||||
</form>
|
||||
</Form>
|
||||
);
|
||||
@@ -302,14 +306,22 @@ function BrandingFieldset(props: {
|
||||
teamSlug: string;
|
||||
requiredPlan: Team["billingPlan"];
|
||||
client: ThirdwebClient;
|
||||
isUpdating: boolean;
|
||||
}) {
|
||||
return (
|
||||
<Fieldset legend="Branding">
|
||||
<SwitchContainer
|
||||
description="Pass a custom logo and app name to be used in the emails sent to users."
|
||||
switchId="branding-switch"
|
||||
title="Custom email logo and name"
|
||||
>
|
||||
<FieldsetWithDescription
|
||||
legend="Branding"
|
||||
description="Set an app name and logo to be used in the emails sent to users"
|
||||
footer={
|
||||
<div className="flex justify-end p-4 md:px-6 border-t border-dashed">
|
||||
<Button className="gap-2" type="submit" size="sm" variant="outline">
|
||||
{props.isUpdating && <Spinner className="size-4" />}
|
||||
Save
|
||||
</Button>
|
||||
</div>
|
||||
}
|
||||
>
|
||||
<div className="absolute top-8 right-6">
|
||||
<GatedSwitch
|
||||
currentPlan={props.teamPlan}
|
||||
requiredPlan={props.requiredPlan}
|
||||
@@ -330,64 +342,69 @@ function BrandingFieldset(props: {
|
||||
teamSlug={props.teamSlug}
|
||||
trackingLabel="customEmailLogoAndName"
|
||||
/>
|
||||
</SwitchContainer>
|
||||
</div>
|
||||
|
||||
<GatedCollapsibleContainer
|
||||
className="grid grid-cols-1 gap-6 lg:grid-cols-2"
|
||||
className="border-t border-dashed pt-5"
|
||||
currentPlan={props.teamPlan}
|
||||
isExpanded={!!props.form.watch("branding")}
|
||||
requiredPlan={props.requiredPlan}
|
||||
>
|
||||
{/* Application Image */}
|
||||
<FormField
|
||||
control={props.form.control}
|
||||
name="branding.applicationImageUrl"
|
||||
render={() => (
|
||||
<FormItem className="space-y-1">
|
||||
<FormLabel>Application Image URL</FormLabel>
|
||||
<FormDescription className="!mb-4">
|
||||
Logo that will display in the emails sent to users.{" "}
|
||||
<br className="max-sm:hidden" /> The image must be squared with
|
||||
recommended size of 72x72 px.
|
||||
</FormDescription>
|
||||
<FormControl>
|
||||
<AppImageFormControl
|
||||
client={props.client}
|
||||
setUri={(uri) => {
|
||||
props.form.setValue("branding.applicationImageUrl", uri, {
|
||||
shouldDirty: true,
|
||||
shouldTouch: true,
|
||||
});
|
||||
}}
|
||||
uri={props.form.watch("branding.applicationImageUrl")}
|
||||
/>
|
||||
</FormControl>
|
||||
<FormMessage />
|
||||
</FormItem>
|
||||
)}
|
||||
/>
|
||||
<div className="flex flex-col gap-6 lg:flex-row">
|
||||
<FormField
|
||||
control={props.form.control}
|
||||
name="branding.applicationImageUrl"
|
||||
render={() => (
|
||||
<FormItem className="lg:border-r lg:border-dashed lg:pr-6">
|
||||
<div className="space-y-1">
|
||||
<FormLabel>Application Image URL</FormLabel>
|
||||
<FormDescription className="!mb-4">
|
||||
Logo that will display in the emails sent to users.{" "}
|
||||
<br className="max-sm:hidden" /> The image must be squared
|
||||
with recommended size of 72x72 px.
|
||||
</FormDescription>
|
||||
|
||||
{/* Application Name */}
|
||||
<FormField
|
||||
control={props.form.control}
|
||||
name="branding.applicationName"
|
||||
render={({ field }) => (
|
||||
<FormItem>
|
||||
<FormLabel>Application Name</FormLabel>
|
||||
<FormDescription className="!mb-2">
|
||||
Name that will be displayed in the emails sent to users.{" "}
|
||||
<br className="max-sm:hidden" /> Defaults to your API Key's
|
||||
name.
|
||||
</FormDescription>
|
||||
<FormControl>
|
||||
<Input {...field} />
|
||||
</FormControl>
|
||||
<FormMessage />
|
||||
</FormItem>
|
||||
)}
|
||||
/>
|
||||
<FormMessage />
|
||||
</div>
|
||||
|
||||
<FormControl>
|
||||
<AppImageFormControl
|
||||
client={props.client}
|
||||
setUri={(uri) => {
|
||||
props.form.setValue("branding.applicationImageUrl", uri, {
|
||||
shouldDirty: true,
|
||||
shouldTouch: true,
|
||||
});
|
||||
}}
|
||||
uri={props.form.watch("branding.applicationImageUrl")}
|
||||
/>
|
||||
</FormControl>
|
||||
</FormItem>
|
||||
)}
|
||||
/>
|
||||
|
||||
{/* Application Name */}
|
||||
<FormField
|
||||
control={props.form.control}
|
||||
name="branding.applicationName"
|
||||
render={({ field }) => (
|
||||
<FormItem>
|
||||
<FormLabel>Application Name</FormLabel>
|
||||
<FormDescription>
|
||||
Name that will be displayed in the emails sent to users.{" "}
|
||||
<br className="max-sm:hidden" /> Defaults to your API Key's
|
||||
name.
|
||||
</FormDescription>
|
||||
<FormControl>
|
||||
<Input {...field} className="max-w-md" />
|
||||
</FormControl>
|
||||
<FormMessage />
|
||||
</FormItem>
|
||||
)}
|
||||
/>
|
||||
</div>
|
||||
</GatedCollapsibleContainer>
|
||||
</Fieldset>
|
||||
</FieldsetWithDescription>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -418,7 +435,7 @@ function AppImageFormControl(props: {
|
||||
<div className="relative">
|
||||
<FileInput
|
||||
accept={{ "image/*": [] }}
|
||||
className="w-24 rounded-full bg-background lg:w-28"
|
||||
className="w-24 rounded-full bg-background"
|
||||
client={props.client}
|
||||
disableHelperText
|
||||
setValue={async (v) => {
|
||||
@@ -514,14 +531,13 @@ function JSONWebTokenFields(props: {
|
||||
description={
|
||||
<>
|
||||
Optionally allow users to authenticate with a custom JWT.{" "}
|
||||
<Link
|
||||
className="text-link-foreground hover:text-foreground"
|
||||
<UnderlineLink
|
||||
href="https://portal.thirdweb.com/wallets/custom-auth"
|
||||
rel="noopener noreferrer"
|
||||
target="_blank"
|
||||
>
|
||||
Learn more
|
||||
</Link>
|
||||
</UnderlineLink>
|
||||
</>
|
||||
}
|
||||
switchId="authentication-switch"
|
||||
@@ -606,14 +622,13 @@ function AuthEndpointFields(props: {
|
||||
<>
|
||||
Optionally allow users to authenticate with any arbitrary payload
|
||||
that you provide.{" "}
|
||||
<Link
|
||||
className="text-link-foreground hover:text-foreground"
|
||||
<UnderlineLink
|
||||
href="https://portal.thirdweb.com/wallets/custom-auth"
|
||||
rel="noopener noreferrer"
|
||||
target="_blank"
|
||||
>
|
||||
Learn more
|
||||
</Link>
|
||||
</UnderlineLink>
|
||||
</>
|
||||
}
|
||||
switchId="auth-endpoint-switch"
|
||||
@@ -743,10 +758,21 @@ function AuthEndpointFieldsContent(props: {
|
||||
|
||||
function NativeAppsFieldset(props: {
|
||||
form: UseFormReturn<ApiKeyEmbeddedWalletsValidationSchema>;
|
||||
isUpdating: boolean;
|
||||
}) {
|
||||
const { form } = props;
|
||||
return (
|
||||
<Fieldset legend="Native Apps">
|
||||
<Fieldset
|
||||
legend="Native Apps"
|
||||
footer={
|
||||
<div className="flex justify-end p-4 md:px-6 border-t border-dashed">
|
||||
<Button className="gap-2" type="submit" size="sm" variant="outline">
|
||||
{props.isUpdating && <Spinner className="size-4" />}
|
||||
Save
|
||||
</Button>
|
||||
</div>
|
||||
}
|
||||
>
|
||||
<FormField
|
||||
control={form.control}
|
||||
name="redirectUrls"
|
||||
@@ -789,21 +815,60 @@ function GatedCollapsibleContainer(props: {
|
||||
return null;
|
||||
}
|
||||
|
||||
return <div className={cn("mt-6", props.className)}>{props.children}</div>;
|
||||
return (
|
||||
<div
|
||||
className={cn("mt-5 fade-in-0 duration-300 animate-in", props.className)}
|
||||
>
|
||||
{props.children}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
function Fieldset(props: { legend: string; children: React.ReactNode }) {
|
||||
function Fieldset(props: {
|
||||
legend: string;
|
||||
children: React.ReactNode;
|
||||
footer?: React.ReactNode;
|
||||
}) {
|
||||
return (
|
||||
<DynamicHeight>
|
||||
<fieldset className="rounded-lg border border-border bg-card p-4 md:p-6">
|
||||
{/* put inside div to remove default styles on legend */}
|
||||
<div className="mb-4 font-semibold text-xl tracking-tight">
|
||||
<legend> {props.legend}</legend>
|
||||
</div>
|
||||
<div className="rounded-lg border bg-card relative">
|
||||
<DynamicHeight>
|
||||
<fieldset className="p-4 md:px-6 md:py-5">
|
||||
{/* put inside div to remove default styles on legend */}
|
||||
<div className="border-b pb-4 mb-5 border-dashed font-semibold text-xl tracking-tight">
|
||||
<legend> {props.legend}</legend>
|
||||
</div>
|
||||
|
||||
{props.children}
|
||||
</fieldset>
|
||||
</DynamicHeight>
|
||||
{props.children}
|
||||
</fieldset>
|
||||
</DynamicHeight>
|
||||
{props.footer}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
function FieldsetWithDescription(props: {
|
||||
legend: string;
|
||||
children: React.ReactNode;
|
||||
footer?: React.ReactNode;
|
||||
description: React.ReactNode;
|
||||
}) {
|
||||
return (
|
||||
<div className="rounded-lg border bg-card relative">
|
||||
<DynamicHeight>
|
||||
<fieldset className="p-4 md:p-6">
|
||||
{/* put inside div to remove default styles on legend */}
|
||||
<div className="pr-20">
|
||||
<legend className="font-semibold text-xl tracking-tight">
|
||||
{props.legend}
|
||||
</legend>
|
||||
<p className="text-muted-foreground text-sm">{props.description}</p>
|
||||
</div>
|
||||
|
||||
{props.children}
|
||||
</fieldset>
|
||||
</DynamicHeight>
|
||||
{props.footer}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
|
||||
+139
-105
@@ -1,6 +1,8 @@
|
||||
/** biome-ignore-all lint/a11y/useSemanticElements: EXPECTED */
|
||||
|
||||
import { CheckIcon, MinusIcon } from "lucide-react";
|
||||
import { CheckIcon, ChevronDownIcon } from "lucide-react";
|
||||
import { useState } from "react";
|
||||
import { Button } from "@/components/ui/button";
|
||||
import { Checkbox } from "@/components/ui/checkbox";
|
||||
import { cn } from "@/lib/utils";
|
||||
import type { SMSCountryTiers } from "../../api/sms";
|
||||
@@ -31,17 +33,6 @@ export default function CountrySelector({
|
||||
return tierCountries.every((country) => isCountrySelected(country));
|
||||
};
|
||||
|
||||
// Check if some countries in a tier are selected
|
||||
const isTierIndeterminate = (tier: string) => {
|
||||
const tierCountries = countryTiers[tier as keyof typeof countryTiers];
|
||||
const selectedInTier = tierCountries.filter((country) =>
|
||||
isCountrySelected(country),
|
||||
);
|
||||
return (
|
||||
selectedInTier.length > 0 && selectedInTier.length < tierCountries.length
|
||||
);
|
||||
};
|
||||
|
||||
// Toggle a tier selection
|
||||
const toggleTier = (tier: string) => {
|
||||
const tierCountries = countryTiers[tier as keyof typeof countryTiers];
|
||||
@@ -81,101 +72,144 @@ export default function CountrySelector({
|
||||
onChange(newSelected);
|
||||
};
|
||||
|
||||
// Get selected countries count for a tier
|
||||
const getSelectedCountInTier = (tier: string) => {
|
||||
const tierCountries = countryTiers[tier as keyof typeof countryTiers];
|
||||
return tierCountries.filter((country) => isCountrySelected(country)).length;
|
||||
};
|
||||
|
||||
return (
|
||||
<div className="space-y-3">
|
||||
{Object.entries(countryTiers).map(([tier, countries], index) => (
|
||||
<div className="overflow-hidden rounded-md border" key={tier}>
|
||||
<div className="flex items-center bg-muted/30 p-2">
|
||||
<div className="relative mr-2 flex items-center">
|
||||
<Checkbox
|
||||
checked={isTierSelected(tier)}
|
||||
className="h-4 w-4"
|
||||
id={`tier-${index + 1}`}
|
||||
onCheckedChange={() => toggleTier(tier)}
|
||||
/>
|
||||
{isTierIndeterminate(tier) && (
|
||||
<MinusIcon className="-translate-x-1/2 -translate-y-1/2 pointer-events-none absolute top-1/2 left-1/2 h-2.5 w-2.5 transform text-primary" />
|
||||
)}
|
||||
</div>
|
||||
<label
|
||||
className="flex-1 cursor-pointer font-medium text-sm"
|
||||
htmlFor={`tier-${index + 1}`}
|
||||
>
|
||||
Tier {index + 1}
|
||||
{isTierIndeterminate(tier) && (
|
||||
<span className="ml-1 font-normal text-muted-foreground text-xs">
|
||||
({getSelectedCountInTier(tier)}/{countries.length})
|
||||
</span>
|
||||
)}
|
||||
</label>
|
||||
<span
|
||||
className={cn(
|
||||
"font-medium text-xs",
|
||||
tier === "tier1"
|
||||
? "text-green-600 dark:text-green-500"
|
||||
: "text-muted-foreground",
|
||||
)}
|
||||
>
|
||||
{tierPricing[tier as keyof typeof tierPricing]}
|
||||
</span>
|
||||
</div>
|
||||
<div className="space-y-5">
|
||||
{Object.entries(countryTiers).map(([tier, tierCountries], index) => {
|
||||
const selectedTierCountries = tierCountries.filter((country) =>
|
||||
isCountrySelected(country),
|
||||
);
|
||||
|
||||
<div
|
||||
className={cn(
|
||||
"grid grid-cols-2 gap-1.5 p-2",
|
||||
countries.length === 2
|
||||
? "md:grid-cols-2"
|
||||
: countries.length === 3
|
||||
? "md:grid-cols-3"
|
||||
: "md:grid-cols-4",
|
||||
)}
|
||||
>
|
||||
{countries.map((country) => (
|
||||
<div
|
||||
className={cn(
|
||||
"relative flex cursor-pointer items-center justify-between rounded-md border p-1.5 transition-colors ",
|
||||
isCountrySelected(country)
|
||||
? "border-primary bg-primary/10"
|
||||
: "border-transparent hover:bg-muted/50",
|
||||
)}
|
||||
key={country}
|
||||
onClick={() => toggleCountry(country)}
|
||||
onKeyDown={(e) => {
|
||||
if (e.key === "Enter" || e.key === " ") {
|
||||
toggleCountry(country);
|
||||
}
|
||||
}}
|
||||
role="button"
|
||||
tabIndex={0}
|
||||
title={countryNames[country] || country}
|
||||
>
|
||||
<span className="flex items-center space-x-2">
|
||||
<span aria-hidden="true" className="text-lg">
|
||||
{getCountryFlag(country)}
|
||||
</span>
|
||||
<span className="font-medium text-xs">
|
||||
{countryNames[country] || country}
|
||||
{countryPrefixes[country]
|
||||
? ` (${countryPrefixes[country]})`
|
||||
: ""}
|
||||
</span>
|
||||
</span>
|
||||
<div className="flex h-3 w-3 items-center justify-center">
|
||||
{isCountrySelected(country) && (
|
||||
<CheckIcon className="h-3 w-3 text-primary" />
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
))}
|
||||
return (
|
||||
<TierCard
|
||||
key={tier}
|
||||
tierIndex={index}
|
||||
tier={tier}
|
||||
onToggleTier={() => toggleTier(tier)}
|
||||
tierCountries={tierCountries}
|
||||
selectedTierCountries={selectedTierCountries}
|
||||
onToggleCountry={toggleCountry}
|
||||
/>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
function TierCard(props: {
|
||||
tier: string;
|
||||
tierIndex: number;
|
||||
onToggleTier: () => void;
|
||||
tierCountries: string[];
|
||||
selectedTierCountries: string[];
|
||||
onToggleCountry: (country: string) => void;
|
||||
}) {
|
||||
const {
|
||||
tier,
|
||||
tierIndex,
|
||||
onToggleTier,
|
||||
tierCountries: countries,
|
||||
selectedTierCountries: selectedCountries,
|
||||
onToggleCountry,
|
||||
} = props;
|
||||
|
||||
const [isExpanded, setIsExpanded] = useState(true);
|
||||
const isPartiallySelected =
|
||||
selectedCountries.length > 0 && selectedCountries.length < countries.length;
|
||||
const isTierFullySelected = selectedCountries.length === countries.length;
|
||||
|
||||
return (
|
||||
<div className="overflow-hidden rounded-xl border bg-background">
|
||||
{/* header */}
|
||||
<div className="flex items-center px-4 py-3 gap-3 justify-between">
|
||||
{/* left */}
|
||||
<div className="flex items-center gap-2">
|
||||
<Checkbox
|
||||
checked={
|
||||
isPartiallySelected ? "indeterminate" : isTierFullySelected
|
||||
}
|
||||
id={`tier-${tier}`}
|
||||
onCheckedChange={onToggleTier}
|
||||
/>
|
||||
<label
|
||||
className="flex-1 cursor-pointer font-medium text-sm"
|
||||
htmlFor={`tier-${tier}`}
|
||||
>
|
||||
Tier {tierIndex + 1}
|
||||
</label>
|
||||
|
||||
{isPartiallySelected && (
|
||||
<span className="text-muted-foreground text-sm">
|
||||
({selectedCountries.length}/{countries.length})
|
||||
</span>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* right */}
|
||||
<div className="flex items-center gap-3">
|
||||
<span
|
||||
className={cn(
|
||||
"text-sm",
|
||||
tier === "tier1"
|
||||
? "text-green-600 dark:text-green-500"
|
||||
: "text-muted-foreground",
|
||||
)}
|
||||
>
|
||||
{tierPricing[tier as keyof typeof tierPricing]}
|
||||
</span>
|
||||
|
||||
<Button
|
||||
variant="ghost"
|
||||
className="p-0 size-7"
|
||||
onClick={() => setIsExpanded(!isExpanded)}
|
||||
>
|
||||
<ChevronDownIcon
|
||||
className={cn(
|
||||
"size-4 transition-transform",
|
||||
isExpanded && "rotate-180",
|
||||
)}
|
||||
/>
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* body */}
|
||||
{isExpanded && (
|
||||
<div
|
||||
className={cn(
|
||||
"grid grid-cols-1 md:grid-cols-2 lg:grid-cols-4 translate-y-[1px] translate-x-[1px] -ml-[1px] border-t",
|
||||
)}
|
||||
>
|
||||
{countries.map((country) => {
|
||||
const isSelected = selectedCountries.includes(country);
|
||||
return (
|
||||
<Button
|
||||
variant="outline"
|
||||
className={cn(
|
||||
"justify-between gap-3 text-start h-auto rounded-none border-l-0 border-t-0 border-border py-2.5 bg-background",
|
||||
)}
|
||||
key={country}
|
||||
onClick={() => onToggleCountry(country)}
|
||||
title={countryNames[country] || country}
|
||||
>
|
||||
<div className="flex items-center gap-3">
|
||||
<div className="text-3xl">{getCountryFlag(country)}</div>
|
||||
<div className="space-y-0.5">
|
||||
<div className="font-medium text-sm">
|
||||
{countryNames[country] || country}
|
||||
</div>
|
||||
<div className="text-muted-foreground text-xs tabular-nums">
|
||||
{countryPrefixes[country]
|
||||
? `${countryPrefixes[country]}`
|
||||
: ""}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
{isSelected && <CheckIcon className="size-5 text-foreground" />}
|
||||
</Button>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
+8
-8
@@ -117,14 +117,14 @@ export const countryNames: Record<string, string> = {
|
||||
KG: "Kyrgyzstan",
|
||||
KH: "Cambodia",
|
||||
KI: "Kiribati",
|
||||
KN: "St Kitts and Nevis",
|
||||
KR: "Korea Republic of",
|
||||
KN: "Saint Kitts and Nevis",
|
||||
KR: "South Korea",
|
||||
KW: "Kuwait",
|
||||
KY: "Cayman Islands",
|
||||
KZ: "Kazakhstan",
|
||||
LA: "Laos PDR",
|
||||
LB: "Lebanon",
|
||||
LC: "St Lucia",
|
||||
LC: "Saint Lucia",
|
||||
LI: "Liechtenstein",
|
||||
LK: "Sri Lanka",
|
||||
LR: "Liberia",
|
||||
@@ -139,7 +139,7 @@ export const countryNames: Record<string, string> = {
|
||||
ME: "Montenegro",
|
||||
MG: "Madagascar",
|
||||
MH: "Marshall Islands",
|
||||
MK: "Macedonia",
|
||||
MK: "North Macedonia",
|
||||
ML: "Mali",
|
||||
MM: "Myanmar",
|
||||
MN: "Mongolia",
|
||||
@@ -172,14 +172,14 @@ export const countryNames: Record<string, string> = {
|
||||
PH: "Philippines",
|
||||
PK: "Pakistan",
|
||||
PL: "Poland",
|
||||
PM: "St Pierre and Miquelon",
|
||||
PM: "Saint Pierre and Miquelon",
|
||||
PR: "Puerto Rico",
|
||||
PS: "Palestinian Territory",
|
||||
PT: "Portugal",
|
||||
PW: "Palau",
|
||||
PY: "Paraguay",
|
||||
QA: "Qatar",
|
||||
RE: "Reunion/Mayotte",
|
||||
RE: "Réunion",
|
||||
RO: "Romania",
|
||||
RS: "Serbia",
|
||||
RW: "Rwanda",
|
||||
@@ -200,7 +200,7 @@ export const countryNames: Record<string, string> = {
|
||||
ST: "Sao Tome and Principe",
|
||||
SV: "El Salvador",
|
||||
SY: "Syria",
|
||||
SZ: "Swaziland",
|
||||
SZ: "Eswatini",
|
||||
TC: "Turks and Caicos Islands",
|
||||
TD: "Chad",
|
||||
TG: "Togo",
|
||||
@@ -217,7 +217,7 @@ export const countryNames: Record<string, string> = {
|
||||
UG: "Uganda",
|
||||
US: "United States",
|
||||
UY: "Uruguay",
|
||||
VC: "St Vincent Grenadines",
|
||||
VC: "Saint Vincent and the Grenadines",
|
||||
VE: "Venezuela",
|
||||
VG: "Virgin Islands, British",
|
||||
VI: "Virgin Islands, U.S.",
|
||||
|
||||
+1
-1
@@ -65,7 +65,7 @@ export function CreateContractWebhookButton({
|
||||
inputAbi: [],
|
||||
name: "",
|
||||
secret: "",
|
||||
sigHash: "",
|
||||
sigHash: [],
|
||||
sigHashAbi: "",
|
||||
toAddresses: "",
|
||||
webhookUrl: "",
|
||||
|
||||
+33
-16
@@ -74,13 +74,16 @@ export function FilterDetailsStep({
|
||||
const knownFunctionSignatures = functionSignatures.map(
|
||||
(sig) => sig.signature,
|
||||
);
|
||||
|
||||
// Handle both single and multiple signatures for custom signature detection
|
||||
const sigHashes = Array.isArray(sigHash) ? sigHash : sigHash ? [sigHash] : [];
|
||||
const isCustomSignature =
|
||||
(watchFilterType === "event" &&
|
||||
sigHash &&
|
||||
!knownEventSignatures.includes(sigHash)) ||
|
||||
sigHashes.some((hash) => hash && !knownEventSignatures.includes(hash))) ||
|
||||
(watchFilterType === "transaction" &&
|
||||
sigHash &&
|
||||
!knownFunctionSignatures.includes(sigHash));
|
||||
sigHashes.some(
|
||||
(hash) => hash && !knownFunctionSignatures.includes(hash),
|
||||
));
|
||||
|
||||
return (
|
||||
<>
|
||||
@@ -140,7 +143,7 @@ export function FilterDetailsStep({
|
||||
</div>
|
||||
<FormControl>
|
||||
<div className="space-y-2">
|
||||
<Input placeholder="0x1234..." {...field} />
|
||||
<Input placeholder="0x1234...,0xabcd..." {...field} />
|
||||
|
||||
{/* ABI fetch status */}
|
||||
<div className="mt-2 flex items-center justify-between">
|
||||
@@ -300,13 +303,13 @@ export function FilterDetailsStep({
|
||||
<div className="flex items-center justify-between text-xs">
|
||||
<FormLabel>
|
||||
{watchFilterType === "event"
|
||||
? "Event Signature (optional)"
|
||||
: "Function Signature (optional)"}
|
||||
? "Event Signatures (optional)"
|
||||
: "Function Signatures (optional)"}
|
||||
</FormLabel>
|
||||
<p className="text-muted-foreground">
|
||||
{watchFilterType === "event"
|
||||
? "Select an event to monitor"
|
||||
: "Select a function to monitor"}
|
||||
? "Select events to monitor"
|
||||
: "Select functions to monitor"}
|
||||
</p>
|
||||
</div>
|
||||
<FormControl>
|
||||
@@ -315,11 +318,16 @@ export function FilterDetailsStep({
|
||||
eventSignatures.length > 0 ? (
|
||||
<SignatureSelector
|
||||
className="block w-full max-w-90 overflow-hidden text-ellipsis"
|
||||
multiSelect={true}
|
||||
onChange={(val) => {
|
||||
field.onChange(val);
|
||||
// If custom signature, clear ABI field
|
||||
const known = eventSignatures.map((sig) => sig.signature);
|
||||
if (val && !known.includes(val)) {
|
||||
const values = Array.isArray(val) ? val : [val];
|
||||
const hasCustomSignature = values.some(
|
||||
(v) => v && !known.includes(v),
|
||||
);
|
||||
if (hasCustomSignature) {
|
||||
form.setValue("abi", "");
|
||||
}
|
||||
}}
|
||||
@@ -328,21 +336,26 @@ export function FilterDetailsStep({
|
||||
label: truncateMiddle(sig.name, 30, 15),
|
||||
value: sig.signature,
|
||||
}))}
|
||||
placeholder="Select or enter an event signature"
|
||||
placeholder="Select or enter event signatures"
|
||||
setAbi={(abi) => form.setValue("sigHashAbi", abi)}
|
||||
value={field.value || ""}
|
||||
value={field.value || []}
|
||||
/>
|
||||
) : watchFilterType === "transaction" &&
|
||||
Object.keys(fetchedTxAbis).length > 0 &&
|
||||
functionSignatures.length > 0 ? (
|
||||
<SignatureSelector
|
||||
multiSelect={true}
|
||||
onChange={(val) => {
|
||||
field.onChange(val);
|
||||
// If custom signature, clear ABI field
|
||||
const known = functionSignatures.map(
|
||||
(sig) => sig.signature,
|
||||
);
|
||||
if (val && !known.includes(val)) {
|
||||
const values = Array.isArray(val) ? val : [val];
|
||||
const hasCustomSignature = values.some(
|
||||
(v) => v && !known.includes(v),
|
||||
);
|
||||
if (hasCustomSignature) {
|
||||
form.setValue("abi", "");
|
||||
}
|
||||
}}
|
||||
@@ -351,9 +364,9 @@ export function FilterDetailsStep({
|
||||
label: truncateMiddle(sig.name, 30, 15),
|
||||
value: sig.signature,
|
||||
}))}
|
||||
placeholder="Select or enter a function signature"
|
||||
placeholder="Select or enter function signatures"
|
||||
setAbi={(abi) => form.setValue("sigHashAbi", abi)}
|
||||
value={field.value || ""}
|
||||
value={field.value || []}
|
||||
/>
|
||||
) : (
|
||||
<Input
|
||||
@@ -367,7 +380,11 @@ export function FilterDetailsStep({
|
||||
? "Fetching event signatures..."
|
||||
: "Fetching function signatures..."
|
||||
}
|
||||
value={field.value}
|
||||
value={
|
||||
Array.isArray(field.value)
|
||||
? field.value.join(", ")
|
||||
: field.value || ""
|
||||
}
|
||||
/>
|
||||
)}
|
||||
</FormControl>
|
||||
|
||||
+17
-2
@@ -194,12 +194,27 @@ export default function ReviewStep({
|
||||
|
||||
<li className="flex justify-between">
|
||||
<span className="text-muted-foreground text-sm">
|
||||
Signature Hash:
|
||||
Signature Hash
|
||||
{Array.isArray(form.watch("sigHash")) &&
|
||||
(form.watch("sigHash")?.length || 0) > 1
|
||||
? "es"
|
||||
: ""}
|
||||
:
|
||||
</span>
|
||||
<span className="font-medium text-sm">
|
||||
{(() => {
|
||||
const sigHash = form.watch("sigHash");
|
||||
return sigHash ? truncateMiddle(sigHash, 10, 6) : "None";
|
||||
if (!sigHash) return "None";
|
||||
|
||||
if (Array.isArray(sigHash)) {
|
||||
if (sigHash.length === 0) return "None";
|
||||
if (sigHash.length === 1) {
|
||||
return truncateMiddle(sigHash[0] || "", 10, 6);
|
||||
}
|
||||
return `${sigHash.length} signature${sigHash.length > 1 ? "s" : ""} selected`;
|
||||
} else {
|
||||
return truncateMiddle(sigHash, 10, 6);
|
||||
}
|
||||
})()}
|
||||
</span>
|
||||
</li>
|
||||
|
||||
+1
-1
@@ -1,4 +1,3 @@
|
||||
import { DialogDescription } from "@radix-ui/react-dialog";
|
||||
import { AlertTriangleIcon } from "lucide-react";
|
||||
import type { WebhookConfig } from "@/api/project/webhook-configs";
|
||||
import { Alert, AlertDescription, AlertTitle } from "@/components/ui/alert";
|
||||
@@ -6,6 +5,7 @@ import { Button } from "@/components/ui/button";
|
||||
import {
|
||||
Dialog,
|
||||
DialogContent,
|
||||
DialogDescription,
|
||||
DialogFooter,
|
||||
DialogHeader,
|
||||
DialogTitle,
|
||||
|
||||
+80
-30
@@ -224,15 +224,18 @@ export function TopicSelectorModal(props: TopicSelectorModalProps) {
|
||||
};
|
||||
|
||||
if (formData.sigHash) {
|
||||
filters.signatures = [
|
||||
{
|
||||
sig_hash: formData.sigHash.startsWith("0x")
|
||||
? formData.sigHash
|
||||
: keccak256(new TextEncoder().encode(formData.sigHash)),
|
||||
const sigHashes = Array.isArray(formData.sigHash)
|
||||
? formData.sigHash
|
||||
: [formData.sigHash];
|
||||
filters.signatures = sigHashes
|
||||
.filter((hash) => hash)
|
||||
.map((hash) => ({
|
||||
sig_hash: hash.startsWith("0x")
|
||||
? hash
|
||||
: keccak256(new TextEncoder().encode(hash)),
|
||||
abi: formData.sigHashAbi || formData.abi,
|
||||
params: {},
|
||||
},
|
||||
];
|
||||
}));
|
||||
}
|
||||
|
||||
return { ...topic, filters };
|
||||
@@ -271,15 +274,18 @@ export function TopicSelectorModal(props: TopicSelectorModalProps) {
|
||||
}
|
||||
|
||||
if (formData.sigHash) {
|
||||
filters.signatures = [
|
||||
{
|
||||
sig_hash: formData.sigHash.startsWith("0x")
|
||||
? formData.sigHash
|
||||
: toFunctionSelector(formData.sigHash),
|
||||
const sigHashes = Array.isArray(formData.sigHash)
|
||||
? formData.sigHash
|
||||
: [formData.sigHash];
|
||||
filters.signatures = sigHashes
|
||||
.filter((hash) => hash)
|
||||
.map((hash) => ({
|
||||
sig_hash: hash.startsWith("0x")
|
||||
? hash
|
||||
: toFunctionSelector(hash),
|
||||
abi: formData.sigHashAbi || formData.abi,
|
||||
params: {},
|
||||
},
|
||||
];
|
||||
}));
|
||||
}
|
||||
|
||||
return { ...topic, filters };
|
||||
@@ -475,8 +481,8 @@ export function TopicSelectorModal(props: TopicSelectorModalProps) {
|
||||
<FormLabel>
|
||||
{topic.id ===
|
||||
"contracts.event.confirmed"
|
||||
? "Event Signature"
|
||||
: "Function Signature"}
|
||||
? "Event Signatures"
|
||||
: "Function Signatures"}
|
||||
</FormLabel>
|
||||
</div>
|
||||
<FormControl>
|
||||
@@ -489,6 +495,7 @@ export function TopicSelectorModal(props: TopicSelectorModalProps) {
|
||||
0 ? (
|
||||
<SignatureSelector
|
||||
className="block w-full max-w-90 overflow-hidden text-ellipsis"
|
||||
multiSelect={true}
|
||||
onChange={(val) => {
|
||||
field.onChange(val);
|
||||
// If custom signature, clear ABI field
|
||||
@@ -497,9 +504,20 @@ export function TopicSelectorModal(props: TopicSelectorModalProps) {
|
||||
(sig) =>
|
||||
sig.signature,
|
||||
);
|
||||
const values =
|
||||
Array.isArray(val)
|
||||
? val
|
||||
: [val];
|
||||
const hasCustomSignature =
|
||||
values.some(
|
||||
(v) =>
|
||||
v &&
|
||||
!known.includes(
|
||||
v,
|
||||
),
|
||||
);
|
||||
if (
|
||||
val &&
|
||||
!known.includes(val)
|
||||
hasCustomSignature
|
||||
) {
|
||||
eventFilterForm.setValue(
|
||||
"abi",
|
||||
@@ -518,14 +536,14 @@ export function TopicSelectorModal(props: TopicSelectorModalProps) {
|
||||
value: sig.signature,
|
||||
}),
|
||||
)}
|
||||
placeholder="Select or enter an event signature"
|
||||
placeholder="Select or enter event signatures"
|
||||
setAbi={(abi) =>
|
||||
eventFilterForm.setValue(
|
||||
"sigHashAbi",
|
||||
abi,
|
||||
)
|
||||
}
|
||||
value={field.value || ""}
|
||||
value={field.value || []}
|
||||
/>
|
||||
) : topic.id ===
|
||||
"contracts.transaction.confirmed" &&
|
||||
@@ -535,6 +553,7 @@ export function TopicSelectorModal(props: TopicSelectorModalProps) {
|
||||
txAbi.signatures.length >
|
||||
0 ? (
|
||||
<SignatureSelector
|
||||
multiSelect={true}
|
||||
onChange={(val) => {
|
||||
field.onChange(val);
|
||||
// If custom signature, clear ABI field
|
||||
@@ -543,9 +562,20 @@ export function TopicSelectorModal(props: TopicSelectorModalProps) {
|
||||
(sig) =>
|
||||
sig.signature,
|
||||
);
|
||||
const values =
|
||||
Array.isArray(val)
|
||||
? val
|
||||
: [val];
|
||||
const hasCustomSignature =
|
||||
values.some(
|
||||
(v) =>
|
||||
v &&
|
||||
!known.includes(
|
||||
v,
|
||||
),
|
||||
);
|
||||
if (
|
||||
val &&
|
||||
!known.includes(val)
|
||||
hasCustomSignature
|
||||
) {
|
||||
transactionFilterForm.setValue(
|
||||
"abi",
|
||||
@@ -564,14 +594,14 @@ export function TopicSelectorModal(props: TopicSelectorModalProps) {
|
||||
value: sig.signature,
|
||||
}),
|
||||
)}
|
||||
placeholder="Select or enter a function signature"
|
||||
placeholder="Select or enter function signatures"
|
||||
setAbi={(abi) =>
|
||||
transactionFilterForm.setValue(
|
||||
"sigHashAbi",
|
||||
abi,
|
||||
)
|
||||
}
|
||||
value={field.value || ""}
|
||||
value={field.value || []}
|
||||
/>
|
||||
) : (
|
||||
<Input
|
||||
@@ -714,7 +744,7 @@ export function TopicSelectorModal(props: TopicSelectorModalProps) {
|
||||
<FormItem className="flex flex-col">
|
||||
<div className="flex items-center justify-between text-xs">
|
||||
<FormLabel>
|
||||
Function Signature
|
||||
Function Signatures
|
||||
</FormLabel>
|
||||
</div>
|
||||
<FormControl>
|
||||
@@ -724,6 +754,7 @@ export function TopicSelectorModal(props: TopicSelectorModalProps) {
|
||||
txAbi.signatures.length >
|
||||
0 ? (
|
||||
<SignatureSelector
|
||||
multiSelect={true}
|
||||
onChange={(val) => {
|
||||
field.onChange(val);
|
||||
// If custom signature, clear ABI field
|
||||
@@ -732,9 +763,20 @@ export function TopicSelectorModal(props: TopicSelectorModalProps) {
|
||||
(sig) =>
|
||||
sig.signature,
|
||||
);
|
||||
const values =
|
||||
Array.isArray(val)
|
||||
? val
|
||||
: [val];
|
||||
const hasCustomSignature =
|
||||
values.some(
|
||||
(v) =>
|
||||
v &&
|
||||
!known.includes(
|
||||
v,
|
||||
),
|
||||
);
|
||||
if (
|
||||
val &&
|
||||
!known.includes(val)
|
||||
hasCustomSignature
|
||||
) {
|
||||
transactionFilterForm.setValue(
|
||||
"abi",
|
||||
@@ -753,14 +795,14 @@ export function TopicSelectorModal(props: TopicSelectorModalProps) {
|
||||
value: sig.signature,
|
||||
}),
|
||||
)}
|
||||
placeholder="Select or enter a function signature"
|
||||
placeholder="Select or enter function signatures"
|
||||
setAbi={(abi) =>
|
||||
transactionFilterForm.setValue(
|
||||
"sigHashAbi",
|
||||
abi,
|
||||
)
|
||||
}
|
||||
value={field.value || ""}
|
||||
value={field.value || []}
|
||||
/>
|
||||
) : (
|
||||
<Input
|
||||
@@ -769,7 +811,15 @@ export function TopicSelectorModal(props: TopicSelectorModalProps) {
|
||||
}
|
||||
onChange={field.onChange}
|
||||
placeholder="Fetching function signatures..."
|
||||
value={field.value}
|
||||
value={
|
||||
Array.isArray(
|
||||
field.value,
|
||||
)
|
||||
? field.value.join(
|
||||
", ",
|
||||
)
|
||||
: field.value || ""
|
||||
}
|
||||
/>
|
||||
)}
|
||||
</FormControl>
|
||||
|
||||
+28
-14
@@ -33,13 +33,20 @@ export function buildEventWebhookPayload(
|
||||
): WebhookPayload {
|
||||
const eventSignatures = [];
|
||||
if (data.sigHash) {
|
||||
eventSignatures.push({
|
||||
abi: data.sigHashAbi || data.abi,
|
||||
params: {},
|
||||
sig_hash: data.sigHash.startsWith("0x")
|
||||
? data.sigHash
|
||||
: keccak256(new TextEncoder().encode(data.sigHash)),
|
||||
});
|
||||
const sigHashes = Array.isArray(data.sigHash)
|
||||
? data.sigHash
|
||||
: [data.sigHash];
|
||||
for (const sigHash of sigHashes) {
|
||||
if (sigHash) {
|
||||
eventSignatures.push({
|
||||
abi: data.sigHashAbi || data.abi,
|
||||
params: {},
|
||||
sig_hash: sigHash.startsWith("0x")
|
||||
? sigHash
|
||||
: keccak256(new TextEncoder().encode(sigHash)),
|
||||
});
|
||||
}
|
||||
}
|
||||
}
|
||||
return {
|
||||
filters: {
|
||||
@@ -59,13 +66,20 @@ export function buildTransactionWebhookPayload(
|
||||
): WebhookPayload {
|
||||
const txSignatures = [];
|
||||
if (data.sigHash) {
|
||||
txSignatures.push({
|
||||
abi: data.sigHashAbi || data.abi,
|
||||
params: {},
|
||||
sig_hash: data.sigHash.startsWith("0x")
|
||||
? data.sigHash
|
||||
: toFunctionSelector(data.sigHash),
|
||||
});
|
||||
const sigHashes = Array.isArray(data.sigHash)
|
||||
? data.sigHash
|
||||
: [data.sigHash];
|
||||
for (const sigHash of sigHashes) {
|
||||
if (sigHash) {
|
||||
txSignatures.push({
|
||||
abi: data.sigHashAbi || data.abi,
|
||||
params: {},
|
||||
sig_hash: sigHash.startsWith("0x")
|
||||
? sigHash
|
||||
: toFunctionSelector(sigHash),
|
||||
});
|
||||
}
|
||||
}
|
||||
}
|
||||
return {
|
||||
filters: {
|
||||
|
||||
+1
-1
@@ -73,7 +73,7 @@ export const webhookFormSchema = z
|
||||
.max(100, { message: "Name must be at most 100 characters long" }),
|
||||
params: z.record(z.unknown()).optional(),
|
||||
secret: z.string().optional(),
|
||||
sigHash: z.string().optional(),
|
||||
sigHash: z.union([z.string(), z.array(z.string())]).optional(),
|
||||
sigHashAbi: z.string().optional(),
|
||||
toAddresses: z
|
||||
.string()
|
||||
|
||||
+96
@@ -0,0 +1,96 @@
|
||||
import type { Meta } from "@storybook/nextjs";
|
||||
import { toUnits } from "thirdweb";
|
||||
import { base } from "thirdweb/chains";
|
||||
import { ThirdwebProvider } from "thirdweb/react";
|
||||
import { storybookThirdwebClient } from "@/storybook/utils";
|
||||
import { ClaimRewardsPageUI } from "./claim-rewards-page";
|
||||
|
||||
const meta = {
|
||||
component: ClaimRewardsPageUI,
|
||||
title: "contracts/extensions/claim-rewards",
|
||||
decorators: [
|
||||
(Story) => (
|
||||
<ThirdwebProvider>
|
||||
<div className="container max-w-[1154px] py-10">
|
||||
<Story />
|
||||
</div>
|
||||
</ThirdwebProvider>
|
||||
),
|
||||
],
|
||||
} satisfies Meta<typeof ClaimRewardsPageUI>;
|
||||
|
||||
export default meta;
|
||||
|
||||
const recipient = "0x8C00f3F231c88CcAc2382AaC6e09A78D4F42129d";
|
||||
const developer = "0x1Af20C6B23373350aD464700B5965CE4B0D2aD94";
|
||||
|
||||
function unclaimedFeesStub(token0Amount: bigint, token1Amount: bigint) {
|
||||
return {
|
||||
token0: {
|
||||
address: "0x1234567890123456789012345678901234567890",
|
||||
amount: token0Amount,
|
||||
symbol: "FOO",
|
||||
},
|
||||
token1: {
|
||||
address: "0x0987654321098765432109876543210987654321",
|
||||
amount: token1Amount,
|
||||
symbol: "BAR",
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
export function LargeAmounts() {
|
||||
return (
|
||||
<Variant
|
||||
token0Amount={toUnits("100000", 18)}
|
||||
token1Amount={toUnits("500000", 18)}
|
||||
includeChainExplorer
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
||||
export function SmallAmounts() {
|
||||
return (
|
||||
<Variant
|
||||
token0Amount={toUnits("0.001", 18)}
|
||||
token1Amount={toUnits("0.0005", 18)}
|
||||
includeChainExplorer
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
||||
export function ZeroAmount() {
|
||||
return (
|
||||
<Variant
|
||||
token0Amount={toUnits("0", 18)}
|
||||
token1Amount={toUnits("0", 18)}
|
||||
includeChainExplorer
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
||||
export function ZeroAmountNoChainExplorer() {
|
||||
return (
|
||||
<Variant token0Amount={toUnits("0", 18)} token1Amount={toUnits("0", 18)} />
|
||||
);
|
||||
}
|
||||
|
||||
function Variant(props: {
|
||||
token0Amount: bigint;
|
||||
token1Amount: bigint;
|
||||
includeChainExplorer?: boolean;
|
||||
}) {
|
||||
return (
|
||||
<ClaimRewardsPageUI
|
||||
developerBps={5000}
|
||||
chainSlug="base"
|
||||
recipient={recipient}
|
||||
developer={developer}
|
||||
client={storybookThirdwebClient}
|
||||
handleClaim={() => {}}
|
||||
isClaimPending={false}
|
||||
chain={base}
|
||||
unclaimedFees={unclaimedFeesStub(props.token0Amount, props.token1Amount)}
|
||||
/>
|
||||
);
|
||||
}
|
||||
+322
@@ -0,0 +1,322 @@
|
||||
"use client";
|
||||
|
||||
import { DollarSignIcon, ExternalLinkIcon, SplitIcon } from "lucide-react";
|
||||
import Link from "next/link";
|
||||
import { toast } from "sonner";
|
||||
import {
|
||||
type Chain,
|
||||
type ThirdwebClient,
|
||||
type ThirdwebContract,
|
||||
toTokens,
|
||||
} from "thirdweb";
|
||||
import { TokenIcon, TokenProvider, useSendTransaction } from "thirdweb/react";
|
||||
import { claimRewards } from "thirdweb/tokens";
|
||||
import { DistributionBarChart } from "@/components/blocks/distribution-chart";
|
||||
import { WalletAddress } from "@/components/blocks/wallet-address";
|
||||
import { TransactionButton } from "@/components/tx-button";
|
||||
import { useDashboardRouter } from "@/lib/DashboardRouter";
|
||||
import { parseError } from "@/utils/errorParser";
|
||||
import { tryCatch } from "@/utils/try-catch";
|
||||
import type { getValidReward } from "../utils/rewards";
|
||||
|
||||
export function ClaimRewardsPage(props: {
|
||||
assetContractClient: ThirdwebContract;
|
||||
entrypointContractClient: ThirdwebContract;
|
||||
reward: NonNullable<Awaited<ReturnType<typeof getValidReward>>>;
|
||||
unclaimedFees: {
|
||||
token0: {
|
||||
address: string;
|
||||
amount: bigint;
|
||||
symbol: string;
|
||||
};
|
||||
token1: {
|
||||
address: string;
|
||||
amount: bigint;
|
||||
symbol: string;
|
||||
};
|
||||
};
|
||||
chainSlug: string;
|
||||
}) {
|
||||
const sendTx = useSendTransaction();
|
||||
const router = useDashboardRouter();
|
||||
|
||||
async function handleClaim() {
|
||||
const claimRewardsTx = claimRewards({
|
||||
asset: props.assetContractClient.address,
|
||||
contract: props.entrypointContractClient,
|
||||
});
|
||||
|
||||
const claimRewardsResult = await tryCatch(
|
||||
sendTx.mutateAsync(claimRewardsTx),
|
||||
);
|
||||
|
||||
if (claimRewardsResult.error) {
|
||||
toast.error("Failed to distribute rewards", {
|
||||
description: parseError(claimRewardsResult.error),
|
||||
});
|
||||
} else {
|
||||
toast.success("Rewards distributed successfully");
|
||||
router.refresh();
|
||||
}
|
||||
}
|
||||
|
||||
return (
|
||||
<ClaimRewardsPageUI
|
||||
unclaimedFees={props.unclaimedFees}
|
||||
recipient={props.reward.recipient}
|
||||
developer={props.reward.developer}
|
||||
handleClaim={handleClaim}
|
||||
isClaimPending={sendTx.isPending}
|
||||
client={props.assetContractClient.client}
|
||||
chain={props.assetContractClient.chain}
|
||||
chainSlug={props.chainSlug}
|
||||
developerBps={props.reward.developerBps}
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
||||
function calculateFees(developerBps: number) {
|
||||
// 20% of is protocol fees
|
||||
// remaining is split between developer and recipient
|
||||
|
||||
const protocolFees = 20;
|
||||
const remaining = 100 - protocolFees;
|
||||
|
||||
const developerPercentageFinal = (remaining * developerBps) / 10000;
|
||||
|
||||
return {
|
||||
protocolFees,
|
||||
developerPercentage: developerPercentageFinal,
|
||||
recipientPercentage: 100 - protocolFees - developerPercentageFinal,
|
||||
};
|
||||
}
|
||||
|
||||
export function ClaimRewardsPageUI(props: {
|
||||
unclaimedFees: {
|
||||
token0: {
|
||||
address: string;
|
||||
amount: bigint;
|
||||
symbol: string;
|
||||
};
|
||||
token1: {
|
||||
address: string;
|
||||
amount: bigint;
|
||||
symbol: string;
|
||||
};
|
||||
};
|
||||
recipient: string;
|
||||
developer: string;
|
||||
developerBps: number;
|
||||
handleClaim: () => void;
|
||||
isClaimPending: boolean;
|
||||
client: ThirdwebClient;
|
||||
chain: Chain;
|
||||
chainSlug: string;
|
||||
}) {
|
||||
const fees = calculateFees(props.developerBps);
|
||||
|
||||
const recipientColor = `hsl(var(--chart-1))`;
|
||||
const developerColor = `hsl(var(--chart-2))`;
|
||||
const protocolFeesColor = `hsl(var(--chart-3))`;
|
||||
|
||||
const hasUnclaimedRewards =
|
||||
props.unclaimedFees.token0.amount > 0 ||
|
||||
props.unclaimedFees.token1.amount > 0;
|
||||
|
||||
return (
|
||||
<div>
|
||||
<div className="bg-card rounded-lg border">
|
||||
<div className="p-4 lg:p-6 border-b border-dashed">
|
||||
<div className="flex mb-3">
|
||||
<div className="rounded-full border bg-background p-2">
|
||||
<DollarSignIcon className="size-5 text-muted-foreground" />
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<h2 className="font-semibold text-2xl tracking-tight mb-0.5">
|
||||
Rewards
|
||||
</h2>
|
||||
<p className="text-muted-foreground text-sm">
|
||||
Earnings received by Liquidity Providers (LPs) in exchange for
|
||||
depositing tokens into {props.unclaimedFees.token0.symbol} /{" "}
|
||||
{props.unclaimedFees.token1.symbol} Uniswap liquidity pool
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<div className="p-4 lg:px-6 py-8 border-b border-dashed">
|
||||
<div className="mb-4">
|
||||
<h3 className="font-medium text-base mb-1">Unclaimed Rewards</h3>
|
||||
<p className="text-muted-foreground text-sm">
|
||||
The rewards that are earned but haven't been distributed yet
|
||||
</p>
|
||||
</div>
|
||||
<div className="flex flex-col gap-4 lg:flex-row">
|
||||
<TokenReward
|
||||
token={props.unclaimedFees.token0}
|
||||
client={props.client}
|
||||
chain={props.chain}
|
||||
chainSlug={props.chainSlug}
|
||||
/>
|
||||
<TokenReward
|
||||
token={props.unclaimedFees.token1}
|
||||
client={props.client}
|
||||
chain={props.chain}
|
||||
chainSlug={props.chainSlug}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="p-4 lg:px-6 py-8">
|
||||
<h3 className="font-medium text-base mb-1">Reward Distribution</h3>
|
||||
<p className="text-muted-foreground text-sm mb-4">
|
||||
The unclaimed rewards will be distributed as:
|
||||
</p>
|
||||
|
||||
<DistributionBarChart
|
||||
segments={[
|
||||
{
|
||||
label: "Recipient",
|
||||
color: recipientColor,
|
||||
percent: fees.recipientPercentage,
|
||||
value: `${fees.recipientPercentage}%`,
|
||||
},
|
||||
{
|
||||
label: "Protocol",
|
||||
color: protocolFeesColor,
|
||||
percent: fees.protocolFees,
|
||||
value: `${fees.protocolFees}%`,
|
||||
},
|
||||
{
|
||||
label: "Developer",
|
||||
color: developerColor,
|
||||
percent: fees.developerPercentage,
|
||||
value: `${fees.developerPercentage}%`,
|
||||
},
|
||||
]}
|
||||
/>
|
||||
|
||||
<div className="h-6" />
|
||||
|
||||
<div className="flex gap-5">
|
||||
<div>
|
||||
<p className="font-medium text-sm">Recipient</p>
|
||||
<WalletAddress
|
||||
address={props.recipient}
|
||||
client={props.client}
|
||||
iconClassName="size-3"
|
||||
className="h-auto py-1 text-xs"
|
||||
fallbackIcon={
|
||||
<div
|
||||
className="size-3 rounded-full"
|
||||
style={{
|
||||
backgroundColor: recipientColor,
|
||||
}}
|
||||
/>
|
||||
}
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div className="border-l pl-5">
|
||||
<p className="font-medium text-sm">Developer</p>
|
||||
<WalletAddress
|
||||
address={props.developer}
|
||||
client={props.client}
|
||||
iconClassName="size-3"
|
||||
className="h-auto py-1 text-xs"
|
||||
fallbackIcon={
|
||||
<div
|
||||
className="size-3 rounded-full"
|
||||
style={{
|
||||
backgroundColor: developerColor,
|
||||
}}
|
||||
/>
|
||||
}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="p-4 lg:p-6 border-t border-dashed flex-col lg:flex-row flex lg:justify-between lg:items-center gap-4">
|
||||
{hasUnclaimedRewards && (
|
||||
<p className="text-muted-foreground text-sm">
|
||||
Click on "Distribute Rewards" to distribute unclaimed rewards
|
||||
</p>
|
||||
)}
|
||||
|
||||
{!hasUnclaimedRewards && (
|
||||
<p className="text-muted-foreground text-sm">
|
||||
There are no unclaimed rewards available for distribution
|
||||
</p>
|
||||
)}
|
||||
<TransactionButton
|
||||
onClick={props.handleClaim}
|
||||
className="bg-background rounded-full"
|
||||
transactionCount={undefined}
|
||||
isPending={props.isClaimPending}
|
||||
txChainID={props.chain.id}
|
||||
isLoggedIn={true}
|
||||
client={props.client}
|
||||
disabled={!hasUnclaimedRewards}
|
||||
size="sm"
|
||||
variant="outline"
|
||||
>
|
||||
<SplitIcon className="size-3.5 text-muted-foreground" />
|
||||
Distribute Rewards
|
||||
</TransactionButton>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
function TokenReward(props: {
|
||||
token: {
|
||||
address: string;
|
||||
amount: bigint;
|
||||
symbol: string;
|
||||
};
|
||||
client: ThirdwebClient;
|
||||
chain: Chain;
|
||||
chainSlug: string;
|
||||
}) {
|
||||
const fallbackIcon = (
|
||||
<div className="size-9 rounded-full from-muted to-card bg-gradient-to-br flex items-center justify-center text-muted-foreground font-bold uppercase">
|
||||
{props.token.symbol[0]}
|
||||
</div>
|
||||
);
|
||||
|
||||
return (
|
||||
<div className="border p-3 rounded-xl flex items-center gap-3 min-w-[300px] bg-background relative hover:border-active-border">
|
||||
<div className="rounded-full border shrink-0">
|
||||
<TokenProvider
|
||||
address={props.token.address}
|
||||
client={props.client}
|
||||
chain={props.chain}
|
||||
>
|
||||
<TokenIcon
|
||||
className="size-9 rounded-full"
|
||||
loadingComponent={fallbackIcon}
|
||||
fallbackComponent={fallbackIcon}
|
||||
/>
|
||||
</TokenProvider>
|
||||
</div>
|
||||
<div className="space-y-0.5">
|
||||
<p className="font-bold text-sm">
|
||||
{toTokens(props.token.amount, 18)} {props.token.symbol}
|
||||
</p>
|
||||
<Link
|
||||
target="_blank"
|
||||
rel="noopener noreferrer"
|
||||
href={`/${props.chainSlug}/${props.token.address}`}
|
||||
className="flex items-center gap-1.5 text-muted-foreground before:absolute before:inset-0"
|
||||
>
|
||||
<span className="text-xs font-mono">
|
||||
{props.token.address.slice(0, 6)}...
|
||||
{props.token.address.slice(-4)}
|
||||
</span>
|
||||
<ExternalLinkIcon className="size-3" />
|
||||
</Link>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
+83
@@ -0,0 +1,83 @@
|
||||
import { notFound, redirect } from "next/navigation";
|
||||
import { getContract } from "thirdweb";
|
||||
import { getDeployedEntrypointERC20 } from "thirdweb/tokens";
|
||||
import { getProject } from "@/api/project/projects";
|
||||
import { getContractPageParamsInfo } from "../../../../../../../(dashboard)/(chain)/[chain_id]/[contractAddress]/_utils/getContractFromParams";
|
||||
import type { ProjectContractPageParams } from "../types";
|
||||
import { ClaimRewardsPage } from "./components/claim-rewards-page";
|
||||
import { getValidReward } from "./utils/rewards";
|
||||
import { getUnclaimedFees } from "./utils/unclaimed-fees";
|
||||
|
||||
export default async function Page(props: {
|
||||
params: Promise<ProjectContractPageParams>;
|
||||
}) {
|
||||
const params = await props.params;
|
||||
const project = await getProject(params.team_slug, params.project_slug);
|
||||
|
||||
if (!project) {
|
||||
notFound();
|
||||
}
|
||||
|
||||
const info = await getContractPageParamsInfo({
|
||||
chainIdOrSlug: params.chainIdOrSlug,
|
||||
contractAddress: params.contractAddress,
|
||||
teamId: project.teamId,
|
||||
});
|
||||
|
||||
if (!info) {
|
||||
notFound();
|
||||
}
|
||||
|
||||
const chain = info.clientContract.chain;
|
||||
const assetContractServer = info.serverContract;
|
||||
const serverClient = assetContractServer.client;
|
||||
|
||||
const { address: entrypointContractAddress } =
|
||||
await getDeployedEntrypointERC20({
|
||||
chain,
|
||||
client: serverClient,
|
||||
});
|
||||
|
||||
// Note: must use server contract/client here
|
||||
const reward = await getValidReward({
|
||||
assetContract: assetContractServer,
|
||||
entrypointContract: getContract({
|
||||
address: entrypointContractAddress,
|
||||
chain,
|
||||
client: serverClient,
|
||||
}),
|
||||
});
|
||||
|
||||
if (!reward) {
|
||||
redirect(
|
||||
`/team/${params.team_slug}/${params.project_slug}/contract/${params.chainIdOrSlug}/${params.contractAddress}`,
|
||||
);
|
||||
}
|
||||
|
||||
// Note: must use server contract/client here
|
||||
const unclaimedFees = await getUnclaimedFees({
|
||||
positionManager: getContract({
|
||||
address: reward.positionManager,
|
||||
chain,
|
||||
client: serverClient,
|
||||
}),
|
||||
reward: {
|
||||
tokenId: reward.positionId,
|
||||
recipient: reward.recipient,
|
||||
},
|
||||
});
|
||||
|
||||
return (
|
||||
<ClaimRewardsPage
|
||||
assetContractClient={info.clientContract}
|
||||
entrypointContractClient={getContract({
|
||||
address: entrypointContractAddress,
|
||||
chain: chain,
|
||||
client: info.clientContract.client,
|
||||
})}
|
||||
reward={reward}
|
||||
unclaimedFees={unclaimedFees}
|
||||
chainSlug={info.chainMetadata.slug}
|
||||
/>
|
||||
);
|
||||
}
|
||||
+23
@@ -0,0 +1,23 @@
|
||||
import type { ThirdwebContract } from "thirdweb";
|
||||
import { getRewards } from "thirdweb/tokens";
|
||||
|
||||
export async function getValidReward(params: {
|
||||
assetContract: ThirdwebContract;
|
||||
entrypointContract: ThirdwebContract;
|
||||
}) {
|
||||
try {
|
||||
const rewards = await getRewards({
|
||||
contract: params.entrypointContract,
|
||||
asset: params.assetContract.address,
|
||||
});
|
||||
|
||||
if (rewards.length === 0) {
|
||||
return null;
|
||||
}
|
||||
|
||||
// It's potentially possible to have multiple rewards locked up, but it's not the default use case.
|
||||
return rewards[0];
|
||||
} catch {
|
||||
return null;
|
||||
}
|
||||
}
|
||||
+88
@@ -0,0 +1,88 @@
|
||||
import type { ThirdwebContract } from "thirdweb";
|
||||
import { getContract, readContract } from "thirdweb";
|
||||
import { symbol } from "thirdweb/extensions/common";
|
||||
|
||||
const maxUint128 = 2n ** 128n - 1n;
|
||||
|
||||
export async function getUnclaimedFees(params: {
|
||||
positionManager: ThirdwebContract;
|
||||
reward: {
|
||||
tokenId: bigint;
|
||||
recipient: string;
|
||||
};
|
||||
}) {
|
||||
const collectResultPromise = readContract({
|
||||
contract: params.positionManager,
|
||||
method:
|
||||
"function collect((uint256 tokenId,address recipient,uint128 amount0Max,uint128 amount1Max)) returns (uint256,uint256)",
|
||||
params: [
|
||||
{
|
||||
tokenId: params.reward.tokenId,
|
||||
recipient: params.reward.recipient,
|
||||
amount0Max: maxUint128,
|
||||
amount1Max: maxUint128,
|
||||
},
|
||||
],
|
||||
});
|
||||
|
||||
const positionsResultPromise = readContract({
|
||||
contract: params.positionManager,
|
||||
method:
|
||||
"function positions(uint256 tokenId) view returns (uint96,address,address,address,uint24,int24,int24,uint128,uint256,uint256,uint128,uint128)",
|
||||
params: [params.reward.tokenId],
|
||||
});
|
||||
|
||||
const [collectResult, positionsResult] = await Promise.all([
|
||||
collectResultPromise,
|
||||
positionsResultPromise,
|
||||
]);
|
||||
|
||||
// 0- nonce
|
||||
// 1- owner
|
||||
// 2- token0
|
||||
// 3- token1
|
||||
// 4 - fee
|
||||
// 5 - tickLower
|
||||
// 6 - tickUpper
|
||||
// 7 - liquidity
|
||||
// 8 - feeGrowthInside0LastX128
|
||||
// 9 - feeGrowthInside1LastX128
|
||||
// 10 - tokensOwed0
|
||||
// 11 - tokensOwed1
|
||||
|
||||
const client = params.positionManager.client;
|
||||
const chain = params.positionManager.chain;
|
||||
|
||||
const token0Address = positionsResult[2];
|
||||
const token1Address = positionsResult[3];
|
||||
|
||||
const [token0Symbol, token1Symbol] = await Promise.all([
|
||||
symbol({
|
||||
contract: getContract({
|
||||
address: token0Address,
|
||||
chain,
|
||||
client,
|
||||
}),
|
||||
}),
|
||||
symbol({
|
||||
contract: getContract({
|
||||
address: token1Address,
|
||||
chain,
|
||||
client,
|
||||
}),
|
||||
}),
|
||||
]);
|
||||
|
||||
return {
|
||||
token0: {
|
||||
address: token0Address,
|
||||
amount: collectResult[0],
|
||||
symbol: token0Symbol,
|
||||
},
|
||||
token1: {
|
||||
address: token1Address,
|
||||
amount: collectResult[1],
|
||||
symbol: token1Symbol,
|
||||
},
|
||||
};
|
||||
}
|
||||
@@ -1,6 +1,6 @@
|
||||
import { Inter } from "next/font/google";
|
||||
import { cn } from "@/lib/utils";
|
||||
import "../../global.css";
|
||||
import "@workspace/ui/global.css";
|
||||
import { BridgeProviders } from "./components/client/Providers.client";
|
||||
|
||||
const fontSans = Inter({
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
import "../../global.css";
|
||||
import "@workspace/ui/global.css";
|
||||
import type { Metadata } from "next";
|
||||
import { Inter } from "next/font/google";
|
||||
import NextTopLoader from "nextjs-toploader";
|
||||
|
||||
@@ -1,10 +1,20 @@
|
||||
import { ShieldCheckIcon } from "lucide-react";
|
||||
import type { Metadata } from "next";
|
||||
import { defineChain, getContract } from "thirdweb";
|
||||
import { getCurrencyMetadata } from "thirdweb/extensions/erc20";
|
||||
import { checksumAddress } from "thirdweb/utils";
|
||||
import { Bridge, defineChain, toTokens } from "thirdweb";
|
||||
import { getChainMetadata } from "thirdweb/chains";
|
||||
import { shortenAddress } from "thirdweb/utils";
|
||||
import { getPaymentLink } from "@/api/universal-bridge/links";
|
||||
import { getClientThirdwebClient } from "@/constants/thirdweb-client.client";
|
||||
import { Badge } from "@/components/ui/badge";
|
||||
import { NEXT_PUBLIC_THIRDWEB_API_HOST } from "@/constants/public-envs";
|
||||
import {
|
||||
API_SERVER_SECRET,
|
||||
DASHBOARD_THIRDWEB_SECRET_KEY,
|
||||
} from "@/constants/server-envs";
|
||||
import { getConfiguredThirdwebClient } from "@/constants/thirdweb.server";
|
||||
import { resolveEns } from "@/lib/ens";
|
||||
import { resolveSchemeWithErrorHandler } from "@/utils/resolveSchemeWithErrorHandler";
|
||||
import { PayPageWidget } from "../components/client/PayPageWidget.client";
|
||||
import { payAppThirdwebClient } from "../constants";
|
||||
|
||||
const title = "thirdweb Pay";
|
||||
const description = "Fast, secure, and simple payments.";
|
||||
@@ -32,40 +42,186 @@ export default async function PayPage({
|
||||
paymentId: id,
|
||||
});
|
||||
|
||||
const tokenContract = getContract({
|
||||
address: paymentLink.destinationToken.address, // for this RPC call, use the dashboard client
|
||||
// eslint-disable-next-line no-restricted-syntax
|
||||
chain: defineChain(Number(paymentLink.destinationToken.chainId)),
|
||||
client: getClientThirdwebClient(undefined),
|
||||
});
|
||||
const {
|
||||
symbol,
|
||||
decimals,
|
||||
name: tokenName,
|
||||
} = await getCurrencyMetadata({
|
||||
contract: tokenContract,
|
||||
});
|
||||
const token = {
|
||||
address: checksumAddress(paymentLink.destinationToken.address),
|
||||
const projectMetadataPromise = getProjectMetadata(paymentLink.clientId);
|
||||
|
||||
const tokensPromise = Bridge.tokens({
|
||||
client: getConfiguredThirdwebClient({
|
||||
secretKey: DASHBOARD_THIRDWEB_SECRET_KEY,
|
||||
teamId: undefined,
|
||||
}),
|
||||
chainId: Number(paymentLink.destinationToken.chainId),
|
||||
decimals,
|
||||
name: tokenName,
|
||||
symbol,
|
||||
};
|
||||
tokenAddress: paymentLink.destinationToken.address,
|
||||
});
|
||||
|
||||
const chainPromise = getChainMetadata(
|
||||
// eslint-disable-next-line no-restricted-syntax
|
||||
defineChain(Number(paymentLink.destinationToken.chainId)),
|
||||
);
|
||||
|
||||
const recipientPromise = resolveEns(
|
||||
paymentLink.receiver,
|
||||
getConfiguredThirdwebClient({
|
||||
secretKey: DASHBOARD_THIRDWEB_SECRET_KEY,
|
||||
teamId: undefined,
|
||||
}),
|
||||
);
|
||||
|
||||
const [tokens, projectMetadata, chain, recipientEnsOrAddress] =
|
||||
await Promise.all([
|
||||
tokensPromise,
|
||||
projectMetadataPromise,
|
||||
chainPromise,
|
||||
recipientPromise,
|
||||
]);
|
||||
|
||||
const token = tokens[0];
|
||||
if (!token) {
|
||||
throw new Error("Token not found");
|
||||
}
|
||||
|
||||
return (
|
||||
<PayPageWidget
|
||||
amount={paymentLink.amount ? BigInt(paymentLink.amount) : undefined}
|
||||
chainId={Number(paymentLink.destinationToken.chainId)}
|
||||
clientId={undefined} // Payment links don't need to use the same client ID to be executed
|
||||
image={paymentLink.imageUrl}
|
||||
name={paymentLink.title}
|
||||
paymentLinkId={id}
|
||||
purchaseData={paymentLink.purchaseData}
|
||||
recipientAddress={paymentLink.receiver}
|
||||
redirectUri={redirectUri}
|
||||
theme={theme}
|
||||
token={token}
|
||||
/>
|
||||
<div className="flex z-10 flex-col lg:flex-row h-full w-full">
|
||||
<header className="min-w-full lg:min-w-[500px] border-b lg:border-r lg:h-full bg-card flex flex-col gap-4 items-start p-4 lg:p-8">
|
||||
<div>
|
||||
<div className="flex flex-row items-center justify-start gap-4">
|
||||
{projectMetadata.image && (
|
||||
<img
|
||||
src={
|
||||
resolveSchemeWithErrorHandler({
|
||||
uri: projectMetadata.image,
|
||||
client: payAppThirdwebClient,
|
||||
}) || ""
|
||||
}
|
||||
alt={projectMetadata.name}
|
||||
width={25}
|
||||
height={25}
|
||||
className="rounded-full overflow-hidden"
|
||||
/>
|
||||
)}
|
||||
<h2 className="text-xl font-bold">{projectMetadata.name}</h2>
|
||||
</div>
|
||||
{projectMetadata.description && (
|
||||
<p className="mt-2 text-sm text-muted-foreground">
|
||||
{projectMetadata.description}
|
||||
</p>
|
||||
)}
|
||||
</div>
|
||||
|
||||
<div className="hidden lg:block my-4 w-full">
|
||||
{paymentLink.amount && (
|
||||
<div className="flex flex-col gap-1 w-full my-4">
|
||||
<span className="text-muted-foreground text-xs">Details</span>
|
||||
<div className="font-medium flex-row flex justify-between items-center w-full">
|
||||
<div className="flex flex-row items-center gap-2">
|
||||
{token.iconUri && (
|
||||
<img
|
||||
src={resolveSchemeWithErrorHandler({
|
||||
uri: token.iconUri,
|
||||
client: getConfiguredThirdwebClient({
|
||||
secretKey: DASHBOARD_THIRDWEB_SECRET_KEY,
|
||||
teamId: undefined,
|
||||
}),
|
||||
})}
|
||||
alt={token.name}
|
||||
width={25}
|
||||
height={25}
|
||||
className="size-5 rounded-full overflow-hidden"
|
||||
/>
|
||||
)}
|
||||
{toTokens(BigInt(paymentLink.amount), token.decimals)}{" "}
|
||||
{token.symbol}
|
||||
</div>
|
||||
{token.prices.USD && (
|
||||
<span>
|
||||
$
|
||||
{(
|
||||
Number(token.prices.USD) *
|
||||
Number(
|
||||
toTokens(BigInt(paymentLink.amount), token.decimals),
|
||||
)
|
||||
).toFixed(2)}
|
||||
</span>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
{chain && (
|
||||
<div className="flex flex-col gap-1 w-full my-4">
|
||||
<span className="text-muted-foreground text-xs">Network</span>
|
||||
<div className="font-medium flex-row flex justify-between items-center w-full">
|
||||
<div className="flex flex-row items-center gap-2">
|
||||
{chain.icon?.url && (
|
||||
<img
|
||||
src={resolveSchemeWithErrorHandler({
|
||||
uri: chain.icon.url,
|
||||
client: getConfiguredThirdwebClient({
|
||||
secretKey: DASHBOARD_THIRDWEB_SECRET_KEY,
|
||||
teamId: undefined,
|
||||
}),
|
||||
})}
|
||||
alt={chain.name}
|
||||
width={chain.icon.width}
|
||||
height={chain.icon.height}
|
||||
className="size-5 rounded-full overflow-hidden"
|
||||
/>
|
||||
)}
|
||||
{chain.name}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
{recipientEnsOrAddress.ensName ||
|
||||
(recipientEnsOrAddress.address && (
|
||||
<div className="flex flex-col gap-1 w-full my-4">
|
||||
<span className="text-muted-foreground text-xs">Seller</span>
|
||||
<div className="font-medium flex-row flex justify-between items-center w-full">
|
||||
{recipientEnsOrAddress.ensName ??
|
||||
shortenAddress(recipientEnsOrAddress.address)}
|
||||
</div>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
|
||||
<div className="mt-auto hidden lg:block">
|
||||
<Badge className="flex items-center gap-1.5 bg-purple-100 text-purple-800 border-purple-200 dark:bg-purple-950 dark:text-purple-300 dark:border-purple-800">
|
||||
<ShieldCheckIcon className="size-3" />
|
||||
Secured by thirdweb
|
||||
</Badge>
|
||||
</div>
|
||||
</header>
|
||||
<main className="flex justify-center p-12 w-full items-center">
|
||||
<PayPageWidget
|
||||
amount={paymentLink.amount ? BigInt(paymentLink.amount) : undefined}
|
||||
chainId={Number(paymentLink.destinationToken.chainId)}
|
||||
clientId={undefined} // Payment links don't need to use the same client ID to be executed
|
||||
image={paymentLink.imageUrl}
|
||||
name={paymentLink.title}
|
||||
paymentLinkId={id}
|
||||
purchaseData={paymentLink.purchaseData}
|
||||
recipientAddress={paymentLink.receiver}
|
||||
redirectUri={redirectUri}
|
||||
theme={theme}
|
||||
token={token}
|
||||
/>
|
||||
</main>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
async function getProjectMetadata(clientId: string) {
|
||||
const url = new URL(`${NEXT_PUBLIC_THIRDWEB_API_HOST}/v2/keys/lookup`);
|
||||
url.searchParams.append("clientId", clientId);
|
||||
const response = await fetch(url.toString(), {
|
||||
headers: {
|
||||
"x-service-api-key": API_SERVER_SECRET,
|
||||
},
|
||||
});
|
||||
if (!response.ok) {
|
||||
throw new Error("Failed to fetch project");
|
||||
}
|
||||
|
||||
const { data } = (await response.json()) as {
|
||||
data: { name: string; image: string | null; description: string | null };
|
||||
};
|
||||
return data;
|
||||
}
|
||||
|
||||
@@ -116,7 +116,7 @@ export function PaymentLinkForm() {
|
||||
|
||||
const url = `${window.location.origin}/pay?${params.toString()}`;
|
||||
setPaymentUrl(url);
|
||||
toast.success("Payment link created!");
|
||||
toast.success("Payment created!");
|
||||
} catch (err) {
|
||||
toast.error(err instanceof Error ? err.message : "An error occurred");
|
||||
} finally {
|
||||
@@ -188,7 +188,7 @@ export function PaymentLinkForm() {
|
||||
<CreditCardIcon className="size-5 sm:size-6" />
|
||||
</div>
|
||||
<CardTitle className="text-center sm:text-left">
|
||||
Create a Payment Link
|
||||
Create a Payment
|
||||
</CardTitle>
|
||||
</div>
|
||||
</CardHeader>
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
import "../../global.css";
|
||||
import "@workspace/ui/global.css";
|
||||
import { Inter } from "next/font/google";
|
||||
import { ThemeProvider } from "next-themes";
|
||||
import { cn } from "@/lib/utils";
|
||||
@@ -30,17 +30,8 @@ export default async function PayLayout({
|
||||
disableTransitionOnChange
|
||||
enableSystem={false}
|
||||
>
|
||||
<div className="relative mx-auto flex h-full w-full flex-col items-center justify-center overflow-x-hidden overflow-y-scroll py-10">
|
||||
<main className="container z-10 flex justify-center">
|
||||
{children}
|
||||
</main>
|
||||
|
||||
{/* eslint-disable-next-line @next/next/no-img-element */}
|
||||
<img
|
||||
alt=""
|
||||
className="-bottom-12 -right-12 pointer-events-none absolute lg:right-0 lg:bottom-0"
|
||||
src="/assets/login/background.svg"
|
||||
/>
|
||||
<div className="relative mx-auto flex h-full w-full items-center justify-center overflow-x-hidden overflow-y-scroll">
|
||||
{children}
|
||||
</div>
|
||||
</ThemeProvider>
|
||||
</PayProviders>
|
||||
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user