Skip to content

Commit b77fdb0

Browse files
committed
update
1 parent f40d247 commit b77fdb0

File tree

5 files changed

+160
-26
lines changed

5 files changed

+160
-26
lines changed

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

Lines changed: 47 additions & 8 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,14 @@ 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";
1214
import { Turnstile } from "@marsidev/react-turnstile";
1315
import { useMutation, useQuery, useQueryClient } from "@tanstack/react-query";
1416
import type { CanClaimResponseType } from "app/api/testnet-faucet/can-claim/CanClaimResponseType";
17+
import { Onboarding } from "components/onboarding";
1518
import { mapV4ChainToV5Chain } from "contexts/map-chains";
1619
import { useTrack } from "hooks/analytics/useTrack";
20+
import { useState } from "react";
1721
import { useForm } from "react-hook-form";
1822
import { toast } from "sonner";
1923
import { toUnits } from "thirdweb";
@@ -113,18 +117,27 @@ export function FaucetButton({
113117
},
114118
});
115119

120+
const accountQuery = useAccount();
121+
const [showOnboarding, setShowOnBoarding] = useState(false);
122+
116123
const canClaimFaucetQuery = useQuery({
117124
queryKey: ["testnet-faucet-can-claim", chainId],
118125
queryFn: async () => {
126+
if (!accountQuery.data) {
127+
throw new Error("Account not connected");
128+
}
119129
const response = await fetch(
120-
`/api/testnet-faucet/can-claim?chainId=${chainId}`,
130+
`/api/testnet-faucet/can-claim?chainId=${chainId}&accountId=${accountQuery.data.id}`,
121131
);
122132
if (!response.ok) {
123133
throw new Error("Failed to get claim status");
124134
}
125135
const data = (await response.json()) as CanClaimResponseType;
126136
return data;
127137
},
138+
// Only run this query if user has logged in
139+
// since we rely on the account id to check for the claim eligibility
140+
enabled: !!accountQuery.data,
128141
});
129142

