From b79474412d42bddd7fd830b96f46faa0c7b44fab Mon Sep 17 00:00:00 2001 From: GustavH Date: Sun, 1 Dec 2024 17:57:39 +0100 Subject: [PATCH] Add checkout and confirmation pages, implement order service, and update routing --- package-lock.json | 24 +++ package.json | 1 + .../checkout/_components/CheckoutPage.tsx | 161 ++++++++++++++++++ .../checkout/_components/ConfirmationPage.tsx | 61 +++++++ .../(client)/checkout/confirmation/page.tsx | 5 + src/app/(client)/checkout/page.tsx | 5 + src/components/ui/separator.tsx | 31 ++++ src/components/webshop/CartSheet.tsx | 32 +++- src/context/AuthContext.tsx | 2 +- src/services/orderService.ts | 32 ++++ src/types/order.ts | 37 ++++ 11 files changed, 388 insertions(+), 3 deletions(-) create mode 100644 src/app/(client)/checkout/_components/CheckoutPage.tsx create mode 100644 src/app/(client)/checkout/_components/ConfirmationPage.tsx create mode 100644 src/app/(client)/checkout/confirmation/page.tsx create mode 100644 src/app/(client)/checkout/page.tsx create mode 100644 src/components/ui/separator.tsx create mode 100644 src/services/orderService.ts create mode 100644 src/types/order.ts diff --git a/package-lock.json b/package-lock.json index 54cde5f..297455e 100644 --- a/package-lock.json +++ b/package-lock.json @@ -13,6 +13,7 @@ "@radix-ui/react-dialog": "^1.1.2", "@radix-ui/react-label": "^2.1.0", "@radix-ui/react-select": "^2.1.2", + "@radix-ui/react-separator": "^1.1.0", "@radix-ui/react-slot": "^1.1.0", "@radix-ui/react-toast": "^1.2.2", "axios": "^1.7.7", @@ -1330,6 +1331,29 @@ } } }, + "node_modules/@radix-ui/react-separator": { + "version": "1.1.0", + "resolved": "https://registry.npmjs.org/@radix-ui/react-separator/-/react-separator-1.1.0.tgz", + "integrity": "sha512-3uBAs+egzvJBDZAzvb/n4NxxOYpnspmWxO2u5NbZ8Y6FM/NdrGSF9bop3Cf6F6C71z1rTSn8KV0Fo2ZVd79lGA==", + "license": "MIT", + "dependencies": { + "@radix-ui/react-primitive": "2.0.0" + }, + "peerDependencies": { + "@types/react": "*", + "@types/react-dom": "*", + "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc", + "react-dom": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" + }, + "peerDependenciesMeta": { + "@types/react": { + "optional": true + }, + "@types/react-dom": { + "optional": true + } + } + }, "node_modules/@radix-ui/react-slot": { "version": "1.1.0", "resolved": "https://registry.npmjs.org/@radix-ui/react-slot/-/react-slot-1.1.0.tgz", diff --git a/package.json b/package.json index 65e62b0..1cb2076 100644 --- a/package.json +++ b/package.json @@ -14,6 +14,7 @@ "@radix-ui/react-dialog": "^1.1.2", "@radix-ui/react-label": "^2.1.0", "@radix-ui/react-select": "^2.1.2", + "@radix-ui/react-separator": "^1.1.0", "@radix-ui/react-slot": "^1.1.0", "@radix-ui/react-toast": "^1.2.2", "axios": "^1.7.7", diff --git a/src/app/(client)/checkout/_components/CheckoutPage.tsx b/src/app/(client)/checkout/_components/CheckoutPage.tsx new file mode 100644 index 0000000..f71ad31 --- /dev/null +++ b/src/app/(client)/checkout/_components/CheckoutPage.tsx @@ -0,0 +1,161 @@ +"use client"; + +import React, { useState } from 'react'; +import { useRouter } from 'next/navigation'; +import { useCart } from '@/context/CartContext'; +import { useAuth } from '@/context/AuthContext'; +import { orderService } from '@/services/orderService'; +import { Button } from "@/components/ui/button"; +import { + Card, + CardContent, + CardDescription, + CardFooter, + CardHeader, + CardTitle, +} from "@/components/ui/card"; +import { useToast } from '@/hooks/use-toast'; +import { Separator } from '@/components/ui/separator'; + +export function CheckoutPage() { + const { cart, totalPrice, clearCart } = useCart(); + const { user } = useAuth(); + const { toast } = useToast(); + const router = useRouter(); + const [isProcessing, setIsProcessing] = useState(false); + + const handleCheckout = async () => { + if (!user) { + toast({ + title: "Please log in", + description: "You need to be logged in to checkout", + variant: "destructive", + }); + router.push('/auth/login'); + return; + } + + try { + setIsProcessing(true); + + // Create order lines from cart + const orderLines = cart.map(item => ({ + productId: item.id, + quantity: item.quantity, + ...(item.selectedVariant && { variantId: item.selectedVariant.id }), + })); + + // Create order + const orderResponse = await orderService.createOrder(user.id, { + currency: "DKK", + orderLines, + }); + + // Create MobilePay payment + const paymentResponse = await orderService.createMobilePayPayment({ + orderId: orderResponse.order.id.toString(), + currency: "DKK", + paymentMethod: "WALLET", + returnUrl: `${window.location.origin}/checkout/confirmation`, + }); + + // Clear cart and redirect to MobilePay + clearCart(); + window.location.href = paymentResponse.redirectUrl; + + } catch (error) { + toast({ + title: "Checkout failed", + description: error instanceof Error ? error.message : "Please try again", + variant: "destructive", + }); + } finally { + setIsProcessing(false); + } + }; + + const formatPrice = (amount: number) => { + return new Intl.NumberFormat('da-DK', { + style: 'currency', + currency: 'DKK', + }).format(amount); + }; + + if (cart.length === 0) { + return ( +
+

Your cart is empty

+ +
+ ); + } + + return ( +
+
+ {/* Order Summary */} +
+ + + Order Summary + Review your items + + + {cart.map((item) => ( +
+
+

{item.name}

+ {item.selectedVariant && ( +

+ Size: {item.selectedVariant.options[0].value} +

+ )} +

+ Quantity: {item.quantity} +

+
+

+ {formatPrice(item.price.amount * item.quantity)} +

+
+ ))} +
+ + +
+

Total

+

{formatPrice(totalPrice)}

+
+
+
+
+ + {/* Payment */} +
+ + + Payment + Choose your payment method + + +

+ You will be redirected to MobilePay to complete your payment +

+
+ + + +
+
+
+
+ ); +} \ No newline at end of file diff --git a/src/app/(client)/checkout/_components/ConfirmationPage.tsx b/src/app/(client)/checkout/_components/ConfirmationPage.tsx new file mode 100644 index 0000000..9eb5411 --- /dev/null +++ b/src/app/(client)/checkout/_components/ConfirmationPage.tsx @@ -0,0 +1,61 @@ +"use client"; + +import React from 'react'; +import { useRouter, useSearchParams } from 'next/navigation'; +import { Card, CardHeader, CardTitle, CardContent } from "@/components/ui/card"; +import { Button } from "@/components/ui/button"; +import { CheckCircle, XCircle } from "lucide-react"; + +export function ConfirmationPage() { + const router = useRouter(); + const searchParams = useSearchParams(); + const success = searchParams.get('success') === 'true'; + const orderId = searchParams.get('orderId'); + + return ( +
+ + +
+ {success ? ( + + ) : ( + + )} +
+ + {success ? 'Order Confirmed' : 'Payment Failed'} + + {orderId && success && ( +

+ Order #{orderId} +

+ )} +
+ +

+ {success + ? 'Thank you for your order. You will receive a confirmation email shortly.' + : 'Sorry, your payment was not successful. Please try again.'} +

+
+ {!success && ( + + )} + +
+
+
+
+ ); +} \ No newline at end of file diff --git a/src/app/(client)/checkout/confirmation/page.tsx b/src/app/(client)/checkout/confirmation/page.tsx new file mode 100644 index 0000000..0ec4d7f --- /dev/null +++ b/src/app/(client)/checkout/confirmation/page.tsx @@ -0,0 +1,5 @@ +import { ConfirmationPage } from "../_components/ConfirmationPage"; + +export default function Page() { + return ; +} \ No newline at end of file diff --git a/src/app/(client)/checkout/page.tsx b/src/app/(client)/checkout/page.tsx new file mode 100644 index 0000000..1463880 --- /dev/null +++ b/src/app/(client)/checkout/page.tsx @@ -0,0 +1,5 @@ +import { CheckoutPage } from "./_components/CheckoutPage"; + +export default function Page() { + return ; +} \ No newline at end of file diff --git a/src/components/ui/separator.tsx b/src/components/ui/separator.tsx new file mode 100644 index 0000000..12d81c4 --- /dev/null +++ b/src/components/ui/separator.tsx @@ -0,0 +1,31 @@ +"use client" + +import * as React from "react" +import * as SeparatorPrimitive from "@radix-ui/react-separator" + +import { cn } from "@/lib/utils" + +const Separator = React.forwardRef< + React.ElementRef, + React.ComponentPropsWithoutRef +>( + ( + { className, orientation = "horizontal", decorative = true, ...props }, + ref + ) => ( + + ) +) +Separator.displayName = SeparatorPrimitive.Root.displayName + +export { Separator } diff --git a/src/components/webshop/CartSheet.tsx b/src/components/webshop/CartSheet.tsx index 0452e33..19ddf57 100644 --- a/src/components/webshop/CartSheet.tsx +++ b/src/components/webshop/CartSheet.tsx @@ -1,6 +1,7 @@ "use client"; import React from 'react'; +import { useRouter } from 'next/navigation'; import { useCart } from '@/context/CartContext'; import { Button } from "@/components/ui/button"; import { @@ -9,15 +10,22 @@ import { SheetHeader, SheetTitle, SheetTrigger, + SheetClose, } from "@/components/ui/sheet"; import { ShoppingCart, Minus, Plus, Trash2 } from "lucide-react"; import { Badge } from "@/components/ui/badge"; +import { useAuth } from '@/context/AuthContext'; +import { useToast } from '@/hooks/use-toast'; +import { CartItem } from '@/types/product'; import Image from 'next/image'; import placeholderImage from '@/public/placeholder.webp'; -import { CartItem } from '@/types/product'; export const CartSheet = () => { + const router = useRouter(); const { cart, totalItems, totalPrice, updateCartItemQuantity, removeFromCart } = useCart(); + const { isAuthenticated } = useAuth(); + const { toast } = useToast(); + const sheetCloseRef = React.useRef(null); const formatPrice = (amount: number, currency: string) => { return new Intl.NumberFormat('da-DK', { @@ -26,6 +34,22 @@ export const CartSheet = () => { }).format(amount); }; + const handleCheckout = () => { + if (!isAuthenticated) { + toast({ + title: "Login Required", + description: "Please log in to proceed with checkout", + variant: "destructive", + }); + sheetCloseRef.current?.click(); // Close the cart sheet + router.push('/auth/login'); + return; + } + + sheetCloseRef.current?.click(); // Close the cart sheet + router.push('/checkout'); + }; + const getVariantDisplay = (item: CartItem) => { if (!item.selectedVariant) return null; @@ -150,11 +174,15 @@ export const CartSheet = () => { {formatPrice(totalPrice, cart[0].price.currency)} - )} + ); diff --git a/src/context/AuthContext.tsx b/src/context/AuthContext.tsx index 507e27c..8cfa202 100644 --- a/src/context/AuthContext.tsx +++ b/src/context/AuthContext.tsx @@ -87,7 +87,7 @@ export function AuthProvider({ children }: { children: React.ReactNode }) { title: "Logged out", description: "You have been successfully logged out", }); - router.push('/login'); + router.push('/auth/login'); }; return ( diff --git a/src/services/orderService.ts b/src/services/orderService.ts new file mode 100644 index 0000000..0a4ab8b --- /dev/null +++ b/src/services/orderService.ts @@ -0,0 +1,32 @@ +import { BaseApiService } from '@/types/apiBase'; +import { + CreateOrderRequest, + CreateOrderResponse, + CreatePaymentRequest, + CreatePaymentResponse +} from '@/types/order'; + +class OrderService extends BaseApiService { + private static instance: OrderService; + + private constructor() { + super('http://localhost:6091/api/v1'); + } + + public static getInstance(): OrderService { + if (!OrderService.instance) { + OrderService.instance = new OrderService(); + } + return OrderService.instance; + } + + async createOrder(userId: number, orderData: CreateOrderRequest): Promise { + return this.post(`/orders/${userId}`, orderData, true); + } + + async createMobilePayPayment(paymentData: CreatePaymentRequest): Promise { + return this.post('/payments/mobilepay/create', paymentData, true); + } +} + +export const orderService = OrderService.getInstance(); \ No newline at end of file diff --git a/src/types/order.ts b/src/types/order.ts new file mode 100644 index 0000000..adfb5c2 --- /dev/null +++ b/src/types/order.ts @@ -0,0 +1,37 @@ +export interface OrderLine { + productId: number; + quantity: number; + variantId?: number; +} + +export interface CreateOrderRequest { + currency: string; + orderLines: OrderLine[]; +} + +export interface Order { + id: number; + userId: number; + totalPrice: number; + currency: string; + orderStatus: string; + createdAt: string; +} + +export interface CreateOrderResponse { + order: Order; + message: string; +} + +export interface CreatePaymentRequest { + orderId: string; + currency: string; + paymentMethod: 'WALLET'; + returnUrl: string; +} + +export interface CreatePaymentResponse { + paymentId: number; + status: string; + redirectUrl: string; +} \ No newline at end of file