diff --git a/.env b/.env new file mode 100644 index 0000000..bf240e3 --- /dev/null +++ b/.env @@ -0,0 +1,23 @@ +#NEXT +NEXT_PUBLIC_SITE_URL=http://localhost:3000 + +#APPWRITE +NEXT_PUBLIC_APPWRITE_ENDPOINT=https://cloud.appwrite.io/v1 +NEXT_PUBLIC_APPWRITE_PROJECT=66b990e9002a422561f2 +APPWRITE_DATABASE_ID=66b991f300286bd0255f +APPWRITE_USER_COLLECTION_ID=66b992040004b1d8de79 +APPWRITE_BANK_COLLECTION_ID=66b9924e0035cfae025a +APPWRITE_TRANSACTION_COLLECTION_ID=66b99230002710ddd1e8 +NEXT_APPWRITE_KEY=9288e2db04879a9f3ce5555c7420955a97dc7e39030b30902c3432f02f2387444f970b919eec9db436b1f5f2fa0274abc4f145edc4b61b2acb0d863bac6b23e99f7a7f974ab972287aba97972cf86c6e6b8136f83437f558ffd686ea4425fd85fa52e277aa49e1bc4d402db10e83230f16e4f197fbbae720bef75442f968fb77 +#PLAID +PLAID_CLIENT_ID= +PLAID_SECRET= +PLAID_ENV= +PLAID_PRODUCTS= +PLAID_COUNTRY_CODES= + +#DWOLLA +DWOLLA_KEY= +DWOLLA_SECRET= +DWOLLA_BASE_URL= +DWOLLA_ENV= \ No newline at end of file diff --git a/.env.example b/.env.example new file mode 100644 index 0000000..59b0c0e --- /dev/null +++ b/.env.example @@ -0,0 +1,25 @@ +#NEXT +NEXT_PUBLIC_SITE_URL=http://localhost:3000 + +#APPWRITE +NEXT_PUBLIC_APPWRITE_ENDPOINT=https://cloud.appwrite.io/v1 +NEXT_PUBLIC_APPWRITE_PROJECT= +APPWRITE_DATABASE_ID= +APPWRITE_USER_COLLECTION_ID= +APPWRITE_ITEM_COLLECTION_ID= +APPWRITE_BANK_COLLECTION_ID= +APPWRITE_TRANSACTION_COLLECTION_ID= +NEXT_APPWRITE_KEY= + +#PLAID +PLAID_CLIENT_ID= +PLAID_SECRET= +PLAID_ENV= +PLAID_PRODUCTS= +PLAID_COUNTRY_CODES= + +#DWOLLA +DWOLLA_KEY= +DWOLLA_SECRET= +DWOLLA_BASE_URL= +DWOLLA_ENV= \ No newline at end of file diff --git a/.gitignore b/.gitignore index fd3dbb5..1dd45b2 100644 --- a/.gitignore +++ b/.gitignore @@ -34,3 +34,6 @@ yarn-error.log* # typescript *.tsbuildinfo next-env.d.ts + +# Sentry Config File +.env.sentry-build-plugin diff --git a/app/(auth)/layout.tsx b/app/(auth)/layout.tsx index 847cd93..1c5de96 100644 --- a/app/(auth)/layout.tsx +++ b/app/(auth)/layout.tsx @@ -1,7 +1,23 @@ +import Image from "next/image"; export default function RootLayout({ children, }: Readonly<{ children: React.ReactNode; }>) { - return
{children}
; + return ( +
+ {children} +
+
+ Auth image +
+
+
+ ); } diff --git a/app/(auth)/sign-in/page.tsx b/app/(auth)/sign-in/page.tsx index e12af16..eab5be9 100644 --- a/app/(auth)/sign-in/page.tsx +++ b/app/(auth)/sign-in/page.tsx @@ -1,7 +1,12 @@ +import AuthForm from "@/components/AuthForm"; import React from "react"; const SignIn = () => { - return
SignIn
; + return ( +
+ +
+ ); }; export default SignIn; diff --git a/app/(auth)/sign-up/page.tsx b/app/(auth)/sign-up/page.tsx index b466e40..b60192d 100644 --- a/app/(auth)/sign-up/page.tsx +++ b/app/(auth)/sign-up/page.tsx @@ -1,7 +1,15 @@ +import AuthForm from "@/components/AuthForm"; +import { getLoggedInUser } from "@/lib/actions/user.actions"; import React from "react"; -const SignUp = () => { - return
SignUp
; +const SignUp = async () => { + const loggedInUser = await getLoggedInUser(); + console.log(loggedInUser); + return ( +
+ +
+ ); }; export default SignUp; diff --git a/app/(root)/layout.tsx b/app/(root)/layout.tsx index 43a83aa..d226eec 100644 --- a/app/(root)/layout.tsx +++ b/app/(root)/layout.tsx @@ -1,12 +1,16 @@ +import MobileNav from "@/components/MobileNav"; import Sidebar from "@/components/Sidebar"; +import { getLoggedInUser } from "@/lib/actions/user.actions"; import Image from "next/image"; +import { redirect } from "next/navigation"; -export default function RootLayout({ +export default async function RootLayout({ children, }: Readonly<{ children: React.ReactNode; }>) { - const loggedIn = { firstName: "Angel", lastName: "Nmesoma" }; + const loggedIn = await getLoggedInUser(); + if (!loggedIn) redirect("/sign-in"); return (
diff --git a/app/(root)/page.tsx b/app/(root)/page.tsx index 31e1bd7..f058a21 100644 --- a/app/(root)/page.tsx +++ b/app/(root)/page.tsx @@ -1,14 +1,11 @@ import HeaderBox from "@/components/HeaderBox"; import RightSidebar from "@/components/RightSidebar"; import TotalBalanceBox from "@/components/TotalBalanceBox"; +import { getLoggedInUser } from "@/lib/actions/user.actions"; import React from "react"; -const Home = () => { - const loggedIn = { - firstName: "Nmesoma", - lastName: "Angel", - email: "contact@angelcode.pro", - }; +const Home = async () => { + const loggedIn = await getLoggedInUser(); return (
@@ -16,7 +13,7 @@ const Home = () => { diff --git a/app/global-error.tsx b/app/global-error.tsx new file mode 100644 index 0000000..9bda5fe --- /dev/null +++ b/app/global-error.tsx @@ -0,0 +1,23 @@ +"use client"; + +import * as Sentry from "@sentry/nextjs"; +import NextError from "next/error"; +import { useEffect } from "react"; + +export default function GlobalError({ error }: { error: Error & { digest?: string } }) { + useEffect(() => { + Sentry.captureException(error); + }, [error]); + + return ( + + + {/* `NextError` is the default Next.js error page component. Its type + definition requires a `statusCode` prop. However, since the App Router + does not expose status codes for errors, we simply pass 0 to render a + generic error message. */} + + + + ); +} \ No newline at end of file diff --git a/components/AuthForm.tsx b/components/AuthForm.tsx new file mode 100644 index 0000000..7fa0019 --- /dev/null +++ b/components/AuthForm.tsx @@ -0,0 +1,207 @@ +"use client"; +import Image from "next/image"; +import Link from "next/link"; +import { useState } from "react"; + +import { zodResolver } from "@hookform/resolvers/zod"; +import { useForm } from "react-hook-form"; +import { z } from "zod"; +import { Button } from "@/components/ui/button"; +import { + Form, + FormControl, + FormDescription, + FormField, + FormItem, + FormLabel, + FormMessage, +} from "@/components/ui/form"; +import { Input } from "@/components/ui/input"; +import { authFormSchema } from "@/lib/utils"; +import CustomInput from "./CustomInput"; +import { Loader2 } from "lucide-react"; +import { useRouter } from "next/navigation"; +import { getLoggedInUser, signIn, signUp } from "@/lib/actions/user.actions"; + +interface propsType { + type: string; +} + +const AuthForm = ({ type }: propsType) => { + const router = useRouter(); + const [user, setUser] = useState(null); + const [isLoading, setIsLoading] = useState(false); + + const formSchema = authFormSchema(type); + + const form = useForm>({ + resolver: zodResolver(formSchema), + defaultValues: { + email: "", + password: "", + }, + }); + + // 2. Define a submit handler. + const onSubmit = async (values: z.infer) => { + setIsLoading(true); + try { + // Sign up with Appwrite and create plaid token + if (type === "sign-up") { + const newUser = await signUp(values); + setUser(newUser); + } + + if (type === "sign-in") { + const response = await signIn({ + email: values.email, + password: values.password, + }); + if (response) router.push("/"); + } + } catch (error) { + console.log(error); + } finally { + setIsLoading(false); + } + }; + + return ( +
+
+ + Horizon logo +

+ Horizon +

+ + +
+

+ {user ? "Link Account" : type === "sign-in" ? "Sign in" : "Sign-up"} +

+ {user + ? "Link your account to get started" + : "Please enter your details"} +

+

+
+
+ {user ? ( +
{/* PlaidLink */}
+ ) : ( + <> +
+ + {type === "sign-up" && ( + <> +
+ + +
+ + +
+ + +
+
+ + +
+ + )} + + +
+ +
+ + + +
+

+ {type === "sign-in" + ? "Don't have an account?" + : "Already have an account?"} +

+ + {type === "sign-in" ? "Sign up" : "Sign in"} + +
+ + )} +
+ ); +}; + +export default AuthForm; diff --git a/components/BankCard.tsx b/components/BankCard.tsx index 5d92c08..ff8d046 100644 --- a/components/BankCard.tsx +++ b/components/BankCard.tsx @@ -12,7 +12,7 @@ const BankCard = ({
-

{account.name}

+

{userName}

{formatAmount(account.currentBalance)}

diff --git a/components/CustomInput.tsx b/components/CustomInput.tsx new file mode 100644 index 0000000..f58a827 --- /dev/null +++ b/components/CustomInput.tsx @@ -0,0 +1,42 @@ +import { FormControl, FormField, FormLabel, FormMessage } from "./ui/form"; +import { Input } from "./ui/input"; + +import { Control, FieldPath } from "react-hook-form"; +import { z } from "zod"; +import { authFormSchema } from "@/lib/utils"; + +const formSchema = authFormSchema("sign-up"); + +interface CustomInput { + control: Control>; + name: FieldPath>; + label: string; + placeholder: string; +} + +const CustomInput = ({ control, name, label, placeholder }: CustomInput) => { + return ( + ( +
+ {label} +
+ + + + +
+
+ )} + /> + ); +}; + +export default CustomInput; diff --git a/components/Footer.tsx b/components/Footer.tsx new file mode 100644 index 0000000..af73db2 --- /dev/null +++ b/components/Footer.tsx @@ -0,0 +1,37 @@ +import { logoutAccount } from "@/lib/actions/user.actions"; +import Image from "next/image"; +import { useRouter } from "next/navigation"; +import React from "react"; + +const Footer = ({ user, type = "desktop" }: FooterProps) => { + const router = useRouter(); + + const handleLogOut = async () => { + const loggedOut = await logoutAccount(); + + if (loggedOut) router.push("/sign-in"); + }; + return ( +
+
+

{user.name[0]}

+
+
+

+ {user?.firstName} +

+

+ {user?.email} +

+
+ +
+ jsm +
+
+ ); +}; + +export default Footer; diff --git a/components/MobileNav.tsx b/components/MobileNav.tsx index fc9690e..5d0693b 100644 --- a/components/MobileNav.tsx +++ b/components/MobileNav.tsx @@ -13,6 +13,7 @@ import Image from "next/image"; import Link from "next/link"; import { cn } from "@/lib/utils"; import { sidebarLinks } from "@/constants"; +import Footer from "./Footer"; const MobileNav = ({ user }: MobileNavProps) => { const pathname = usePathname(); @@ -85,7 +86,7 @@ const MobileNav = ({ user }: MobileNavProps) => { - {/*
*/} +
diff --git a/components/RightSidebar.tsx b/components/RightSidebar.tsx index 0054b6f..a4442cc 100644 --- a/components/RightSidebar.tsx +++ b/components/RightSidebar.tsx @@ -10,13 +10,11 @@ const RightSidebar = ({ user, transactions, banks }: RightSidebarProps) => {
- {user.firstName[0]} + {user.name[0]}
-

- {user.firstName} {user.lastName} -

+

{user.name}

{user.email}

@@ -37,7 +35,7 @@ const RightSidebar = ({ user, transactions, banks }: RightSidebarProps) => {
@@ -46,7 +44,7 @@ const RightSidebar = ({ user, transactions, banks }: RightSidebarProps) => {
diff --git a/components/Sidebar.tsx b/components/Sidebar.tsx index 2d5ba95..ada4c79 100644 --- a/components/Sidebar.tsx +++ b/components/Sidebar.tsx @@ -4,6 +4,7 @@ import { cn } from "@/lib/utils"; import Image from "next/image"; import Link from "next/link"; import { usePathname } from "next/navigation"; +import Footer from "./Footer"; const Sidebar = ({ user }: SiderbarProps) => { const pathname = usePathname(); @@ -20,7 +21,6 @@ const Sidebar = ({ user }: SiderbarProps) => { />

Horizon

- {sidebarLinks.map((item) => { const isActive = pathname === item.route || pathname.startsWith(`${item.route}/`); @@ -44,7 +44,9 @@ const Sidebar = ({ user }: SiderbarProps) => { ); })} + USER +
); }; diff --git a/components/ui/form.tsx b/components/ui/form.tsx new file mode 100644 index 0000000..ce264ae --- /dev/null +++ b/components/ui/form.tsx @@ -0,0 +1,178 @@ +"use client" + +import * as React from "react" +import * as LabelPrimitive from "@radix-ui/react-label" +import { Slot } from "@radix-ui/react-slot" +import { + Controller, + ControllerProps, + FieldPath, + FieldValues, + FormProvider, + useFormContext, +} from "react-hook-form" + +import { cn } from "@/lib/utils" +import { Label } from "@/components/ui/label" + +const Form = FormProvider + +type FormFieldContextValue< + TFieldValues extends FieldValues = FieldValues, + TName extends FieldPath = FieldPath +> = { + name: TName +} + +const FormFieldContext = React.createContext( + {} as FormFieldContextValue +) + +const FormField = < + TFieldValues extends FieldValues = FieldValues, + TName extends FieldPath = FieldPath +>({ + ...props +}: ControllerProps) => { + return ( + + + + ) +} + +const useFormField = () => { + const fieldContext = React.useContext(FormFieldContext) + const itemContext = React.useContext(FormItemContext) + const { getFieldState, formState } = useFormContext() + + const fieldState = getFieldState(fieldContext.name, formState) + + if (!fieldContext) { + throw new Error("useFormField should be used within ") + } + + const { id } = itemContext + + return { + id, + name: fieldContext.name, + formItemId: `${id}-form-item`, + formDescriptionId: `${id}-form-item-description`, + formMessageId: `${id}-form-item-message`, + ...fieldState, + } +} + +type FormItemContextValue = { + id: string +} + +const FormItemContext = React.createContext( + {} as FormItemContextValue +) + +const FormItem = React.forwardRef< + HTMLDivElement, + React.HTMLAttributes +>(({ className, ...props }, ref) => { + const id = React.useId() + + return ( + +
+ + ) +}) +FormItem.displayName = "FormItem" + +const FormLabel = React.forwardRef< + React.ElementRef, + React.ComponentPropsWithoutRef +>(({ className, ...props }, ref) => { + const { error, formItemId } = useFormField() + + return ( +