Skip to content
New issue

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

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

Already on GitHub? Sign in to your account

Feat/email verification flow completed #233

Merged
merged 12 commits into from
May 29, 2024
1 change: 1 addition & 0 deletions apps/academy/package.json
Original file line number Diff line number Diff line change
Expand Up @@ -20,6 +20,7 @@
"@next/font": "^14.1.0",
"@rainbow-me/rainbowkit": "^1.0.10",
"@rainbow-me/rainbowkit-siwe-next-auth": "^0.3.0",
"@sendgrid/mail": "^8.1.3",
"@t3-oss/env-nextjs": "^0.6.1",
"@tanstack/react-query": "^4.33.0",
"@trpc/client": "^10.38.1",
Expand Down
38 changes: 37 additions & 1 deletion apps/academy/src/components/Layout.tsx
Original file line number Diff line number Diff line change
@@ -1,9 +1,12 @@
import localFont from "next/font/local";
import { useRouter } from "next/router";
import type { FunctionComponent, PropsWithChildren } from "react";
import { useSession } from "next-auth/react";
import { type FunctionComponent, type PropsWithChildren, useEffect, useState } from "react";
import { Footer } from "ui";

import { Header } from "@/components/Header";
import { RequestEmailDialog } from "@/components/RequestEmailDialog";
import { api } from "@/utils/api";

