Skip to content

Commit 0d68199

Browse files
kien-ngoMananTank
authored andcommitted
update
1 parent 6030bf1 commit 0d68199

File tree

8 files changed

+184
-39
lines changed

8 files changed

+184
-39
lines changed

apps/dashboard/.env.example

Lines changed: 2 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -91,4 +91,5 @@ API_SERVER_SECRET=""
9191

9292
# Used for the Faucet page (/<chain_id>)
9393
NEXT_PUBLIC_TURNSTILE_SITE_KEY=""
94-
TURNSTILE_SECRET_KEY=""
94+
TURNSTILE_SECRET_KEY=""
95+
REDIS_URL=""

apps/dashboard/src/@/constants/env.ts

Lines changed: 10 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -8,9 +8,6 @@ export const IPFS_GATEWAY_URL =
88
(process.env.NEXT_PUBLIC_IPFS_GATEWAY_URL as string) ||
99
"https://{clientId}.ipfscdn.io/ipfs/{cid}/{path}";
1010

11-
export const THIRDWEB_ENGINE_FAUCET_WALLET =
12-
process.env.NEXT_PUBLIC_THIRDWEB_ENGINE_FAUCET_WALLET || "";
13-
1411
export const isProd =
1512
(process.env.VERCEL_ENV || process.env.NEXT_PUBLIC_VERCEL_ENV) ===
1613
"production";
@@ -24,5 +21,15 @@ export const DASHBOARD_STORAGE_URL =
2421
export const API_SERVER_URL =
2522
process.env.NEXT_PUBLIC_THIRDWEB_API_HOST || "https://api.thirdweb.com";
2623

24+
/**
25+
* Faucet stuff
26+
*/
27+
// Cloudflare Turnstile Site key
2728
export const TURNSTILE_SITE_KEY =
2829
process.env.NEXT_PUBLIC_TURNSTILE_SITE_KEY || "";
30+
export const THIRDWEB_ENGINE_FAUCET_WALLET =
31+
process.env.NEXT_PUBLIC_THIRDWEB_ENGINE_FAUCET_WALLET || "";
32+
export const THIRDWEB_ENGINE_URL = process.env.THIRDWEB_ENGINE_URL;
33+
export const THIRDWEB_ACCESS_TOKEN = process.env.THIRDWEB_ACCESS_TOKEN;
34+
// Comma-separated list of chain IDs to disable faucet for.
35+
export const DISABLE_FAUCET_CHAIN_IDS = process.env.DISABLE_FAUCET_CHAIN_IDS;

apps/dashboard/src/app/(dashboard)/(chain)/[chain_id]/(chainPage)/components/client/FaucetButton.tsx

Lines changed: 53 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -1,5 +1,6 @@
11
"use client";
22

3+
import { ChakraProviderSetup } from "@/components/ChakraProviderSetup";
34
import { Spinner } from "@/components/ui/Spinner/Spinner";
45
import { Button } from "@/components/ui/button";
56
import { Form, FormControl, FormField, FormItem } from "@/components/ui/form";
@@ -9,11 +10,15 @@ import {
910
} from "@/constants/env";
1011
import { useThirdwebClient } from "@/constants/thirdweb.client";
1112
import { CustomConnectWallet } from "@3rdweb-sdk/react/components/connect-wallet";
13+
import { useAccount } from "@3rdweb-sdk/react/hooks/useApi";
14+
import { useLoggedInUser } from "@3rdweb-sdk/react/hooks/useLoggedInUser";
1215
import { Turnstile } from "@marsidev/react-turnstile";
1316
import { useMutation, useQuery, useQueryClient } from "@tanstack/react-query";
1417
import type { CanClaimResponseType } from "app/api/testnet-faucet/can-claim/CanClaimResponseType";
18+
import { Onboarding } from "components/onboarding";
1519
import { mapV4ChainToV5Chain } from "contexts/map-chains";
1620
import { useTrack } from "hooks/analytics/useTrack";
21+
import { useState } from "react";
1722
import { useForm } from "react-hook-form";
1823
import { toast } from "sonner";
1924
import { toUnits } from "thirdweb";
@@ -113,6 +118,10 @@ export function FaucetButton({
113118
},
114119
});
115120

