Ecosyste.ms: Awesome

An open API service indexing awesome lists of open source software.

Awesome Lists | Featured Topics | Projects

https://github.com/faridzeynalov2000/ai_saas_app

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://github.com/faridzeynalov2000/ai_saas_app

clerk cloudinary next14 nextjs saas

Last synced: about 1 month ago
JSON representation

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.

Awesome Lists containing this project

README

        



nextdotjs
typescript
stripe
mongodb
tailwindcss

An AI SaaS Platform


## Table of Contents

1. [Introduction](#introduction)
2. [Tech Stack](#tech-stack)
3. [Features](#features)
4. [Quick Start](#quick-start)
5. [Snippets](#snippets)
6. [Links](#links)

## πŸ€– 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.

## βš™οΈ Tech Stack

- Next.js
- TypeScript
- MongoDB
- Clerk
- Cloudinary
- Stripe
- Shadcn
- TailwindCSS

## πŸ”‹ 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:

- [Git](https://git-scm.com/)
- [Node.js](https://nodejs.org/en)
- [npm](https://www.npmjs.com/) (Node Package Manager)

**Set Up Environment Variables**

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

```env
#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](https://clerk.com/), [MongoDB](https://www.mongodb.com/), [Cloudinary](https://cloudinary.com/) and [Stripe](https://stripe.com)

**Running the Project**

```bash
npm run dev
```

## πŸ•ΈοΈ 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")}
>



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 && (


onPageChange("prev")}
>


{page} / {totalPages}

onPageChange("next")}
disabled={Number(page) >= totalPages}
>




)}
>
);
};

const Card = ({ image }: { image: IImage }) => {
return (






  • {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 (


    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


    {/* TRANSFORMED IMAGE */}

    {userId === image.author.clerkId && (




    Update Image



    )}

    >
    );
    };

    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 (



    Delete Image




    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) => (




    • {plan.name}


      ${plan.price}


      {plan.credits} Credits


      {/* Inclusions */}


        {plan.inclusions.map((inclusion) => (


      • {inclusion.label}



      • ))}

      {plan.name === "Free" ? (

      Free Consumable

      ) : (



      )}


    • ))}


    >
    );
    };

    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 (



    Buy Credit



    );
    };

    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




    {user.creditBalance}




    IMAGE MANIPULATION DONE




    {images?.data.length}







    >
    );
    };

    export default Profile;
    ```

    ## πŸ”— Links

    Public Assets used in the project can be found [here](https://drive.google.com/file/d/1uv1zyCjbYBQE9qnwh2snwO0NBgoop5gz/view?usp=sharing)