npx create-next-app@14 projectName- choose typescript and eslint
- main course repo
- nextjs-jobify-app/assets
npm install @clerk/nextjs@^4.27.7 @prisma/client@^5.7.0 @tanstack/react-query@^5.14.0 @tanstack/react-query-devtools@^5.14.0 dayjs@^1.11.10 next-themes@^0.2.1 recharts@^2.10.3
npm install prisma@^5.7.0 -D- follow Next.js install steps (starting with 2)
- open another terminal window (optional)
npx shadcn@latest init- setup Button
npx shadcn@latest add buttonpage.tsx
import { Button } from "@/components/ui/button";
import { Camera } from "lucide-react";
export default function Home() {
return (
<div className="h-screen flex items-center justify-center">
<Button>default button</Button>
<Button variant="outline" size="icon">
<Camera />
</Button>
</div>
);
}-
Import necessary modules and components:
- Import the
Imagecomponent from 'next/image' for displaying images. - Import the
LogoandLandingImgSVG files from the assets directory. - Import the
Buttoncomponent from the UI components directory. - Import the
Linkcomponent from 'next/link' for navigation.
- Import the
-
Define the
Homecomponent:- This component doesn't receive any props.
-
Inside the
Homecomponent, return the JSX:- The main wrapper is a
mainHTML element. - Inside
main, there are two main sections:headerandsection. - The
headercontains theImagecomponent that displays theLogo. - The
sectioncontains adivand anImagecomponent. - The
divcontains ah1heading, apparagraph, and aButtoncomponent. - The
Buttoncomponent wraps aLinkcomponent that navigates to the '/add-job' route when clicked. - The
Imagecomponent displays theLandingImg.
- The main wrapper is a
-
Apply CSS classes for styling:
- CSS classes are applied to the elements for styling. These classes are from Tailwind CSS, a utility-first CSS framework.
-
Export the
Homecomponent as the default export of the module.
- setup title and description
- add favicon
- setup home page
layout.tsx
export const metadata: Metadata = {
title: "Jobify Dev",
description: "Job application tracking system for job hunters",
};page.tsx
import Image from "next/image";
import Logo from "../assets/logo.svg";
import LandingImg from "../assets/main.svg";
import { Button } from "@/components/ui/button";
import Link from "next/link";
export default function Home() {
return (
<main>
<header className="max-w-6xl mx-auto px-4 sm:px-8 py-6 ">
<Image src={Logo} alt="logo" />
</header>
<section className="max-w-6xl mx-auto px-4 sm:px-8 h-screen -mt-20 grid lg:grid-cols-[1fr,400px] items-center">
<div>
<h1 className="capitalize text-4xl md:text-7xl font-bold">
job <span className="text-primary">tracking</span> app
</h1>
<p className="leading-loose max-w-md mt-4 ">
I am baby wayfarers hoodie next level taiyaki brooklyn cliche blue
bottle single-origin coffee chia. Aesthetic post-ironic venmo,
quinoa lo-fi tote bag adaptogen everyday carry meggings +1 brunch
narwhal.
</p>
<Button asChild className="mt-4">
<Link href="/add-job">Get Started</Link>
</Button>
</div>
<Image src={LandingImg} alt="landing" className="hidden lg:block " />
</section>
</main>
);
}- create add-job, jobs and stats pages
- group them in (dashboard)
- setup a layout file (just pass children)
- create add-job, jobs and stats pages
- group them in (dashboard)
- setup a layout file (just pass children)
(dashboard)/layout.tsx
function layout({ children }: { children: React.ReactNode }) {
return <div>{children}</div>;
}
export default layout;- setup new app, configure fields - (or use existing)
- add ENV Vars
- wrap layout in Clerk Provider
- add middleware
- set only home page public
- restart dev server
- setup new app, configure fields - (or use existing)
- add ENV Vars
- wrap layout
- add middleware
- make '/' public
- restart dev server
layout.tsx
import { ClerkProvider } from "@clerk/nextjs";
export default function RootLayout({
children,
}: {
children: React.ReactNode;
}) {
return (
<ClerkProvider>
<html lang="en">
<body className={inter.className}>{children}</body>
</html>
</ClerkProvider>
);
}middleware.tsx
import { authMiddleware } from "@clerk/nextjs";
// This example protects all routes including api/trpc routes
// Please edit this to allow other routes to be public as needed.
// See https://clerk.com/docs/references/nextjs/auth-middleware for more information about configuring your Middleware
export default authMiddleware({
publicRoutes: ["/"],
});
export const config = {
matcher: ["/((?!.+\\.[\\w]+$|_next).*)", "/", "/(api|trpc)(.*)"],
};-
Create File and Import necessary modules and components:
- create utils/links.tsx
- Import the
AreaChart,Layers, andAppWindowcomponents from 'lucide-react' for displaying icons.
-
Define the
NavLinktype:- This type has three properties:
href(a string),label(a string), andicon(a React Node).
- This type has three properties:
-
Define the
linksconstant:- This constant is an array of
NavLinkobjects. - Each object represents a navigation link with a
href,label, andicon.
- This constant is an array of
-
Define the navigation links:
- The first link has a
hrefof '/add-job', alabelof 'add job', and aniconof<Layers />. - The second link has a
hrefof '/jobs', alabelof 'all jobs', and aniconof<AppWindow />. - The third link has a
hrefof '/stats', alabelof 'stats', and aniconis not defined yet.
- The first link has a
-
Export the
linksconstant:- This constant can be imported in other components to create navigation menus.
- create utils/links.tsx
utils/links.tsx
import { AreaChart, Layers, AppWindow } from "lucide-react";
type NavLink = {
href: string;
label: string;
icon: React.ReactNode;
};
const links: NavLink[] = [
{
href: "/add-job",
label: "add job",
icon: <Layers />,
},
{
href: "/jobs",
label: "all jobs",
icon: <AppWindow />,
},
{
href: "/stats",
label: "stats",
icon: <AreaChart />,
},
];
export default links;-
create following components :
- Sidebar
- Navbar
- LinksDropdown
- ThemeToggle
-
setup (dashboard/layout.tsx)
-
Import necessary modules and components:
- Import
NavbarandSidebarcomponents. - Import
PropsWithChildrenfrom 'react'.
- Import
-
Define the
layoutcomponent:- This component receives
childrenas props.
- This component receives
-
Return the JSX:
- The main wrapper is a
mainelement with a grid layout. - The first
divcontains theSidebarcomponent and is hidden on small screens. - The second
divspans 4 columns on large screens and contains theNavbarcomponent and thechildren.
- The main wrapper is a
-
Export the
layoutcomponent. dashboard/layout.tsx
-
create following components :
- Sidebar
- Navbar
- LinksDropdown
- ThemeToggle
(dashboard/layout.tsx)
import Navbar from "@/components/Navbar";
import Sidebar from "@/components/Sidebar";
import { PropsWithChildren } from "react";
function layout({ children }: PropsWithChildren) {
return (
<main className="grid lg:grid-cols-5">
{/* first-col hide on small screen */}
<div className="hidden lg:block lg:col-span-1 lg:min-h-screen">
<Sidebar />
</div>
{/* second-col hide dropdown on big screen */}
<div className="lg:col-span-4">
<Navbar />
<div className="py-16 px-4 sm:px-8 lg:px-16">{children}</div>
</div>
</main>
);
}
export default layout;-
Import necessary modules and components:
- Import
Logo,links,Image,Link,Button, andusePathname.
- Import
-
Define the
Sidebarcomponent:- Use
usePathnameto get the current route.
- Use
-
Return the JSX:
- The main wrapper is an
asideelement. - Inside
aside, display theLogousingImage. - Map over
linksto createButtoncomponents for each link. - Each
Buttonwraps aLinkthat navigates to the link'shref.
- The main wrapper is an
-
Export the
Sidebarcomponent.
- render links and logo
- check the path, if active use different variant Sidebar.tsx
"use client";
import Logo from "@/assets/images/logo.svg";
import links from "@/utils/links";
import Image from "next/image";
import Link from "next/link";
import { Button } from "./ui/button";
import { usePathname } from "next/navigation";
function Sidebar() {
const pathname = usePathname();
return (
<aside className="py-4 px-8 bg-muted h-full">
<Image src={Logo} alt="logo" className="mx-auto" />
<div className="flex flex-col mt-20 gap-y-4">
{links.map((link) => {
return (
<Button
asChild
key={link.href}
variant={pathname === link.href ? "default" : "link"}
>
<Link href={link.href} className="flex items-center gap-x-2 ">
{link.icon} <span className="capitalize">{link.label}</span>
</Link>
</Button>
);
})}
</div>
</aside>
);
}
export default Sidebar;-
Import necessary modules and components:
- Import
LinksDropdown,UserButtonfrom '@clerk/nextjs', andThemeToggle.
- Import
-
Define the
Navbarcomponent:- This component doesn't receive any props.
-
Return the JSX:
- The main wrapper is a
navelement with Tailwind CSS classes for styling. - Inside
nav, there are twodivelements. - The first
divcontains theLinksDropdowncomponent. - The second
divcontains theThemeToggleandUserButtoncomponents.
- The main wrapper is a
-
Export the
Navbarcomponent.
Navbar.tsx
import LinksDropdown from "./LinksDropdown";
import { UserButton } from "@clerk/nextjs";
import ThemeToggle from "./ThemeToggle";
function Navbar() {
return (
<nav className="bg-muted py-4 sm:px-16 lg:px-24 px-4 flex items-center justify-between">
<div>
<LinksDropdown />
</div>
<div className="flex items-center gap-x-4">
<ThemeToggle />
<UserButton afterSignOutUrl="/" />
</div>
</nav>
);
}
export default Navbar;-
Explore the Dropdown-Menu Component:
- Explore the dropdown-menu component in the shadcn library.
-
Install the Dropdown-Menu Component:
- Install it using
npx shadcn@latest add dropdown-menu
- Install it using
-
Import necessary modules and components:
- Import
DropdownMenu,DropdownMenuContent,DropdownMenuItem,DropdownMenuTriggerfrom the dropdown-menu component. - Import
AlignLeftfrom 'lucide-react' for the menu icon. - Import
Buttonfrom the local UI components. - Import
linksfrom the local utilities. - Import
Linkfrom 'next/link' for navigation.
- Import
-
Define the
DropdownLinksfunction component:- This component doesn't receive any props.
-
Inside the
DropdownLinkscomponent, return the JSX:- The main wrapper is the
DropdownMenucomponent. - Inside
DropdownMenu, there is aDropdownMenuTriggercomponent that triggers the dropdown menu. It has aButtoncomponent with anAlignLefticon. This button is hidden on large screens. - The
DropdownMenuContentcomponent contains the dropdown menu items. Each item is aDropdownMenuItemcomponent that wraps aLinkcomponent. TheLinkcomponent navigates to the link'shrefwhen clicked.
- The main wrapper is the
-
Export the
DropdownLinkscomponent:- The
DropdownLinkscomponent is exported as the default export of the module. This allows it to be imported in other files using the file path.
- The
npx shadcn@latest add dropdown-menuLinksDropdown.tsx
import {
DropdownMenu,
DropdownMenuContent,
DropdownMenuItem,
DropdownMenuTrigger,
} from "@/components/ui/dropdown-menu";
import { AlignLeft } from "lucide-react";
import { Button } from "./ui/button";
import links from "@/utils/links";
import Link from "next/link";
function DropdownLinks() {
return (
<DropdownMenu>
<DropdownMenuTrigger asChild className="lg:hidden">
<Button variant="outline" size="icon">
<AlignLeft />
<span className="sr-only">Toggle links</span>
</Button>
</DropdownMenuTrigger>
<DropdownMenuContent
className="w-52 lg:hidden "
align="start"
sideOffset={25}
>
{links.map((link) => {
return (
<DropdownMenuItem key={link.href}>
<Link href={link.href} className="flex items-center gap-x-2 ">
{link.icon} <span className="capitalize">{link.label}</span>
</Link>
</DropdownMenuItem>
);
})}
</DropdownMenuContent>
</DropdownMenu>
);
}
export default DropdownLinks;- reference shadcn docs
- setup theme in globals.css
- create providers.tsx
- wrap children in layout
- add suppressHydrationWarning prop
- create providers.tsx
- wrap children in layout
- add suppressHydrationWarning prop
app/providers.tsx
"use client";
const Providers = ({ children }: { children: React.ReactNode }) => {
return <>{children}</>;
};
export default Providers;app/layout
<html lang="en" suppressHydrationWarning>
<body className={inter.className}>
<Providers>{children}</Providers>
</body>
</html>- reference shadcn docs and setup dark theme Dark Mode
npm install next-themes
components/theme-provider.tsx
"use client";
import * as React from "react";
import { ThemeProvider as NextThemesProvider } from "next-themes";
import { type ThemeProviderProps } from "next-themes/dist/types";
export function ThemeProvider({ children, ...props }: ThemeProviderProps) {
return <NextThemesProvider {...props}>{children}</NextThemesProvider>;
}app/providers.tsx
"use client";
import { ThemeProvider } from "@/components/theme-provider";
const Providers = ({ children }: { children: React.ReactNode }) => {
return (
<>
<ThemeProvider
attribute="class"
defaultTheme="system"
enableSystem
disableTransitionOnChange
>
{children}
</ThemeProvider>
</>
);
};
export default Providers;ThemeToggle.tsx
"use client";
import * as React from "react";
import { Moon, Sun } from "lucide-react";
import { useTheme } from "next-themes";
import { Button } from "@/components/ui/button";
import {
DropdownMenu,
DropdownMenuContent,
DropdownMenuItem,
DropdownMenuTrigger,
} from "@/components/ui/dropdown-menu";
export function ModeToggle() {
const { setTheme } = useTheme();
return (
<DropdownMenu>
<DropdownMenuTrigger asChild>
<Button variant="outline" size="icon">
<Sun className="h-[1.2rem] w-[1.2rem] rotate-0 scale-100 transition-all dark:-rotate-90 dark:scale-0" />
<Moon className="absolute h-[1.2rem] w-[1.2rem] rotate-90 scale-0 transition-all dark:rotate-0 dark:scale-100" />
<span className="sr-only">Toggle theme</span>
</Button>
</DropdownMenuTrigger>
<DropdownMenuContent align="end">
<DropdownMenuItem onClick={() => setTheme("light")}>
Light
</DropdownMenuItem>
<DropdownMenuItem onClick={() => setTheme("dark")}>
Dark
</DropdownMenuItem>
<DropdownMenuItem onClick={() => setTheme("system")}>
System
</DropdownMenuItem>
</DropdownMenuContent>
</DropdownMenu>
);
}- components/CreateJobForm
- render in add-job/page.tsx
npx shadcn@latest add form input"use client";
import { zodResolver } from "@hookform/resolvers/zod";
import { useForm } from "react-hook-form";
import * as z from "zod";
import { Button } from "@/components/ui/button";
import {
Form,
FormControl,
FormField,
FormItem,
FormLabel,
FormMessage,
} from "@/components/ui/form";
import { Input } from "@/components/ui/input";
const formSchema = z.object({
username: z.string().min(2, {
message: "Username must be at least 2 characters.",
}),
});
function CreateJobForm() {
// 1. Define your form.
const form = useForm<z.infer<typeof formSchema>>({
resolver: zodResolver(formSchema),
defaultValues: {
username: "",
},
});
// 2. Define a submit handler.
function onSubmit(values: z.infer<typeof formSchema>) {
// Do something with the form values.
// ✅ This will be type-safe and validated.
console.log(values);
}
return (
<Form {...form}>
<form onSubmit={form.handleSubmit(onSubmit)} className="space-y-8">
<FormField
control={form.control}
name="username"
render={({ field }) => (
<FormItem>
<FormLabel>Username</FormLabel>
<FormControl>
<Input placeholder="shadcn" {...field} />
</FormControl>
<FormMessage />
</FormItem>
)}
/>
<Button type="submit">Submit</Button>
</form>
</Form>
);
}
export default CreateJobForm;-
Imports: Necessary modules and components are imported. This includes form handling and validation libraries, UI components, and the zod schema validation library.
-
Form Schema: A
formSchemais defined using zod. This schema specifies that theusernamefield is a string and must be at least 2 characters long. -
CreateJobForm Component: This is the main component. It uses the
useFormhook fromreact-hook-formto create a form instance which can be used to manage form state, handle form submission, and perform form validation. The form instance is configured with the zod schema as its resolver and a default value for theusernamefield. -
Submit Handler: A
onSubmitfunction is defined. This function logs the form values when the form is submitted. The form values are type-checked and validated against the zod schema. -
Render: The component returns a form with a single
usernamefield and a submit button. Theusernamefield is rendered using theFormFieldcomponent, which is passed the form control and the field name. Therenderprop ofFormFieldis used to render the actual input field and its associated label and message. -
Export: The
CreateJobFormcomponent is exported as the default export of the module. This allows it to be imported in other files using the file path.
-
Create utils/types.ts:
- Create a new file named
types.tsinside theutilsdirectory.
- Create a new file named
-
Define the
JobStatusandJobModeenums:- Define the
JobStatusenum with the values 'applied', 'interview', 'offer', and 'rejected'. - Define the
JobModeenum with the values 'fullTime', 'partTime', and 'internship'.
- Define the
-
Define the
createAndEditJobSchemaobject:- Use
z.object()from the zod library to define a schema for creating and editing jobs. - The schema includes
position,company,location,status, andmode. Each of these fields is a string with a minimum length of 2 characters, except forstatusandmodewhich are enums.
- Use
-
Export the
createAndEditJobSchemaobject:- Export the
createAndEditJobSchemaobject so it can be used in other files.
- Export the
-
Define and export the
CreateAndEditJobTypetype:- Use
z.infer<typeof createAndEditJobSchema>to infer the type of thecreateAndEditJobSchemaobject. - Export the
CreateAndEditJobTypetype so it can be used in other files.
- Use
Enums in TypeScript are a special type that allows you to define a set of named constants. They can be numeric or string-based.
- utils/types.ts
import * as z from "zod";
export type JobType = {
id: string;
createdAt: Date;
updatedAt: Date;
clerkId: string;
position: string;
company: string;
location: string;
status: string;
mode: string;
};
export enum JobStatus {
Pending = "pending",
Interview = "interview",
Declined = "declined",
}
export enum JobMode {
FullTime = "full-time",
PartTime = "part-time",
Internship = "internship",
}
export const createAndEditJobSchema = z.object({
position: z.string().min(2, {
message: "position must be at least 2 characters.",
}),
company: z.string().min(2, {
message: "company must be at least 2 characters.",
}),
location: z.string().min(2, {
message: "location must be at least 2 characters.",
}),
status: z.nativeEnum(JobStatus),
mode: z.nativeEnum(JobMode),
});
export type CreateAndEditJobType = z.infer<typeof createAndEditJobSchema>;- install
npx shadcn@latest add select-
Import necessary libraries and components
- Import the
Controltype fromreact-hook-form. - Import the
Select,SelectContent,SelectItem,SelectTrigger, andSelectValuecomponents from your UI library. - Import the
FormControl,FormField,FormItem,FormLabel, andFormMessagecomponents from your UI library. - Import the
Inputcomponent from your local UI components.
- Import the
-
Define the types for CustomFormField and CustomFormSelect components
- Define a type
CustomFormFieldPropsthat includesnameandcontrolproperties. - Define a type
CustomFormSelectPropsthat includesname,control,items, andlabelTextproperties.
- Define a type
-
Define the CustomFormField component
- Define a new function component named
CustomFormFieldthat takesCustomFormFieldPropsas props.
- Define a new function component named
-
Create the CustomFormField UI
- Inside the
CustomFormFieldcomponent, return aFormFieldcomponent. - Pass
controlandnameto theFormFieldcomponent. - Inside the
FormFieldcomponent, render aFormItemthat contains aFormLabel, aFormControlwith anInput, and aFormMessage.
- Inside the
-
Define the CustomFormSelect component
- Define a new function component named
CustomFormSelectthat takesCustomFormSelectPropsas props.
- Define a new function component named
-
Create the CustomFormSelect UI
- Inside the
CustomFormSelectcomponent, return aFormFieldcomponent. - Pass
controlandnameto theFormFieldcomponent. - Inside the
FormFieldcomponent, render aFormItemthat contains aFormLabel, aSelectwith aSelectTriggerandSelectContent, and aFormMessage. - Inside the
SelectContent, map over theitemsand return aSelectItemfor each item.
- Inside the
-
Export the components
- Export
CustomFormFieldandCustomFormSelectso they can be used in other parts of your application.
- Export
- components/FormComponents
import { Control } from "react-hook-form";
import {
Select,
SelectContent,
SelectItem,
SelectTrigger,
SelectValue,
} from "@/components/ui/select";
import {
FormControl,
FormField,
FormItem,
FormLabel,
FormMessage,
} from "@/components/ui/form";
import { Input } from "./ui/input";
type CustomFormFieldProps = {
name: string;
control: Control<any>;
};
export function CustomFormField({ name, control }: CustomFormFieldProps) {
return (
<FormField
control={control}
name={name}
render={({ field }) => (
<FormItem>
<FormLabel className="capitalize">{name}</FormLabel>
<FormControl>
<Input {...field} />
</FormControl>
<FormMessage />
</FormItem>
)}
/>
);
}
type CustomFormSelectProps = {
name: string;
control: Control<any>;
items: string[];
labelText?: string;
};
export function CustomFormSelect({
name,
control,
items,
labelText,
}: CustomFormSelectProps) {
return (
<FormField
control={control}
name={name}
render={({ field }) => (
<FormItem>
<FormLabel className="capitalize">{labelText || name}</FormLabel>
<Select onValueChange={field.onChange} defaultValue={field.value}>
<FormControl>
<SelectTrigger>
<SelectValue />
</SelectTrigger>
</FormControl>
<SelectContent>
{items.map((item) => {
return (
<SelectItem key={item} value={item}>
{item}
</SelectItem>
);
})}
</SelectContent>
</Select>
<FormMessage />
</FormItem>
)}
/>
);
}
export default CustomFormSelect;-
Import necessary libraries and components
- Import the
zodResolverfrom@hookform/resolvers/zodfor form validation. - Import the
useFormhook fromreact-hook-formfor form handling. - Import the necessary types and schemas for your form from
@/utils/types. - Import the
ButtonandFormcomponents from@/components/ui. - Import the
CustomFormFieldandCustomFormSelectcomponents from./FormComponents.
- Import the
-
Define the CreateJobForm component
- Define a new function component named
CreateJobForm.
- Define a new function component named
-
Initialize the form with useForm
- Inside the
CreateJobFormcomponent, use theuseFormhook to initialize your form. - Pass the
CreateAndEditJobTypefor your form data touseForm. - Use
zodResolverwith yourcreateAndEditJobSchemafor form validation.
- Inside the
-
Define default values for the form
- Define default values for your form fields in the
useFormhook.
- Define default values for your form fields in the
-
Define the form submission handler
- Inside the
CreateJobFormcomponent, define a function for handling form submission. - This function should take the form data as its parameter.
- Inside the
-
Create the form UI
- In the component's return statement, create the form UI using the
Formcomponent. - Use your custom form field components to create the form fields.
- Add a submit button to the form.
- In the component's return statement, create the form UI using the
-
Export the CreateJobForm component
- After defining the
CreateJobFormcomponent, export it so it can be used in other parts of your application.
- After defining the
"use client";
import { zodResolver } from "@hookform/resolvers/zod";
import { useForm } from "react-hook-form";
import {
JobStatus,
JobMode,
createAndEditJobSchema,
CreateAndEditJobType,
} from "@/utils/types";
import { Button } from "@/components/ui/button";
import { Form } from "@/components/ui/form";
import { CustomFormField, CustomFormSelect } from "./FormComponents";
function CreateJobForm() {
// 1. Define your form.
const form = useForm<CreateAndEditJobType>({
resolver: zodResolver(createAndEditJobSchema),
defaultValues: {
position: "",
company: "",
location: "",
status: JobStatus.Pending,
mode: JobMode.FullTime,
},
});
function onSubmit(values: CreateAndEditJobType) {
// Do something with the form values.
// ✅ This will be type-safe and validated.
console.log(values);
}
return (
<Form {...form}>
<form
onSubmit={form.handleSubmit(onSubmit)}
className="bg-muted p-8 rounded"
>
<h2 className="capitalize font-semibold text-4xl mb-6">add job</h2>
<div className="grid gap-4 md:grid-cols-2 lg:grid-cols-3 items-start">
{/* position */}
<CustomFormField name="position" control={form.control} />
{/* company */}
<CustomFormField name="company" control={form.control} />
{/* location */}
<CustomFormField name="location" control={form.control} />
{/* job status */}
<CustomFormSelect
name="status"
control={form.control}
labelText="job status"
items={Object.values(JobStatus)}
/>
{/* job type */}
<CustomFormSelect
name="mode"
control={form.control}
labelText="job mode"
items={Object.values(JobMode)}
/>
<Button type="submit" className="self-end capitalize">
create job
</Button>
</div>
</form>
</Form>
);
}
export default CreateJobForm;- create .env
- add to .gitignore
- copy external URL DATABASE_URL =
- setup new prisma instance
- setup connection file
- create Job model
model Job {
id String @id @default(uuid())
clerkId String
createdAt DateTime @default(now())
updatedAt DateTime @updatedAt
position String
company String
location String
status String
mode String
}- push changes to render
- setup new prisma instance
npx prisma init- setup connection file
utils/db.ts
import { PrismaClient } from "@prisma/client";
const prismaClientSingleton = () => {
return new PrismaClient();
};
type PrismaClientSingleton = ReturnType<typeof prismaClientSingleton>;
const globalForPrisma = globalThis as unknown as {
prisma: PrismaClientSingleton | undefined;
};
const prisma = globalForPrisma.prisma ?? prismaClientSingleton();
export default prisma;
if (process.env.NODE_ENV !== "production") globalForPrisma.prisma = prisma;- create Job model
schema.prisma
/ This is your Prisma schema file,
// learn more about it in the docs: https://pris.ly/d/prisma-schema
generator client {
provider = "prisma-client-js"
}
datasource db {
provider = "postgresql"
url = env("DATABASE_URL")
}
model Job {
id String @id @default(uuid())
clerkId String
createdAt DateTime @default(now())
updatedAt DateTime @updatedAt
position String
company String
location String
status String
mode String
}
- push changes to render
npx prisma db push-
Import necessary libraries and modules
- Create utils/action.ts file
- Import the prisma instance from your database configuration file.
- Import the auth function from
@clerk/nextjsfor user authentication. - Import the necessary types and schemas from your types file.
- Import the redirect function from
next/navigationfor redirection. - Import the
Prismanamespace from@prisma/clientfor database operations. - Import
dayjsfor date and time manipulation.
-
Define the authenticateAndRedirect function
- Define a function named
authenticateAndRedirectthat doesn't take any parameters. - Inside this function, call the auth function and destructure
userIdfrom its return value. - If
userIdis not defined, call the redirect function with'/'as the argument to redirect the user to the home page. - Return
userId.
- Define a function named
-
Define the createJobAction function
- Define an asynchronous function named
createJobActionthat takes values of typeCreateAndEditJobTypeas a parameter. - This function should return a Promise that resolves to
JobTypeor null.
- Define an asynchronous function named
-
Authenticate the user and validate the form values
- Inside the
createJobActionfunction, callauthenticateAndRedirectand store its return value inuserId. - Call
createAndEditJobSchema.parsewithvaluesas the argument to validate the form values.
- Inside the
-
Create a new job in the database
- Use the
prisma.job.createmethod to create a new job in the database. - Pass an object to this method with a
dataproperty. - The
dataproperty should be an object that spreads thevaluesand adds aclerkIdproperty withuserIdas its value. - Store the return value of this method in
job.
- Use the
-
Handle errors
- Wrap the validation and database operation in a try-catch block.
- If an error occurs, log the error to the console and return null.
-
Return the new job
- After the try-catch block, return
job.
- After the try-catch block, return
-
Export the createJobAction function
- Export
createJobActionso it can be used in other parts of your application.
- Export
- utils/actions
"use server";
import prisma from "./db";
import { auth } from "@clerk/nextjs";
import { JobType, CreateAndEditJobType, createAndEditJobSchema } from "./types";
import { redirect } from "next/navigation";
import { Prisma } from "@prisma/client";
import dayjs from "dayjs";
function authenticateAndRedirect(): string {
const { userId } = auth();
if (!userId) {
redirect("/");
}
return userId;
}
export async function createJobAction(
values: CreateAndEditJobType
): Promise<JobType | null> {
// await new Promise((resolve) => setTimeout(resolve, 3000));
const userId = authenticateAndRedirect();
try {
createAndEditJobSchema.parse(values);
const job: JobType = await prisma.job.create({
data: {
...values,
clerkId: userId,
},
});
return job;
} catch (error) {
console.error(error);
return null;
}
}- install
npx shadcn@latest add toast
- add React Query and Toaster to providers.tsx
- wrap Home Page in React Query
- app/provider
"use client";
import { ThemeProvider } from "@/components/theme-provider";
import { useState } from "react";
import { QueryClient, QueryClientProvider } from "@tanstack/react-query";
import { ReactQueryDevtools } from "@tanstack/react-query-devtools";
import { Toaster } from "@/components/ui/toaster";
const Providers = ({ children }: { children: React.ReactNode }) => {
const [queryClient] = useState(
() =>
new QueryClient({
defaultOptions: {
queries: {
// With SSR, we usually want to set some default staleTime
// above 0 to avoid refetching immediately on the client
staleTime: 60 * 1000 * 5,
},
},
})
);
return (
<ThemeProvider
attribute="class"
defaultTheme="system"
enableSystem
disableTransitionOnChange
>
<Toaster />
<QueryClientProvider client={queryClient}>
{children}
<ReactQueryDevtools initialIsOpen={false} />
</QueryClientProvider>
</ThemeProvider>
);
};
export default Providers;- add-job/page
import CreateJobForm from "@/components/CreateJobForm";
import {
dehydrate,
HydrationBoundary,
QueryClient,
} from "@tanstack/react-query";
function AddJobPage() {
const queryClient = new QueryClient();
return (
<HydrationBoundary state={dehydrate(queryClient)}>
<CreateJobForm />
</HydrationBoundary>
);
}
export default AddJobPage;// imports
import { useMutation, useQueryClient } from "@tanstack/react-query";
import { createJobAction } from "@/utils/actions";
import { useToast } from "@/components/ui/use-toast";
import { useRouter } from "next/navigation";
// logic
const queryClient = useQueryClient();
const { toast } = useToast();
const router = useRouter();
const { mutate, isPending } = useMutation({
mutationFn: (values: CreateAndEditJobType) => createJobAction(values),
onSuccess: (data) => {
if (!data) {
toast({
description: "there was an error",
});
return;
}
toast({ description: "job created" });
queryClient.invalidateQueries({ queryKey: ["jobs"] });
queryClient.invalidateQueries({ queryKey: ["stats"] });
queryClient.invalidateQueries({ queryKey: ["charts"] });
router.push("/jobs");
// form.reset();
},
});
function onSubmit(values: CreateAndEditJobType) {
mutate(values);
}
// return
<Button type="submit" className="self-end capitalize" disabled={isPending}>
{isPending ? "loading..." : "create job"}
</Button>;-
Define the getAllJobsAction function
- Define an asynchronous function named
getAllJobsActionthat takes an object as a parameter. - This object should have
search,jobStatus,page, andlimitproperties. - The
pageandlimitproperties should have default values of 1 and 10, respectively. - This function should return a Promise that resolves to an object with
jobs,count,page, andtotalPagesproperties.
- Define an asynchronous function named
-
Authenticate the user
- Inside the
getAllJobsActionfunction, callauthenticateAndRedirectand store its return value inuserId.
- Inside the
-
Define the whereClause object
- Define a
whereClauseobject with aclerkIdproperty that hasuserIdas its value.
- Define a
-
Modify the whereClause object based on search and jobStatus
- If
searchis defined, add anORproperty towhereClausethat is an array of objects. - Each object in the
ORarray should represent a condition where a field contains the search string. - If
jobStatusis defined and not equal to 'all', add astatusproperty towhereClausethat hasjobStatusas its value.
- If
-
Fetch jobs from the database
- Use the
prisma.job.findManymethod to fetch jobs from the database. - Pass an object to this method with
whereandorderByproperties. - The
whereproperty should havewhereClauseas its value. - The
orderByproperty should be an object with acreatedAtproperty that has 'desc' as its value. - Store the return value of this method in
jobs.
- Use the
-
Handle errors
- Wrap the database operation in a try-catch block.
- If an error occurs, log the error to the console and return an object with
jobs,count,page, andtotalPagesproperties, all of which have 0 or [] as their values.
-
Return the jobs
- After the try-catch block, return an object with
jobs,count,page, andtotalPagesproperties.
- After the try-catch block, return an object with
-
Export the getAllJobsAction function
- Export
getAllJobsActionso it can be used in other parts of your application.
- Export
- actions
type GetAllJobsActionTypes = {
search?: string;
jobStatus?: string;
page?: number;
limit?: number;
};
export async function getAllJobsAction({
search,
jobStatus,
page = 1,
limit = 10,
}: GetAllJobsActionTypes): Promise<{
jobs: JobType[];
count: number;
page: number;
totalPages: number;
}> {
const userId = authenticateAndRedirect();
try {
let whereClause: Prisma.JobWhereInput = {
clerkId: userId,
};
if (search) {
whereClause = {
...whereClause,
OR: [
{
position: {
contains: search,
},
},
{
company: {
contains: search,
},
},
],
};
}
if (jobStatus && jobStatus !== "all") {
whereClause = {
...whereClause,
status: jobStatus,
};
}
const jobs: JobType[] = await prisma.job.findMany({
where: whereClause,
orderBy: {
createdAt: "desc",
},
});
return { jobs, count: 0, page: 1, totalPages: 0 };
} catch (error) {
console.error(error);
return { jobs: [], count: 0, page: 1, totalPages: 0 };
}
}- create SearchForm, JobsList, JobCard, JobInfo, DeleteJobBtn components
- setup jobs/loading.tsx
- wrap jobs/page in React Query and pre-fetch getAllJobsAction
- create SearchForm, JobsList, JobCard, JobInfo, DeleteJobBtn
- setup jobs/loading.tsx
function loading() {
return <h2 className="text-xl font-medium capitalize">loading...</h2>;
}
export default loading;JobCard.tsx
import { JobType } from "@/utils/types";
function JobCard({ job }: { job: JobType }) {
return <h1 className="text-3xl">JobCard</h1>;
}
export default JobCard;jobs/page.tsx
import JobsList from "@/components/JobsList";
import SearchForm from "@/components/SearchForm";
import {
dehydrate,
HydrationBoundary,
QueryClient,
} from "@tanstack/react-query";
import { getAllJobsAction } from "@/utils/actions";
async function AllJobsPage() {
const queryClient = new QueryClient();
await queryClient.prefetchQuery({
queryKey: ["jobs", "", "all", 1],
queryFn: () => getAllJobsAction({}),
});
return (
<HydrationBoundary state={dehydrate(queryClient)}>
<SearchForm />
<JobsList />
</HydrationBoundary>
);
}
export default AllJobsPage;-
Import necessary libraries and components
- Import the
InputandButtoncomponents from your UI library. - Import the
usePathname,useRouter, anduseSearchParamshooks fromnext/navigation. - Import the
Select,SelectContent,SelectItem,SelectTrigger, andSelectValuecomponents from your UI library. - Import the
JobStatustype from your types file.
- Import the
-
Define the SearchContainer component
- Define a function component named
SearchContainer.
- Define a function component named
-
Use hooks to get necessary data
- Inside
SearchContainer, use theuseSearchParamshook to get the current search parameters. - Use the
getmethod of thesearchParamsobject to get thesearchandjobStatusparameters. - Use the
useRouterhook to get the router object. - Use the
usePathnamehook to get the current pathname.
- Inside
-
Define the form submission handler
- Inside
SearchContainer, define a function namedhandleSubmitfor handling form submission. - This function should take an event object as its parameter.
- Inside this function, prevent the default form submission behavior.
- Create a new
URLSearchParamsobject and a newFormDataobject. - Use the
getmethod of theformDataobject to get thesearchandjobStatusform values. - Use the
setmethod of theparamsobject to set thesearchandjobStatusparameters. - Use the
pushmethod of the router object to navigate to the current pathname with the new search parameters.
- Inside
-
Create the form UI
- In the component's return statement, create the form UI using the form element.
- Use the
InputandSelectcomponents to create the form fields. - Use the
Buttoncomponent to create the submit button. - Pass the
handleSubmitfunction as theonSubmitprop to the form element.
-
Export the SearchContainer component
- After defining the
SearchContainercomponent, export it so it can be used in other parts of your application.
- After defining the
"use client";
import { Input } from "./ui/input";
import { usePathname, useRouter, useSearchParams } from "next/navigation";
import { Button } from "./ui/button";
import {
Select,
SelectContent,
SelectItem,
SelectTrigger,
SelectValue,
} from "@/components/ui/select";
import { JobStatus } from "@/utils/types";
function SearchContainer() {
// set default values
const searchParams = useSearchParams();
const search = searchParams.get("search") || "";
const jobStatus = searchParams.get("jobStatus") || "all";
const router = useRouter();
const pathname = usePathname();
const handleSubmit = (e: React.FormEvent<HTMLFormElement>) => {
e.preventDefault();
let params = new URLSearchParams();
const formData = new FormData(e.currentTarget);
const search = formData.get("search") as string;
const jobStatus = formData.get("jobStatus") as string;
params.set("search", search);
params.set("jobStatus", jobStatus);
router.push(`${pathname}?${params.toString()}`);
};
return (
<form
className="bg-muted mb-16 p-8 grid sm:grid-cols-2 md:grid-cols-3 gap-4 rounded-lg"
onSubmit={handleSubmit}
>
<Input
type="text"
placeholder="Search Jobs"
name="search"
defaultValue={search}
/>
<Select defaultValue={jobStatus} name="jobStatus">
<SelectTrigger>
<SelectValue />
</SelectTrigger>
<SelectContent>
{["all", ...Object.values(JobStatus)].map((jobStatus) => {
return (
<SelectItem key={jobStatus} value={jobStatus}>
{jobStatus}
</SelectItem>
);
})}
</SelectContent>
</Select>
<Button type="submit">Search</Button>
</form>
);
}
export default SearchContainer;-
Import necessary libraries and modules
- Import the
useSearchParamshook fromnext/navigation. - Import the
getAllJobsActionfunction from your actions file. - Import the
useQueryhook from@tanstack/react-query.
- Import the
-
Define the JobsList component
- Define a function component named
JobsList.
- Define a function component named
-
Use hooks to get necessary data
- Inside
JobsList, use theuseSearchParamshook to get the current search parameters. - Use the
getmethod of thesearchParamsobject to get thesearchandjobStatusparameters. - If
searchorjobStatusis null, default them to an empty string and 'all', respectively. - Use the
getmethod of thesearchParamsobject to get thepageparameter. - If
pageis null, default it to 1.
- Inside
-
Fetch the jobs from the server
- Use the
useQueryhook to fetch the jobs from the server. - Pass an object to this hook with
queryKeyandqueryFnproperties. - The
queryKeyproperty should be an array with 'jobs',search,jobStatus, andpageNumber. - The
queryFnproperty should be a function that callsgetAllJobsActionwith an object that hassearch,jobStatus, andpageproperties. - Store the return value of this hook in
dataandisPending.
- Use the
-
Handle loading and empty states
- If
isPendingis true, return ah2element with 'Please Wait...' as its child. - If
jobsis an empty array, return ah2element with 'No Jobs Found...' as its child.
- If
-
Export the JobsList component
- After defining the
JobsListcomponent, export it so it can be used in other parts of your application.
- After defining the
"use client";
import JobCard from "./JobCard";
import { useSearchParams } from "next/navigation";
import { getAllJobsAction } from "@/utils/actions";
import { useQuery } from "@tanstack/react-query";
function JobsList() {
const searchParams = useSearchParams();
const search = searchParams.get("search") || "";
const jobStatus = searchParams.get("jobStatus") || "all";
const pageNumber = Number(searchParams.get("page")) || 1;
const { data, isPending } = useQuery({
queryKey: ["jobs", search ?? "", jobStatus, pageNumber],
queryFn: () => getAllJobsAction({ search, jobStatus, page: pageNumber }),
});
const jobs = data?.jobs || [];
if (isPending) return <h2 className="text-xl">Please Wait...</h2>;
if (jobs.length < 1) return <h2 className="text-xl">No Jobs Found...</h2>;
return (
<>
{/*button container */}
<div className="grid md:grid-cols-2 gap-8">
{jobs.map((job) => {
return <JobCard key={job.id} job={job} />;
})}
</div>
</>
);
}
export default JobsList;- install
npx shadcn@latest add badge separator card
-
Import necessary libraries and components
- Import the
JobTypetype from your types file. - Import the
MapPin,Briefcase,CalendarDays, andRadioTowercomponents fromlucide-react. - Import the
Linkcomponent fromnext/link. - Import the
Card,CardContent,CardDescription,CardFooter,CardHeader, andCardTitlecomponents from your UI library. - Import the
Separator,Button,Badge,JobInfo, andDeleteJobButtoncomponents from your components directory.
- Import the
-
Define the JobCard component
- Define a function component named
JobCardthat takes an object as a prop. - This object should have a
jobproperty of typeJobType.
- Define a function component named
-
Convert the job's creation date to a locale string
- Inside
JobCard, create a new Date object withjob.createdAtas its argument. - Call the
toLocaleDateStringmethod on this object and store its return value indate.
- Inside
-
Create the component UI
- In the component's return statement, create the component UI using the
Card,CardHeader,CardTitle,CardDescription,Separator,CardContent,CardFooter,Button,Link, andDeleteJobButtoncomponents. - Pass the
job.positionandjob.companyas the children of theCardTitleandCardDescriptioncomponents, respectively. - Pass the
job.idas thehrefprop to theLinkcomponent. - Pass the
dateas the child of theCalendarDayscomponent.
- In the component's return statement, create the component UI using the
-
Export the JobCard component
- After defining the
JobCardcomponent, export it so it can be used in other parts of your application.
- After defining the
JobCard
import { JobType } from "@/utils/types";
import { MapPin, Briefcase, CalendarDays, RadioTower } from "lucide-react";
import Link from "next/link";
import {
Card,
CardContent,
CardDescription,
CardFooter,
CardHeader,
CardTitle,
} from "@/components/ui/card";
import { Separator } from "./ui/separator";
import { Button } from "./ui/button";
import { Badge } from "./ui/badge";
import JobInfo from "./JobInfo";
import DeleteJobButton from "./DeleteJobButton";
function JobCard({ job }: { job: JobType }) {
const date = new Date(job.createdAt).toLocaleDateString();
return (
<Card className="bg-muted">
<CardHeader>
<CardTitle>{job.position}</CardTitle>
<CardDescription>{job.company}</CardDescription>
</CardHeader>
<Separator />
<CardContent>{/* card info */}</CardContent>
<CardFooter className="flex gap-4">
<Button asChild size="sm">
<Link href={`/jobs/${job.id}`}>edit</Link>
</Button>
<DeleteJobButton />
</CardFooter>
</Card>
);
}
export default JobCard;-
Define the JobInfo component
- Define a function component named
JobInfothat takes an object as a prop. - This object should have
iconandtextproperties. - The
iconproperty should be of typeReact.ReactNodeand thetextproperty should be of typestring.
- Define a function component named
-
Create the component UI
- In the component's return statement, create a
divelement with aclassNameof 'flex gap-x-2 items-center'. - Inside this
div, render theiconandtextprops.
- In the component's return statement, create a
-
Export the JobInfo component
- After defining the
JobInfocomponent, export it so it can be used in other parts of your application.
- After defining the
-
Use the JobInfo component
- In the
CardContentcomponent, use theJobInfocomponent four times. - For each
JobInfocomponent, pass aniconprop and atextprop. - The
iconprop should be aBriefcase,MapPin,CalendarDays, orRadioTowercomponent. - The
textprop should bejob.mode,job.location,date, orjob.status. - Wrap the last
JobInfocomponent in aBadgecomponent with aclassNameof 'w-32 justify-center'.
- In the
JobInfo.tsx
function JobInfo({ icon, text }: { icon: React.ReactNode; text: string }) {
return (
<div className="flex gap-x-2 items-center">
{icon}
{text}
</div>
);
}
export default JobInfo;JobCard.tsx
<CardContent className="mt-4 grid grid-cols-2 gap-4">
<JobInfo icon={<Briefcase />} text={job.mode} />
<JobInfo icon={<MapPin />} text={job.location} />
<JobInfo icon={<CalendarDays />} text={date} />
<Badge className="w-32 justify-center">
<JobInfo icon={<RadioTower className="w-4 h-4" />} text={job.status} />
</Badge>
</CardContent>-
Define the deleteJobAction function
- Define an asynchronous function named
deleteJobActionthat takes a stringidas a parameter. - This function should return a Promise that resolves to a
JobTypeobject or null.
- Define an asynchronous function named
-
Authenticate the user
- Inside the
deleteJobActionfunction, callauthenticateAndRedirectand store its return value inuserId.
- Inside the
-
Delete the job from the database
- Use the
prisma.job.deletemethod to delete the job from the database. - Pass an object to this method with a
whereproperty. - The
whereproperty should be an object withidandclerkIdproperties. - The
idproperty should haveidas its value and theclerkIdproperty should haveuserIdas its value. - Store the return value of this method in
job.
- Use the
-
Handle errors
- Wrap the database operation in a try-catch block.
- If an error occurs, return null.
-
Return the deleted job
- After the try-catch block, return
job.
- After the try-catch block, return
-
Export the deleteJobAction function
- Export
deleteJobActionso it can be used in other parts of your application.
- Export
actions
export async function deleteJobAction(id: string): Promise<JobType | null> {
const userId = authenticateAndRedirect();
try {
const job: JobType = await prisma.job.delete({
where: {
id,
clerkId: userId,
},
});
return job;
} catch (error) {
return null;
}
}-
Import necessary libraries and components
- Import the
Button,Badge,JobInfo, anduseToastcomponents from your components directory. - Import the
useMutationanduseQueryClienthooks from@tanstack/react-query. - Import the
deleteJobActionfunction from your actions file.
- Import the
-
Define the DeleteJobBtn component
- Define a function component named
DeleteJobBtnthat takes an object as a prop. - This object should have an
idproperty of type string.
- Define a function component named
-
Use hooks to get necessary data and functions
- Inside
DeleteJobBtn, use theuseToasthook to get thetoastfunction. - Use the
useQueryClienthook to get thequeryClientobject. - Use the
useMutationhook to get themutatefunction andisPendingstate. - Pass an object to the
useMutationhook withmutationFnandonSuccessproperties. - The
mutationFnproperty should be a function that takesidas a parameter and callsdeleteJobActionwithid. - The
onSuccessproperty should be a function that takesdataas a parameter and invalidates thejobs,stats, andchartsqueries if data is truthy. If data is falsy, it should calltoastwith an object that has adescriptionproperty of 'there was an error'.
- Inside
-
Create the component UI
- In the component's return statement, create the component UI using the
Buttoncomponent. - Pass the
mutatefunction as theonClickprop to theButtoncomponent. - Pass
isPendingas theloadingprop to theButtoncomponent.
- In the component's return statement, create the component UI using the
-
Export the DeleteJobBtn component
- After defining the
DeleteJobBtncomponent, export it so it can be used in other parts of your application.
- After defining the
import { Button } from "./ui/button";
import { Badge } from "./ui/badge";
import JobInfo from "./JobInfo";
import { useMutation, useQueryClient } from "@tanstack/react-query";
import { deleteJobAction } from "@/utils/actions";
import { useToast } from "@/components/ui/use-toast";
function DeleteJobBtn({ id }: { id: string }) {
const { toast } = useToast();
const queryClient = useQueryClient();
const { mutate, isPending } = useMutation({
mutationFn: (id: string) => deleteJobAction(id),
onSuccess: (data) => {
if (!data) {
toast({
description: "there was an error",
});
return;
}
queryClient.invalidateQueries({ queryKey: ["jobs"] });
queryClient.invalidateQueries({ queryKey: ["stats"] });
queryClient.invalidateQueries({ queryKey: ["charts"] });
toast({ description: "job removed" });
},
});
return (
<Button
size="sm"
disabled={isPending}
onClick={() => {
mutate(id);
}}
>
{isPending ? "deleting..." : "delete"}
</Button>
);
}
export default DeleteJobBtn;-
Define the getSingleJobAction function
- Define an asynchronous function named
getSingleJobActionthat takes a stringidas a parameter. - This function should return a Promise that resolves to a
JobTypeobject or null.
- Define an asynchronous function named
-
Authenticate the user
- Inside the
getSingleJobActionfunction, callauthenticateAndRedirectand store its return value inuserId.
- Inside the
-
Fetch the job from the database
- Use the
prisma.job.findUniquemethod to fetch the job from the database. - Pass an object to this method with a
whereproperty. - The
whereproperty should be an object withidandclerkIdproperties. - The
idproperty should haveidas its value and theclerkIdproperty should haveuserIdas its value. - Store the return value of this method in
job.
- Use the
-
Handle errors
- Wrap the database operation in a try-catch block.
- If an error occurs, set
jobto null.
-
Redirect if the job is not found
- After the try-catch block, check if
jobis falsy. - If
jobis falsy, callredirectwith '/jobs' as its argument.
- After the try-catch block, check if
-
Return the fetched job
- After the if statement, return
job.
- After the if statement, return
-
Export the getSingleJobAction function
- Export
getSingleJobActionso it can be used in other parts of your application.
- Export
export async function getSingleJobAction(id: string): Promise<JobType | null> {
let job: JobType | null = null;
const userId = authenticateAndRedirect();
try {
job = await prisma.job.findUnique({
where: {
id,
clerkId: userId,
},
});
} catch (error) {
job = null;
}
if (!job) {
redirect("/jobs");
}
return job;
}- create single job page (dynamic)
- create EditJobForm which accepts jobId props (string)
-
Import necessary libraries and components
- Import the
EditJobFormcomponent from your components directory. - Import the
getSingleJobActionfunction from your actions file. - Import the
dehydrate,HydrationBoundary, andQueryClientcomponents from@tanstack/react-query.
- Import the
-
Define the JobDetailPage component
- Define an asynchronous function component named
JobDetailPagethat takes an object as a prop. - This object should have a
paramsproperty, which is also an object with anidproperty of type string.
- Define an asynchronous function component named
-
Create a new query client
- Inside
JobDetailPage, create a newQueryClientinstance and store it inqueryClient.
- Inside
-
Prefetch the job data
- Use the
prefetchQuerymethod ofqueryClientto prefetch the job data. - Pass an object to this method with
queryKeyandqueryFnproperties. - The
queryKeyproperty should be an array with 'job' andparams.id. - The
queryFnproperty should be a function that callsgetSingleJobActionwithparams.id.
- Use the
-
Create the component UI
- In the component's return statement, create the component UI using the
HydrationBoundaryandEditJobFormcomponents. - Pass the result of calling
dehydratewithqueryClientas thestateprop toHydrationBoundary. - Pass
params.idas thejobIdprop toEditJobForm.
- In the component's return statement, create the component UI using the
-
Export the JobDetailPage component
- After defining the
JobDetailPagecomponent, export it so it can be used in other parts of your application.
- After defining the
jobs/[id]/page.tsx
import EditJobForm from "@/components/EditJobForm";
import { getSingleJobAction } from "@/utils/actions";
import {
dehydrate,
HydrationBoundary,
QueryClient,
} from "@tanstack/react-query";
async function JobDetailPage({ params }: { params: { id: string } }) {
const queryClient = new QueryClient();
await queryClient.prefetchQuery({
queryKey: ["job", params.id],
queryFn: () => getSingleJobAction(params.id),
});
return (
<HydrationBoundary state={dehydrate(queryClient)}>
<EditJobForm jobId={params.id} />
</HydrationBoundary>
);
}
export default JobDetailPage;-
Define the updateJobAction function
- Define an asynchronous function named
updateJobActionthat takes a stringidand an objectvaluesas parameters. - The
valuesparameter should be of typeCreateAndEditJobType. - This function should return a Promise that resolves to a
JobTypeobject or null.
- Define an asynchronous function named
-
Authenticate the user
- Inside the
updateJobActionfunction, callauthenticateAndRedirectand store its return value inuserId.
- Inside the
-
Update the job in the database
- Use the
prisma.job.updatemethod to update the job in the database. - Pass an object to this method with
whereanddataproperties. - The
whereproperty should be an object withidandclerkIdproperties. - The
idproperty should haveidas its value and theclerkIdproperty should haveuserIdas its value. - The
dataproperty should be an object that spreadsvalues. - Store the return value of this method in
job.
- Use the
-
Handle errors
- Wrap the database operation in a try-catch block.
- If an error occurs, return null.
-
Return the updated job
- After the try-catch block, return
job.
- After the try-catch block, return
-
Export the updateJobAction function
- Export
updateJobActionso it can be used in other parts of your application.
- Export
export async function updateJobAction(
id: string,
values: CreateAndEditJobType
): Promise<JobType | null> {
const userId = authenticateAndRedirect();
try {
const job: JobType = await prisma.job.update({
where: {
id,
clerkId: userId,
},
data: {
...values,
},
});
return job;
} catch (error) {
return null;
}
}-
Import necessary libraries and components
- Import
zodResolverfrom@hookform/resolvers/zod. - Import
useFormfromreact-hook-form. - Import
JobStatus,JobMode,createAndEditJobSchema, andCreateAndEditJobTypefrom your types file. - Import
Buttonfrom your UI components directory. - Import
Formfrom your UI components directory. - Import
CustomFormFieldandCustomFormSelectfrom your localFormComponentsfile. - Import
useMutation,useQueryClient, anduseQueryfromreact-query. - Import
createJobAction,getSingleJobAction, andupdateJobActionfrom your actions file. - Import
useToastfrom your UI components directory. - Import
useRouterfromnext/router.
- Import
-
Define the EditJobForm component
- Define a function component named
EditJobFormthat takes an object as a prop. - This object should have a
jobIdproperty of type string.
- Define a function component named
-
Use hooks to get necessary data and functions
- Inside
EditJobForm, use theuseQueryClienthook to get thequeryClientobject. - Use the
useToasthook to get thetoastfunction. - Use the
useRouterhook to get the router object. - Use the
useQueryhook to fetch the job data. - Use the
useMutationhook to get themutatefunction andisPendingstate.
- Inside
-
Use the useForm hook to get form functions
- Use the
useFormhook to get the form object. - Pass an object to this hook with
resolveranddefaultValuesproperties.
- Use the
-
Define the submit handler
- Define a function
onSubmitthat callsmutatewith values.
- Define a function
-
Create the component UI
- In the component's return statement, create the component UI using the
Form,CustomFormField,CustomFormSelect, andButtoncomponents.
- In the component's return statement, create the component UI using the
-
Export the EditJobForm component
- After defining the
EditJobFormcomponent, export it so it can be used in other parts of your application.
- After defining the
"use client";
import { zodResolver } from "@hookform/resolvers/zod";
import { useForm } from "react-hook-form";
import {
JobStatus,
JobMode,
createAndEditJobSchema,
CreateAndEditJobType,
} from "@/utils/types";
import { Button } from "@/components/ui/button";
import { Form } from "@/components/ui/form";
import { CustomFormField, CustomFormSelect } from "./FormComponents";
import { useMutation, useQueryClient, useQuery } from "@tanstack/react-query";
import {
createJobAction,
getSingleJobAction,
updateJobAction,
} from "@/utils/actions";
import { useToast } from "@/components/ui/use-toast";
import { useRouter } from "next/navigation";
function EditJobForm({ jobId }: { jobId: string }) {
const queryClient = useQueryClient();
const { toast } = useToast();
const router = useRouter();
const { data } = useQuery({
queryKey: ["job", jobId],
queryFn: () => getSingleJobAction(jobId),
});
const { mutate, isPending } = useMutation({
mutationFn: (values: CreateAndEditJobType) =>
updateJobAction(jobId, values),
onSuccess: (data) => {
if (!data) {
toast({
description: "there was an error",
});
return;
}
toast({ description: "job updated" });
queryClient.invalidateQueries({ queryKey: ["jobs"] });
queryClient.invalidateQueries({ queryKey: ["job", jobId] });
queryClient.invalidateQueries({ queryKey: ["stats"] });
router.push("/jobs");
// form.reset();
},
});
// 1. Define your form.
const form = useForm<CreateAndEditJobType>({
resolver: zodResolver(createAndEditJobSchema),
defaultValues: {
position: data?.position || "",
company: data?.company || "",
location: data?.location || "",
status: (data?.status as JobStatus) || JobStatus.Pending,
mode: (data?.mode as JobMode) || JobMode.FullTime,
},
});
// 2. Define a submit handler.
function onSubmit(values: CreateAndEditJobType) {
// Do something with the form values.
// ✅ This will be type-safe and validated.
mutate(values);
}
return (
<Form {...form}>
<form
onSubmit={form.handleSubmit(onSubmit)}
className="bg-muted p-8 rounded"
>
<h2 className="capitalize font-semibold text-4xl mb-6">edit job</h2>
<div className="grid gap-4 md:grid-cols-2 lg:grid-cols-3 items-start">
{/* position */}
<CustomFormField name="position" control={form.control} />
{/* company */}
<CustomFormField name="company" control={form.control} />
{/* location */}
<CustomFormField name="location" control={form.control} />
{/* job status */}
<CustomFormSelect
name="status"
control={form.control}
labelText="job status"
items={Object.values(JobStatus)}
/>
{/* job type */}
<CustomFormSelect
name="mode"
control={form.control}
labelText="job mode"
items={Object.values(JobMode)}
/>
<Button
type="submit"
className="self-end capitalize"
disabled={isPending}
>
{isPending ? "updating..." : "edit job"}
</Button>
</div>
</form>
</Form>
);
}
export default EditJobForm;- create fake data in Mockaroo docs
- copy from assets or final project
- log user id
- create seed.js
- run "node prisma/seed"
const { PrismaClient } = require("@prisma/client");
const data = require("./mock-data.json");
const prisma = new PrismaClient();
async function main() {
const clerkId = "user_2ZUfUOtKM8W9eF8hSQbISv7aQmn";
const jobs = data.map((job) => {
return {
...job,
clerkId,
};
});
for (const job of jobs) {
await prisma.job.create({
data: job,
});
}
}
main()
.then(async () => {
await prisma.$disconnect();
})
.catch(async (e) => {
console.error(e);
await prisma.$disconnect();
process.exit(1);
});-
Define the getStatsAction function
- Define an asynchronous function named
getStatsAction. - This function should return a Promise that resolves to an object with
pending,interview, anddeclinedproperties, all of type number.
- Define an asynchronous function named
-
Authenticate the user
- Inside the
getStatsActionfunction, callauthenticateAndRedirectand store its return value inuserId.
- Inside the
-
Fetch the job stats from the database
- Use the
prisma.job.groupBymethod to fetch the job stats from the database. - Pass an object to this method with
by,_count, andwhereproperties. - The
byproperty should be an array with 'status'. - The
_countproperty should be an object withstatusset to true. - The
whereproperty should be an object withclerkIdset touserId. - Store the return value of this method in
stats.
- Use the
-
Convert the stats array to an object
- Use the
Array.prototype.reducemethod to convertstatsto an object and store it instatsObject. - The initial value of the accumulator should be an empty object.
- In each iteration, set the property of the accumulator object with the key of
curr.statustocurr._count.status.
- Use the
-
Create the default stats object
- Create an object
defaultStatswithpending,declined, andinterviewproperties all set to 0. - Use the spread operator to add the properties of
statsObjecttodefaultStats.
- Create an object
-
Handle errors
- Wrap the database operation and the stats conversion in a try-catch block.
- If an error occurs, call
redirectwith '/jobs'.
-
Return the stats object
- After the try-catch block, return
defaultStats.
- After the try-catch block, return
-
Export the getStatsAction function
- Export
getStatsActionso it can be used in other parts of your application.
- Export
export async function getStatsAction(): Promise<{
pending: number;
interview: number;
declined: number;
}> {
const userId = authenticateAndRedirect();
// just to show Skeleton
// await new Promise((resolve) => setTimeout(resolve, 5000));
try {
const stats = await prisma.job.groupBy({
by: ["status"],
_count: {
status: true,
},
where: {
clerkId: userId, // replace userId with the actual clerkId
},
});
const statsObject = stats.reduce((acc, curr) => {
acc[curr.status] = curr._count.status;
return acc;
}, {} as Record<string, number>);
const defaultStats = {
pending: 0,
declined: 0,
interview: 0,
...statsObject,
};
return defaultStats;
} catch (error) {
redirect("/jobs");
}
}-
Define the getChartsDataAction function
- Define an asynchronous function named
getChartsDataAction. - This function should return a Promise that resolves to an array of objects, each with
dateandcountproperties.
- Define an asynchronous function named
-
Authenticate the user
- Inside the
getChartsDataActionfunction, callauthenticateAndRedirectand store its return value inuserId.
- Inside the
-
Calculate the date six months ago
- Use
dayjsto get the current date, subtract 6 months from it, and convert it to a JavaScript Date object. Store this value insixMonthsAgo.
- Use
-
Fetch the jobs from the database
- Use the
prisma.job.findManymethod to fetch the jobs from the database. - Pass an object to this method with
whereandorderByproperties. - The
whereproperty should be an object withclerkIdandcreatedAtproperties. - The
clerkIdproperty should haveuserIdas its value. - The
createdAtproperty should be an object withgteset tosixMonthsAgo. - The
orderByproperty should be an object withcreatedAtset to 'asc'. - Store the return value of this method in
jobs.
- Use the
-
Calculate the number of applications per month
- Use the
Array.prototype.reducemethod to calculate the number of applications per month and store it inapplicationsPerMonth. - In each iteration, format the
createdAtproperty of the current job to 'MMM YY' and store it indate. - Find an entry in the accumulator with
dateequal todateand store it inexistingEntry. - If
existingEntryexists, increment itscountproperty by 1. - If
existingEntrydoes not exist, push a new object to the accumulator withdateandcountproperties.
- Use the
-
Handle errors
- Wrap the database operation and the applications per month calculation in a try-catch block.
- If an error occurs, call
redirectwith '/jobs'.
-
Return the applications per month
- After the try-catch block, return
applicationsPerMonth.
- After the try-catch block, return
-
Export the getChartsDataAction function
- Export
getChartsDataActionso it can be used in other parts of your application.
- Export
export async function getChartsDataAction(): Promise<
Array<{ date: string; count: number }>
> {
const userId = authenticateAndRedirect();
const sixMonthsAgo = dayjs().subtract(6, "month").toDate();
try {
const jobs = await prisma.job.findMany({
where: {
clerkId: userId,
createdAt: {
gte: sixMonthsAgo,
},
},
orderBy: {
createdAt: "asc",
},
});
let applicationsPerMonth = jobs.reduce((acc, job) => {
const date = dayjs(job.createdAt).format("MMM YY");
const existingEntry = acc.find((entry) => entry.date === date);
if (existingEntry) {
existingEntry.count += 1;
} else {
acc.push({ date, count: 1 });
}
return acc;
}, [] as Array<{ date: string; count: number }>);
return applicationsPerMonth;
} catch (error) {
redirect("/jobs");
}
}- create StatsContainer and ChartsContainer components
- create loading in stats
- wrap stats page in React Query and pre-fetch
-
Import necessary libraries and components
- Import
ChartsContainerandStatsContainerfrom your components directory. - Import
getChartsDataActionandgetStatsActionfrom your actions file. - Import
dehydrate,HydrationBoundary, andQueryClientfrom@tanstack/react-query.
- Import
-
Define the StatsPage component
- Define an asynchronous function component named
StatsPage.
- Define an asynchronous function component named
-
Initialize the query client
- Inside
StatsPage, create a new instance ofQueryClientand store it inqueryClient.
- Inside
-
Prefetch the stats and charts data
- Use the
queryClient.prefetchQuerymethod to prefetch the stats and charts data. - Pass an object to this method with
queryKeyandqueryFnproperties. - The
queryKeyproperty should be an array with 'stats' or 'charts'. - The
queryFnproperty should be a function that callsgetStatsActionorgetChartsDataAction.
- Use the
-
Create the component UI
- In the component's return statement, create the component UI using the
HydrationBoundary,StatsContainer, andChartsContainercomponents. - Pass the result of calling
dehydratewithqueryClientas thestateprop toHydrationBoundary.
- In the component's return statement, create the component UI using the
-
Export the StatsPage component
- After defining the
StatsPagecomponent, export it so it can be used in other parts of your application.
- After defining the
- create StatsContainer and ChartsContainer components
function loading() {
return <h2 className="text-xl font-medium capitalize">loading...</h2>;
}
export default loading;import ChartsContainer from "@/components/ChartsContainer";
import StatsContainer from "@/components/StatsContainer";
import { getChartsDataAction, getStatsAction } from "@/utils/actions";
import {
dehydrate,
HydrationBoundary,
QueryClient,
} from "@tanstack/react-query";
async function StatsPage() {
const queryClient = new QueryClient();
await queryClient.prefetchQuery({
queryKey: ["stats"],
queryFn: () => getStatsAction(),
});
await queryClient.prefetchQuery({
queryKey: ["charts"],
queryFn: () => getChartsDataAction(),
});
return (
<HydrationBoundary state={dehydrate(queryClient)}>
<StatsContainer />
<ChartsContainer />
</HydrationBoundary>
);
}
export default StatsPage;- install
npx shadcn@latest add skeleton
- create StatsCard component
-
Import necessary libraries and components for StatsCards
- Import
Card,CardDescription,CardHeader, andCardTitlefrom your UI components directory.
- Import
-
Define the StatsCards component
- Define a function component named
StatsCardsthat takestitleandvalueas props. - In the component's return statement, create the component UI using the
Card,CardHeader,CardTitle, andCardDescriptioncomponents. - The
Cardcomponent should have aCardHeaderchild. - The
CardHeadercomponent should haveCardTitleandCardDescriptionchildren. - The
CardTitlecomponent should display thetitleprop. - The
CardDescriptioncomponent should display thevalueprop.
- Define a function component named
-
Export the StatsCards component
- After defining the
StatsCardscomponent, export it so it can be used in other parts of your application.
- After defining the
-
Import necessary libraries and components for StatsLoadingCard
- Import
Reactfrom the react library. - Import
Card,CardHeaderfrom your UI components directory. - Import
Skeletonfrom your UI components directory.
- Import
-
Define the StatsLoadingCard component
- Define a function component named
StatsLoadingCard. - In the component's return statement, create the component UI using the
Card,CardHeader, andSkeletoncomponents. - The
Cardcomponent should have aCardHeaderchild. - The
CardHeadercomponent should have a div child with twoSkeletonchildren. - The div element should have a
classNameof 'space-y-2'. - The first
Skeletoncomponent should have aclassNameof 'h-12 w-12 rounded-full'. - The second
Skeletoncomponent should have aclassNameof 'h-4 w-[150px]'. - The third
Skeletoncomponent should have aclassNameof 'h-4 w-[100px]'.
- Define a function component named
-
Export the StatsLoadingCard component
- After defining the
StatsLoadingCardcomponent, export it so it can be used in other parts of your application.
- After defining the
import {
Card,
CardDescription,
CardHeader,
CardTitle,
} from "@/components/ui/card";
import { Skeleton } from "./ui/skeleton";
type StatsCardsProps = {
title: string;
value: number;
};
function StatsCards({ title, value }: StatsCardsProps) {
return (
<Card className="bg-muted">
<CardHeader className="flex flex-row justify-between items-center">
<CardTitle className="capitalize">{title}</CardTitle>
<CardDescription className="text-4xl font-extrabold text-primary mt-[0px!important]">
{value}
</CardDescription>
</CardHeader>
</Card>
);
}
export function StatsLoadingCard() {
return (
<Card className="w-[330px] h-[88px]">
<CardHeader className="flex flex-row justify-between items-center">
<div className="flex items-center space-x-4">
<Skeleton className="h-12 w-12 rounded-full" />
<div className="space-y-2">
<Skeleton className="h-4 w-[150px]" />
<Skeleton className="h-4 w-[100px]" />
</div>
</div>
</CardHeader>
</Card>
);
}
export default StatsCards;-
Import necessary libraries and components
- Import
useQueryfrom the@tanstack/react-querylibrary. - Import
getStatsActionfrom your actions file. - Import
StatsCardandStatsLoadingCardfrom your components directory.
- Import
-
Define the StatsContainer component
- Define a function component named
StatsContainer.
- Define a function component named
-
Use the useQuery hook
- Inside
StatsContainer, call theuseQueryhook and destructuredataandisPendingfrom its return value. - Pass an object to
useQuerywithqueryKeyandqueryFnproperties. - The
queryKeyproperty should be an array with 'stats'. - The
queryFnproperty should be a function that callsgetStatsAction.
- Inside
-
Handle the loading state
- Inside
StatsContainer, add a conditional return statement that checks ifisPendingis true. - If
isPendingis true, return adivelement with threeStatsLoadingCardchildren.
- Inside
-
Handle the data state
- After the loading state check, return a
divelement with threeStatsCardchildren. - Each
StatsCardshould havetitleandvalueprops. - The
titleprop should be a string that describes the data. - The
valueprop should be a value from the data object or 0 if the value is undefined.
- After the loading state check, return a
-
Export the StatsContainer component
- After defining the
StatsContainercomponent, export it so it can be used in other parts of your application.
- After defining the
'use client';
import { useQuery } from '@tanstack/react-query';
import { getStatsAction } from '@/utils/actions';
import StatsCardfrom './StatsCard';
function StatsContainer() {
const { data } = useQuery({
queryKey: ['stats'],
queryFn: () => getStatsAction(),
});
return (
<div className='grid md:grid-cols-2 gap-4 lg:grid-cols-3'>
<StatsCard title='pending jobs' value={data?.pending || 0} />
<StatsCard title='interviews set' value={data?.interview || 0} />
<StatsCard title='jobs declined' value={data?.declined || 0} />
</div>
);
}
export default StatsContainer;stats/loading.tsx
import { StatsLoadingCard } from "@/components/StatsCard";
function loading() {
return (
<div className="grid md:grid-cols-2 gap-4 lg:grid-cols-3">
<StatsLoadingCard />
<StatsLoadingCard />
<StatsLoadingCard />
</div>
);
}
export default loading;jobs/loading.tsx
import { Skeleton } from "@/components/ui/skeleton";
function loading() {
return (
<div className="p-8 grid sm:grid-cols-2 md:grid-cols-3 gap-4 rounded-lg border">
<Skeleton className="h-10" />
<Skeleton className="h-10 " />
<Skeleton className="h-10 " />
</div>
);
}
export default loading;-
Import necessary libraries and components
- Import
useQueryfrom the react-query library. - Import
ResponsiveContainer,BarChart,CartesianGrid,XAxis,YAxis,Tooltip, andBarfrom recharts, a composable charting library built on React components.
- Import
-
Define the ChartsContainer component
- Define a function component named
ChartsContainer.
- Define a function component named
-
Use the useQuery hook
- Inside
ChartsContainer, call theuseQueryhook and destructuredata,isPendingfrom its return value. - Pass an object to
useQuerywithqueryKeyandqueryFnproperties. - The
queryKeyproperty should be an array with a unique key. - The
queryFnproperty should be a function that fetches the data you want to display in the chart.
- Inside
-
Handle the loading state
- Inside
ChartsContainer, add a conditional return statement that checks ifisPendingis true. - If
isPendingis true, return ah2element with a message indicating that the data is loading.
- Inside
-
Handle the empty data state
- After the loading state check, add a conditional return statement that checks if
datais null ordata.lengthis less than 1. - If the condition is true, return null.
- After the loading state check, add a conditional return statement that checks if
-
Render the chart
- After the empty data state check, return a
sectionelement. - Inside the
sectionelement, render ah1element with a title for the chart. - After the
h1element, render aResponsiveContainercomponent. - Inside the
ResponsiveContainercomponent, render aBarChartcomponent. - Pass the
datato theBarChartcomponent. - Inside the
BarChartcomponent, renderCartesianGrid,XAxis,YAxis,Tooltip, andBarcomponents. - Pass appropriate props to each component.
- After the empty data state check, return a
-
Export the ChartsContainer component
- After defining the
ChartsContainercomponent, export it so it can be used in other parts of your application.
- After defining the
"use client";
import {
BarChart,
Bar,
XAxis,
YAxis,
CartesianGrid,
Tooltip,
ResponsiveContainer,
} from "recharts";
import { useQuery } from "@tanstack/react-query";
import { getChartsDataAction } from "@/utils/actions";
function ChartsContainer() {
const { data, isPending } = useQuery({
queryKey: ["charts"],
queryFn: () => getChartsDataAction(),
});
if (isPending) return <h2 className="text-xl font-medium">Please wait...</h2>;
if (!data || data.length < 1) return null;
return (
<section className="mt-16">
<h1 className="text-4xl font-semibold text-center">
Monthly Applications
</h1>
<ResponsiveContainer width="100%" height={300}>
<BarChart data={data} margin={{ top: 50 }}>
<CartesianGrid strokeDasharray="3 3" />
<XAxis dataKey="date" />
<YAxis allowDecimals={false} />
<Tooltip />
<Bar dataKey="count" fill="#2563eb" barSize={75} />
</BarChart>
</ResponsiveContainer>
</section>
);
}
export default ChartsContainer;export async function getAllJobsAction({
search,
jobStatus,
page = 1,
limit = 10,
}: GetAllJobsActionTypes): Promise<{
jobs: JobType[];
count: number;
page: number;
totalPages: number;
}> {
const userId = authenticateAndRedirect();
try {
let whereClause: Prisma.JobWhereInput = {
clerkId: userId,
};
if (search) {
whereClause = {
...whereClause,
OR: [
{
position: {
contains: search,
},
},
{
company: {
contains: search,
},
},
],
};
}
if (jobStatus && jobStatus !== "all") {
whereClause = {
...whereClause,
status: jobStatus,
};
}
const skip = (page - 1) * limit;
const jobs: JobType[] = await prisma.job.findMany({
where: whereClause,
skip,
take: limit,
orderBy: {
createdAt: "desc",
},
});
const count: number = await prisma.job.count({
where: whereClause,
});
const totalPages = Math.ceil(count / limit);
return { jobs, count, page, totalPages };
} catch (error) {
console.error(error);
return { jobs: [], count: 0, page: 1, totalPages: 0 };
}
}"use client";
import { usePathname, useRouter, useSearchParams } from "next/navigation";
type ButtonContainerProps = {
currentPage: number;
totalPages: number;
};
import { Button } from "./ui/button";
function ButtonContainer({ currentPage, totalPages }: ButtonContainerProps) {
return <h2 className="text-4xl">button container</h2>;
}
export default ButtonContainer;const jobs = data?.jobs || [];
// add
const count = data?.count || 0;
const page = data?.page || 0;
const totalPages = data?.totalPages || 0;
if (isPending) return <h2 className='text-xl'>Please Wait...</h2>;
if (jobs.length < 1) return <h2 className='text-xl'>No Jobs Found...</h2>;
return (
<>
<div className='flex items-center justify-between mb-8'>
<h2 className='text-xl font-semibold capitalize '>
{count} jobs found
</h2>
{totalPages < 2 ? null : (
<ButtonContainer currentPage={page} totalPages={totalPages} />
)}
</div>
<>"use client";
import { usePathname, useRouter, useSearchParams } from "next/navigation";
type ButtonContainerProps = {
currentPage: number;
totalPages: number;
};
import { Button } from "./ui/button";
function ButtonContainer({ currentPage, totalPages }: ButtonContainerProps) {
const searchParams = useSearchParams();
const router = useRouter();
const pathname = usePathname();
const pageButtons = Array.from({ length: totalPages }, (_, i) => i + 1);
const handlePageChange = (page: number) => {
const defaultParams = {
search: searchParams.get("search") || "",
jobStatus: searchParams.get("jobStatus") || "",
page: String(page),
};
let params = new URLSearchParams(defaultParams);
router.push(`${pathname}?${params.toString()}`);
};
return (
<div className="flex gap-x-2">
{pageButtons.map((page) => {
return (
<Button
key={page}
size="icon"
variant={currentPage === page ? "default" : "outline"}
onClick={() => handlePageChange(page)}
>
{page}
</Button>
);
})}
</div>
);
}
export default ButtonContainer;"use client";
import { usePathname, useRouter, useSearchParams } from "next/navigation";
import { ChevronLeft, ChevronRight } from "lucide-react";
type ButtonContainerProps = {
currentPage: number;
totalPages: number;
};
type ButtonProps = {
page: number;
activeClass: boolean;
};
import { Button } from "./ui/button";
function ButtonContainer({ currentPage, totalPages }: ButtonContainerProps) {
const searchParams = useSearchParams();
const router = useRouter();
const pathname = usePathname();
const pageButtons = Array.from({ length: totalPages }, (_, i) => i + 1);
const handlePageChange = (page: number) => {
const defaultParams = {
search: searchParams.get("search") || "",
jobStatus: searchParams.get("jobStatus") || "",
page: String(page),
};
let params = new URLSearchParams(defaultParams);
router.push(`${pathname}?${params.toString()}`);
};
const addPageButton = ({ page, activeClass }: ButtonProps) => {
return (
<Button
key={page}
size="icon"
variant={activeClass ? "default" : "outline"}
onClick={() => handlePageChange(page)}
>
{page}
</Button>
);
};
const renderPageButtons = () => {
const pageButtons = [];
// first page
pageButtons.push(
addPageButton({ page: 1, activeClass: currentPage === 1 })
);
// dots
if (currentPage > 3) {
pageButtons.push(
<Button size="icon" variant="outline" key="dots-1">
...
</Button>
);
}
// one before current page
if (currentPage !== 1 && currentPage !== 2) {
pageButtons.push(
addPageButton({
page: currentPage - 1,
activeClass: false,
})
);
}
// current page
if (currentPage !== 1 && currentPage !== totalPages) {
pageButtons.push(
addPageButton({
page: currentPage,
activeClass: true,
})
);
}
// one after current page
if (currentPage !== totalPages && currentPage !== totalPages - 1) {
pageButtons.push(
addPageButton({
page: currentPage + 1,
activeClass: false,
})
);
}
if (currentPage < totalPages - 2) {
pageButtons.push(
<Button size="icon" variant="outline" key="dots-1">
...
</Button>
);
}
pageButtons.push(
addPageButton({
page: totalPages,
activeClass: currentPage === totalPages,
})
);
return pageButtons;
};
return (
<div className="flex gap-x-2">
{/* prev */}
<Button
className="flex items-center gap-x-2 "
variant="outline"
onClick={() => {
let prevPage = currentPage - 1;
if (prevPage < 1) prevPage = totalPages;
handlePageChange(prevPage);
}}
>
<ChevronLeft />
prev
</Button>
{renderPageButtons()}
{/* next */}
<Button
className="flex items-center gap-x-2 "
onClick={() => {
let nextPage = currentPage + 1;
if (nextPage > totalPages) nextPage = 1;
handlePageChange(nextPage);
}}
variant="outline"
>
next
<ChevronRight />
</Button>
</div>
);
}
export default ButtonContainer;