diff --git a/apps/api-gateway/src/main.ts b/apps/api-gateway/src/main.ts index f29d4f9..5318908 100644 --- a/apps/api-gateway/src/main.ts +++ b/apps/api-gateway/src/main.ts @@ -132,6 +132,7 @@ app.post('/order/api/create-order', express.raw({ type: 'application/json' }), ( 'upgrade', ]; + // Set all headers BEFORE piping the response Object.keys(proxyRes.headers).forEach((key) => { if (!hopByHopHeaders.includes(key.toLowerCase())) { const value = proxyRes.headers[key]; @@ -141,7 +142,13 @@ app.post('/order/api/create-order', express.raw({ type: 'application/json' }), ( } }); - // Forward response body + // Check if headers have already been sent before piping + if (res.headersSent) { + console.error('Headers already sent, cannot pipe response'); + return; + } + + // Forward response body AFTER all headers are set proxyRes.pipe(res); }); diff --git a/apps/order-service/src/controller/order.controller.ts b/apps/order-service/src/controller/order.controller.ts index c58d0fd..0f6e2f5 100644 --- a/apps/order-service/src/controller/order.controller.ts +++ b/apps/order-service/src/controller/order.controller.ts @@ -1,13 +1,13 @@ import Stripe from 'stripe'; import redis from '@packages/libs/redis'; - import prisma from '@packages/libs/prisma'; import crypto from 'crypto'; -import { processOrdersForAllShops } from '../utils/order.utils'; -import { generateCartHash, normalizeCart } from '../utils/cart.utils'; -import { CartItem, SellerData, PaymentSession, Coupon } from '../types'; import { Request, Response, NextFunction } from 'express'; +import { generateCartHash, normalizeCart } from '../utils/cart.utils'; +import { CartItem, SellerData, PaymentSession } from '../types'; +import { processOrdersForAllShops, resolveCouponsForCart } from '../utils/order.utils'; + const stripe = new Stripe(process.env.STRIPE_SECRET_KEY as string); // Request interfaces @@ -16,7 +16,7 @@ interface CreatePaymentSessionRequest extends Request { cart: CartItem[]; userId: string; selectedAddressId?: string; - coupon?: Coupon; + couponCodes?: string[]; }; } @@ -142,14 +142,25 @@ export const verifyPaymentSession = async (req: VerifySessionRequest, res: Respo }); } + const { allocation } = await resolveCouponsForCart(sessionData.cart, sessionData.couponCodes || []); + + // Calculate discounted total + const totalDiscount = allocation.summary.totalDiscount || 0; + const discountedTotal = sessionData.totalAmount - totalDiscount; + return res.status(200).json({ success: true, session: { sessionId: sessionData.sessionId, cart: sessionData.cart, totalAmount: sessionData.totalAmount, + discountedTotal: discountedTotal, shippingAddressId: sessionData.shippingAddressId, - coupon: sessionData.coupon, + couponCodes: sessionData.couponCodes || [], + appliedCoupons: allocation.summary, + perItemCoupons: allocation.perItemCoupons, + invalidCouponCodes: allocation.invalidCouponCodes, + unappliedCouponCodes: allocation.unappliedCouponCodes, sellers: sessionData.sellers, }, }); @@ -163,6 +174,36 @@ export const verifyPaymentSession = async (req: VerifySessionRequest, res: Respo } }; +export const previewCartCoupons = async (req: Request, res: Response) => { + try { + const { cart, couponCodes = [] } = req.body as { cart: CartItem[]; couponCodes?: string[] }; + + if (!Array.isArray(cart) || cart.length === 0) { + return res.status(400).json({ + success: false, + message: 'Cart must be a non-empty array', + }); + } + + const { allocation, missingCouponCodes } = await resolveCouponsForCart(cart, couponCodes || []); + + return res.status(200).json({ + success: true, + appliedCoupons: allocation.summary, + perItemCoupons: allocation.perItemCoupons, + invalidCouponCodes: allocation.invalidCouponCodes.concat(missingCouponCodes), + unappliedCouponCodes: allocation.unappliedCouponCodes, + }); + } catch (error) { + console.error('Error previewing coupons:', error); + return res.status(500).json({ + success: false, + message: 'Failed to preview coupons', + error: error instanceof Error ? error.message : 'Unknown error', + }); + } +}; + export const createPaymentIntent = async (req: CreatePaymentIntentRequest, res: Response) => { try { const { amount, stripeSellerAccountId, sessionId } = req.body; @@ -234,7 +275,7 @@ export const createPaymentIntent = async (req: CreatePaymentIntentRequest, res: */ export const createPaymentSession = async (req: CreatePaymentSessionRequest, res: Response) => { try { - const { cart, userId, selectedAddressId, coupon } = req.body; + const { cart, userId, selectedAddressId, couponCodes = [] } = req.body; // Validate required fields if (!cart || !userId) { @@ -320,14 +361,20 @@ export const createPaymentSession = async (req: CreatePaymentSessionRequest, res // Create session payload using normalized cart\ //TODO: should we use car + const { allocation, sanitizedCouponCodes, missingCouponCodes } = await resolveCouponsForCart(cart, couponCodes); + const sessionData: PaymentSession = { userId, sessionId, - cart: cart, + cart, sellers: sellerData, totalAmount: cartTotal, shippingAddressId: selectedAddressId || undefined, - coupon: coupon || undefined, + couponCodes: sanitizedCouponCodes, + appliedCoupons: allocation.summary, + perItemCoupons: allocation.perItemCoupons, + invalidCouponCodes: allocation.invalidCouponCodes.concat(missingCouponCodes), + unappliedCouponCodes: allocation.unappliedCouponCodes, }; // Store session with simple key structure and 10-minute expiration @@ -341,6 +388,10 @@ export const createPaymentSession = async (req: CreatePaymentSessionRequest, res success: true, sessionId, isExisting: false, + appliedCoupons: allocation.summary, + perItemCoupons: allocation.perItemCoupons, + invalidCouponCodes: allocation.invalidCouponCodes.concat(missingCouponCodes), + unappliedCouponCodes: allocation.unappliedCouponCodes, }); } catch (error) { console.error('Error creating payment session:', error); diff --git a/apps/order-service/src/routes/order.route.ts b/apps/order-service/src/routes/order.route.ts index 45c9cb7..b4e57f4 100644 --- a/apps/order-service/src/routes/order.route.ts +++ b/apps/order-service/src/routes/order.route.ts @@ -3,6 +3,7 @@ import { createPaymentIntent, createPaymentSession, verifyPaymentSession, + previewCartCoupons, getSellerOrders, getOrderDetails, updateOrderStatus, @@ -26,6 +27,7 @@ router.get('/health', (req, res) => { // Payment routes with validation router.post('/create-payment-intent', validateCreatePaymentIntent, isAuthenticated, createPaymentIntent); router.post('/create-payment-session', validateCreatePaymentSession, createPaymentSession); +router.post('/preview-coupons', previewCartCoupons); // Session verification route router.get('/verify-session/:sessionId', verifyPaymentSession); diff --git a/apps/order-service/src/types/index.ts b/apps/order-service/src/types/index.ts index c22b8e1..a36ede5 100644 --- a/apps/order-service/src/types/index.ts +++ b/apps/order-service/src/types/index.ts @@ -7,11 +7,39 @@ export interface CartItem { selectedOptions?: Record; } +export type CouponDiscountType = 'PERCENT' | 'FLAT'; + export interface Coupon { code: string; - discountPercent?: number; - discountAmount?: number; - discountedProductId?: string; + discountType: CouponDiscountType; + discountValue: number; +} + +export interface AppliedCoupon { + code: string; + discountAmount: number; + appliedProductIds: string[]; + discountType: CouponDiscountType; + discountValue: number; +} + +export interface AppliedCouponSummary { + totalDiscount: number; + coupons: AppliedCoupon[]; +} + +export interface OrderItemCoupon { + code: string; + discountAmount: number; + discountType: CouponDiscountType; + discountValue: number; +} + +export interface CouponAllocationResult { + perItemCoupons: Record; + summary: AppliedCouponSummary; + invalidCouponCodes: string[]; + unappliedCouponCodes: string[]; } export interface SellerData { @@ -27,7 +55,12 @@ export interface PaymentSession { sellers: SellerData[]; totalAmount: number; shippingAddressId?: string; - coupon?: Coupon; + couponCodes?: string[]; + perItemCoupons?: Record; + appliedCoupons?: AppliedCouponSummary; + invalidCouponCodes?: string[]; + unappliedCouponCodes?: string[]; + subtotal?: number; } export interface OrderItemData { @@ -35,6 +68,8 @@ export interface OrderItemData { quantity: number; price: number; selectedOptions?: Record; + discountAmount?: number; + coupon?: OrderItemCoupon | null; } export interface OrderData { @@ -43,8 +78,8 @@ export interface OrderData { total: number; status: 'PENDING' | 'PAID' | 'PROCESSING' | 'SHIPPED' | 'DELIVERED' | 'CANCELLED' | 'REFUNDED'; shippingAddressId?: string; - couponCode?: string; - discountAmount: number; + totalDiscount: number; + appliedCoupons?: AppliedCouponSummary; items: OrderItemData[]; } diff --git a/apps/order-service/src/utils/cart.utils.ts b/apps/order-service/src/utils/cart.utils.ts index 74c0c34..8f27786 100644 --- a/apps/order-service/src/utils/cart.utils.ts +++ b/apps/order-service/src/utils/cart.utils.ts @@ -4,10 +4,17 @@ export const normalizeCart = (cart: any[]) => { const uniqueItems = new Map(); cart.forEach((item) => { - const key = `${item.productId}_${item.variantId || 'default'}`; + if (!item || !item.id) { + return; + } + + const key = `${item.id}_${item.variantId || 'default'}`; if (uniqueItems.has(key)) { - // If duplicate found, sum the quantities - uniqueItems.get(key).quantity += item.quantity; + const existing = uniqueItems.get(key); + existing.quantity += item.quantity; + existing.sale_price = item.sale_price; + existing.shopId = item.shopId; + existing.selectedOptions = item.selectedOptions; } else { uniqueItems.set(key, { ...item }); } @@ -15,7 +22,7 @@ export const normalizeCart = (cart: any[]) => { // Convert back to array and sort by product ID // The JSON.stringify in generateCartHash will handle the normalization - return Array.from(uniqueItems.values()).sort((a, b) => a.productId.localeCompare(b.productId)); + return Array.from(uniqueItems.values()).sort((a, b) => a.id.localeCompare(b.id)); }; import crypto from 'crypto'; diff --git a/apps/order-service/src/utils/order.utils.ts b/apps/order-service/src/utils/order.utils.ts index 4cc724e..d6e0488 100644 --- a/apps/order-service/src/utils/order.utils.ts +++ b/apps/order-service/src/utils/order.utils.ts @@ -1,6 +1,18 @@ import { Prisma } from '@prisma/client'; import prisma from '@packages/libs/prisma'; -import { CartItem, OrderData, OrderItemData, PaymentSession, UserAction } from '../types'; +import { + AppliedCoupon, + AppliedCouponSummary, + CartItem, + Coupon, + CouponAllocationResult, + CouponDiscountType, + OrderData, + OrderItemCoupon, + OrderItemData, + PaymentSession, + UserAction, +} from '../types'; import { sendEmail } from './send-email'; /** @@ -16,26 +28,286 @@ export const groupCartByShop = (cart: CartItem[]): Record => }, {} as Record); }; +const normalizeCouponDiscountType = (discountType: string | null | undefined): CouponDiscountType => { + if (typeof discountType !== 'string') { + return 'FLAT'; + } + + return discountType.toLowerCase() === 'percentage' ? 'PERCENT' : 'FLAT'; +}; + +const mapDiscountCodeToCoupon = (discount: { + discountCode: string; + discountType: string | null; + discountValue: number; +}): Coupon => { + return { + code: discount.discountCode, + discountType: normalizeCouponDiscountType(discount.discountType), + discountValue: discount.discountValue, + }; +}; + +const sanitizeCouponCodes = (couponCodes: string[] = []): string[] => { + return Array.from( + new Set(couponCodes.map((code) => (typeof code === 'string' ? code.trim() : '')).filter((code) => code.length > 0)), + ); +}; + +export const fetchCouponsForCart = async ( + cart: CartItem[], + couponCodes: string[], +): Promise<{ + coupons: Coupon[]; + productCouponMap: Record; + sanitizedCouponCodes: string[]; + missingCouponCodes: string[]; +}> => { + const sanitizedCouponCodes = sanitizeCouponCodes(couponCodes); + + const productCouponMap: Record = {}; + for (const item of cart) { + if (!productCouponMap[item.id]) { + productCouponMap[item.id] = []; + } + } + + if (sanitizedCouponCodes.length === 0) { + return { + coupons: [], + productCouponMap, + sanitizedCouponCodes, + missingCouponCodes: [], + }; + } + + const discountCodes = await prisma.discount_codes.findMany({ + where: { + discountCode: { + in: sanitizedCouponCodes, + }, + }, + select: { + id: true, + discountCode: true, + discountType: true, + discountValue: true, + }, + }); + + const coupons = discountCodes.map(mapDiscountCodeToCoupon); + const foundCodes = new Set(discountCodes.map((discount) => discount.discountCode)); + const missingCouponCodes = sanitizedCouponCodes.filter((code) => !foundCodes.has(code)); + + const couponCodeById = new Map(discountCodes.map((discount) => [discount.id, discount.discountCode])); + + const productIds = Array.from(new Set(cart.map((item) => item.id))); + + if (productIds.length > 0) { + const products = await prisma.products.findMany({ + where: { + id: { + in: productIds, + }, + }, + select: { + id: true, + discount_codes: true, + }, + }); + + for (const product of products) { + const mappedCodes = (product.discount_codes ?? []) + .map((discountId: string) => couponCodeById.get(discountId)) + .filter((code): code is string => Boolean(code) && sanitizedCouponCodes.includes(code as string)); + + if (mappedCodes.length > 0) { + productCouponMap[product.id] = mappedCodes; + } + } + } + + return { + coupons, + productCouponMap, + sanitizedCouponCodes, + missingCouponCodes, + }; +}; + +export const resolveCouponsForCart = async ( + cart: CartItem[], + couponCodes: string[] = [], +): Promise<{ + coupons: Coupon[]; + productCouponMap: Record; + sanitizedCouponCodes: string[]; + missingCouponCodes: string[]; + allocation: CouponAllocationResult; +}> => { + const { coupons, productCouponMap, sanitizedCouponCodes, missingCouponCodes } = await fetchCouponsForCart( + cart, + couponCodes, + ); + + const allocation = allocateCouponsToItems(cart, coupons, sanitizedCouponCodes, productCouponMap); + + return { + coupons, + productCouponMap, + sanitizedCouponCodes, + missingCouponCodes, + allocation, + }; +}; + +export const summarizeCouponsForItems = ( + items: CartItem[], + perItemCoupons: Record, +): AppliedCouponSummary => { + const appliedCouponsMap: Record = {}; + let totalDiscount = 0; + + for (const item of items) { + const coupon = perItemCoupons[item.id]; + if (!coupon) { + continue; + } + + totalDiscount += coupon.discountAmount; + + if (!appliedCouponsMap[coupon.code]) { + appliedCouponsMap[coupon.code] = { + code: coupon.code, + discountAmount: 0, + appliedProductIds: [], + discountType: coupon.discountType, + discountValue: coupon.discountValue, + }; + } + + appliedCouponsMap[coupon.code].discountAmount += coupon.discountAmount; + appliedCouponsMap[coupon.code].appliedProductIds.push(item.id); + } + + return { + totalDiscount, + coupons: Object.values(appliedCouponsMap), + }; +}; + /** - * Calculate order total with coupon discount + * Calculate discount amount for a product using provided coupon */ -export const calculateOrderTotal = ( +const calculateDiscountForCoupon = (item: CartItem, coupon: Coupon): number => { + const baseAmount = item.sale_price * item.quantity; + + if (baseAmount <= 0) { + return 0; + } + + if (coupon.discountType === 'PERCENT') { + const percent = Math.max(0, coupon.discountValue); + return Math.min(baseAmount, (baseAmount * percent) / 100); + } + + return Math.min(baseAmount, Math.max(0, coupon.discountValue)); +}; + +/** + * Determine best coupon application across items + */ +export const allocateCouponsToItems = ( items: CartItem[], - coupon?: { discountPercent?: number; discountAmount?: number; discountedProductId?: string }, -): number => { - let total = items.reduce((sum, item) => sum + item.quantity * item.sale_price, 0); + coupons: Coupon[], + requestedCouponCodes: string[], + productCouponMap: Record, +): CouponAllocationResult => { + const perItemCoupons: Record = {}; + const appliedCouponsMap: Record = {}; + const appliedCodes = new Set(); + + const validCouponCodes = new Set(coupons.map((coupon) => coupon.code)); + const invalidCouponCodes = requestedCouponCodes.filter((code) => !validCouponCodes.has(code)); + + for (const item of items) { + const applicableCoupons = (productCouponMap[item.id] || []) + .map((code) => coupons.find((coupon) => coupon.code === code)) + .filter((coupon): coupon is Coupon => Boolean(coupon)); + + if (applicableCoupons.length === 0) { + continue; + } + + const bestCoupon = applicableCoupons.reduce((best, coupon) => { + const discount = calculateDiscountForCoupon(item, coupon); + if (discount <= 0) { + return best; + } + + if (!best || discount > best.discountAmount) { + return { + code: coupon.code, + discountAmount: discount, + discountType: coupon.discountType, + discountValue: coupon.discountValue, + }; + } - if (coupon?.discountedProductId && coupon.discountPercent) { - const discountedItems = items.find((item) => item.id === coupon.discountedProductId); - if (discountedItems) { - const discount = (discountedItems.sale_price * discountedItems.quantity * coupon.discountPercent) / 100; - total -= discount; + return best; + }, undefined); + + if (!bestCoupon) { + continue; } - } else if (coupon?.discountAmount) { - total -= coupon.discountAmount; + + perItemCoupons[item.id] = bestCoupon; + appliedCodes.add(bestCoupon.code); + + if (!appliedCouponsMap[bestCoupon.code]) { + appliedCouponsMap[bestCoupon.code] = { + code: bestCoupon.code, + discountAmount: 0, + appliedProductIds: [], + discountType: bestCoupon.discountType, + discountValue: bestCoupon.discountValue, + }; + } + + appliedCouponsMap[bestCoupon.code].discountAmount += bestCoupon.discountAmount; + appliedCouponsMap[bestCoupon.code].appliedProductIds.push(item.id); } - return total; + const couponsArray = Object.values(appliedCouponsMap); + const totalDiscount = couponsArray.reduce((sum, coupon) => sum + coupon.discountAmount, 0); + + const unappliedCouponCodes = requestedCouponCodes.filter( + (code) => validCouponCodes.has(code) && !appliedCodes.has(code), + ); + + return { + perItemCoupons, + summary: { + totalDiscount, + coupons: couponsArray, + }, + invalidCouponCodes, + unappliedCouponCodes, + }; +}; + +/** + * Calculate order total with coupon allocations + */ +export const calculateOrderTotal = ( + items: CartItem[], + perItemCoupons: Record, +): number => { + return items.reduce((sum, item) => { + const basePrice = item.quantity * item.sale_price; + const discount = perItemCoupons[item.id]?.discountAmount || 0; + return sum + Math.max(basePrice - discount, 0); + }, 0); }; /** @@ -49,14 +321,16 @@ export const createOrderInDB = async (orderData: OrderData): Promise => total: orderData.total, status: orderData.status, shippingAddressId: orderData.shippingAddressId || null, - couponCode: orderData.couponCode || null, - discountAmount: orderData.discountAmount, + totalDiscount: orderData.totalDiscount, + appliedCoupons: orderData.appliedCoupons ? (orderData.appliedCoupons as unknown as Prisma.InputJsonValue) : null, items: { create: orderData.items.map((item: OrderItemData) => ({ productId: item.productId, quantity: item.quantity, price: item.price, selectedOptions: item.selectedOptions, + discountAmount: item.discountAmount ?? 0, + coupon: item.coupon ? (item.coupon as unknown as Prisma.InputJsonValue) : null, })), }, }, @@ -199,7 +473,10 @@ export const processOrdersForAllShops = async ( sessionData: PaymentSession, user: { name: string; email: string }, ): Promise => { - const { cart, totalAmount, shippingAddressId, coupon, sessionId, userId } = sessionData; + const { cart, totalAmount, shippingAddressId, sessionId, userId, couponCodes = [] } = sessionData; + + const { allocation } = await resolveCouponsForCart(cart, couponCodes); + const perItemCoupons = allocation.perItemCoupons; // Group cart items by shop const shopGrouped = groupCartByShop(cart); @@ -207,7 +484,14 @@ export const processOrdersForAllShops = async ( // Process each shop's orders for (const [shopId, items] of Object.entries(shopGrouped)) { // Calculate order total for this shop - const orderTotal = calculateOrderTotal(items, coupon); + const shopPerItemCoupons: Record = {}; + for (const item of items) { + shopPerItemCoupons[item.id] = perItemCoupons[item.id]; + } + + const orderTotal = calculateOrderTotal(items, shopPerItemCoupons); + + const shopCoupons = summarizeCouponsForItems(items, shopPerItemCoupons); // Create order data const orderData: OrderData = { @@ -216,13 +500,22 @@ export const processOrdersForAllShops = async ( total: orderTotal, status: 'PAID', shippingAddressId: shippingAddressId || undefined, - couponCode: coupon?.code, - discountAmount: coupon?.discountAmount || 0, + totalDiscount: shopCoupons.totalDiscount, + appliedCoupons: shopCoupons, items: items.map((item) => ({ productId: item.id, quantity: item.quantity, price: item.sale_price, selectedOptions: item.selectedOptions, + discountAmount: perItemCoupons[item.id]?.discountAmount || 0, + coupon: perItemCoupons[item.id] + ? { + code: perItemCoupons[item.id]!.code, + discountAmount: perItemCoupons[item.id]!.discountAmount, + discountType: perItemCoupons[item.id]!.discountType, + discountValue: perItemCoupons[item.id]!.discountValue, + } + : null, })), }; @@ -237,7 +530,7 @@ export const processOrdersForAllShops = async ( } // Send order confirmation email - const finalTotal = coupon?.discountAmount ? totalAmount - coupon.discountAmount : totalAmount; + const finalTotal = totalAmount - allocation.summary.totalDiscount; await sendOrderConfirmationEmail(user.email, user.name, cart, finalTotal, sessionId); // Create notifications for sellers diff --git a/apps/seller-ui/public/placeholder-image.jpg b/apps/seller-ui/public/placeholder-image.jpg new file mode 100644 index 0000000..589e800 Binary files /dev/null and b/apps/seller-ui/public/placeholder-image.jpg differ diff --git a/apps/seller-ui/src/app/(routes)/dashboard/all-products/page.tsx b/apps/seller-ui/src/app/(routes)/dashboard/all-products/page.tsx index 09b40d6..71377ef 100644 --- a/apps/seller-ui/src/app/(routes)/dashboard/all-products/page.tsx +++ b/apps/seller-ui/src/app/(routes)/dashboard/all-products/page.tsx @@ -24,10 +24,10 @@ const AllProducts = () => { accessorKey: 'image', header: 'Image', cell: ({ row }: any) => { - const image = row.original.images[0]; + const image = row.original.images?.[0]; return ( {row.original.title} {

Are you sure you want to delete this product?

- Product will be moved to delete state and automatically deleted after 24 hours. You can reover it, in + Product will be moved to delete state and automatically deleted after 24 hours. You can recover it, in this time.

diff --git a/apps/seller-ui/src/app/(routes)/dashboard/orders/[orderId]/page.tsx b/apps/seller-ui/src/app/(routes)/dashboard/orders/[orderId]/page.tsx index 86a5dad..2f26e9b 100644 --- a/apps/seller-ui/src/app/(routes)/dashboard/orders/[orderId]/page.tsx +++ b/apps/seller-ui/src/app/(routes)/dashboard/orders/[orderId]/page.tsx @@ -160,7 +160,7 @@ export default function OrderDetailsPage() { - {order.discountAmount > 0 && ( + {order.totalDiscount || order.discountAmount ? (
@@ -169,12 +169,12 @@ export default function OrderDetailsPage() {

Discount

- -{formatPrice(order.discountAmount)} + -{formatPrice(order.totalDiscount || order.discountAmount || 0)}

- )} + ) : null}
{/* Order Status Update */} @@ -247,7 +247,18 @@ export default function OrderDetailsPage() { {/* Order Items */}
-

Order Items

+

Order Items

+ {order?.appliedCoupons?.coupons?.length ? ( +
+ Coupons:{' '} + {order.appliedCoupons.coupons.map((c, idx) => ( + + {c.code} (-{formatPrice(c.discountAmount)}) + {idx < order.appliedCoupons!.coupons.length - 1 ? ',' : ''} + + ))} +
+ ) : null}
{items?.map((item) => (
@@ -259,7 +270,11 @@ export default function OrderDetailsPage() { className="w-20 h-20 rounded-lg object-cover" /> ) : ( - + {item.product?.title )}
@@ -267,6 +282,15 @@ export default function OrderDetailsPage() {

Quantity: {item.quantity} × {formatPrice(item.price)}

+ {item.coupon?.code ? ( +

+ Coupon {item.coupon.code} applied (- + {item.coupon.discountType === 'PERCENT' + ? `${item.coupon.discountValue}%` + : `${formatPrice(item.coupon.discountAmount)}`} + ) +

+ ) : null} {item.selectedOptions && Object.keys(item.selectedOptions).length > 0 && (
Options:{' '} @@ -277,7 +301,12 @@ export default function OrderDetailsPage() { )}
-

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

+

+ {item.discountAmount ? formatPrice(item.price * item.quantity) : ''} +

+

+ {formatPrice(item.price * item.quantity - (item.discountAmount || 0))} +

)) || ( diff --git a/apps/seller-ui/src/app/(routes)/dashboard/page.tsx b/apps/seller-ui/src/app/(routes)/dashboard/page.tsx index 983ebb8..e426a26 100644 --- a/apps/seller-ui/src/app/(routes)/dashboard/page.tsx +++ b/apps/seller-ui/src/app/(routes)/dashboard/page.tsx @@ -1,9 +1,91 @@ -import React from 'react' +'use client'; + +import React from 'react'; +import { + SellerHomeHeader, + SellerHomeOverview, + SellerHomeProductsSection, + SellerHomeOffersSection, + SellerHomeOrdersSection, + SellerHomeEventsSection, + SellerHomeHero, +} from '../../../shared/organisms/home'; +import { useOrders } from '../../../hooks/useOrders'; +import useProduct from '../../../hooks/useProduct'; +import { useShopInfo } from '../../../hooks/useShop'; +import { useDiscountCodes } from '../../../hooks/useDiscountCodes'; +import { useEvents } from '../../../hooks/useEvents'; +import useSeller from '../../../hooks/useSeller'; + +const DashboardPage = () => { + // Fetch data for dashboard + const { data: ordersData } = useOrders(); + const { productsQuery } = useProduct(); + const { data: shopData } = useShopInfo(); + const { data: discountCodesData } = useDiscountCodes(); + const { data: eventsData } = useEvents(1, 10); // Fetch first 10 events + const { seller } = useSeller(); + + const orders = ordersData?.data || []; + const products = productsQuery?.data || []; + const offers = discountCodesData?.discountCodes || []; + const events = eventsData?.events || []; + const shopName = shopData?.name || 'Your Shop'; + + // Hero props from shop and seller data + const heroProps = { + bannerUrl: shopData?.coverBanner || null, + avatarUrl: shopData?.avatar?.url || null, + name: shopData?.name || seller?.name || 'Your Shop', + category: shopData?.category || undefined, + address: shopData?.address || undefined, + website: shopData?.website || null, + rating: shopData?.ratings || undefined, + }; -const page = () => { return ( -
page
- ) -} +
+ {/* Hero Section */} + + + {/* Main Content */} +
+ {/* Header */} + + + {/* Overview Stats */} +
+ +
+ + {/* Content Grid */} +
+ {/* Recent Orders */} +
+ +
+ + {/* Products Section */} +
+ +
+
+ + {/* Additional Sections */} +
+ {/* Offers Section */} +
+ +
+ + {/* Events Section */} +
+ +
+
+
+
+ ); +}; -export default page \ No newline at end of file +export default DashboardPage; diff --git a/apps/seller-ui/src/hooks/chat.ts b/apps/seller-ui/src/hooks/chat.ts index a223d0d..5eb2b8b 100644 --- a/apps/seller-ui/src/hooks/chat.ts +++ b/apps/seller-ui/src/hooks/chat.ts @@ -1,4 +1,4 @@ -import { useQuery, useMutation, useQueryClient } from '@tanstack/react-query'; +import { useQuery, useMutation, useQueryClient, useInfiniteQuery } from '@tanstack/react-query'; import axiosInstance from '../utils/axiosIsntance'; import { useWebSocket } from '../context/websocket-context'; @@ -106,14 +106,28 @@ export const useMessages = ({ }); }; +// Infinite query for seller chat messages +export const useInfiniteMessages = ({ conversationId, limit = 20 }: { conversationId: string; limit?: number }) => { + return useInfiniteQuery({ + queryKey: ['seller-messages', 'infinite', conversationId, limit], + queryFn: ({ pageParam = 1 }) => fetchSellerMessages({ conversationId, page: pageParam, limit }), + getNextPageParam: (lastPage) => { + return lastPage.hasMore ? lastPage.currentPage + 1 : undefined; + }, + initialPageParam: 1, + enabled: !!conversationId, + staleTime: 1000 * 10, // 10 seconds + refetchOnWindowFocus: false, + }); +}; + export const useSendMessage = () => { - const queryClient = useQueryClient(); const { ws, isConnected, error: wsError } = useWebSocket(); return useMutation< { conversationId: string }, Error, - MarkAsSeenData, + SendMessageData, { previousData?: { conversations: Conversation[] }; previousUnreadCount?: number; @@ -136,6 +150,8 @@ export const useSendMessage = () => { senderType: data.senderType, }), ); + + return { conversationId: data.conversationId }; }, }); }; @@ -179,7 +195,7 @@ export const useMarkAsSeen = () => { const hadUnreadEntry = Object.prototype.hasOwnProperty.call(unreadCounts, conversationId); const previousUnreadCount = unreadCounts[conversationId]; - queryClient.setQueryData(['seller-conversations'], (oldData: any) => { + queryClient.setQueryData(['seller-conversations'], (oldData: { conversations: Conversation[] } | undefined) => { if (!oldData) return oldData; return { diff --git a/apps/seller-ui/src/hooks/useOrders.tsx b/apps/seller-ui/src/hooks/useOrders.tsx index 03f7b21..dbb75bd 100644 --- a/apps/seller-ui/src/hooks/useOrders.tsx +++ b/apps/seller-ui/src/hooks/useOrders.tsx @@ -8,6 +8,13 @@ export interface OrderItem { productId: string; quantity: number; price: number; + discountAmount?: number; + coupon?: { + code: string; + discountAmount: number; + discountType?: 'PERCENT' | 'FLAT' | string; + discountValue?: number; + } | null; selectedOptions?: Record | null; createdAt: string; updatedAt: string; @@ -28,8 +35,21 @@ export interface Order { total: number; status: 'PENDING' | 'PAID' | 'PROCESSING' | 'SHIPPED' | 'DELIVERED' | 'CANCELLED' | 'REFUNDED'; shippingAddressId?: string; + // Legacy single-coupon fields may exist on old data couponCode?: string; - discountAmount: number; + discountAmount?: number; + // New multi-coupon summary + totalDiscount?: number; + appliedCoupons?: { + totalDiscount: number; + coupons: Array<{ + code: string; + discountAmount: number; + appliedProductIds: string[]; + discountType?: 'PERCENT' | 'FLAT' | string; + discountValue?: number; + }>; + } | null; trackingNumber?: string; estimatedDelivery?: string; createdAt: string; diff --git a/apps/seller-ui/src/shared/molecules/home/ProductSummaryList.tsx b/apps/seller-ui/src/shared/molecules/home/ProductSummaryList.tsx index e6a7a9e..f0907ab 100644 --- a/apps/seller-ui/src/shared/molecules/home/ProductSummaryList.tsx +++ b/apps/seller-ui/src/shared/molecules/home/ProductSummaryList.tsx @@ -33,7 +33,8 @@ const ProductSummaryList: React.FC = ({ products, empty // eslint-disable-next-line @next/next/no-img-element {p.title} ) : ( - IMG + // eslint-disable-next-line @next/next/no-img-element + {p.title} )}
diff --git a/apps/seller-ui/src/shared/organisms/chat/ChatWindow.tsx b/apps/seller-ui/src/shared/organisms/chat/ChatWindow.tsx index 30343d2..5314325 100644 --- a/apps/seller-ui/src/shared/organisms/chat/ChatWindow.tsx +++ b/apps/seller-ui/src/shared/organisms/chat/ChatWindow.tsx @@ -1,10 +1,10 @@ 'use client'; -import React, { useEffect, useRef, useState } from 'react'; +import React, { useEffect, useRef, useState, useMemo } from 'react'; import { Loader2, MessageCircle } from 'lucide-react'; import useSeller from '../../../hooks/useSeller'; import { useWebSocket } from '../../../context/websocket-context'; -import { useMarkAsSeen, useMessages, useSendMessage } from '../../../hooks/chat'; +import { useMarkAsSeen, useInfiniteMessages, useSendMessage } from '../../../hooks/chat'; import { ChatInput, ChatMessage } from '../../molecules'; import { useQueryClient } from '@tanstack/react-query'; @@ -27,26 +27,64 @@ interface ChatWindowProps { const ChatWindow: React.FC = ({ conversationId, user }) => { const messagesEndRef = useRef(null); + const messagesContainerRef = useRef(null); const { seller } = useSeller(); - const { isConnected, isConnecting, error: wsError, ws, setMessageHandler } = useWebSocket(); + const { isConnected, isConnecting, error: wsError, setMessageHandler } = useWebSocket(); const [messages, setMessages] = useState([]); const queryClient = useQueryClient(); - const messagesQuery = useMessages({ + const messagesQuery = useInfiniteMessages({ conversationId: conversationId || '', - page: 1, - limit: 50, + limit: 20, }); const { mutate: sendMessage, isPending: isSending } = useSendMessage(); const { mutate: markAsSeen, isPending: isMarking } = useMarkAsSeen(); - const { data, isLoading, isError, error } = messagesQuery; - const userInfo = data?.user || user; + const { data, isLoading, isError, error, fetchNextPage, hasNextPage, isFetchingNextPage } = messagesQuery; - // Scroll to bottom when new messages arrive + // Flatten all messages from all pages and sort by creation date + const allMessages = useMemo(() => { + if (!data?.pages) return []; + + const messages = data.pages.flatMap((page) => page.messages); + // Sort by createdAt to ensure proper chronological order + return messages.sort((a, b) => new Date(a.createdAt).getTime() - new Date(b.createdAt).getTime()); + }, [data?.pages]); + const userInfo = data?.pages[0]?.user || user; + + // Scroll to bottom when new messages arrive (only for new messages, not when loading more) useEffect(() => { - messagesEndRef.current?.scrollIntoView({ behavior: 'smooth' }); - }, [messages]); + // Only auto-scroll if we're not currently loading more messages + // and if the user is near the bottom of the chat + if (!isFetchingNextPage && messagesContainerRef.current) { + const { scrollTop, scrollHeight, clientHeight } = messagesContainerRef.current; + const isNearBottom = scrollHeight - scrollTop - clientHeight < 100; // 100px threshold + + if (isNearBottom) { + messagesEndRef.current?.scrollIntoView({ behavior: 'smooth' }); + } + } + }, [messages, isFetchingNextPage]); + + // Handle scroll to load more messages + const handleScroll = (e: React.UIEvent) => { + const { scrollTop, scrollHeight } = e.currentTarget; + + // Load more messages when scrolled to top + if (scrollTop === 0 && hasNextPage && !isFetchingNextPage) { + // Store current scroll height to maintain position after loading + const currentScrollHeight = scrollHeight; + + fetchNextPage().then(() => { + // After loading, adjust scroll to maintain position + if (messagesContainerRef.current) { + const newScrollHeight = messagesContainerRef.current.scrollHeight; + const heightDifference = newScrollHeight - currentScrollHeight; + messagesContainerRef.current.scrollTop = heightDifference; + } + }); + } + }; // Mark messages as seen when conversation is selected useEffect(() => { @@ -80,10 +118,10 @@ const ChatWindow: React.FC = ({ conversationId, user }) => { }; useEffect(() => { - if (data) { - setMessages(data.messages); + if (allMessages.length > 0) { + setMessages(allMessages); } - }, [data]); + }, [allMessages]); //Receive new messages from websocket useEffect(() => { @@ -129,15 +167,26 @@ const ChatWindow: React.FC = ({ conversationId, user }) => { return; } - queryClient.setQueryData(['seller-messages', conversationId], (oldData: any) => { + // Update infinite query data - add new message to the last page (most recent) + queryClient.setQueryData(['seller-messages', 'infinite', conversationId, 20], (oldData: any) => { if (!oldData) return oldData; + + const newPages = [...oldData.pages]; + if (newPages.length > 0) { + const lastPageIndex = newPages.length - 1; + newPages[lastPageIndex] = { + ...newPages[lastPageIndex], + messages: [...newPages[lastPageIndex].messages, data], + }; + } + return { ...oldData, - messages: [...oldData.messages, data], + pages: newPages, }; }); - if (data.senderId !== seller?.id && !isMarking) { + if (data.senderId !== seller?.id && !isMarking && conversationId) { markAsSeen({ conversationId }); } }; @@ -193,7 +242,7 @@ const ChatWindow: React.FC = ({ conversationId, user }) => { return (
{/* Messages Area */} -
+
{messages.length === 0 ? (
@@ -204,6 +253,14 @@ const ChatWindow: React.FC = ({ conversationId, user }) => {
) : ( <> + {/* Load more indicator */} + {isFetchingNextPage && ( +
+ + Loading more messages... +
+ )} + {messages.map((message, index) => ( { return ( @@ -22,14 +25,7 @@ const CartPage = () => { ); }; -const CartLoadingFallback = () => ( -
-
- - Loading your cart… -
-
-); +const CartLoadingFallback = () => ; const CartScreen = () => { const { cart, removeFromCart, setCartQuantity } = useStore(); @@ -39,9 +35,21 @@ const CartScreen = () => { const { getProductsQuery } = useProducts({}); const { data: addresses = [], isLoading: addressesLoading } = useUserAddresses(); - const { createPaymentSession, isLoading: isCreatingSession, error: sessionError } = usePaymentSession(); + const { + createPaymentSession, + isLoading: isCreatingSession, + error: sessionError, + appliedCoupons, + perItemCoupons, + invalidCouponCodes, + unappliedCouponCodes, + couponCodes, + updateCouponState, + resetCouponState, + } = usePaymentSession(); - const [coupon, setCoupon] = useState(''); + const [couponInput, setCouponInput] = useState(''); + const [couponError, setCouponError] = useState(null); const [selectedAddressId, setSelectedAddressId] = useState(''); const [paymentMethod, setPaymentMethod] = useState('online'); @@ -71,6 +79,85 @@ const CartScreen = () => { .filter(Boolean); }, [cart, getProductsQuery.data]); + const previousCartSignatureRef = React.useRef(undefined); + + React.useEffect(() => { + if (enrichedCart.length === 0) { + previousCartSignatureRef.current = undefined; + resetCouponState(); + return; + } + + const signature = enrichedCart.map((item: any) => `${item.id}:${item.quantity}`).join('|'); + + if (previousCartSignatureRef.current !== signature) { + previousCartSignatureRef.current = signature; + if (couponCodes.length > 0) { + updateCouponState(enrichedCart, couponCodes); + } + } + }, [enrichedCart, couponCodes, updateCouponState, resetCouponState]); + + const handleAddCoupon = async () => { + const trimmed = couponInput.trim(); + + if (!trimmed) { + setCouponError('Enter a coupon code'); + return; + } + + if (couponCodes.includes(trimmed)) { + setCouponError('Coupon already added'); + return; + } + + if (enrichedCart.length === 0) { + setCouponError('Add items to cart before applying coupons'); + return; + } + + const previousCodes = [...couponCodes]; + + try { + const result = await updateCouponState(enrichedCart, [...couponCodes, trimmed]); + const invalid = result?.invalidCouponCodes ?? []; + const unapplied = result?.unappliedCouponCodes ?? []; + + if (invalid.includes(trimmed) || unapplied.includes(trimmed)) { + setCouponError( + invalid.includes(trimmed) ? 'Coupon is invalid' : 'Coupon does not apply to any items in your cart', + ); + await updateCouponState(enrichedCart, previousCodes); + } else { + setCouponError(null); + } + + setCouponInput(''); + } catch (error) { + console.error('Failed to validate coupon:', error); + setCouponError('Unable to validate coupon right now'); + } + }; + + const handleRemoveCoupon = async (code: string) => { + if (enrichedCart.length === 0) { + resetCouponState(); + return; + } + + setCouponError(null); + + try { + await updateCouponState( + enrichedCart, + couponCodes.filter((couponCode) => couponCode.toLowerCase() !== code.toLowerCase()), + ); + } catch (error) { + console.error('Failed to update coupons after removal:', error); + setCouponError('Unable to update coupons right now'); + } + }; + const handleQuantityChange = (product: any, newQuantity: number) => { if (newQuantity < 1) { removeFromCart(product.id, user, location, deviceInfo); @@ -79,6 +166,8 @@ const CartScreen = () => { } }; + const perItemCouponLookup = perItemCoupons || {}; + const columns = useMemo(() => { return [ { @@ -89,13 +178,13 @@ const CartScreen = () => { return (
{product.title} -
+
{ {product.title}

{product.category}

+ {perItemCouponLookup[product.id]?.code ? ( +
+ + + Coupon {perItemCouponLookup[product.id].code} (- + {perItemCouponLookup[product.id].discountType === 'PERCENT' + ? `${perItemCouponLookup[product.id].discountValue}%` + : `$${perItemCouponLookup[product.id].discountAmount.toFixed(2)}`} + ) + +
+ ) : appliedCoupons?.coupons?.length > 0 ? ( +
+ + No coupon applied +
+ ) : null}
); @@ -155,7 +261,7 @@ const CartScreen = () => { }, }, ]; - }, [removeFromCart, setCartQuantity, user, location, deviceInfo]); + }, [removeFromCart, setCartQuantity, user, location, deviceInfo, perItemCouponLookup, appliedCoupons]); const table = useReactTable({ data: enrichedCart, @@ -167,17 +273,11 @@ const CartScreen = () => { return enrichedCart.reduce((acc, item) => acc + item.sale_price * item.quantity, 0); }, [enrichedCart]); - const total = subtotal; // For now, total is the same as subtotal + const discountAmount = appliedCoupons?.totalDiscount ?? 0; + const total = useMemo(() => Math.max(subtotal - discountAmount, 0), [subtotal, discountAmount]); if (getProductsQuery.isLoading) { - return ( -
-
- - Loading your cart… -
-
- ); + return ; } if (cart.length === 0) { @@ -231,24 +331,73 @@ const CartScreen = () => { Subtotal ${subtotal.toFixed(2)}
+
+ Discounts + - ${discountAmount.toFixed(2)} +

-
- + setCoupon(e.target.value)} + value={couponInput} + onChange={(e) => setCouponInput(e.target.value)} placeholder="Enter coupon code" - className="flex-grow p-2 border rounded-md bg-background text-foreground border-border" + className="flex-grow" /> -
+ {couponError &&

{couponError}

} + {sessionError &&

{sessionError.message}

} + {invalidCouponCodes && invalidCouponCodes.length > 0 && ( +

Invalid: {invalidCouponCodes.join(', ')}

+ )} + {unappliedCouponCodes && unappliedCouponCodes.length > 0 && ( +

Not applied: {unappliedCouponCodes.join(', ')}

+ )} + {couponCodes.length > 0 && ( +
+

Applied coupons

+
+ {couponCodes.map((code) => ( + + {code} + + + ))} +
+
+ )} + {appliedCoupons?.coupons?.length ? ( +
+ {appliedCoupons.coupons.map((coupon: any) => ( +
+ {coupon.code} + - ${coupon.discountAmount.toFixed(2)} +
+ ))} +
+ ) : null}

@@ -325,7 +474,7 @@ const CartScreen = () => { cart: enrichedCart, userId: user.id, selectedAddressId, - coupon: coupon || undefined, + couponCodes: couponCodes, }); } catch (err) { console.error('Failed to create payment session:', err); diff --git a/apps/user-ui/src/app/(routes)/checkout/CheckoutForm.tsx b/apps/user-ui/src/app/(routes)/checkout/CheckoutForm.tsx index 9a4fa1b..fd3ccd4 100644 --- a/apps/user-ui/src/app/(routes)/checkout/CheckoutForm.tsx +++ b/apps/user-ui/src/app/(routes)/checkout/CheckoutForm.tsx @@ -1,7 +1,8 @@ 'use client'; import React, { useState } from 'react'; -import { CardElement, useStripe, useElements } from '@stripe/react-stripe-js'; +import { CardNumberElement, CardExpiryElement, CardCvcElement, useStripe, useElements } from '@stripe/react-stripe-js'; +import { Loader2, CreditCard, Calendar, Lock, TestTube } from 'lucide-react'; interface CheckoutFormProps { sessionData: any; @@ -23,6 +24,7 @@ const CheckoutForm: React.FC = ({ const [isProcessing, setIsProcessing] = useState(false); const [error, setError] = useState(null); + const [isTestMode, setIsTestMode] = useState(false); const externalErrorMessage = paymentIntentError instanceof Error ? paymentIntentError.message @@ -30,6 +32,15 @@ const CheckoutForm: React.FC = ({ ? paymentIntentError : null; + const handleTestPayment = () => { + setIsTestMode(true); + setError(null); + + // Note: Stripe Elements don't support programmatic value setting for security reasons + // This is a visual indicator that test mode is active + // Users will need to manually enter test card details + }; + const handleSubmit = async (event: React.FormEvent) => { event.preventDefault(); event.stopPropagation(); @@ -72,18 +83,20 @@ const CheckoutForm: React.FC = ({ throw new Error('Failed to create payment intents'); } - // Get the card element - const cardElement = elements.getElement(CardElement); - if (!cardElement) { - throw new Error('Card element not found'); + // Get the card elements + const cardNumberElement = elements.getElement(CardNumberElement); + const cardExpiryElement = elements.getElement(CardExpiryElement); + const cardCvcElement = elements.getElement(CardCvcElement); + + if (!cardNumberElement || !cardExpiryElement || !cardCvcElement) { + throw new Error('Card elements not found'); } // Process each payment intent - for (const paymentIntent of createdPaymentIntents) { const { error: confirmError } = await stripe.confirmCardPayment(paymentIntent.clientSecret, { payment_method: { - card: cardElement, + card: cardNumberElement, billing_details: { name: 'Customer Name', // You can get this from user data email: 'customer@example.com', // You can get this from user data @@ -110,6 +123,7 @@ const CheckoutForm: React.FC = ({ base: { fontSize: '16px', color: '#424770', + fontFamily: 'system-ui, -apple-system, sans-serif', '::placeholder': { color: '#aab7c4', }, @@ -122,11 +136,57 @@ const CheckoutForm: React.FC = ({ return (
- {/* Card Element */} -
- -
- + {/* Card Details */} +
+ + + {/* Card Number */} +
+ +
+ +
+
+ + {/* Expiry and CVC */} +
+
+ +
+ +
+
+ +
+ +
+ +
+
@@ -137,17 +197,56 @@ const CheckoutForm: React.FC = ({
)} + {/* Test Payment Button */} + {!isTestMode && ( + + )} + + {/* Test Mode Instructions */} + {isTestMode && ( +
+
+ +
+

Test Mode Active

+

Use these test card details:

+
+
+ Card Number: 4242 4242 4242 4242 +
+
+ Expiry: Any future date (e.g., 12/34) +
+
+ CVC: Any 3 digits (e.g., 123) +
+
+
+
+
+ )} + {/* Submit Button */} {/* Security Notice */} diff --git a/apps/user-ui/src/app/(routes)/checkout/page.tsx b/apps/user-ui/src/app/(routes)/checkout/page.tsx index a2b4ab8..a8f8bd2 100644 --- a/apps/user-ui/src/app/(routes)/checkout/page.tsx +++ b/apps/user-ui/src/app/(routes)/checkout/page.tsx @@ -134,6 +134,8 @@ const CheckoutContent = () => { } const items = sessionData.cart || []; + const perItemCoupons: Record = sessionData.perItemCoupons || {}; + const totalDiscount = sessionData.appliedCoupons?.totalDiscount || 0; const sellerLookup = new Map(); (sessionData.sellers || []).forEach((seller: any) => { sellerLookup.set(String(seller.shopId), seller); @@ -141,7 +143,7 @@ const CheckoutContent = () => { const subtotal = items.reduce((sum: number, item: any) => sum + (item.sale_price || 0) * (item.quantity || 0), 0); const totalItems = items.reduce((count: number, item: any) => count + (item.quantity || 0), 0); - const totalAmount = sessionData.totalAmount || subtotal; + const totalAmount = sessionData.discountedTotal || (sessionData.totalAmount || subtotal) - totalDiscount; const nextSteps = [ 'You will receive order confirmation emails from each seller in your cart.', @@ -267,9 +269,12 @@ const CheckoutContent = () => {
{items.map((item: any, index: number) => { + const original = (item.sale_price || 0) * (item.quantity || 0); + const itemDiscount = perItemCoupons[item.id]?.discountAmount || 0; + const final = Math.max(original - itemDiscount, 0); const seller = sellerLookup.get(String(item.shopId)); const sellerName = seller?.shopName || seller?.name || seller?.shop?.name; - const imageSrc = item?.images?.[0]?.url || '/images/placeholder.png'; + const imageSrc = item?.images?.[0]?.url || '/placeholder-image.jpg'; return (
{ {sellerName &&

Sold by {sellerName}

}

Qty {item.quantity}

- - ${((item.sale_price || 0) * (item.quantity || 0)).toFixed(2)} - +
+

+ {itemDiscount ? `$${original.toFixed(2)}` : ''} +

+ ${final.toFixed(2)} + {perItemCoupons[item.id]?.code ? ( +

Coupon {perItemCoupons[item.id].code}

+ ) : null} +
); })} @@ -302,10 +313,10 @@ const CheckoutContent = () => { Subtotal ${subtotal.toFixed(2)}
- {sessionData.coupon && ( -
- Coupon applied - {sessionData.coupon.code || sessionData.coupon.id || 'Saved'} + {totalDiscount > 0 && ( +
+ Discounts + - ${totalDiscount.toFixed(2)}
)}
diff --git a/apps/user-ui/src/app/(routes)/orders/[orderId]/page.tsx b/apps/user-ui/src/app/(routes)/orders/[orderId]/page.tsx new file mode 100644 index 0000000..5ade0ff --- /dev/null +++ b/apps/user-ui/src/app/(routes)/orders/[orderId]/page.tsx @@ -0,0 +1,185 @@ +'use client'; + +import React from 'react'; +import Image from 'next/image'; +import { useParams, useRouter } from 'next/navigation'; +import { useUserOrderDetails } from '../../../../hooks/useUserOrderDetails'; +import { ArrowLeft, DollarSign, MapPin, Package as PackageIcon } from 'lucide-react'; +import { PageLoader } from 'apps/user-ui/src/shared/components/molecules'; + +export default function UserOrderDetailsPage() { + const params = useParams(); + const router = useRouter(); + const orderId = params.orderId as string; + + const { data, isLoading, error } = useUserOrderDetails(orderId); + const order = data?.data?.order; + const items = data?.data?.items || []; + const shippingAddress = data?.data?.shippingAddress || null; + + const formatDate = (dateString: string) => + new Date(dateString).toLocaleDateString('en-US', { + year: 'numeric', + month: 'long', + day: 'numeric', + hour: '2-digit', + minute: '2-digit', + }); + + const formatPrice = (price: number) => + new Intl.NumberFormat('en-US', { style: 'currency', currency: 'USD' }).format(price); + + if (isLoading) { + return ( +
+ +
+ ); + } + + if (error || !order) { + return ( +
+ +

Unable to load order.

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

Order #{order.id.slice(-8)}

+

Placed on {formatDate(order.createdAt)}

+
+
+ +
+
+
+
+ +
+
+

Total

+

{formatPrice(order.total)}

+
+
+
+ + {order.totalDiscount || order.discountAmount ? ( +
+
+
+ +
+
+

Discount

+

+ -{formatPrice(order.totalDiscount || order.discountAmount || 0)} +

+
+
+
+ ) : null} + + {shippingAddress && ( +
+
+
+ +
+
+

Ship to

+

+ {shippingAddress.name} — {shippingAddress.street}, {shippingAddress.city}, {shippingAddress.zip},{' '} + {shippingAddress.country} +

+
+
+
+ )} +
+ + {order?.appliedCoupons?.coupons?.length ? ( +
+

Coupons

+
+ {order.appliedCoupons.coupons.map((c, idx) => ( + + {c.code} (-{formatPrice(c.discountAmount)}) + {idx < order.appliedCoupons!.coupons.length - 1 ? ',' : ''} + + ))} +
+
+ ) : null} + +
+

Items

+
+ {items.length ? ( + items.map((item) => { + const original = item.price * item.quantity; + const discount = item.discountAmount || 0; + const final = Math.max(original - discount, 0); + const imageSrc = item.product?.images?.[0]?.url || '/placeholder-image.jpg'; + return ( +
+
+ {item.product?.title +
+
+

{item.product?.title || 'Product'}

+

+ Quantity: {item.quantity} × {formatPrice(item.price)} +

+ {item.coupon?.code ? ( +

+ Coupon {item.coupon.code} applied (- + {item.coupon.discountType === 'PERCENT' + ? `${item.coupon.discountValue}%` + : `${formatPrice(item.coupon.discountAmount)}`} + ) +

+ ) : null} +
+
+

+ {discount ? formatPrice(original) : ''} +

+

{formatPrice(final)}

+
+
+ ); + }) + ) : ( +
+ +

No items found for this order

+
+ )} +
+
+
+ ); +} diff --git a/apps/user-ui/src/app/(routes)/product/[slug]/page.tsx b/apps/user-ui/src/app/(routes)/product/[slug]/page.tsx index cd7a5f9..a4249a5 100644 --- a/apps/user-ui/src/app/(routes)/product/[slug]/page.tsx +++ b/apps/user-ui/src/app/(routes)/product/[slug]/page.tsx @@ -33,14 +33,14 @@ export async function generateMetadata({ params }: PageProps): Promise openGraph: { title: product.name || 'Product Details', description: product.short_description || product.description || 'Product details and information', - images: product.images?.[0]?.url ? [product.images[0].url] : [], + images: product.images?.[0]?.url ? [product.images[0].url] : ['/placeholder-image.jpg'], type: 'website', }, twitter: { card: 'summary_large_image', title: product.name || 'Product Details', description: product.short_description || product.description || 'Product details and information', - images: product.images?.[0]?.url ? [product.images[0].url] : [], + images: product.images?.[0]?.url ? [product.images[0].url] : ['/placeholder-image.jpg'], }, }; } catch (error) { diff --git a/apps/user-ui/src/app/(routes)/shop/[shopId]/page.tsx b/apps/user-ui/src/app/(routes)/shop/[shopId]/page.tsx index f3fc3c6..b99559e 100644 --- a/apps/user-ui/src/app/(routes)/shop/[shopId]/page.tsx +++ b/apps/user-ui/src/app/(routes)/shop/[shopId]/page.tsx @@ -29,7 +29,7 @@ export async function generateMetadata({ params }: PageProps): Promise }; } - const bannerImage = shop.coverBanner || '/images/shop-banner-placeholder.png'; + const bannerImage = shop.coverBanner || '/images/shop-banner-placeholder.svg'; return { title: `${shop.name} | Shop`, diff --git a/apps/user-ui/src/app/(routes)/wishlist/page.tsx b/apps/user-ui/src/app/(routes)/wishlist/page.tsx index 3bb8169..a1981c7 100644 --- a/apps/user-ui/src/app/(routes)/wishlist/page.tsx +++ b/apps/user-ui/src/app/(routes)/wishlist/page.tsx @@ -10,6 +10,7 @@ import useUser from 'apps/user-ui/src/hooks/userUser'; import useLocationTracking from 'apps/user-ui/src/hooks/useLocationTracking'; import useDeviceTracking from 'apps/user-ui/src/hooks/useDeviceTracking'; import useProducts from 'apps/user-ui/src/hooks/useProducts'; +import { PageLoader } from '../../../shared/components/molecules'; const WishlistPage = () => { const { wishlist, cart, addToCart, removeFromWishlist } = useStore(); @@ -50,7 +51,7 @@ const WishlistPage = () => { return (
{product.title} { }); if (getProductsQuery.isLoading) { - return
Loading...
; // Or a spinner + return ; } if (wishlist.length === 0) { return (

Your Wishlist is Empty

-

Looks like you haven't added anything to your wishlist yet.

+

Looks like you haven't added anything to your wishlist yet.

Continue Shopping diff --git a/apps/user-ui/src/app/layout.tsx b/apps/user-ui/src/app/layout.tsx index 4026da2..328abb5 100644 --- a/apps/user-ui/src/app/layout.tsx +++ b/apps/user-ui/src/app/layout.tsx @@ -1,7 +1,6 @@ import Header from '../shared/widgets/header'; import { Footer } from '../shared/widgets/footer'; import './global.css'; -import { DarkModeToggle } from '../components/ui/DarkModeToggle'; import { Poppins, Roboto } from 'next/font/google'; import Providers from './Providers'; import { ThemeScript } from './theme-script'; @@ -35,7 +34,6 @@ export default function RootLayout({ children }: { children: React.ReactNode })
{children}