Skip to content

Commit

Permalink
checkout using midtrans done
Browse files Browse the repository at this point in the history
  • Loading branch information
fachrihawari committed Sep 26, 2024
1 parent adea126 commit 6be5714
Show file tree
Hide file tree
Showing 21 changed files with 392 additions and 60 deletions.
5 changes: 4 additions & 1 deletion .env.example
Original file line number Diff line number Diff line change
Expand Up @@ -3,4 +3,7 @@ MONGO_DATABASE=dbname
TOTAL_PRODUCTS=2000
PORT=3000
NEXT_PUBLIC_URL=http://localhost:3000
JWT_SECRET=secret
JWT_SECRET=secret
MIDTRANS_SERVER_KEY=server_key
MIDTRANS_CLIENT_KEY=client_key
NEXT_PUBLIC_MIDTRANS_CLIENT_KEY=client_key
Binary file modified bun.lockb
Binary file not shown.
6 changes: 6 additions & 0 deletions env.d.ts
Original file line number Diff line number Diff line change
Expand Up @@ -4,6 +4,12 @@ declare global {
MONGO_URI: string;
MONGO_DATABASE: string;
TOTAL_PRODUCTS: number;
PORT: number;
NEXT_PUBLIC_URL: string;
JWT_SECRET: string;
MIDTRANS_SERVER_KEY: string;
MIDTRANS_CLIENT_KEY: string;
NEXT_PUBLIC_MIDTRANS_CLIENT_KEY: string;
}
}
}
Expand Down
1 change: 1 addition & 0 deletions package.json
Original file line number Diff line number Diff line change
Expand Up @@ -14,6 +14,7 @@
"@faker-js/faker": "^9.0.0",
"bcryptjs": "^2.4.3",
"jose": "^5.9.2",
"midtrans-client": "^1.3.1",
"mongodb": "^6.8.1",
"next": "14.2.7",
"react": "^18",
Expand Down
40 changes: 7 additions & 33 deletions src/app/(main)/cart/components/CartItems.tsx
Original file line number Diff line number Diff line change
@@ -1,4 +1,3 @@
import { formatCurrency } from "@/lib/utils/number";
import type { CartItem as CartItemType } from "@/lib/db/cart_collection";
import { CartItem } from "./CartItem";

Expand All @@ -7,39 +6,14 @@ type CartItemsProps = {
}

export default function CartItems({ cartItems }: CartItemsProps) {
const totalPrice = cartItems.reduce((total, item) => total + item.price * item.quantity, 0);
return (
<div className="grid grid-cols-1 md:grid-cols-3 gap-6">
<div className="md:col-span-2 bg-white rounded-lg flex flex-col gap-4">
{cartItems.map((item) => (
<CartItem
key={item.productId.toString()}
item={item}
/>
))}
</div>
<div className="bg-white rounded-lg shadow-sm border border-gray-200 p-6 sticky top-24 self-start">
<h2 className="text-2xl font-bold mb-4">Order Summary</h2>
<div className="space-y-4">
<div className="flex justify-between items-center">
<span className="text-gray-600">Subtotal</span>
<span className="font-semibold">{formatCurrency(totalPrice)}</span>
</div>
<div className="flex justify-between items-center">
<span className="text-gray-600">Shipping</span>
<span className="font-semibold">Free</span>
</div>
<div className="border-t border-gray-200 pt-4">
<div className="flex justify-between items-center">
<span className="text-lg font-semibold">Total</span>
<span className="text-2xl font-bold text-green-600">{formatCurrency(totalPrice)}</span>
</div>
</div>
</div>
<button className="mt-6 w-full bg-green-500 text-white py-3 rounded-lg hover:bg-green-600 transition-colors duration-300 font-semibold">
Proceed to Checkout
</button>
</div>
<div className="md:col-span-2 bg-white rounded-lg flex flex-col gap-4">
{cartItems.map((item) => (
<CartItem
key={item.productId.toString()}
item={item}
/>
))}
</div>
);
}
43 changes: 43 additions & 0 deletions src/app/(main)/cart/components/CartPlaceholder.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,43 @@
export default function CartItemsPlaceholder() {
return (
<div className="grid grid-cols-1 md:grid-cols-3 gap-6">
<div className="md:col-span-2 bg-white rounded-lg flex flex-col gap-4">
{[...new Array(3)].map((_, index) => (
<div key={index} className="flex items-center p-4 border border-gray-200 rounded-lg mb-4 last:mb-0">
<div className="w-20 h-20 bg-gray-200 rounded-md mr-4"></div>
<div className="flex-grow">
<div className="h-5 bg-gray-200 rounded w-3/4 mb-2"></div>
<div className="h-4 bg-gray-200 rounded w-1/2 mb-2"></div>
<div className="h-4 bg-gray-200 rounded w-1/4"></div>
</div>
<div className="flex flex-col items-end">
<div className="h-5 bg-gray-200 rounded w-20 mb-2"></div>
<div className="h-8 bg-gray-200 rounded w-24"></div>
</div>
</div>
))}
</div>
<div className="bg-white rounded-lg shadow-sm border border-gray-200 p-6 sticky top-24 self-start">
<div className="h-6 bg-gray-200 rounded w-3/4 mb-4"></div>
<div className="space-y-4">
<div className="flex justify-between items-center">
<div className="h-4 bg-gray-200 rounded w-1/4"></div>
<div className="h-4 bg-gray-200 rounded w-1/6"></div>
</div>
<div className="flex justify-between items-center">
<div className="h-4 bg-gray-200 rounded w-1/5"></div>
<div className="h-4 bg-gray-200 rounded w-1/8"></div>
</div>
<div className="pt-4 border-t border-gray-200">
<div className="flex justify-between items-center">
<div className="h-5 bg-gray-200 rounded w-1/4"></div>
<div className="h-5 bg-gray-200 rounded w-1/5"></div>
</div>
</div>
</div>
<div className="h-10 bg-gray-200 rounded w-full mt-6"></div>
</div>
</div>
)
}

