From 9bacbf4553062b2521168f020bb055336abb12f3 Mon Sep 17 00:00:00 2001 From: Jaseem T K Date: Thu, 12 Mar 2026 10:53:09 +0530 Subject: [PATCH 1/3] feat: rabbitmq --- frontend/web/src/app/(user)/account/page.tsx | 134 ++--- frontend/web/src/app/(user)/checkout/page.tsx | 329 ++++++------ .../order-success/OrderSuccessClient.tsx | 149 ++++++ .../web/src/app/(user)/order-success/page.tsx | 12 + .../web/src/app/(user)/orders/OrdersPage.tsx | 42 +- frontend/web/src/app/(user)/wishlist/page.tsx | 2 +- .../src/components/OrderSuccessOverlay.tsx | 146 +++++ .../web/src/components/store/ProductCard.tsx | 57 +- .../store/ProductPurchaseSection.tsx | 24 +- frontend/web/src/services/checkout.service.ts | 38 +- .../checkout-service-routes/checkout.route.ts | 12 + .../checkout-service-routes/order.route.ts | 6 + package.json | 6 +- pnpm-lock.yaml | 92 +++- services/checkout-service/package.json | 7 +- .../src/api/routes/checkout.route.ts | 22 +- .../src/api/routes/webhook.route.ts | 3 + services/checkout-service/src/config/env.ts | 3 + .../src/controllers/checkout.controller.ts | 59 +++ .../src/controllers/order.controller.ts | 5 +- .../controllers/stripe.webhook.controller.ts | 69 +-- .../src/domain/payment.types.ts | 5 + .../repositories/order.repository.dynamo.ts | 50 +- .../repositories/payment.repository.dynamo.ts | 16 + .../src/repositories/payment.repository.ts | 1 + services/checkout-service/src/server.ts | 20 +- .../src/services/checkout.service.ts | 497 ++++++++++++------ .../src/services/order.service.ts | 33 +- .../src/services/payment.service.ts | 65 +++ .../src/services/rabbitmq.service.ts | 71 +++ .../src/workers/checkout.worker.ts | 234 +++++++++ .../src/workers/order.worker.ts | 114 ++++ .../src/workers/stock.worker.ts | 90 ++++ .../src/repositories/stock.repositories.ts | 23 +- 34 files changed, 1866 insertions(+), 570 deletions(-) create mode 100644 frontend/web/src/app/(user)/order-success/OrderSuccessClient.tsx create mode 100644 frontend/web/src/app/(user)/order-success/page.tsx create mode 100644 frontend/web/src/components/OrderSuccessOverlay.tsx create mode 100644 services/checkout-service/src/services/rabbitmq.service.ts create mode 100644 services/checkout-service/src/workers/checkout.worker.ts create mode 100644 services/checkout-service/src/workers/order.worker.ts create mode 100644 services/checkout-service/src/workers/stock.worker.ts diff --git a/frontend/web/src/app/(user)/account/page.tsx b/frontend/web/src/app/(user)/account/page.tsx index 757b58d..199f7ef 100644 --- a/frontend/web/src/app/(user)/account/page.tsx +++ b/frontend/web/src/app/(user)/account/page.tsx @@ -202,80 +202,84 @@ export default function AccountPage() { -
-
-
-

- Forgot password reset -

-

- Send an OTP to your email and reset the password from this - page. -

+ {user?.provider !== 'google' && ( + +
+
+

+ Forgot password reset +

+

+ Send an OTP to your email and reset the password from this + page. +

+
-
-
- +
+ + +
+ + +
-
-
- -
- -
- {error &&

{error}

} - {message &&

{message}

} -
+
+ {error &&

{error}

} + {message && ( +

{message}

+ )} +
- - + + + )}
diff --git a/frontend/web/src/app/(user)/checkout/page.tsx b/frontend/web/src/app/(user)/checkout/page.tsx index 39e7b7b..c8531f8 100644 --- a/frontend/web/src/app/(user)/checkout/page.tsx +++ b/frontend/web/src/app/(user)/checkout/page.tsx @@ -10,6 +10,7 @@ import { PaymentMethod, } from '@/src/services/checkout.service'; import { cartService } from '@/src/services/cart.service'; +import OrderSuccessOverlay from '@/src/components/OrderSuccessOverlay'; type CartItem = { variantId: string; @@ -27,6 +28,7 @@ export default function CheckoutPage() { const router = useRouter(); const [loading, setLoading] = useState(true); const [placingOrder, setPlacingOrder] = useState(false); + const [showSuccess, setShowSuccess] = useState(false); const [items, setItems] = useState([]); const [addresses, setAddresses] = useState([]); const [selectedAddressId, setSelectedAddressId] = useState(''); @@ -89,34 +91,30 @@ export default function CheckoutPage() { setPlacingOrder(true); try { - const res = await checkoutService.checkout({ - addressId: selectedAddressId, - paymentMethod, - }); + if (paymentMethod === 'STRIPE') { + const res = + await checkoutService.createStripeSession(selectedAddressId); - if (!res.canProceed) { - toast.error(res.message ?? 'Unable to proceed with checkout'); - return; - } + if (!res.sessionUrl) { + toast.error('Unable to start Stripe checkout'); + return; + } - if (paymentMethod === 'COD') { - toast.success('Order placed with Cash on Delivery'); - router.push('/orders'); + window.location.href = res.sessionUrl; return; } - if (!res.paymentIntentClientSecret) { - toast.error('Stripe payment initialization failed'); + const res = await checkoutService.checkout({ + addressId: selectedAddressId, + paymentMethod: 'COD', + }); + + if (res.status !== 'PROCESSING') { + toast.error(res.message ?? 'Unable to proceed with checkout'); return; } - toast.success('Redirecting to Stripe payment'); - - router.push( - `/checkout/stripe?clientSecret=${encodeURIComponent( - res.paymentIntentClientSecret, - )}&orderId=${encodeURIComponent(res.orderId ?? '')}`, - ); + setShowSuccess(true); } catch (error) { console.error('Checkout failed', error); toast.error('Checkout failed'); @@ -125,161 +123,172 @@ export default function CheckoutPage() { } }; + const handleOverlayComplete = async () => { + router.push('/orders'); + }; + if (loading) { return
Loading checkout...
; } return ( -
-
-

Checkout

-

- Select address and payment method to place your order. -

-
- - {/* Addresses */} -
-
-

- - Saved Addresses -

+ <> + {showSuccess && ( + + )} +
+
+

Checkout

+

+ Select address and payment method to place your order. +

+
+ + {/* Addresses */} +
+
+

+ + Saved Addresses +

+ + +
- -
+ {addresses.length === 0 ? ( +
+ No saved addresses found. Add one to continue checkout. +
+ ) : ( +
+ {addresses.map((address) => ( + + ))} +
+ )} +
+ + {/* Payment */} +
+

+ + Payment Method +

- {addresses.length === 0 ? ( -
- No saved addresses found. Add one to continue checkout. +
+ + +
- ) : ( -
- {addresses.map((address) => ( -
+ + {/* Summary */} +
+

+ + Order Summary +

-

- {address.addressLine1} - {address.addressLine2 ? `, ${address.addressLine2}` : ''} - {`, ${address.city}, ${address.state}, ${address.country} - ${address.zip}`} + {items.length === 0 ? ( +

Your cart is empty.

+ ) : ( +
+ {items.map((item) => { + const subtotal = + item.subtotal ?? (item.price ?? 0) * (item.quantity ?? 0); + + return ( +
+

+ {item.name} x {item.quantity ?? 0}

+ +

₹{subtotal.toFixed(2)}

-
- - ))} + ); + })} +
+ )} + +
+

Total

+

₹{total.toFixed(2)}

- )} - - - {/* Payment */} -
-

- - Payment Method -

- -
- -
-
- - {/* Summary */} -
-

- - Order Summary -

- - {items.length === 0 ? ( -

Your cart is empty.

- ) : ( -
- {items.map((item) => { - const subtotal = - item.subtotal ?? (item.price ?? 0) * (item.quantity ?? 0); - - return ( -
-

- {item.name} x {item.quantity ?? 0} -

- -

₹{subtotal.toFixed(2)}

-
- ); - })} -
- )} - -
-

Total

-

₹{total.toFixed(2)}

-
- - -
- + + + ); } diff --git a/frontend/web/src/app/(user)/order-success/OrderSuccessClient.tsx b/frontend/web/src/app/(user)/order-success/OrderSuccessClient.tsx new file mode 100644 index 0000000..8a7714a --- /dev/null +++ b/frontend/web/src/app/(user)/order-success/OrderSuccessClient.tsx @@ -0,0 +1,149 @@ +'use client'; + +import { useEffect, useState } from 'react'; +import { useRouter, useSearchParams } from 'next/navigation'; +import { toast } from 'sonner'; +import OrderSuccessOverlay from '@/src/components/OrderSuccessOverlay'; +import { checkoutService } from '@/src/services/checkout.service'; + +export default function OrderSuccessClient() { + const router = useRouter(); + const searchParams = useSearchParams(); + const sessionId = searchParams.get('session_id'); + + const [status, setStatus] = useState<'loading' | 'success' | 'failed'>( + 'loading', + ); + const [errorMessage, setErrorMessage] = useState(null); + const [orderId, setOrderId] = useState(null); + + useEffect(() => { + let cancelled = false; + + const getErrorMessage = (error: unknown) => { + if ( + error && + typeof error === 'object' && + 'response' in error && + error.response && + typeof error.response === 'object' && + 'data' in error.response && + error.response.data && + typeof error.response.data === 'object' && + 'message' in error.response.data && + typeof error.response.data.message === 'string' + ) { + return error.response.data.message; + } + + return 'Unable to verify Stripe payment.'; + }; + + const verify = async () => { + if (!sessionId) { + setStatus('failed'); + setErrorMessage('Missing Stripe session id.'); + return; + } + + for (let attempt = 0; attempt < 8; attempt += 1) { + try { + const result = await checkoutService.verifyStripeSession(sessionId); + + if (cancelled) return; + + if (result.status === 'SUCCESS') { + setOrderId(result.orderId ?? null); + setStatus('success'); + return; + } + + if (result.status === 'FAILED') { + setStatus('failed'); + setErrorMessage('Stripe payment was not completed.'); + return; + } + } catch (error) { + console.error('Failed to verify Stripe session', error); + + if (cancelled) return; + + if (attempt === 7) { + setStatus('failed'); + setErrorMessage(getErrorMessage(error)); + return; + } + } + + await new Promise((resolve) => setTimeout(resolve, 1500)); + } + + if (!cancelled) { + setStatus('failed'); + setErrorMessage('Stripe payment is still processing. Please refresh.'); + } + }; + + verify(); + + return () => { + cancelled = true; + }; + }, [sessionId]); + + const handleOverlayComplete = () => { + const target = orderId + ? `/orders?orderId=${encodeURIComponent(orderId)}` + : '/orders'; + router.replace(target); + }; + + if (status === 'success') { + return ; + } + + if (status === 'failed') { + return ( +
+
+

+ Payment not completed +

+

+ {errorMessage ?? 'Stripe did not confirm this payment.'} +

+
+ + +
+
+
+ ); + } + + return ( +
+
+

Verifying payment...

+

+ Confirming your Stripe checkout session. +

+
+
+ ); +} diff --git a/frontend/web/src/app/(user)/order-success/page.tsx b/frontend/web/src/app/(user)/order-success/page.tsx new file mode 100644 index 0000000..2ec937a --- /dev/null +++ b/frontend/web/src/app/(user)/order-success/page.tsx @@ -0,0 +1,12 @@ +import { Suspense } from 'react'; +import OrderSuccessClient from './OrderSuccessClient'; + +export const dynamic = 'force-dynamic'; + +export default function OrderSuccessPage() { + return ( + Verifying payment...}> + + + ); +} diff --git a/frontend/web/src/app/(user)/orders/OrdersPage.tsx b/frontend/web/src/app/(user)/orders/OrdersPage.tsx index 0652eaa..9f14eba 100644 --- a/frontend/web/src/app/(user)/orders/OrdersPage.tsx +++ b/frontend/web/src/app/(user)/orders/OrdersPage.tsx @@ -255,30 +255,32 @@ export default function OrdersPage() { }; const cancelOrder = async (order: UserOrder) => { - try { - setCancellingOrderId(order.orderId); + // Optimistically mark as cancelled in UI + setOrders((prev) => + prev.map((o) => + o.orderId === order.orderId + ? { ...o, fulfillmentStatus: 'CANCELLED' as const } + : o, + ), + ); + setCancellingOrderId(order.orderId); + try { await orderService.cancelOrder(order.orderId); - toast.success('Order cancelled successfully'); - - await loadOrders(true); + // Refresh to get the definitive server state + setTimeout(() => loadOrders(true), 1500); } catch (error) { - console.warn('Cancel request delayed, retrying status check...'); - - // wait 2 seconds then reload orders - setTimeout(async () => { - const updated = await loadOrders(true); - - const latest = updated.find((o) => o.orderId === order.orderId); - - if (latest?.fulfillmentStatus === 'CANCELLED') { - toast.success('Order cancelled successfully'); - } else { - toast.error('Unable to cancel this order'); - } - throw error; - }, 2000); + console.error('Cancel order failed', error); + // Revert the optimistic update on failure + setOrders((prev) => + prev.map((o) => + o.orderId === order.orderId + ? { ...o, fulfillmentStatus: order.fulfillmentStatus } + : o, + ), + ); + toast.error('Unable to cancel this order'); } finally { setCancellingOrderId(null); } diff --git a/frontend/web/src/app/(user)/wishlist/page.tsx b/frontend/web/src/app/(user)/wishlist/page.tsx index bf6b65a..c3db1dd 100644 --- a/frontend/web/src/app/(user)/wishlist/page.tsx +++ b/frontend/web/src/app/(user)/wishlist/page.tsx @@ -101,7 +101,7 @@ export default function WishlistPage() { {version.name} )} diff --git a/frontend/web/src/components/OrderSuccessOverlay.tsx b/frontend/web/src/components/OrderSuccessOverlay.tsx new file mode 100644 index 0000000..55a21a6 --- /dev/null +++ b/frontend/web/src/components/OrderSuccessOverlay.tsx @@ -0,0 +1,146 @@ +'use client'; + +import { useEffect, useState } from 'react'; + +interface OrderSuccessOverlayProps { + onComplete: () => void; +} + +export default function OrderSuccessOverlay({ + onComplete, +}: OrderSuccessOverlayProps) { + const [phase, setPhase] = useState<'ripple' | 'check' | 'text' | 'fade'>( + 'ripple', + ); + + useEffect(() => { + // Phase timeline: ripple → check → text → fade → complete + const t1 = setTimeout(() => setPhase('check'), 400); + const t2 = setTimeout(() => setPhase('text'), 900); + const t3 = setTimeout(() => setPhase('fade'), 2400); + const t4 = setTimeout(() => onComplete(), 2900); + + return () => { + clearTimeout(t1); + clearTimeout(t2); + clearTimeout(t3); + clearTimeout(t4); + }; + }, [onComplete]); + + return ( +
+ {/* Ripple rings */} +
+ {/* Outer ripple */} + + {/* Middle ripple */} + + {/* Green circle */} +
+ {/* Checkmark SVG */} + + + + +
+
+ + {/* Text */} +
+

Order Placed!

+

+ Your order is being processed +

+
+ + +
+ ); +} diff --git a/frontend/web/src/components/store/ProductCard.tsx b/frontend/web/src/components/store/ProductCard.tsx index ebe7c08..8cd0490 100644 --- a/frontend/web/src/components/store/ProductCard.tsx +++ b/frontend/web/src/components/store/ProductCard.tsx @@ -15,6 +15,9 @@ const ProductCard = ({ product }: { product: Product }) => { const router = useRouter(); const { incrementCart } = useCart(); + const isOutOfStock = product.stock === 0; + const isLowStock = product.stock > 0 && product.stock <= 5; + const handleAddToCart = async () => { if (qty > product.stock) { toast.error('Not enough stock'); @@ -33,9 +36,7 @@ const ProductCard = ({ product }: { product: Product }) => { quantity: qty, }); - // 🔹 instant UI update incrementCart(qty); - toast.success('Added to cart'); } catch (err) { console.error(err); @@ -44,47 +45,75 @@ const ProductCard = ({ product }: { product: Product }) => { }; return ( -
+
+ {/* Image + out-of-stock overlay */}
router.push(`/store/${product.id}`)} > {product.id} + {isOutOfStock && ( +
+ + Out of Stock + +
+ )}

{product.name}

₹{product.price}

-

Stock: {product.stock}

+ + {/* Stock badge */} + {isOutOfStock ? ( + + Out of Stock + + ) : isLowStock ? ( + + Only {product.stock} left + + ) : ( + + In Stock + + )}
-
-
+
+
- {qty} -
diff --git a/frontend/web/src/components/store/ProductPurchaseSection.tsx b/frontend/web/src/components/store/ProductPurchaseSection.tsx index 8576981..284f727 100644 --- a/frontend/web/src/components/store/ProductPurchaseSection.tsx +++ b/frontend/web/src/components/store/ProductPurchaseSection.tsx @@ -49,6 +49,24 @@ export default function ProductPurchaseSection({ return (
+ {/* Stock badge */} + {stock === 0 ? ( +
+ + Out of Stock +
+ ) : stock <= 5 ? ( +
+ + Only {stock} left — Order soon! +
+ ) : ( +
+ + In Stock +
+ )} + {/* Quantity Selector */}
@@ -69,14 +87,16 @@ export default function ProductPurchaseSection({
-

Stock: {stock}

+

+ {stock > 0 ? `${stock} available` : ''} +

{/* Add to Cart */}