diff --git a/.gitignore b/.gitignore index 5ef6a52..d86875d 100644 --- a/.gitignore +++ b/.gitignore @@ -39,3 +39,6 @@ yarn-error.log* # typescript *.tsbuildinfo next-env.d.ts + +# idea +.idea diff --git a/package.json b/package.json index 0b3c1fc..d312693 100644 --- a/package.json +++ b/package.json @@ -1,6 +1,7 @@ { "name": "multitenant-ecommerce", "version": "0.1.0", + "type": "module", "private": true, "scripts": { "dev": "next dev", diff --git a/public/auth-bg.png b/public/auth-bg.png new file mode 100644 index 0000000..09e7467 Binary files /dev/null and b/public/auth-bg.png differ diff --git a/src/app/(app)/(auth)/sign-in/page.tsx b/src/app/(app)/(auth)/sign-in/page.tsx new file mode 100644 index 0000000..3d9de2f --- /dev/null +++ b/src/app/(app)/(auth)/sign-in/page.tsx @@ -0,0 +1,7 @@ +import { SignInView } from "@/modules/auth/ui/views/sign-in-view"; + +const Page = () => { + return +} + +export default Page; diff --git a/src/app/(app)/(auth)/sign-up/page.tsx b/src/app/(app)/(auth)/sign-up/page.tsx new file mode 100644 index 0000000..08a2016 --- /dev/null +++ b/src/app/(app)/(auth)/sign-up/page.tsx @@ -0,0 +1,7 @@ +import { SignUpView } from "@/modules/auth/ui/views/sign-up-view"; + +const Page = () => { + return +} + +export default Page; diff --git a/src/app/(app)/(home)/navbar.tsx b/src/app/(app)/(home)/navbar.tsx index 5635cce..77e34e5 100644 --- a/src/app/(app)/(home)/navbar.tsx +++ b/src/app/(app)/(home)/navbar.tsx @@ -12,109 +12,109 @@ import { Button } from '@/components/ui/button'; import NavbarSidebar from './navbar-sidebar'; const poppins = Poppins({ - subsets: ['latin'], - weight: ['700'], + subsets: ['latin'], + weight: ['700'], }); interface NavbarItemProps { - href: string; - children: ReactNode; - isActive?: boolean; + href: string; + children: ReactNode; + isActive?: boolean; } const navbarItems = [ - { - href: '/', - children: 'Home', - }, - { - href: '/about', - children: 'About', - }, - { - href: '/features', - children: 'Features', - }, - { - href: '/pricing', - children: 'Pricing', - }, - { - href: '/contact', - children: 'Contact', - }, + { + href: '/', + children: 'Home', + }, + { + href: '/about', + children: 'About', + }, + { + href: '/features', + children: 'Features', + }, + { + href: '/pricing', + children: 'Pricing', + }, + { + href: '/contact', + children: 'Contact', + }, ]; const NavbarItem = ({ href, children, isActive }: NavbarItemProps) => { - return ( - - ); + return ( + + ); }; const Navbar = () => { - const pathname = usePathname(); - const [isSidebarOpen, setIsSidebarOpen] = useState(false); + const pathname = usePathname(); + const [isSidebarOpen, setIsSidebarOpen] = useState(false); - return ( - + ); }; export default Navbar; diff --git a/src/app/(app)/(home)/page.tsx b/src/app/(app)/(home)/page.tsx index d2fdc12..c68f1c1 100644 --- a/src/app/(app)/(home)/page.tsx +++ b/src/app/(app)/(home)/page.tsx @@ -1,3 +1,12 @@ +'use client'; + +import { useQuery } from "@tanstack/react-query"; + +import { useTRPC } from "@/trpc/client"; + export default function Home() { - return
Home page
; + const trpc = useTRPC(); + const { data } = useQuery(trpc.auth.session.queryOptions()); + + return
{JSON.stringify(data?.user, null, 2)}
; } diff --git a/src/app/(app)/layout.tsx b/src/app/(app)/layout.tsx index c9e2b49..53406cd 100644 --- a/src/app/(app)/layout.tsx +++ b/src/app/(app)/layout.tsx @@ -1,29 +1,36 @@ import type { Metadata } from 'next'; import { DM_Sans } from 'next/font/google'; +import { ReactNode } from "react"; import { TRPCReactProvider } from '@/trpc/client'; +// Components +import { Toaster } from "@/components/ui/sonner"; + import './globals.css'; const dmSans = DM_Sans({ - subsets: ['latin'], + subsets: ['latin'], }); export const metadata: Metadata = { - title: 'Create Next App', - description: 'Generated by create next app', + title: 'Create Next App', + description: 'Generated by create next app', }; export default function RootLayout({ - children, + children, }: Readonly<{ - children: React.ReactNode; + children: ReactNode; }>) { - return ( - - - {children} - - - ); + return ( + + + + {children} + + + + + ); } diff --git a/src/collections/Users.ts b/src/collections/Users.ts index c683d0e..3d9a6cb 100644 --- a/src/collections/Users.ts +++ b/src/collections/Users.ts @@ -7,7 +7,11 @@ export const Users: CollectionConfig = { }, auth: true, fields: [ - // Email added by default - // Add more fields as needed + { + name: 'username', + required: true, + unique: true, + type: 'text' + } ], } diff --git a/src/modules/auth/constants.ts b/src/modules/auth/constants.ts new file mode 100644 index 0000000..32e1c5f --- /dev/null +++ b/src/modules/auth/constants.ts @@ -0,0 +1 @@ +export const AUTH_COOKIE = 'payload-token'; \ No newline at end of file diff --git a/src/modules/auth/schemas.ts b/src/modules/auth/schemas.ts new file mode 100644 index 0000000..68ef5dd --- /dev/null +++ b/src/modules/auth/schemas.ts @@ -0,0 +1,25 @@ +import { z } from "zod"; + +export const registerSchema = z.object({ + email: z.string().email(), + password: z.string().min(3), + username: z.string() + .min(3, 'Username must be at lease 3 characters') + .max(63, 'Username must be less than 63 characters') + .regex(/^[a-z0-9][a-z0-9-]*[a-z0-9]$/, + 'Username can only contain lowercase letters, numbers and hyphens. It must start and end a letter or number' + ).refine( + (val) => !val.includes('--'), + 'Username cannot contain consecutive hyphens' + ) + .transform((val) => val.toLowerCase()), +}); + +export type RegisterSchema = z.infer; + +export const loginSchema = z.object({ + email: z.string().email(), + password: z.string(), +}); + +export type LoginSchema = z.infer \ No newline at end of file diff --git a/src/modules/auth/server/procedures.ts b/src/modules/auth/server/procedures.ts new file mode 100644 index 0000000..284d8c8 --- /dev/null +++ b/src/modules/auth/server/procedures.ts @@ -0,0 +1,101 @@ +import { headers as getHeaders, cookies as getCookies } from "next/headers"; + +import { TRPCError } from "@trpc/server"; +import { AUTH_COOKIE } from "@/modules/auth/constants"; +import { loginSchema, registerSchema } from "@/modules/auth/schemas"; +import { baseProcedure, createTRPCRouter } from "@/trpc/init"; + +export const authRouter = createTRPCRouter({ + session: baseProcedure.query(async ({ ctx }) => { + const headers = await getHeaders(); + + return await ctx.db.auth({ headers }); + }), + logout: baseProcedure.mutation((async () => { + const cookies = await getCookies(); + + cookies.delete(AUTH_COOKIE); + })), + register: baseProcedure + .input( + registerSchema + ) + .mutation(async ({ input, ctx }) => { + const existingData = await ctx.db.find({ + collection: 'users', + limit: 1, + where: { + username: { equals: input.username } + } + }) + + const existingUser = existingData.docs[0]; + + if (existingUser) { + throw new TRPCError({ + code: 'BAD_REQUEST', + message: 'Username already taken', + }) + } + + await ctx.db.create({ + collection: 'users', + data: { + email: input.email, + username: input.username, + password: input.password, + } + }) + + const data = await ctx.db.login({ + collection: 'users', + data: { + email: input.email, + password: input.password, + } + }) + + if (!data.token) { + throw new TRPCError({ + code: 'UNAUTHORIZED', + message: 'Failed to login', + }) + } + + const cookies = await getCookies(); + cookies.set({ + name: AUTH_COOKIE, + value: data.token, + httpOnly: true, + path: '/', + }); + }), + login: baseProcedure + .input(loginSchema) + .mutation(async ({ input, ctx }) => { + const data = await ctx.db.login({ + collection: 'users', + data: { + email: input.email, + password: input.password, + } + }) + + if (!data.token) { + throw new TRPCError({ + code: 'UNAUTHORIZED', + message: 'Failed to login', + }) + } + + const cookies = await getCookies(); + cookies.set({ + name: AUTH_COOKIE, + value: data.token, + httpOnly: true, + path: '/', + }); + + return data; + }) +}) \ No newline at end of file diff --git a/src/modules/auth/ui/views/sign-in-view.tsx b/src/modules/auth/ui/views/sign-in-view.tsx new file mode 100644 index 0000000..2a411e7 --- /dev/null +++ b/src/modules/auth/ui/views/sign-in-view.tsx @@ -0,0 +1,106 @@ +'use client'; + +import { Button } from "@/components/ui/button"; +import { Form, FormControl, FormField, FormItem, FormLabel, FormMessage } from "@/components/ui/form"; +import { Input } from "@/components/ui/input"; +import { cn } from "@/lib/utils"; +import { LoginSchema, loginSchema } from "@/modules/auth/schemas"; +import { useTRPC } from "@/trpc/client"; +import { zodResolver } from "@hookform/resolvers/zod"; +import { useMutation } from "@tanstack/react-query"; +import { Poppins } from "next/font/google"; +import Link from "next/link"; +import { useRouter } from "next/navigation"; +import { useForm } from "react-hook-form"; +import { toast } from "sonner"; + +const poppins = Poppins({ + subsets: ['latin'], + weight: ['700'] +}) + +export const SignInView = () => { + const router = useRouter(); + const trpc = useTRPC(); + const login = useMutation(trpc.auth.login.mutationOptions({ + onError: (error) => { + toast.error(error.message) + }, + onSuccess: () => { + router.push('/'); + } + })); + const form = useForm({ + mode: 'all', + resolver: zodResolver(loginSchema), + defaultValues: { + email: '', + password: '', + } + }) + + const onSubmit = (values: LoginSchema) => { + login.mutate(values); + } + + return ( +
+
+
+ +
+ + funroad + + +
+ +