69 changes: 69 additions & 0 deletions src/app/(main)/cart/components/CartSummary.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,69 @@
'use client'

import { formatCurrency } from "@/lib/utils/number";
import type { CartItem as CartItemType } from "@/lib/db/cart_collection";
import { checkout } from "@/lib/actions/orders";
import { useEffect } from "react";
import { useFormState, useFormStatus } from "react-dom";
import { useMidtrans } from "@/lib/hooks/useMidtrans";

type CartSummaryProps = {
cartItems: CartItemType[]
}

function CheckoutButton() {
const { pending } = useFormStatus()
const className = pending ? "bg-gray-500" : "bg-green-500 hover:bg-green-600 "
return (
<button disabled={pending} className={`mt-6 w-full text-white py-3 rounded-lg transition-colors duration-300 font-semibold ${className}`}>
{pending ? 'Loading...' : 'Proceed to Checkout'}
</button>
)
}

type CheckoutState = {
paymentToken: string
}

export default function CartSummary({ cartItems }: CartSummaryProps) {
const { isReady, pay } = useMidtrans({
onClose: () => {
console.log("close")
}
})
const [checkoutState, handleCheckout] = useFormState<CheckoutState>(checkout, {
paymentToken: ""
})
const totalPrice = cartItems.reduce((total, item) => total + item.price * item.quantity, 0);

useEffect(() => {
if (checkoutState.paymentToken && isReady) {
pay(checkoutState.paymentToken)
}
}, [checkoutState.paymentToken, isReady, pay])

return (
<div className="bg-white rounded-lg shadow-sm border border-gray-200 p-6 sticky top-24 self-start">
<h2 className="text-2xl font-bold mb-4">Order Summary</h2>
<div className="space-y-4">
<div className="flex justify-between items-center">
<span className="text-gray-600">Subtotal</span>
<span className="font-semibold">{formatCurrency(totalPrice)}</span>
</div>
<div className="flex justify-between items-center">
<span className="text-gray-600">Shipping</span>
<span className="font-semibold">Free</span>
</div>
<div className="border-t border-gray-200 pt-4">
<div className="flex justify-between items-center">
<span className="text-lg font-semibold">Total</span>
<span className="text-2xl font-bold text-green-600">{formatCurrency(totalPrice)}</span>
</div>
</div>
</div>
<form action={handleCheckout}>
<CheckoutButton />
</form>
</div>
);
}
10 changes: 10 additions & 0 deletions src/app/(main)/cart/loading.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,10 @@
import CartPlaceholder from "./components/CartPlaceholder";

export default function CartLoading() {
return (
<div className="container mx-auto px-4 py-8">
<h1 className="text-2xl font-bold mb-6">Shopping Cart</h1>
<CartPlaceholder />
</div>
)
}
12 changes: 9 additions & 3 deletions src/app/(main)/cart/page.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -3,18 +3,24 @@ import CartEmpty from './components/CartEmpty';
import CartItems from './components/CartItems';
import { getCart } from '@/lib/actions/cart';
import { isLoggedIn } from '@/lib/utils/auth';
import CartSummary from './components/CartSummary';

