adrianhajdin / ai_saas_app

Build a REAL Software-as-a-Service app with AI features and payments & credits system that you might even turn into a side income or business idea using Next.js 14, Clerk, MongoDB, Cloudinary AI, and Stripe.
https://jsmastery.pro
925 stars 279 forks source link
clerk cloudinary next14 nextjs saas

Project Banner
nextdotjs typescript stripe mongodb tailwindcss

An AI SaaS Platform

Build this project step by step with our detailed tutorial on JavaScript Mastery YouTube. Join the JSM family!

πŸ“‹ Table of Contents

  1. πŸ€– Introduction
  2. βš™οΈ Tech Stack
  3. πŸ”‹ Features
  4. 🀸 Quick Start
  5. πŸ•ΈοΈ Snippets
  6. πŸ”— Links
  7. πŸš€ More

🚨 Tutorial

This repository contains the code corresponding to an in-depth tutorial available on our YouTube channel, JavaScript Mastery.

If you prefer visual learning, this is the perfect resource for you. Follow our tutorial to learn how to build projects like these step-by-step in a beginner-friendly manner!

πŸ€– Introduction

Build an AI image SaaS platform that excels in image processing capabilities, integrates a secure payment infrastructure, offers advanced image search functionalities, and supports multiple AI features, including image restoration, recoloring, object removal, generative filling, and background removal. This project can be a guide for your next AI image tool and a boost to your portfolio.

If you're getting started and need assistance or face any bugs, join our active Discord community with over 27k+ members. It's a place where people help each other out.

βš™οΈ Tech Stack

πŸ”‹ Features

πŸ‘‰ Authentication and Authorization: Secure user access with registration, login, and route protection.

πŸ‘‰ Community Image Showcase: Explore user transformations with easy navigation using pagination

πŸ‘‰ Advanced Image Search: Find images by content or objects present inside the image quickly and accurately

πŸ‘‰ Image Restoration: Revive old or damaged images effortlessly

πŸ‘‰ Image Recoloring: Customize images by replacing objects with desired colors easily

πŸ‘‰ Image Generative Fill: Fill in missing areas of images seamlessly

πŸ‘‰ Object Removal: Clean up images by removing unwanted objects with precision

πŸ‘‰ Background Removal: Extract objects from backgrounds with ease

πŸ‘‰ Download Transformed Images: Save and share AI-transformed images conveniently

πŸ‘‰ Transformed Image Details: View details of transformations for each image

πŸ‘‰ Transformation Management: Control over deletion and updates of transformations

πŸ‘‰ Credits System: Earn or purchase credits for image transformations

πŸ‘‰ Profile Page: Access transformed images and credit information personally

πŸ‘‰ Credits Purchase: Securely buy credits via Stripe for uninterrupted use

πŸ‘‰ Responsive UI/UX: A seamless experience across devices with a user-friendly interface

and many more, including code architecture and reusability

🀸 Quick Start

Follow these steps to set up the project locally on your machine.

Prerequisites

Make sure you have the following installed on your machine:

Cloning the Repository

git clone https://github.com/adrianhajdin/imaginify.git
cd imaginify

Installation

Install the project dependencies using npm:

npm run dev

Set Up Environment Variables

Create a new file named .env.local in the root of your project and add the following content:

#NEXT
NEXT_PUBLIC_SERVER_URL=

#MONGODB
MONGODB_URL=

#CLERK
NEXT_PUBLIC_CLERK_PUBLISHABLE_KEY=
CLERK_SECRET_KEY=
WEBHOOK_SECRET=

NEXT_PUBLIC_CLERK_SIGN_IN_URL=/sign-in
NEXT_PUBLIC_CLERK_SIGN_UP_URL=/sign-up
NEXT_PUBLIC_CLERK_AFTER_SIGN_IN_URL=/
NEXT_PUBLIC_CLERK_AFTER_SIGN_UP_URL=/

#CLOUDINARY
NEXT_PUBLIC_CLOUDINARY_CLOUD_NAME=
CLOUDINARY_API_KEY=
CLOUDINARY_API_SECRET=

#STRIPE
STRIPE_SECRET_KEY=
STRIPE_WEBHOOK_SECRET=
NEXT_PUBLIC_STRIPE_PUBLISHABLE_KEY=

Replace the placeholder values with your actual respective account credentials. You can obtain these credentials by signing up on the Clerk, MongoDB, Cloudinary and Stripe

Running the Project

npm run dev

Open http://localhost:3000 in your browser to view the project.

πŸ•ΈοΈ Snippets