const bttf = localFont({
src: "../../public/fonts/BTTF.ttf",
Expand All @@ -23,8 +26,41 @@ const fontVars = `${bttf.variable} ${deathstar.variable} ${andale.variable}`;
export const Layout: FunctionComponent<PropsWithChildren> = ({ children }) => {
const router = useRouter();
const { pathname } = router;
const [requestEmail, setRequestEmail] = useState(false);

const { status } = useSession();

const { data: userEmailData, refetch: refetchGetUSerEMailData } =
api.user.getUserEmail.useQuery();

useEffect(() => {
if (status === "authenticated") {
const fetchGetUserEmailData = async () => {
await refetchGetUSerEMailData();
};
void fetchGetUserEmailData();
}
}, [status]);

useEffect(() => {
console.log({ userEmailData });

if (
status === "authenticated" &&
(userEmailData?.email === null || userEmailData?.emailVerified === null)
) {
setRequestEmail(true);
}
}, [userEmailData, status]);

return (
<>
<RequestEmailDialog
open={requestEmail}
setIsOpen={() => {
setRequestEmail(false);
}}
/>
<Header />
<main className={fontVars}>{children}</main>
{pathname !== "/tracks" && pathname !== "/fundamentals" ? <Footer /> : null}
Expand Down
166 changes: 166 additions & 0 deletions apps/academy/src/components/RequestEmailDialog.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,166 @@
import { type Dispatch, type SetStateAction, useEffect, useState } from "react";
import { Button, InputOTP, InputOTPGroup, InputOTPSeparator, InputOTPSlot } from "ui";
import { Dialog, DialogContent, DialogFooter, DialogHeader, DialogTitle } from "ui";
import { Input } from "ui";
import { Label } from "ui";
import { useToast } from "ui";

import { api } from "@/utils/api";

interface Props {
open: boolean;
setIsOpen: Dispatch<SetStateAction<boolean>>;
}

export function RequestEmailDialog({ open, setIsOpen }: Props) {
const { toast } = useToast();
const [userEmail, setUserEmail] = useState("");
const [saveBtnClicked, setSaveBtnClicked] = useState(false);
const [showInputOtp, setShowInputOtp] = useState(false);
const [numberToVerify, setNumberToVerify] = useState("");

const { mutate: saveUserEmail, data: userData } = api.user.addEmail.useMutation({
onSuccess: () => {
toast({
title: "Amazing!",
description: "Now Check your inbox to verify your email address",
});
setSaveBtnClicked(true);
const timer = setTimeout(() => {
setShowInputOtp(true);
}, 350);
return () => {
clearTimeout(timer);
};
},
});

const { mutate: saveEmailVerificatedSuccess } = api.user.emailVerificatedSuccess.useMutation({
onSuccess: () => {
toast({
title: "Email verified!",
description: "Thank you so much for verifying you email address, keep learning now. Enjoy!",
});
setIsOpen(false);
},
});

const handleSaveBtnClick = (e: any) => {
e.preventDefault();
if (userEmail !== "") {
saveUserEmail(userEmail);
}
};

const { data: userEmailData } = api.user.getUserEmail.useQuery();

const handleVerifyVerificationNumber = () => {
if (userData?.verificationNumber !== null && userData?.verificationNumber !== undefined) {
const verificationCorrect = Number(numberToVerify) === userData.verificationNumber;
if (verificationCorrect) {
saveEmailVerificatedSuccess();
} else {
console.log("notttt correct");
//TODO: resend another email with another number
}
} else {
console.log("weird else");
}
};

useEffect(() => {
if (userEmailData?.email !== null && userEmailData?.emailVerified === null) {
setShowInputOtp(true);
}
}, [userEmailData]);

return (
<Dialog open={open}>
<DialogContent
className=" h-fit w-fit gap-10 border-[#44AF96] bg-black"
onEscapeKeyDown={(e) => {
e.preventDefault();
}}
onPointerDown={(e) => {
e.preventDefault();
}}
onInteractOutside={(e) => {
e.preventDefault();
}}
>
<DialogHeader className="gap-2">
<DialogTitle className="text-3xl text-[#44AF96]">
{showInputOtp ? "Insert the verification code sent" : "Configure your email"}
</DialogTitle>
</DialogHeader>
<div className="flex h-fit flex-col gap-10">
<div className="flex flex-col gap-10">
<Label htmlFor="collaborators" className="text-left text-sm text-[#999999]">
Your email address will be used to receive notifications about updates, join the
frens!
</Label>
{showInputOtp ? (
<div className="flex justify-center">
<InputOTP
maxLength={6}
onChange={(val) => {
console.log({ val });
setNumberToVerify(val);
}}
>
<InputOTPGroup>
<InputOTPSlot index={0} className="border border-[#44AF96] text-[#44AF96]" />
<InputOTPSlot index={1} className="border border-[#44AF96] text-[#44AF96]" />
<InputOTPSlot index={2} className="border border-[#44AF96] text-[#44AF96]" />
</InputOTPGroup>
<InputOTPSeparator />
<InputOTPGroup>
<InputOTPSlot index={3} className="border border-[#44AF96] text-[#44AF96]" />
<InputOTPSlot index={4} className="border border-[#44AF96] text-[#44AF96]" />
<InputOTPSlot index={5} className="border border-[#44AF96] text-[#44AF96]" />
</InputOTPGroup>
</InputOTP>
</div>
) : (
<Input
id="email"
type="email" //TODO: VALIDATE EMAIL FORMAT VALID EMAIL FORMAT https://ui.shadcn.com/docs/components/input#form
placeholder="Keep up to date with Academy's updates!"
className="col-span-3 border-0 bg-[#333333] text-[#999999]"
value={userEmail}
onChange={(e) => {
setUserEmail(e.target.value);
}}
/>
)}
</div>
</div>
<DialogFooter className="w-full justify-end">
{showInputOtp ? (
<Button
variant="primary"
disabled={numberToVerify.length !== 6}
onClick={handleVerifyVerificationNumber}
className="disabled:bg-gray-600 disabled:hover:bg-gray-500"
>
Verify Email
</Button>
) : !saveBtnClicked ? (
<Button
variant="primary"
disabled={!userEmail}
onClick={handleSaveBtnClick}
className="disabled:bg-gray-600 disabled:hover:bg-gray-500"
>
Save
</Button>
) : (
<Label htmlFor="collaborators" className="w-full text-center text-sm text-[#999999]">
You will receive a verification email, check your inbox!
</Label>
)}
</DialogFooter>
</DialogContent>
</Dialog>
);
}
88 changes: 0 additions & 88 deletions apps/academy/src/contexts/AppContextProvider.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -14,7 +14,6 @@ interface PropsInterface {
export function AppContextProvider({ children }: PropsInterface) {
const [completedQuizzesIds, setCompletedQuizzesIds] = useState<string[]>([]);
const [sessionDataUser, setSessionDataUser] = useState<any>(null);
// const [formattedAllTracksData, setFormattedAllTracksData] = useState<TrackWithTags>([]);

const { data: sessionData, status: sessionStatus } = useSession();
const { address, status: walletStatus } = useAccount();
Expand Down Expand Up @@ -57,27 +56,6 @@ export function AppContextProvider({ children }: PropsInterface) {
// eslint-disable-next-line react-hooks/exhaustive-deps
}, [completedQuizzesAllData]);

// const fetchFromDirs = async () => {
// const lessonsData = await fetch("/api/readfiles").then(async (res) => res.json());

// const lessonsFormatResult: FormatedLessonInterface = lessonsData.reduce(
// (acc: any, curr: any) => {
// if (acc[curr.path] === undefined) acc[curr.path] = [];

// acc[curr.path].push(curr);
// return acc as Project | Fundamental;
// },
// {},
// );

// setFundamentals(lessonsFormatResult.fundamentals);
// setProjects(lessonsFormatResult.projects);
// };

// useEffect(() => {
// void fetchFromDirs();
// }, []);

// - Get All Tracks data
const { data: allTracksData, isLoading: allTracksDataIsLoading } = api.tracks.getAll.useQuery(
undefined,
Expand All @@ -94,72 +72,6 @@ export function AppContextProvider({ children }: PropsInterface) {
},
);

// useEffect(() => {
// if (
// allLessonsData !== undefined &&
// projects !== undefined &&
// completedQuizzesIds.length !== 0
// ) {
// const projectsWithCompleteStatus = projects.map((lesson) => {
// const currentLessonId = allLessonsData.find(
// (lessonData: any) =>
// lessonData.projectLessonNumber?.toString() === lesson.slug.toString(), // DEV_NOTE: forcing .toString() to avoid type errors
// )?.id;

// const completed =
// currentLessonId !== undefined && completedQuizzesIds.includes(currentLessonId)
// ? true
// : false; // DEV_NOTE: if the lesson is not found, it is not completed
// return { ...lesson, completed };
// });

// setProjects(projectsWithCompleteStatus);
// } else if (
// allLessonsData !== undefined &&
// projects !== undefined &&
// completedQuizzesIds.length === 0
// ) {
// const projectsWithCompleteStatus = projects.map((lesson) => {
// return { ...lesson, completed: false };
// });
// setProjects(projectsWithCompleteStatus);
// }
// // eslint-disable-next-line react-hooks/exhaustive-deps
// }, [completedQuizzesIds]);

// useEffect(() => {
// if (
// allLessonsData !== undefined &&
// fundamentals !== undefined &&
// completedQuizzesIds.length !== 0
// ) {
// const fundamentalsWithCompleteStatus = fundamentals.map((lesson) => {
// const currentLessonId = allLessonsData.find(
// (lessonData: any) =>
// lessonData.fundamentalLessonName?.toString() === lesson.slug.toString(), // DEV_NOTE: forcing .toString() to avoid type errors
// )?.id;

// const completed =
// currentLessonId !== undefined && completedQuizzesIds.includes(currentLessonId)
// ? true
// : false; // DEV_NOTE: if the lesson is not found, it is not completed
// return { ...lesson, completed };
// });

// setFundamentals(fundamentalsWithCompleteStatus);
// } else if (
// allLessonsData !== undefined &&
// fundamentals !== undefined &&
// completedQuizzesIds.length === 0
// ) {
// const fundamentalsWithCompleteStatus = fundamentals.map((lesson) => {
// return { ...lesson, completed: false };
// });
// setFundamentals(fundamentalsWithCompleteStatus);
// }
// // eslint-disable-next-line react-hooks/exhaustive-deps
// }, [completedQuizzesIds]);

return (
<AppContext.Provider
value={{
Expand Down
2 changes: 2 additions & 0 deletions apps/academy/src/env.mjs
Original file line number Diff line number Diff line change
Expand Up @@ -21,6 +21,7 @@ const server = z.object({
// DISCORD_CLIENT_ID: z.string(),
// DISCORD_CLIENT_SECRET: z.string(),
ENVIRONMENT: z.enum(["local", "staging", "production"]),
SENDGRID_API_KEY: z.string().min(1),
});

/**
Expand All @@ -47,6 +48,7 @@ const processEnv = {
// NEXT_PUBLIC_CLIENTVAR: process.env.NEXT_PUBLIC_CLIENTVAR,
NEXT_PUBLIC_WALLET_CONNECT_ID: process.env["NEXT_PUBLIC_WALLET_CONNECT_ID"],
ENVIRONMENT: process.env["ENVIRONMENT"],
SENDGRID_API_KEY: process.env["SENDGRID_API_KEY"],
};

// Don't touch the part below
Expand Down
38 changes: 38 additions & 0 deletions apps/academy/src/pages/api/email/index.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,38 @@
import sgMail from "@sendgrid/mail";
import type { NextApiRequest, NextApiResponse } from "next";

import { env } from "@/env.mjs";

sgMail.setApiKey(env.SENDGRID_API_KEY);

interface ResponseData {
message: string;
}

export default function handler(req: NextApiRequest, res: NextApiResponse<ResponseData>) {
if (req.method === "POST") {
const { body }: { body: { email: string; verificationNumber: string } } = req;
// Process a POST request
const msg = {
to: body.email,
from: "[email protected]",
subject: "D_D Academy Verification Code",
text: "This is your e-mail verification code for Developer DAO Academy ",
html: `<strong>${body.verificationNumber}</strong>`,
};

sgMail
.send(msg)
.then(() => {
console.log("Email sent successfully");
res.status(200).json({ message: "success" });
})
.catch((error: any) => {
console.error(error);
res.status(200).json({ message: error });
});
} else {
// Handle any GET method
res.status(200).json({ message: "Hellooooo" });
}
}
Loading