+ Welcome back to Funroad. +

+ + ( + + Email + + + + + + )} + /> + + ( + + Password + + + + + + )} + /> + + + + + +
+ +
+
+ ) +} \ No newline at end of file diff --git a/src/modules/auth/ui/views/sign-up-view.tsx b/src/modules/auth/ui/views/sign-up-view.tsx new file mode 100644 index 0000000..d56075c --- /dev/null +++ b/src/modules/auth/ui/views/sign-up-view.tsx @@ -0,0 +1,129 @@ +'use client'; + +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 { cn } from "@/lib/utils"; +import { registerSchema, RegisterSchema } from "@/modules/auth/schemas"; +import { useTRPC } from "@/trpc/client"; +import { zodResolver } from "@hookform/resolvers/zod"; +import { useMutation } from "@tanstack/react-query"; +import { Poppins } from "next/font/google"; +import Link from "next/link"; +import { useRouter } from "next/navigation"; +import { useForm } from "react-hook-form"; +import { toast } from "sonner"; + +const poppins = Poppins({ + subsets: ['latin'], + weight: ['700'] +}) + +export const SignUpView = () => { + const router = useRouter(); + const trpc = useTRPC(); + const register = useMutation(trpc.auth.register.mutationOptions({ + onError: (error) => { + toast.error(error.message) + }, + onSuccess: () => { + router.push('/'); + } + })); + const form = useForm({ + mode: 'all', + resolver: zodResolver(registerSchema), + defaultValues: { + email: '', + password: '', + username: '', + } + }) + + const onSubmit = (values: RegisterSchema) => { + register.mutate(values); + } + + const username = form.watch('username'); + const usernameErrors = form.formState.errors.username; + const showPreview = username && !usernameErrors; + + return ( +
+
+
+ +
+ + funroad + + +
+ +

+ Join over 1,580 creators earning money on Funroad. +

+ + ( + + Username + + + + + + Your store will be available at  + {/* TODO: Use proper method to generate the preview url */} + {username}.shop.com + + + + )} + /> + + ( + + Email + + + + + + )} + /> + + ( + + Password + + + + + + )} + /> + + + + + +
+ +
+
+ ) +} \ No newline at end of file diff --git a/src/payload-types.ts b/src/payload-types.ts index 2af02a9..7885ecb 100644 --- a/src/payload-types.ts +++ b/src/payload-types.ts @@ -125,6 +125,7 @@ export interface UserAuthOperations { */ export interface User { id: string; + username: string; updatedAt: string; createdAt: string; email: string; @@ -239,6 +240,7 @@ export interface PayloadMigration { * via the `definition` "users_select". */ export interface UsersSelect { + username?: T; updatedAt?: T; createdAt?: T; email?: T; diff --git a/src/payload.config.ts b/src/payload.config.ts index f49fdf3..1487641 100644 --- a/src/payload.config.ts +++ b/src/payload.config.ts @@ -7,7 +7,7 @@ import { buildConfig } from 'payload' import { fileURLToPath } from 'url' import sharp from 'sharp' -import { Users } from './collections/Users' +import { Users } from './collections/Users'; import { Media } from './collections/Media' import { Categories } from './collections/Categories' diff --git a/src/trpc/routers/_app.ts b/src/trpc/routers/_app.ts index 2e0ed93..f7ea56b 100644 --- a/src/trpc/routers/_app.ts +++ b/src/trpc/routers/_app.ts @@ -1,7 +1,11 @@ +import { authRouter } from "@/modules/auth/server/procedures"; import { categoriesRouter } from '@/modules/categories/server/procedures'; + + import { createTRPCRouter } from '../init'; export const appRouter = createTRPCRouter({ + auth: authRouter, categories: categoriesRouter, }); // export type definition of API