121+
const accountQuery = useAccount();
122+
const userQuery = useLoggedInUser();
123+
const [showOnboarding, setShowOnBoarding] = useState(false);
124+
116125
const canClaimFaucetQuery = useQuery({
117126
queryKey: ["testnet-faucet-can-claim", chainId],
118127
queryFn: async () => {
@@ -133,6 +142,32 @@ export function FaucetButton({
133142

134143
const form = useForm<z.infer<typeof claimFaucetSchema>>();
135144

145+
// Force users to log in to claim the faucet
146+
if (!address || !userQuery.user) {
147+
return (
148+
<CustomConnectWallet
149+
loginRequired={true}
150+
connectButtonClassName="!w-full !rounded !bg-primary !text-primary-foreground !px-4 !py-2 !text-sm"
151+
/>
152+
);
153+
}
154+
155+
if (accountQuery.isPending) {
156+
return (
157+
<Button variant="outline" className="w-full gap-2">
158+
Loading account <Spinner className="size-3" />
159+
</Button>
160+
);
161+
}
162+
163+
if (!accountQuery.data) {
164+
return (
165+
<Button variant="outline" className="w-full gap-2" disabled>
166+
Failed to load account
167+
</Button>
168+
);
169+
}
170+
136171
// loading state
137172
if (faucetWalletBalanceQuery.isPending || canClaimFaucetQuery.isPending) {
138173
return (
@@ -145,7 +180,7 @@ export function FaucetButton({
145180
// faucet is empty
146181
if (isFaucetEmpty) {
147182
return (
148-
<Button variant="outline" disabled className="!opacity-100 w-full ">
183+
<Button variant="outline" disabled className="!opacity-100 w-full">
149184
Faucet is empty right now
150185
</Button>
151186
);
@@ -168,12 +203,24 @@ export function FaucetButton({
168203
);
169204
}
170205

171-
if (!address) {
206+
// Email verification is required to claim from the faucet
207+
if (accountQuery.data.status === "noCustomer") {
172208
return (
173-
<CustomConnectWallet
174-
loginRequired={false}
175-
connectButtonClassName="!w-full !rounded !bg-primary !text-primary-foreground !px-4 !py-2 !text-sm"
176-
/>
209+
<>
210+
<Button
211+
variant="outline"
212+
className="!opacity-100 w-full"
213+
onClick={() => setShowOnBoarding(true)}
214+
>
215+
Verify your Email
216+
</Button>
217+
{/* We will show the modal only if the user click on it, because this is a public page */}
218+
{showOnboarding && (
219+
<ChakraProviderSetup>
220+
<Onboarding onOpenChange={setShowOnBoarding} />
221+
</ChakraProviderSetup>
222+
)}
223+
</>
177224
);
178225
}
179226

apps/dashboard/src/app/api/testnet-faucet/can-claim/route.ts

Lines changed: 7 additions & 9 deletions
Original file line numberDiff line numberDiff line change
@@ -1,17 +1,15 @@
1+
import {
2+
DISABLE_FAUCET_CHAIN_IDS,
3+
THIRDWEB_ACCESS_TOKEN,
4+
THIRDWEB_ENGINE_FAUCET_WALLET,
5+
THIRDWEB_ENGINE_URL,
6+
} from "@/constants/env";
17
import { ipAddress } from "@vercel/functions";
28
import { cacheTtl } from "lib/redis";
39
import { NextResponse } from "next/server";
410
import type { NextRequest } from "next/server";
511
import type { CanClaimResponseType } from "./CanClaimResponseType";
612

7-
const THIRDWEB_ENGINE_URL = process.env.THIRDWEB_ENGINE_URL;
8-
const NEXT_PUBLIC_THIRDWEB_ENGINE_FAUCET_WALLET =
9-
process.env.NEXT_PUBLIC_THIRDWEB_ENGINE_FAUCET_WALLET;
10-
const THIRDWEB_ACCESS_TOKEN = process.env.THIRDWEB_ACCESS_TOKEN;
11-
12-
// Comma-separated list of chain IDs to disable faucet for.
13-
const DISABLE_FAUCET_CHAIN_IDS = process.env.DISABLE_FAUCET_CHAIN_IDS;
14-
1513
// Note: This handler cannot use "edge" runtime because of Redis usage.
1614
export const GET = async (req: NextRequest) => {
1715
const searchParams = req.nextUrl.searchParams;
@@ -50,7 +48,7 @@ export const GET = async (req: NextRequest) => {
5048

5149
if (
5250
!THIRDWEB_ENGINE_URL ||
53-
!NEXT_PUBLIC_THIRDWEB_ENGINE_FAUCET_WALLET ||
51+
!THIRDWEB_ENGINE_FAUCET_WALLET ||
5452
!THIRDWEB_ACCESS_TOKEN ||
5553
isFaucetDisabled
5654
) {

apps/dashboard/src/app/api/testnet-faucet/claim/route.ts

Lines changed: 86 additions & 14 deletions
Original file line numberDiff line numberDiff line change
@@ -1,15 +1,18 @@
1+
import { COOKIE_ACTIVE_ACCOUNT, COOKIE_PREFIX_TOKEN } from "@/constants/cookie";
2+
import {
3+
API_SERVER_URL,
4+
THIRDWEB_ACCESS_TOKEN,
5+
THIRDWEB_ENGINE_FAUCET_WALLET,
6+
THIRDWEB_ENGINE_URL,
7+
} from "@/constants/env";
8+
import type { Account } from "@3rdweb-sdk/react/hooks/useApi";
19
import { ipAddress } from "@vercel/functions";
210
import { startOfToday } from "date-fns";
311
import { cacheGet, cacheSet } from "lib/redis";
412
import { type NextRequest, NextResponse } from "next/server";
5-
import { ZERO_ADDRESS } from "thirdweb";
13+
import { ZERO_ADDRESS, getAddress } from "thirdweb";
614
import { getFaucetClaimAmount } from "./claim-amount";
715

8-
const THIRDWEB_ENGINE_URL = process.env.THIRDWEB_ENGINE_URL;
9-
const NEXT_PUBLIC_THIRDWEB_ENGINE_FAUCET_WALLET =
10-
process.env.NEXT_PUBLIC_THIRDWEB_ENGINE_FAUCET_WALLET;
11-
const THIRDWEB_ACCESS_TOKEN = process.env.THIRDWEB_ACCESS_TOKEN;
12-
1316
interface RequestTestnetFundsPayload {
1417
chainId: number;
1518
toAddress: string;
@@ -18,8 +21,68 @@ interface RequestTestnetFundsPayload {
1821
turnstileToken: string;
1922
}
2023

21-
// Note: This handler cannot use "edge" runtime because of Redis usage.
24+
/**
25+
* How this endpoint works:
26+
* Only users who have signed in to thirdweb.com with an account that is email-verified can claim.
27+
* Those who satisfy the requirement above can claim once per 24 hours for every account
28+
*
29+
* Note: This handler cannot use "edge" runtime because of Redis usage.
30+
*/
2231
export const POST = async (req: NextRequest) => {
32+
// Make sure user's connected to the site
33+
const activeAccount = req.cookies.get(COOKIE_ACTIVE_ACCOUNT)?.value;
34+
35+
if (!activeAccount) {
36+
return NextResponse.json(
37+
{
38+
error: "No wallet detected",
39+
},
40+
{ status: 400 },
41+
);
42+
}
43+
const authCookieName = COOKIE_PREFIX_TOKEN + getAddress(activeAccount);
44+
45+
const authCookie = req.cookies.get(authCookieName);
46+
47+
if (!authCookie) {
48+
return NextResponse.json(
49+
{
50+
error: "No wallet connected",
51+
},
52+
{ status: 400 },
53+
);
54+
}
55+
56+
// Make sure the connected wallet has a thirdweb account
57+
const accountRes = await fetch(`${API_SERVER_URL}/v1/account/me`, {
58+
method: "GET",
59+
headers: {
60+
Authorization: `Bearer ${authCookie.value}`,
61+
},
62+
});
63+
64+
if (accountRes.status !== 200) {
65+
// Account not found on this connected address
66+
return NextResponse.json(
67+
{
68+
error: "thirdweb account not found",
69+
},
70+
{ status: 400 },
71+
);
72+
}
73+
74+
const account: { data: Account } = await accountRes.json();
75+
76+
// Make sure the logged-in account has verified its email
77+
if (account.data.status === "noCustomer") {
78+
return NextResponse.json(
79+
{
80+
error: "Account owner hasn't verified email",
81+
},
82+
{ status: 400 },
83+
);
84+
}
85+
2386
const requestBody = (await req.json()) as RequestTestnetFundsPayload;
2487
const { chainId, toAddress, turnstileToken } = requestBody;
2588
if (Number.isNaN(chainId)) {
@@ -28,7 +91,7 @@ export const POST = async (req: NextRequest) => {
2891

2992
if (
3093
!THIRDWEB_ENGINE_URL ||
31-
!NEXT_PUBLIC_THIRDWEB_ENGINE_FAUCET_WALLET ||
94+
!THIRDWEB_ENGINE_FAUCET_WALLET ||
3295
!THIRDWEB_ACCESS_TOKEN
3396
) {
3497
return NextResponse.json(
@@ -89,15 +152,23 @@ export const POST = async (req: NextRequest) => {
89152

90153
const ipCacheKey = `testnet-faucet:${chainId}:${ip}`;
91154
const addressCacheKey = `testnet-faucet:${chainId}:${toAddress}`;
155+
const accountCacheKey = `testnet-faucet:${chainId}:${account.data.id}`;
92156

93157
// Assert 1 request per IP/chain every 24 hours.
94158
// get the cached value
95-
const [ipCacheValue, addressCache] = await Promise.all([
96-
cacheGet(ipCacheKey),
97-
cacheGet(addressCacheKey),
98-
]);
159+
const [ipCacheValue, accountCacheValue, addressCacheValue] =
160+
await Promise.all([
161+
cacheGet(ipCacheKey),
162+
cacheGet(accountCacheKey),
163+
cacheGet(addressCacheKey),
164+
]);
165+
99166
// if we have a cached value, return an error
100-
if (ipCacheValue !== null || addressCache !== null) {
167+
if (
168+
ipCacheValue !== null ||
169+
accountCacheValue !== null ||
170+
addressCacheValue !== null
171+
) {
101172
return NextResponse.json(
102173
{ error: "Already requested funds on this chain in the past 24 hours." },
103174
{ status: 429 },
@@ -117,6 +188,7 @@ export const POST = async (req: NextRequest) => {
117188
// Store the claim request for 24 hours.
118189
await Promise.all([
119190
cacheSet(ipCacheKey, "claimed", 24 * 60 * 60),
191+
cacheSet(accountCacheKey, "claimed", 24 * 60 * 60),
120192
cacheSet(addressCacheKey, "claimed", 24 * 60 * 60),
121193
]);
122194
// then actually transfer the funds
@@ -126,7 +198,7 @@ export const POST = async (req: NextRequest) => {
126198
headers: {
127199
"Content-Type": "application/json",
128200
"x-idempotency-key": idempotencyKey,
129-
"x-backend-wallet-address": NEXT_PUBLIC_THIRDWEB_ENGINE_FAUCET_WALLET,
201+
"x-backend-wallet-address": THIRDWEB_ENGINE_FAUCET_WALLET,
130202
Authorization: `Bearer ${THIRDWEB_ACCESS_TOKEN}`,
131203
},
132204
body: JSON.stringify({

apps/dashboard/src/components/onboarding/Modal.tsx

Lines changed: 6 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -1,26 +1,30 @@
11
import { Dialog, DialogContent } from "@/components/ui/dialog";
22
import { cn } from "@/lib/utils";
33
import { IconLogo } from "components/logo";
4+
import type { Dispatch, SetStateAction } from "react";
45
import type { ComponentWithChildren } from "types/component-with-children";
56

67
interface OnboardingModalProps {
78
isOpen: boolean;
89
wide?: boolean;
10+
// Pass this props to make the modal closable (it will enable backdrop + the "x" icon)
11+
onOpenChange?: Dispatch<SetStateAction<boolean>>;
912
}
1013

1114
export const OnboardingModal: ComponentWithChildren<OnboardingModalProps> = ({
1215
children,
1316
isOpen,
1417
wide,
18+
onOpenChange,
1519
}) => {
1620
return (
17-
<Dialog open={isOpen}>
21+
<Dialog open={isOpen} onOpenChange={onOpenChange}>
1822
<DialogContent
1923
dialogOverlayClassName="z-[10000]"
2024
className={cn("z-[10001] max-h-[90vh] overflow-auto", {
2125
"!max-w-[768px]": wide,
2226
})}
23-
dialogCloseClassName="hidden"
27+
dialogCloseClassName={onOpenChange !== undefined ? "" : "hidden"}
2428
>
2529
<div className="flex flex-col gap-4">
2630
<div className="w-[40px]">

apps/dashboard/src/components/onboarding/index.tsx

Lines changed: 17 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -5,7 +5,14 @@ import {
55
useAccount,
66
} from "@3rdweb-sdk/react/hooks/useApi";
77
import { useLoggedInUser } from "@3rdweb-sdk/react/hooks/useLoggedInUser";
8-
import { Suspense, lazy, useEffect, useState } from "react";
8+
import {
9+
type Dispatch,
10+
type SetStateAction,
11+
Suspense,
12+
lazy,
13+
useEffect,
14+
useState,
15+
} from "react";
916
import { useActiveWallet } from "thirdweb/react";
1017
import { useTrack } from "../../hooks/analytics/useTrack";
1118
import { LazyOnboardingBilling } from "./LazyOnboardingBilling";
@@ -42,7 +49,10 @@ type OnboardingState =
4249
| "skipped"
4350
| undefined;
4451

45-
export const Onboarding: React.FC = () => {
52+
export const Onboarding: React.FC<{
53+
// Pass this props to make the modal closable (it will enable backdrop + the "x" icon)
54+
onOpenChange?: Dispatch<SetStateAction<boolean>>;
55+
}> = ({ onOpenChange }) => {
4656
const meQuery = useAccount();
4757

4858
const { isLoggedIn } = useLoggedInUser();
@@ -202,7 +212,11 @@ export const Onboarding: React.FC = () => {
202212
}
203213

204214
return (
205-
<OnboardingModal isOpen={!!state} wide={state === "plan"}>
215+
<OnboardingModal
216+
isOpen={!!state}
217+
wide={state === "plan"}
218+
onOpenChange={onOpenChange}
219+
>
206220
{state === "onboarding" && (
207221
<Suspense fallback={<Loading />}>
208222
<OnboardingGeneral

0 commit comments

Comments
 (0)