export default async function CartPage() {
const cart = await getCart();

let content;

if (!isLoggedIn()) {
content = <CartNotLoggedIn />
} else if (cart.items.length === 0) {
content = <CartEmpty />
} else {
content = <CartItems cartItems={cart.items} />
content = (
<div className="grid grid-cols-1 md:grid-cols-3 gap-6">
<CartItems cartItems={cart.items} />
<CartSummary cartItems={cart.items} />
</div>
)
}

return (
Expand Down
85 changes: 85 additions & 0 deletions src/app/api/orders/route.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,85 @@
import { ObjectId } from "mongodb";
import { OrderForm, OrderSchema, ordersCollection } from "@/lib/db/order_collection";
import { NextRequest, NextResponse } from "next/server";
import { cartsCollection } from "@/lib/db/cart_collection";
import midtransClient from "midtrans-client";
import { usersCollection } from "@/lib/db/user_collection";

export async function GET(req: NextRequest) {
const userId = String(req.headers.get("x-user-id"))
const orders = await ordersCollection.find({ userId: new ObjectId(userId) }).toArray()
return NextResponse.json(orders)
}

export async function POST(req: NextRequest) {
const userId = String(req.headers.get("x-user-id"))

try {
const user = await usersCollection.findOne({ _id: new ObjectId(userId) })
if (!user) {
return NextResponse.json({ error: "User not found" }, { status: 404 })
}

const cart = await cartsCollection.findOne({ userId: new ObjectId(userId) })
if (!cart) {
return NextResponse.json({ error: "Cart not found" }, { status: 404 })
}

// Create a new order
const newOrder: OrderForm = {
userId: new ObjectId(userId),
products: cart.items,
total: cart.items.reduce((acc, item) => acc + item.price * item.quantity, 0),
status: "pending",
createdAt: new Date(),
updatedAt: new Date()
};

OrderSchema.parse(newOrder)

// Insert the order into the database
const result = await ordersCollection.insertOne(newOrder);

// Prepare Midtrans payment request
const snap = new midtransClient.Snap({
isProduction: process.env.NODE_ENV === "production",
serverKey: process.env.MIDTRANS_SERVER_KEY,
clientKey: process.env.MIDTRANS_CLIENT_KEY
});

const transactionDetails = {
order_id: result.insertedId.toString(),
gross_amount: newOrder.total
};

const itemDetails = newOrder.products.map((product: any) => ({
id: product.productId,
price: product.price,
quantity: product.quantity,
name: product.name
}));

const customerDetails = {
first_name: user.name,
email: user.email
};

const midtransParameter = {
transaction_details: transactionDetails,
item_details: itemDetails,
customer_details: customerDetails
};

// Create Midtrans transaction
const transaction = await snap.createTransaction(midtransParameter);

// Return the Midtrans token
return NextResponse.json({
orderId: result.insertedId,
paymentToken: transaction.token
}, { status: 201 });

} catch (error) {
return NextResponse.json({ error: "Failed to create order" }, { status: 500 });
}
}
Empty file.
21 changes: 12 additions & 9 deletions src/components/SweetAlert.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -15,20 +15,23 @@ function SweetAlert() {
useEffect(() => {
if (message && type) {
const isError = type === 'error'

const messageArray = message.split(',')

const titleHtml = isError && messageArray.length > 1
? `<p class="mb-2 text-gray-700">Please correct the following ${message.includes(',') ? 'errors' : 'error'}:</p>` : ''
const messageHtml = messageArray.length > 1 ?
`<ul class="list-disc list-inside space-y-1">
${messageArray.map((msg) => `<li>${msg.trim()}</li>`).join('')}
</ul>` : `<p class="text-center">${messageArray[0]}</p>`

Swal.fire({
icon: isError ? 'error' : 'success',
title: title || (isError ? 'Error' : 'Success'),
html: `
<div class="text-left">
${isError
? `<p class="mb-2 text-gray-700">Please correct the following ${message.includes(',') ? 'errors' : 'error'}:</p>
<ul class="list-disc list-inside space-y-1">
${message.split(',').map((msg) => `<li>${msg.trim()}</li>`).join('')}
</ul>`
: `<p class="text-center">${message}</p>`
}
</div>
${titleHtml}
${messageHtml}
</div >
`,
customClass: {
popup: 'rounded-lg shadow-xl border border-gray-200',
Expand Down
29 changes: 29 additions & 0 deletions src/lib/actions/orders.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,29 @@
'use server'

import { cookies } from "next/headers";
import { setQueryParams } from "../utils/url";
import { redirect } from "next/navigation";

const NEXT_PUBLIC_URL = process.env.NEXT_PUBLIC_URL

export const checkout = async () => {
await new Promise(resolve => setTimeout(resolve, 3000))
return {
paymentToken: "f39dbe10-2dc5-4b41-8359-a17d6dc2e5eb"
}

const res = await fetch(`${NEXT_PUBLIC_URL}/api/orders`, {
method: 'POST',
headers: {
Cookie: cookies().toString()
}
});

if (!res.ok) {
const errorQuery = setQueryParams({ type: 'error', title: "Error", message: "Failed to checkout" });
return redirect('/cart?' + errorQuery);
}

const data = await res.json()
return data
}
Loading

0 comments on commit 6be5714

Please sign in to comment.