130143
const isFaucetEmpty =
@@ -134,7 +147,11 @@ export function FaucetButton({
134147
const form = useForm<z.infer<typeof claimFaucetSchema>>();
135148

136149
// loading state
137-
if (faucetWalletBalanceQuery.isPending || canClaimFaucetQuery.isPending) {
150+
if (
151+
faucetWalletBalanceQuery.isPending ||
152+
canClaimFaucetQuery.isPending ||
153+
accountQuery.isLoading
154+
) {
138155
return (
139156
<Button variant="outline" className="w-full gap-2">
140157
Checking Faucet <Spinner className="size-3" />
@@ -145,12 +162,22 @@ export function FaucetButton({
145162
// faucet is empty
146163
if (isFaucetEmpty) {
147164
return (
148-
<Button variant="outline" disabled className="!opacity-100 w-full ">
165+
<Button variant="outline" disabled className="!opacity-100 w-full">
149166
Faucet is empty right now
150167
</Button>
151168
);
152169
}
153170

171+
// Force users to log in to claim the faucet
172+
if (!address || !accountQuery.data) {
173+
return (
174+
<CustomConnectWallet
175+
loginRequired={true}
176+
connectButtonClassName="!w-full !rounded !bg-primary !text-primary-foreground !px-4 !py-2 !text-sm"
177+
/>
178+
);
179+
}
180+
154181
// Can not claim
155182
if (canClaimFaucetQuery.data && canClaimFaucetQuery.data.canClaim === false) {
156183
return (
@@ -168,12 +195,24 @@ export function FaucetButton({
168195
);
169196
}
170197

171-
if (!address) {
198+
// Email verification is required to claim from the faucet
199+
if (accountQuery.data.status === "noCustomer") {
172200
return (
173-
<CustomConnectWallet
174-
loginRequired={false}
175-
connectButtonClassName="!w-full !rounded !bg-primary !text-primary-foreground !px-4 !py-2 !text-sm"
176-
/>
201+
<>
202+
<Button
203+
variant="outline"
204+
className="!opacity-100 w-full"
205+
onClick={() => setShowOnBoarding(true)}
206+
>
207+
Verify your email
208+
</Button>
209+
{/* We will show the modal only if the user click on it, because this is a public page */}
210+
{showOnboarding && (
211+
<ChakraProviderSetup>
212+
<Onboarding onOpenChange={setShowOnBoarding} />
213+
</ChakraProviderSetup>
214+
)}
215+
</>
177216
);
178217
}
179218

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

Lines changed: 16 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -1,11 +1,10 @@
1+
import { THIRDWEB_ENGINE_FAUCET_WALLET } from "@/constants/env";
12
import { cacheTtl } from "lib/redis";
23
import { NextResponse } from "next/server";
34
import type { NextRequest } from "next/server";
45
import type { CanClaimResponseType } from "./CanClaimResponseType";
56

67
const THIRDWEB_ENGINE_URL = process.env.THIRDWEB_ENGINE_URL;
7-
const NEXT_PUBLIC_THIRDWEB_ENGINE_FAUCET_WALLET =
8-
process.env.NEXT_PUBLIC_THIRDWEB_ENGINE_FAUCET_WALLET;
98
const THIRDWEB_ACCESS_TOKEN = process.env.THIRDWEB_ACCESS_TOKEN;
109

1110
// Comma-separated list of chain IDs to disable faucet for.
@@ -36,6 +35,19 @@ export const GET = async (req: NextRequest) => {
3635
);
3736
}
3837

38+
const accountId = searchParams.get("accountId");
39+
40+
if (!accountId) {
41+
return NextResponse.json(
42+
{
43+
error: "Account not connected",
44+
},
45+
{ status: 400 },
46+
);
47+
}
48+
// No need to verify if the accountId is valid. That is done in /api/testnet-faucet/claim
49+
// We only need the accountId here for checking the claim countdown (using Redis)
50+
3951
// Check if faucet is disabled for this chain.
4052
let isFaucetDisabled = false;
4153
if (DISABLE_FAUCET_CHAIN_IDS) {
@@ -49,7 +61,7 @@ export const GET = async (req: NextRequest) => {
4961

5062
if (
5163
!THIRDWEB_ENGINE_URL ||
52-
!NEXT_PUBLIC_THIRDWEB_ENGINE_FAUCET_WALLET ||
64+
!THIRDWEB_ENGINE_FAUCET_WALLET ||
5365
!THIRDWEB_ACCESS_TOKEN ||
5466
isFaucetDisabled
5567
) {
@@ -73,7 +85,7 @@ export const GET = async (req: NextRequest) => {
7385
{ status: 400 },
7486
);
7587
}
76-
const cacheKey = `testnet-faucet:${chainId}:${ipAddress}`;
88+
const cacheKey = `testnet-faucet:${chainId}:${accountId}`;
7789
const ttlSeconds = await cacheTtl(cacheKey);
7890

7991
const res: CanClaimResponseType = {

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

Lines changed: 72 additions & 9 deletions
Original file line numberDiff line numberDiff line change
@@ -1,7 +1,10 @@
1+
import { COOKIE_ACTIVE_ACCOUNT, COOKIE_PREFIX_TOKEN } from "@/constants/cookie";
2+
import { API_SERVER_URL } from "@/constants/env";
3+
import type { Account } from "@3rdweb-sdk/react/hooks/useApi";
14
import { startOfToday } from "date-fns";
25
import { cacheGet, cacheSet } from "lib/redis";
36
import { type NextRequest, NextResponse } from "next/server";
4-
import { ZERO_ADDRESS } from "thirdweb";
7+
import { ZERO_ADDRESS, getAddress } from "thirdweb";
58
import { getFaucetClaimAmount } from "./claim-amount";
69

710
const THIRDWEB_ENGINE_URL = process.env.THIRDWEB_ENGINE_URL;
@@ -17,8 +20,68 @@ interface RequestTestnetFundsPayload {
1720
turnstileToken: string;
1821
}
1922

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

89-
const ipCacheKey = `testnet-faucet:${chainId}:${ipAddress}`;
90152
const addressCacheKey = `testnet-faucet:${chainId}:${toAddress}`;
153+
const accountCacheKey = `testnet-faucet:${chainId}:${account.id}`;
91154

92-
// Assert 1 request per IP/chain every 24 hours.
155+
// Assert 1 request per userId every 24 hours.
93156
// get the cached value
94-
const [ipCacheValue, addressCache] = await Promise.all([
95-
cacheGet(ipCacheKey),
157+
const [accountCacheValue, addressCache] = await Promise.all([
158+
cacheGet(accountCacheKey),
96159
cacheGet(addressCacheKey),
97160
]);
98161
// if we have a cached value, return an error
99-
if (ipCacheValue !== null || addressCache !== null) {
162+
if (accountCacheValue !== null || addressCache !== null) {
100163
return NextResponse.json(
101164
{ error: "Already requested funds on this chain in the past 24 hours." },
102165
{ status: 429 },
@@ -109,13 +172,13 @@ export const POST = async (req: NextRequest) => {
109172
todayLocal.getTime() - todayLocal.getTimezoneOffset() * 60000,
110173
);
111174
const todayUTCSeconds = Math.floor(todayUTC.getTime() / 1000);
112-
const idempotencyKey = `${ipCacheKey}:${todayUTCSeconds}`;
175+
const idempotencyKey = `${addressCacheKey}:${todayUTCSeconds}`;
113176
const amountToClaim = getFaucetClaimAmount(chainId).toString();
114177

115178
try {
116179
// Store the claim request for 24 hours.
117180
await Promise.all([
118-
cacheSet(ipCacheKey, "claimed", 24 * 60 * 60),
181+
cacheSet(accountCacheKey, "claimed", 24 * 60 * 60),
119182
cacheSet(addressCacheKey, "claimed", 24 * 60 * 60),
120183
]);
121184
// then actually transfer the funds

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

Lines changed: 8 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -1,26 +1,32 @@
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+
isClosable?: boolean;
11+
12+
// Pass this props to make the modal closable (it will enable backdrop + the "x" icon)
13+
onOpenChange?: Dispatch<SetStateAction<boolean>>;
914
}
1015

1116
export const OnboardingModal: ComponentWithChildren<OnboardingModalProps> = ({
1217
children,
1318
isOpen,
1419
wide,
20+
onOpenChange,
1521
}) => {
1622
return (
17-
<Dialog open={isOpen}>
23+
<Dialog open={isOpen} onOpenChange={onOpenChange}>
1824
<DialogContent
1925
dialogOverlayClassName="z-[10000]"
2026
className={cn("z-[10001] max-h-[90vh] overflow-auto", {
2127
"!max-w-[768px]": wide,
2228
})}
23-
dialogCloseClassName="hidden"
29+
dialogCloseClassName={onOpenChange !== undefined ? "" : "hidden"}
2430
>
2531
<div className="flex flex-col gap-4">
2632
<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)