tailwind.config.ts ```typescript /** @type {import('tailwindcss').Config} */ module.exports = { darkMode: ["class"], content: [ "./pages/**/*.{ts,tsx}", "./components/**/*.{ts,tsx}", "./app/**/*.{ts,tsx}", "./src/**/*.{ts,tsx}", ], prefix: "", theme: { container: { center: true, padding: "2rem", screens: { "2xl": "1400px", }, }, extend: { colors: { border: "hsl(var(--border))", input: "hsl(var(--input))", ring: "hsl(var(--ring))", background: "hsl(var(--background))", foreground: "hsl(var(--foreground))", purple: { 100: "#F4F7FE", 200: "#BCB6FF", 400: "#868CFF", 500: "#7857FF", 600: "#4318FF", }, dark: { 400: "#7986AC", 500: "#606C80", 600: "#2B3674", 700: "#384262", }, primary: { DEFAULT: "hsl(var(--primary))", foreground: "hsl(var(--primary-foreground))", }, secondary: { DEFAULT: "hsl(var(--secondary))", foreground: "hsl(var(--secondary-foreground))", }, destructive: { DEFAULT: "hsl(var(--destructive))", foreground: "hsl(var(--destructive-foreground))", }, muted: { DEFAULT: "hsl(var(--muted))", foreground: "hsl(var(--muted-foreground))", }, accent: { DEFAULT: "hsl(var(--accent))", foreground: "hsl(var(--accent-foreground))", }, popover: { DEFAULT: "hsl(var(--popover))", foreground: "hsl(var(--popover-foreground))", }, card: { DEFAULT: "hsl(var(--card))", foreground: "hsl(var(--card-foreground))", }, }, fontFamily: { IBMPlex: ["var(--font-ibm-plex)"], }, backgroundImage: { "purple-gradient": "url('/assets/images/gradient-bg.svg')", banner: "url('/assets/images/banner-bg.png')", }, borderRadius: { lg: "var(--radius)", md: "calc(var(--radius) - 2px)", sm: "calc(var(--radius) - 4px)", }, keyframes: { "accordion-down": { from: { height: "0" }, to: { height: "var(--radix-accordion-content-height)" }, }, "accordion-up": { from: { height: "var(--radix-accordion-content-height)" }, to: { height: "0" }, }, }, animation: { "accordion-down": "accordion-down 0.2s ease-out", "accordion-up": "accordion-up 0.2s ease-out", }, }, }, plugins: [require("tailwindcss-animate")], }; ```
globals.css ```css @tailwind base; @tailwind components; @tailwind utilities; @layer base { :root { --background: 0 0% 100%; --foreground: 222.2 84% 4.9%; --card: 0 0% 100%; --card-foreground: 222.2 84% 4.9%; --popover: 0 0% 100%; --popover-foreground: 222.2 84% 4.9%; --primary: 222.2 47.4% 11.2%; --primary-foreground: 210 40% 98%; --secondary: 210 40% 96.1%; --secondary-foreground: 222.2 47.4% 11.2%; --muted: 210 40% 96.1%; --muted-foreground: 215.4 16.3% 46.9%; --accent: 210 40% 96.1%; --accent-foreground: 222.2 47.4% 11.2%; --destructive: 0 84.2% 60.2%; --destructive-foreground: 210 40% 98%; --border: 214.3 31.8% 91.4%; --input: 214.3 31.8% 91.4%; --ring: 222.2 84% 4.9%; --radius: 0.5rem; } .dark { --background: 222.2 84% 4.9%; --foreground: 210 40% 98%; --card: 222.2 84% 4.9%; --card-foreground: 210 40% 98%; --popover: 222.2 84% 4.9%; --popover-foreground: 210 40% 98%; --primary: 210 40% 98%; --primary-foreground: 222.2 47.4% 11.2%; --secondary: 217.2 32.6% 17.5%; --secondary-foreground: 210 40% 98%; --muted: 217.2 32.6% 17.5%; --muted-foreground: 215 20.2% 65.1%; --accent: 217.2 32.6% 17.5%; --accent-foreground: 210 40% 98%; --destructive: 0 62.8% 30.6%; --destructive-foreground: 210 40% 98%; --border: 217.2 32.6% 17.5%; --input: 217.2 32.6% 17.5%; --ring: 212.7 26.8% 83.9%; } } @layer base { * { @apply border-border; } body { @apply bg-background text-foreground; } } .auth { @apply flex-center min-h-screen w-full bg-purple-100 } .root { @apply flex min-h-screen w-full flex-col bg-white lg:flex-row; } .root-container { @apply mt-16 flex-1 overflow-auto py-8 lg:mt-0 lg:max-h-screen lg:py-10 } /* ========================================== TAILWIND STYLES */ @layer utilities { /* ===== UTILITIES */ .wrapper { @apply max-w-5xl mx-auto px-5 md:px-10 w-full text-dark-400 p-16-regular; } .gradient-text { @apply bg-purple-gradient bg-cover bg-clip-text text-transparent; } /* ===== ALIGNMENTS */ .flex-center { @apply flex justify-center items-center; } .flex-between { @apply flex justify-between items-center; } /* ===== TYPOGRAPHY */ /* 44 */ .h1-semibold { @apply text-[36px] font-semibold sm:text-[44px] leading-[120%] sm:leading-[56px]; } /* 36 */ .h2-bold { @apply text-[30px] font-bold md:text-[36px] leading-[110%]; } /* 30 */ .h3-bold { @apply font-bold text-[30px] leading-[140%]; } /* 24 */ .p-24-bold { @apply font-bold text-[24px] leading-[120%]; } /* 20 */ .p-20-semibold { @apply font-semibold text-[20px] leading-[140%]; } .p-20-regular { @apply font-normal text-[20px] leading-[140%]; } /* 18 */ .p-18-semibold { @apply font-semibold text-[18px] leading-[140%]; } /* 16 */ .p-16-semibold { @apply font-semibold text-[16px] leading-[140%]; } .p-16-medium { @apply font-medium text-[16px] leading-[140%]; } .p-16-regular { @apply font-normal text-[16px] leading-[140%]; } /* 14 */ .p-14-medium { @apply font-medium text-[14px] leading-[120%]; } /* 10 */ .p-10-medium { @apply font-medium text-[10px] leading-[140%]; } /* ===== SHADCN OVERRIDES */ .button { @apply py-4 px-6 flex-center gap-3 rounded-full p-16-semibold focus-visible:ring-offset-0 focus-visible:ring-transparent !important; } .dropdown-content { @apply shadow-lg rounded-md overflow-hidden p-0; } .dropdown-item { @apply p-16-semibold text-dark-700 cursor-pointer transition-all px-4 py-3 rounded-none outline-none hover:border-none focus-visible:ring-transparent hover:text-white hover:bg-purple-gradient hover:bg-cover focus-visible:ring-offset-0 focus-visible:outline-none !important; } .input-field { @apply rounded-[16px] border-2 border-purple-200/20 shadow-sm shadow-purple-200/15 text-dark-600 disabled:opacity-100 p-16-semibold h-[50px] md:h-[54px] focus-visible:ring-offset-0 px-4 py-3 focus-visible:ring-transparent !important; } .search-field { @apply border-0 bg-transparent text-dark-600 w-full placeholder:text-dark-400 h-[50px] p-16-medium focus-visible:ring-offset-0 p-3 focus-visible:ring-transparent !important; } .submit-button { @apply bg-purple-gradient bg-cover rounded-full py-4 px-6 p-16-semibold h-[50px] w-full md:h-[54px]; } .select-field { @apply w-full border-2 border-purple-200/20 shadow-sm shadow-purple-200/15 rounded-[16px] h-[50px] md:h-[54px] text-dark-600 p-16-semibold disabled:opacity-100 placeholder:text-dark-400/50 px-4 py-3 focus:ring-offset-0 focus-visible:ring-transparent focus:ring-transparent focus-visible:ring-0 focus-visible:outline-none !important; } .select-trigger { @apply flex items-center gap-2 py-5 capitalize focus-visible:outline-none; } .select-item { @apply py-3 cursor-pointer hover:bg-purple-100; } .IconButton { @apply focus-visible:ring-transparent focus:ring-offset-0 focus-visible:ring-offset-0 focus-visible:outline-none focus-visible:border-none !important; } .sheet-content button { @apply focus:ring-0 focus-visible:ring-transparent focus:ring-offset-0 focus-visible:ring-offset-0 focus-visible:outline-none focus-visible:border-none !important; } .success-toast { @apply bg-green-100 text-green-900; } .error-toast { @apply bg-red-100 text-red-900; } /* Home Page */ .home { @apply sm:flex-center hidden h-72 flex-col gap-4 rounded-[20px] border bg-banner bg-cover bg-no-repeat p-10 shadow-inner; } .home-heading { @apply h1-semibold max-w-[500px] flex-wrap text-center text-white shadow-sm; } /* Credits Page */ .credits-list { @apply mt-11 grid grid-cols-1 gap-5 sm:grid-cols-2 md:gap-9 xl:grid-cols-3; } .credits-item { @apply w-full rounded-[16px] border-2 border-purple-200/20 bg-white p-8 shadow-xl shadow-purple-200/20 lg:max-w-none; } .credits-btn { @apply w-full rounded-full bg-purple-100 bg-cover text-purple-500 hover:text-purple-500; } /* Profile Page */ .profile { @apply mt-5 flex flex-col gap-5 sm:flex-row md:mt-8 md:gap-10; } .profile-balance { @apply w-full rounded-[16px] border-2 border-purple-200/20 bg-white p-5 shadow-lg shadow-purple-200/10 md:px-6 md:py-8; } .profile-image-manipulation { @apply w-full rounded-[16px] border-2 border-purple-200/20 bg-white p-5 shadow-lg shadow-purple-200/10 md:px-6 md:py-8; } /* Transformation Details */ .transformation-grid { @apply grid h-fit min-h-[200px] grid-cols-1 gap-5 py-8 md:grid-cols-2; } .transformation-original_image { @apply h-fit min-h-72 w-full rounded-[10px] border border-dashed bg-purple-100/20 object-cover p-2; } /* Collection Component */ .collection-heading { @apply md:flex-between mb-6 flex flex-col gap-5 md:flex-row; } .collection-list { @apply grid grid-cols-1 gap-6 sm:grid-cols-2 xl:grid-cols-3; } .collection-empty { @apply flex-center h-60 w-full rounded-[10px] border border-dark-400/10 bg-white/20; } .collection-btn { @apply button w-32 bg-purple-gradient bg-cover text-white; } .collection-card { @apply flex flex-1 cursor-pointer flex-col gap-5 rounded-[16px] border-2 border-purple-200/15 bg-white p-4 shadow-xl shadow-purple-200/10 transition-all hover:shadow-purple-200/20; } /* MediaUploader Component */ .media-uploader_cldImage { @apply h-fit min-h-72 w-full rounded-[10px] border border-dashed bg-purple-100/20 object-cover p-2; } .media-uploader_cta { @apply flex-center flex h-72 cursor-pointer flex-col gap-5 rounded-[16px] border border-dashed bg-purple-100/20 shadow-inner; } .media-uploader_cta-image { @apply rounded-[16px] bg-white p-5 shadow-sm shadow-purple-200/50; } /* Navbar Component */ .header { @apply flex-between fixed h-16 w-full border-b-4 border-purple-100 bg-white p-5 lg:hidden; } .header-nav_elements { @apply mt-8 flex w-full flex-col items-start gap-5; } /* Search Component */ .search { @apply flex w-full rounded-[16px] border-2 border-purple-200/20 bg-white px-4 shadow-sm shadow-purple-200/15 md:max-w-96; } /* Sidebar Component */ .sidebar { @apply hidden h-screen w-72 bg-white p-5 shadow-md shadow-purple-200/50 lg:flex; } .sidebar-logo { @apply flex items-center gap-2 md:py-2; } .sidebar-nav { @apply h-full flex-col justify-between md:flex md:gap-4; } .sidebar-nav_elements { @apply hidden w-full flex-col items-start gap-2 md:flex; } .sidebar-nav_element { @apply flex-center p-16-semibold w-full whitespace-nowrap rounded-full bg-cover transition-all hover:bg-purple-100 hover:shadow-inner; } .sidebar-link { @apply p-16-semibold flex size-full gap-4 p-4; } /* TransformationForm Component */ .prompt-field { @apply flex flex-col gap-5 lg:flex-row lg:gap-10; } .media-uploader-field { @apply grid h-fit min-h-[200px] grid-cols-1 gap-5 py-4 md:grid-cols-2; } /* TransformedImage Component */ .download-btn { @apply p-14-medium mt-2 flex items-center gap-2 px-2; } .transformed-image { @apply h-fit min-h-72 w-full rounded-[10px] border border-dashed bg-purple-100/20 object-cover p-2; } .transforming-loader { @apply flex-center absolute left-[50%] top-[50%] size-full -translate-x-1/2 -translate-y-1/2 flex-col gap-2 rounded-[10px] border bg-dark-700/90; } .transformed-placeholder { @apply flex-center p-14-medium h-full min-h-72 flex-col gap-5 rounded-[16px] border border-dashed bg-purple-100/20 shadow-inner; } } /* ===== CLERK OVERRIDES */ .cl-userButtonBox { display: flex; flex-flow: row-reverse; gap: 12px; } .cl-userButtonOuterIdentifier { font-size: 16px; font-weight: 600; color: #384262; } ```
constants/index.ts ```typescript export const navLinks = [ { label: "Home", route: "/", icon: "/assets/icons/home.svg", }, { label: "Image Restore", route: "/transformations/add/restore", icon: "/assets/icons/image.svg", }, { label: "Generative Fill", route: "/transformations/add/fill", icon: "/assets/icons/stars.svg", }, { label: "Object Remove", route: "/transformations/add/remove", icon: "/assets/icons/scan.svg", }, { label: "Object Recolor", route: "/transformations/add/recolor", icon: "/assets/icons/filter.svg", }, { label: "Background Remove", route: "/transformations/add/removeBackground", icon: "/assets/icons/camera.svg", }, { label: "Profile", route: "/profile", icon: "/assets/icons/profile.svg", }, { label: "Buy Credits", route: "/credits", icon: "/assets/icons/bag.svg", }, ]; export const plans = [ { _id: 1, name: "Free", icon: "/assets/icons/free-plan.svg", price: 0, credits: 20, inclusions: [ { label: "20 Free Credits", isIncluded: true, }, { label: "Basic Access to Services", isIncluded: true, }, { label: "Priority Customer Support", isIncluded: false, }, { label: "Priority Updates", isIncluded: false, }, ], }, { _id: 2, name: "Pro Package", icon: "/assets/icons/free-plan.svg", price: 40, credits: 120, inclusions: [ { label: "120 Credits", isIncluded: true, }, { label: "Full Access to Services", isIncluded: true, }, { label: "Priority Customer Support", isIncluded: true, }, { label: "Priority Updates", isIncluded: false, }, ], }, { _id: 3, name: "Premium Package", icon: "/assets/icons/free-plan.svg", price: 199, credits: 2000, inclusions: [ { label: "2000 Credits", isIncluded: true, }, { label: "Full Access to Services", isIncluded: true, }, { label: "Priority Customer Support", isIncluded: true, }, { label: "Priority Updates", isIncluded: true, }, ], }, ]; export const transformationTypes = { restore: { type: "restore", title: "Restore Image", subTitle: "Refine images by removing noise and imperfections", config: { restore: true }, icon: "image.svg", }, removeBackground: { type: "removeBackground", title: "Background Remove", subTitle: "Removes the background of the image using AI", config: { removeBackground: true }, icon: "camera.svg", }, fill: { type: "fill", title: "Generative Fill", subTitle: "Enhance an image's dimensions using AI outpainting", config: { fillBackground: true }, icon: "stars.svg", }, remove: { type: "remove", title: "Object Remove", subTitle: "Identify and eliminate objects from images", config: { remove: { prompt: "", removeShadow: true, multiple: true }, }, icon: "scan.svg", }, recolor: { type: "recolor", title: "Object Recolor", subTitle: "Identify and recolor objects from the image", config: { recolor: { prompt: "", to: "", multiple: true }, }, icon: "filter.svg", }, }; export const aspectRatioOptions = { "1:1": { aspectRatio: "1:1", label: "Square (1:1)", width: 1000, height: 1000, }, "3:4": { aspectRatio: "3:4", label: "Standard Portrait (3:4)", width: 1000, height: 1334, }, "9:16": { aspectRatio: "9:16", label: "Phone Portrait (9:16)", width: 1000, height: 1778, }, }; export const defaultValues = { title: "", aspectRatio: "", color: "", prompt: "", publicId: "", }; export const creditFee = -1; ```
user.model.ts ```typescript import { Schema, model, models } from "mongoose"; const UserSchema = new Schema({ clerkId: { type: String, required: true, unique: true, }, email: { type: String, required: true, unique: true, }, username: { type: String, required: true, unique: true, }, photo: { type: String, required: true, }, firstName: { type: String, }, lastName: { type: String, }, planId: { type: Number, default: 1, }, creditBalance: { type: Number, default: 10, }, }); const User = models?.User || model("User", UserSchema); export default User; ```
transaction.model.ts ```typescript import { Schema, model, models } from "mongoose"; const TransactionSchema = new Schema({ createdAt: { type: Date, default: Date.now, }, stripeId: { type: String, required: true, unique: true, }, amount: { type: Number, required: true, }, plan: { type: String, }, credits: { type: Number, }, buyer: { type: Schema.Types.ObjectId, ref: "User", }, }); const Transaction = models?.Transaction || model("Transaction", TransactionSchema); export default Transaction; ```
InsufficientCreditsModal.tsx ```typescript "use client"; import Image from "next/image"; import { useRouter } from "next/navigation"; import { AlertDialog, AlertDialogAction, AlertDialogCancel, AlertDialogContent, AlertDialogDescription, AlertDialogFooter, AlertDialogHeader, AlertDialogTitle, } from "@/components/ui/alert-dialog"; export const InsufficientCreditsModal = () => { const router = useRouter(); return (

Insufficient Credits

router.push("/profile")} > credit coins
credit coins Oops.... Looks like you've run out of free credits! No worries, though - you can keep enjoying our services by grabbing more credits.
router.push("/profile")} > No, Cancel router.push("/credits")} > Yes, Proceed
); }; ```
user.action.ts ```typescript "use server"; import { revalidatePath } from "next/cache"; import User from "../database/models/user.model"; import { connectToDatabase } from "../database/mongoose"; import { handleError } from "../utils"; // CREATE export async function createUser(user: CreateUserParams) { try { await connectToDatabase(); const newUser = await User.create(user); return JSON.parse(JSON.stringify(newUser)); } catch (error) { handleError(error); } } // READ export async function getUserById(userId: string) { try { await connectToDatabase(); const user = await User.findOne({ clerkId: userId }); if (!user) throw new Error("User not found"); return JSON.parse(JSON.stringify(user)); } catch (error) { handleError(error); } } // UPDATE export async function updateUser(clerkId: string, user: UpdateUserParams) { try { await connectToDatabase(); const updatedUser = await User.findOneAndUpdate({ clerkId }, user, { new: true, }); if (!updatedUser) throw new Error("User update failed"); return JSON.parse(JSON.stringify(updatedUser)); } catch (error) { handleError(error); } } // DELETE export async function deleteUser(clerkId: string) { try { await connectToDatabase(); // Find user to delete const userToDelete = await User.findOne({ clerkId }); if (!userToDelete) { throw new Error("User not found"); } // Delete user const deletedUser = await User.findByIdAndDelete(userToDelete._id); revalidatePath("/"); return deletedUser ? JSON.parse(JSON.stringify(deletedUser)) : null; } catch (error) { handleError(error); } } // USE CREDITS export async function updateCredits(userId: string, creditFee: number) { try { await connectToDatabase(); const updatedUserCredits = await User.findOneAndUpdate( { _id: userId }, { $inc: { creditBalance: creditFee }}, { new: true } ) if(!updatedUserCredits) throw new Error("User credits update failed"); return JSON.parse(JSON.stringify(updatedUserCredits)); } catch (error) { handleError(error); } } ```
utils.ts ```typescript /* eslint-disable prefer-const */ /* eslint-disable no-prototype-builtins */ import { type ClassValue, clsx } from "clsx"; import qs from "qs"; import { twMerge } from "tailwind-merge"; import { aspectRatioOptions } from "@/constants"; export function cn(...inputs: ClassValue[]) { return twMerge(clsx(inputs)); } // ERROR HANDLER export const handleError = (error: unknown) => { if (error instanceof Error) { // This is a native JavaScript error (e.g., TypeError, RangeError) console.error(error.message); throw new Error(`Error: ${error.message}`); } else if (typeof error === "string") { // This is a string error message console.error(error); throw new Error(`Error: ${error}`); } else { // This is an unknown type of error console.error(error); throw new Error(`Unknown error: ${JSON.stringify(error)}`); } }; // PLACEHOLDER LOADER - while image is transforming const shimmer = (w: number, h: number) => ` `; const toBase64 = (str: string) => typeof window === "undefined" ? Buffer.from(str).toString("base64") : window.btoa(str); export const dataUrl = `data:image/svg+xml;base64,${toBase64( shimmer(1000, 1000) )}`; // ==== End // FORM URL QUERY export const formUrlQuery = ({ searchParams, key, value, }: FormUrlQueryParams) => { const params = { ...qs.parse(searchParams.toString()), [key]: value }; return `${window.location.pathname}?${qs.stringify(params, { skipNulls: true, })}`; }; // REMOVE KEY FROM QUERY export function removeKeysFromQuery({ searchParams, keysToRemove, }: RemoveUrlQueryParams) { const currentUrl = qs.parse(searchParams); keysToRemove.forEach((key) => { delete currentUrl[key]; }); // Remove null or undefined values Object.keys(currentUrl).forEach( (key) => currentUrl[key] == null && delete currentUrl[key] ); return `${window.location.pathname}?${qs.stringify(currentUrl)}`; } // DEBOUNCE export const debounce = (func: (...args: any[]) => void, delay: number) => { let timeoutId: NodeJS.Timeout | null; return (...args: any[]) => { if (timeoutId) clearTimeout(timeoutId); timeoutId = setTimeout(() => func.apply(null, args), delay); }; }; // GE IMAGE SIZE export type AspectRatioKey = keyof typeof aspectRatioOptions; export const getImageSize = ( type: string, image: any, dimension: "width" | "height" ): number => { if (type === "fill") { return ( aspectRatioOptions[image.aspectRatio as AspectRatioKey]?.[dimension] || 1000 ); } return image?.[dimension] || 1000; }; // DOWNLOAD IMAGE export const download = (url: string, filename: string) => { if (!url) { throw new Error("Resource URL not provided! You need to provide one"); } fetch(url) .then((response) => response.blob()) .then((blob) => { const blobURL = URL.createObjectURL(blob); const a = document.createElement("a"); a.href = blobURL; if (filename && filename.length) a.download = `${filename.replace(" ", "_")}.png`; document.body.appendChild(a); a.click(); }) .catch((error) => console.log({ error })); }; // DEEP MERGE OBJECTS export const deepMergeObjects = (obj1: any, obj2: any) => { if(obj2 === null || obj2 === undefined) { return obj1; } let output = { ...obj2 }; for (let key in obj1) { if (obj1.hasOwnProperty(key)) { if ( obj1[key] && typeof obj1[key] === "object" && obj2[key] && typeof obj2[key] === "object" ) { output[key] = deepMergeObjects(obj1[key], obj2[key]); } else { output[key] = obj1[key]; } } } return output; }; ```
types/index.d.ts ```typescript /* eslint-disable no-unused-vars */ // ====== USER PARAMS declare type CreateUserParams = { clerkId: string; email: string; username: string; firstName: string; lastName: string; photo: string; }; declare type UpdateUserParams = { firstName: string; lastName: string; username: string; photo: string; }; // ====== IMAGE PARAMS declare type AddImageParams = { image: { title: string; publicId: string; transformationType: string; width: number; height: number; config: any; secureURL: string; transformationURL: string; aspectRatio: string | undefined; prompt: string | undefined; color: string | undefined; }; userId: string; path: string; }; declare type UpdateImageParams = { image: { _id: string; title: string; publicId: string; transformationType: string; width: number; height: number; config: any; secureURL: string; transformationURL: string; aspectRatio: string | undefined; prompt: string | undefined; color: string | undefined; }; userId: string; path: string; }; declare type Transformations = { restore?: boolean; fillBackground?: boolean; remove?: { prompt: string; removeShadow?: boolean; multiple?: boolean; }; recolor?: { prompt?: string; to: string; multiple?: boolean; }; removeBackground?: boolean; }; // ====== TRANSACTION PARAMS declare type CheckoutTransactionParams = { plan: string; credits: number; amount: number; buyerId: string; }; declare type CreateTransactionParams = { stripeId: string; amount: number; credits: number; plan: string; buyerId: string; createdAt: Date; }; declare type TransformationTypeKey = | "restore" | "fill" | "remove" | "recolor" | "removeBackground"; // ====== URL QUERY PARAMS declare type FormUrlQueryParams = { searchParams: string; key: string; value: string | number | null; }; declare type UrlQueryParams = { params: string; key: string; value: string | null; }; declare type RemoveUrlQueryParams = { searchParams: string; keysToRemove: string[]; }; declare type SearchParamProps = { params: { id: string; type: TransformationTypeKey }; searchParams: { [key: string]: string | string[] | undefined }; }; declare type TransformationFormProps = { action: "Add" | "Update"; userId: string; type: TransformationTypeKey; creditBalance: number; data?: IImage | null; config?: Transformations | null; }; declare type TransformedImageProps = { image: any; type: string; title: string; transformationConfig: Transformations | null; isTransforming: boolean; hasDownload?: boolean; setIsTransforming?: React.Dispatch>; }; ```
api/webhooks/clerk/route.ts ```typescript /* eslint-disable camelcase */ import { clerkClient } from "@clerk/nextjs"; import { WebhookEvent } from "@clerk/nextjs/server"; import { headers } from "next/headers"; import { NextResponse } from "next/server"; import { Webhook } from "svix"; import { createUser, deleteUser, updateUser } from "@/lib/actions/user.actions"; export async function POST(req: Request) { // You can find this in the Clerk Dashboard -> Webhooks -> choose the webhook const WEBHOOK_SECRET = process.env.WEBHOOK_SECRET; if (!WEBHOOK_SECRET) { throw new Error( "Please add WEBHOOK_SECRET from Clerk Dashboard to .env or .env.local" ); } // Get the headers const headerPayload = headers(); const svix_id = headerPayload.get("svix-id"); const svix_timestamp = headerPayload.get("svix-timestamp"); const svix_signature = headerPayload.get("svix-signature"); // If there are no headers, error out if (!svix_id || !svix_timestamp || !svix_signature) { return new Response("Error occured -- no svix headers", { status: 400, }); } // Get the body const payload = await req.json(); const body = JSON.stringify(payload); // Create a new Svix instance with your secret. const wh = new Webhook(WEBHOOK_SECRET); let evt: WebhookEvent; // Verify the payload with the headers try { evt = wh.verify(body, { "svix-id": svix_id, "svix-timestamp": svix_timestamp, "svix-signature": svix_signature, }) as WebhookEvent; } catch (err) { console.error("Error verifying webhook:", err); return new Response("Error occured", { status: 400, }); } // Get the ID and type const { id } = evt.data; const eventType = evt.type; // CREATE if (eventType === "user.created") { const { id, email_addresses, image_url, first_name, last_name, username } = evt.data; const user = { clerkId: id, email: email_addresses[0].email_address, username: username!, firstName: first_name, lastName: last_name, photo: image_url, }; const newUser = await createUser(user); // Set public metadata if (newUser) { await clerkClient.users.updateUserMetadata(id, { publicMetadata: { userId: newUser._id, }, }); } return NextResponse.json({ message: "OK", user: newUser }); } // UPDATE if (eventType === "user.updated") { const { id, image_url, first_name, last_name, username } = evt.data; const user = { firstName: first_name, lastName: last_name, username: username!, photo: image_url, }; const updatedUser = await updateUser(id, user); return NextResponse.json({ message: "OK", user: updatedUser }); } // DELETE if (eventType === "user.deleted") { const { id } = evt.data; const deletedUser = await deleteUser(id!); return NextResponse.json({ message: "OK", user: deletedUser }); } console.log(`Webhook with and ID of ${id} and type of ${eventType}`); console.log("Webhook body:", body); return new Response("", { status: 200 }); } ```
components/shared/CustomField.tsx ```typescript import React from "react"; import { Control } from "react-hook-form"; import { z } from "zod"; import { FormField, FormItem, FormControl, FormMessage, FormLabel, } from "../ui/form"; import { formSchema } from "./TransformationForm"; type CustomFieldProps = { control: Control> | undefined; render: (props: { field: any }) => React.ReactNode; name: keyof z.infer; formLabel?: string; className?: string; }; export const CustomField = ({ control, render, name, formLabel, className, }: CustomFieldProps) => { return ( ( {formLabel && {formLabel}} {render({ field })} )} /> ); }; ```
components/shared/Collection.tsx ```typescript "use client"; import Image from "next/image"; import Link from "next/link"; import { useSearchParams, useRouter } from "next/navigation"; import { CldImage } from "next-cloudinary"; import { Pagination, PaginationContent, PaginationNext, PaginationPrevious, } from "@/components/ui/pagination"; import { transformationTypes } from "@/constants"; import { IImage } from "@/lib/database/models/image.model"; import { formUrlQuery } from "@/lib/utils"; import { Button } from "../ui/button"; import { Search } from "./Search"; export const Collection = ({ hasSearch = false, images, totalPages = 1, page, }: { images: IImage[]; totalPages?: number; page: number; hasSearch?: boolean; }) => { const router = useRouter(); const searchParams = useSearchParams(); // PAGINATION HANDLER const onPageChange = (action: string) => { const pageValue = action === "next" ? Number(page) + 1 : Number(page) - 1; const newUrl = formUrlQuery({ searchParams: searchParams.toString(), key: "page", value: pageValue, }); router.push(newUrl, { scroll: false }); }; return ( <>

Recent Edits

{hasSearch && }
{images.length > 0 ? (
    {images.map((image) => ( ))}
) : (

Empty List

)} {totalPages > 1 && (

{page} / {totalPages}

)} ); }; const Card = ({ image }: { image: IImage }) => { return (
  • {image.title}

    {image.title}
  • ); }; ```
    components/shared/Search.tsx ```typescript "use client"; import Image from "next/image"; import { useRouter, useSearchParams } from "next/navigation"; import { useEffect, useState } from "react"; import { Input } from "@/components/ui/input"; import { formUrlQuery, removeKeysFromQuery } from "@/lib/utils"; export const Search = () => { const router = useRouter(); const searchParams = useSearchParams(); const [query, setQuery] = useState(""); useEffect(() => { const delayDebounceFn = setTimeout(() => { if (query) { const newUrl = formUrlQuery({ searchParams: searchParams.toString(), key: "query", value: query, }); router.push(newUrl, { scroll: false }); } else { const newUrl = removeKeysFromQuery({ searchParams: searchParams.toString(), keysToRemove: ["query"], }); router.push(newUrl, { scroll: false }); } }, 300); return () => clearTimeout(delayDebounceFn); }, [router, searchParams, query]); return (
    search setQuery(e.target.value)} />
    ); }; ```
    image.actions.ts ```typescript "use server"; import { revalidatePath } from "next/cache"; import { connectToDatabase } from "../database/mongoose"; import { handleError } from "../utils"; import User from "../database/models/user.model"; import Image from "../database/models/image.model"; import { redirect } from "next/navigation"; import { v2 as cloudinary } from 'cloudinary' const populateUser = (query: any) => query.populate({ path: 'author', model: User, select: '_id firstName lastName clerkId' }) // ADD IMAGE export async function addImage({ image, userId, path }: AddImageParams) { try { await connectToDatabase(); const author = await User.findById(userId); if (!author) { throw new Error("User not found"); } const newImage = await Image.create({ ...image, author: author._id, }) revalidatePath(path); return JSON.parse(JSON.stringify(newImage)); } catch (error) { handleError(error) } } // UPDATE IMAGE export async function updateImage({ image, userId, path }: UpdateImageParams) { try { await connectToDatabase(); const imageToUpdate = await Image.findById(image._id); if (!imageToUpdate || imageToUpdate.author.toHexString() !== userId) { throw new Error("Unauthorized or image not found"); } const updatedImage = await Image.findByIdAndUpdate( imageToUpdate._id, image, { new: true } ) revalidatePath(path); return JSON.parse(JSON.stringify(updatedImage)); } catch (error) { handleError(error) } } // DELETE IMAGE export async function deleteImage(imageId: string) { try { await connectToDatabase(); await Image.findByIdAndDelete(imageId); } catch (error) { handleError(error) } finally{ redirect('/') } } // GET IMAGE export async function getImageById(imageId: string) { try { await connectToDatabase(); const image = await populateUser(Image.findById(imageId)); if(!image) throw new Error("Image not found"); return JSON.parse(JSON.stringify(image)); } catch (error) { handleError(error) } } // GET IMAGES export async function getAllImages({ limit = 9, page = 1, searchQuery = '' }: { limit?: number; page: number; searchQuery?: string; }) { try { await connectToDatabase(); cloudinary.config({ cloud_name: process.env.NEXT_PUBLIC_CLOUDINARY_CLOUD_NAME, api_key: process.env.CLOUDINARY_API_KEY, api_secret: process.env.CLOUDINARY_API_SECRET, secure: true, }) let expression = 'folder=imaginify'; if (searchQuery) { expression += ` AND ${searchQuery}` } const { resources } = await cloudinary.search .expression(expression) .execute(); const resourceIds = resources.map((resource: any) => resource.public_id); let query = {}; if(searchQuery) { query = { publicId: { $in: resourceIds } } } const skipAmount = (Number(page) -1) * limit; const images = await populateUser(Image.find(query)) .sort({ updatedAt: -1 }) .skip(skipAmount) .limit(limit); const totalImages = await Image.find(query).countDocuments(); const savedImages = await Image.find().countDocuments(); return { data: JSON.parse(JSON.stringify(images)), totalPage: Math.ceil(totalImages / limit), savedImages, } } catch (error) { handleError(error) } } // GET IMAGES BY USER export async function getUserImages({ limit = 9, page = 1, userId, }: { limit?: number; page: number; userId: string; }) { try { await connectToDatabase(); const skipAmount = (Number(page) - 1) * limit; const images = await populateUser(Image.find({ author: userId })) .sort({ updatedAt: -1 }) .skip(skipAmount) .limit(limit); const totalImages = await Image.find({ author: userId }).countDocuments(); return { data: JSON.parse(JSON.stringify(images)), totalPages: Math.ceil(totalImages / limit), }; } catch (error) { handleError(error); } } ```
    transformations/[id]/page.tsx ```typescript import { auth } from "@clerk/nextjs"; import Image from "next/image"; import Link from "next/link"; import Header from "@/components/shared/Header"; import TransformedImage from "@/components/shared/TransformedImage"; import { Button } from "@/components/ui/button"; import { getImageById } from "@/lib/actions/image.actions"; import { getImageSize } from "@/lib/utils"; import { DeleteConfirmation } from "@/components/shared/DeleteConfirmation"; const ImageDetails = async ({ params: { id } }: SearchParamProps) => { const { userId } = auth(); const image = await getImageById(id); return ( <>

    Transformation:

    {image.transformationType}

    {image.prompt && ( <>

    Prompt:

    {image.prompt}

    )} {image.color && ( <>

    Color:

    {image.color}

    )} {image.aspectRatio && ( <>

    Aspect Ratio:

    {image.aspectRatio}

    )}
    {/* MEDIA UPLOADER */}

    Original

    image
    {/* TRANSFORMED IMAGE */}
    {userId === image.author.clerkId && (
    )}
    ); }; export default ImageDetails; ```
    transformations/[id]/update/page.tsx ```typescript import { auth } from "@clerk/nextjs"; import { redirect } from "next/navigation"; import Header from "@/components/shared/Header"; import TransformationForm from "@/components/shared/TransformationForm"; import { transformationTypes } from "@/constants"; import { getUserById } from "@/lib/actions/user.actions"; import { getImageById } from "@/lib/actions/image.actions"; const Page = async ({ params: { id } }: SearchParamProps) => { const { userId } = auth(); if (!userId) redirect("/sign-in"); const user = await getUserById(userId); const image = await getImageById(id); const transformation = transformationTypes[image.transformationType as TransformationTypeKey]; return ( <>
    ); }; export default Page; ```
    components/shared/DeleteConfirmation.tsx ```typescript "use client"; import { useTransition } from "react"; import { AlertDialog, AlertDialogAction, AlertDialogCancel, AlertDialogContent, AlertDialogDescription, AlertDialogFooter, AlertDialogHeader, AlertDialogTitle, AlertDialogTrigger, } from "@/components/ui/alert-dialog"; import { deleteImage } from "@/lib/actions/image.actions"; import { Button } from "../ui/button"; export const DeleteConfirmation = ({ imageId }: { imageId: string }) => { const [isPending, startTransition] = useTransition(); return ( Are you sure you want to delete this image? This will permanently delete this image Cancel startTransition(async () => { await deleteImage(imageId); }) } > {isPending ? "Deleting..." : "Delete"} ); }; ```
    api/webhooks/stripe/route.ts ```typescript /* eslint-disable camelcase */ import { createTransaction } from "@/lib/actions/transaction.action"; import { NextResponse } from "next/server"; import stripe from "stripe"; export async function POST(request: Request) { const body = await request.text(); const sig = request.headers.get("stripe-signature") as string; const endpointSecret = process.env.STRIPE_WEBHOOK_SECRET!; let event; try { event = stripe.webhooks.constructEvent(body, sig, endpointSecret); } catch (err) { return NextResponse.json({ message: "Webhook error", error: err }); } // Get the ID and type const eventType = event.type; // CREATE if (eventType === "checkout.session.completed") { const { id, amount_total, metadata } = event.data.object; const transaction = { stripeId: id, amount: amount_total ? amount_total / 100 : 0, plan: metadata?.plan || "", credits: Number(metadata?.credits) || 0, buyerId: metadata?.buyerId || "", createdAt: new Date(), }; const newTransaction = await createTransaction(transaction); return NextResponse.json({ message: "OK", transaction: newTransaction }); } return new Response("", { status: 200 }); } ```
    credits/page.tsx ```typescript import { SignedIn, auth } from "@clerk/nextjs"; import Image from "next/image"; import { redirect } from "next/navigation"; import Header from "@/components/shared/Header"; import { Button } from "@/components/ui/button"; import { plans } from "@/constants"; import { getUserById } from "@/lib/actions/user.actions"; import Checkout from "@/components/shared/Checkout"; const Credits = async () => { const { userId } = auth(); if (!userId) redirect("/sign-in"); const user = await getUserById(userId); return ( <>
      {plans.map((plan) => (
    • check

      {plan.name}

      ${plan.price}

      {plan.credits} Credits

      {/* Inclusions */}
        {plan.inclusions.map((inclusion) => (
      • check

        {inclusion.label}

      • ))}
      {plan.name === "Free" ? ( ) : ( )}
    • ))}
    ); }; export default Credits; ```
    components/shared/Checkout.tsx ```typescript "use client"; import { loadStripe } from "@stripe/stripe-js"; import { useEffect } from "react"; import { useToast } from "@/components/ui/use-toast"; import { checkoutCredits } from "@/lib/actions/transaction.action"; import { Button } from "../ui/button"; const Checkout = ({ plan, amount, credits, buyerId, }: { plan: string; amount: number; credits: number; buyerId: string; }) => { const { toast } = useToast(); useEffect(() => { loadStripe(process.env.NEXT_PUBLIC_STRIPE_PUBLISHABLE_KEY!); }, []); useEffect(() => { // Check to see if this is a redirect back from Checkout const query = new URLSearchParams(window.location.search); if (query.get("success")) { toast({ title: "Order placed!", description: "You will receive an email confirmation", duration: 5000, className: "success-toast", }); } if (query.get("canceled")) { toast({ title: "Order canceled!", description: "Continue to shop around and checkout when you're ready", duration: 5000, className: "error-toast", }); } }, []); const onCheckout = async () => { const transaction = { plan, amount, credits, buyerId, }; await checkoutCredits(transaction); }; return (
    ); }; export default Checkout; ```
    profile/page.tsx ```typescript import { auth } from "@clerk/nextjs"; import Image from "next/image"; import { redirect } from "next/navigation"; import { Collection } from "@/components/shared/Collection"; import Header from "@/components/shared/Header"; import { getUserImages } from "@/lib/actions/image.actions"; import { getUserById } from "@/lib/actions/user.actions"; const Profile = async ({ searchParams }: SearchParamProps) => { const page = Number(searchParams?.page) || 1; const { userId } = auth(); if (!userId) redirect("/sign-in"); const user = await getUserById(userId); const images = await getUserImages({ page, userId: user._id }); return ( <>

    CREDITS AVAILABLE

    coins

    {user.creditBalance}

    IMAGE MANIPULATION DONE

    coins

    {images?.data.length}

    ); }; export default Profile; ```

    πŸ”— Links

    Public Assets used in the project can be found here

    πŸš€ More

    Advance your skills with Next.js 14 Pro Course

    Enjoyed creating this project? Dive deeper into our PRO courses for a richer learning adventure. They're packed with detailed explanations, cool features, and exercises to boost your skills. Give it a go!

    Project Banner



    Accelerate your professional journey with the Expert Training program

    And if you're hungry for more than just a course and want to understand how we learn and tackle tech challenges, hop into our personalized masterclass. We cover best practices, different web skills, and offer mentorship to boost your confidence. Let's learn and grow together!

    Project Banner

    #