Ecosyste.ms: Awesome
An open API service indexing awesome lists of open source software.
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.
- Host: GitHub
- URL: https://github.com/faridzeynalov2000/ai_saas_app
- Owner: faridzeynalov2000
- Created: 2024-06-16T14:07:46.000Z (6 months ago)
- Default Branch: main
- Last Pushed: 2024-06-23T13:37:52.000Z (6 months ago)
- Last Synced: 2024-06-23T14:49:27.619Z (6 months ago)
- Topics: clerk, cloudinary, next14, nextjs, saas
- Language: TypeScript
- Homepage:
- Size: 400 KB
- Stars: 0
- Watchers: 1
- Forks: 0
- Open Issues: 0
-
Metadata Files:
- Readme: README.md
Awesome Lists containing this project
README
An AI SaaS Platform
1. [Introduction](#introduction)
2. [Tech Stack](#tech-stack)
3. [Features](#features)
4. [Quick Start](#quick-start)
5. [Snippets](#snippets)
6. [Links](#links)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.
- Next.js
- TypeScript
- MongoDB
- Clerk
- Cloudinary
- Stripe
- Shadcn
- TailwindCSSπ **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
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
```
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 (
<>
-
{plan.name}
${plan.price}
{plan.credits} Credits
{/* Inclusions */}
-
{inclusion.label}
{plan.inclusions.map((inclusion) => (
))}
{plan.name === "Free" ? (
Free Consumable
) : (
)}
-
{plans.map((plan) => (
))}
>
);
};
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)