From 42cc69b262a62ed09027fa10b08e53a7135914de Mon Sep 17 00:00:00 2001 From: HimanM <67066047+HimanM@users.noreply.github.com> Date: Sun, 21 Jun 2026 19:07:37 +0530 Subject: [PATCH] Add checkout pay link card --- backend/agent/tools.py | 86 +++++++++++++++++++++ backend/tests/test_tool_arg_sanitizer.py | 24 +++++- docs/feature-checklists/03-pay-link-card.md | 25 +++--- frontend/src/components/ChatMessage.tsx | 11 ++- frontend/src/components/PayLinkCard.tsx | 50 ++++++++++++ frontend/src/hooks/useChat.ts | 7 +- frontend/src/lib/api.ts | 10 +++ 7 files changed, 197 insertions(+), 16 deletions(-) create mode 100644 frontend/src/components/PayLinkCard.tsx diff --git a/backend/agent/tools.py b/backend/agent/tools.py index f16110b..dcef3f5 100644 --- a/backend/agent/tools.py +++ b/backend/agent/tools.py @@ -352,6 +352,87 @@ def parse_tracking_result(text: str) -> dict: return result +def parse_checkout_result(text: str) -> dict: + raw = text or "" + result = { + "payment_url": "", + "order_number": "", + "total": None, + "currency": "LKR", + "expires_at": "", + "raw": raw, + } + + if not raw or raw.startswith("MISSING_INFO:"): + return result + + data: Any = raw + if isinstance(raw, str): + stripped = raw.strip() + if stripped.startswith("{"): + try: + data = json.loads(stripped) + except json.JSONDecodeError: + data = raw + + if isinstance(data, dict): + order = data.get("order") if isinstance(data.get("order"), dict) else data + total_raw = ( + order.get("total") + or order.get("amount") + or order.get("grand_total") + or order.get("total_amount") + ) + result["payment_url"] = str( + order.get("payment_url") + or order.get("paymentUrl") + or order.get("pay_url") + or order.get("payUrl") + or order.get("click_to_pay_url") + or order.get("checkout_url") + or order.get("pay_link") + or order.get("url") + or "" + ).strip() + result["order_number"] = str( + order.get("order_number") + or order.get("orderNumber") + or order.get("order_id") + or order.get("orderId") + or order.get("id") + or "" + ).strip() + result["expires_at"] = str( + order.get("expires_at") + or order.get("expiresAt") + or order.get("expiry") + or order.get("price_lock_expires_at") + or "" + ).strip() + + if isinstance(total_raw, dict): + amount = total_raw.get("amount") or total_raw.get("value") or total_raw.get("price") + if amount not in (None, ""): + try: + result["total"] = _coerce_int(amount) + except (TypeError, ValueError): + result["total"] = None + result["currency"] = str(total_raw.get("currency") or order.get("currency") or "LKR").strip() or "LKR" + elif total_raw not in (None, ""): + try: + result["total"] = _coerce_int(total_raw) + except (TypeError, ValueError): + result["total"] = None + result["currency"] = str(order.get("currency") or "LKR").strip() or "LKR" + else: + result["payment_url"] = _first_url(raw) + order_match = re.search(r"(?:order(?:\s+number)?|order_id|order id)\D{0,8}([A-Z0-9-]{4,})", raw, re.IGNORECASE) + if order_match: + result["order_number"] = order_match.group(1).strip() + + return result + + def build_tool_result_event(tool_name: str, result: Any) -> dict[str, Any]: event: dict[str, Any] = {"result": result} if tool_name == "search_products" and isinstance(result, str): @@ -369,6 +450,11 @@ def build_tool_result_event(tool_name: str, result: Any) -> dict[str, Any]: if tracking.get("order_number") or tracking.get("status") or tracking.get("events"): event["tracking"] = tracking event["raw"] = result + elif tool_name == "checkout" and isinstance(result, str): + order = parse_checkout_result(result) + if order.get("payment_url") or order.get("order_number"): + event["order"] = order + event["raw"] = result return event diff --git a/backend/tests/test_tool_arg_sanitizer.py b/backend/tests/test_tool_arg_sanitizer.py index 03bd56b..d03993c 100644 --- a/backend/tests/test_tool_arg_sanitizer.py +++ b/backend/tests/test_tool_arg_sanitizer.py @@ -1,7 +1,7 @@ import unittest from unittest.mock import AsyncMock, patch -from agent.tools import _search_products, build_tool_result_event, coerce_tool_args, format_tool_result_for_model, parse_failed_tool_generation, parse_product_markdown, parse_search_products_markdown, parse_tracking_result +from agent.tools import _search_products, build_tool_result_event, coerce_tool_args, format_tool_result_for_model, parse_checkout_result, parse_failed_tool_generation, parse_product_markdown, parse_search_products_markdown, parse_tracking_result class ToolArgSanitizerTest(unittest.TestCase): @@ -140,6 +140,28 @@ def test_parses_tracking_result_and_emits_tracking_event(self): self.assertIn("tracking", event) self.assertEqual(event["tracking"]["location"], "Colombo") + def test_parses_checkout_result_and_emits_order_event(self): + checkout_json = """{ + "order_number": "ORD-12345", + "payment_url": "https://pay.kapruka.com/checkout/ORD-12345", + "total": {"amount": 12500, "currency": "LKR"}, + "expires_at": "2026-06-21T20:00:00+05:30" + }""" + + parsed = parse_checkout_result(checkout_json) + self.assertEqual(parsed["order_number"], "ORD-12345") + self.assertEqual(parsed["payment_url"], "https://pay.kapruka.com/checkout/ORD-12345") + self.assertEqual(parsed["total"], 12500) + self.assertEqual(parsed["currency"], "LKR") + + event = build_tool_result_event("checkout", checkout_json) + self.assertIn("order", event) + self.assertEqual(event["order"]["expires_at"], "2026-06-21T20:00:00+05:30") + + def test_checkout_missing_info_stays_plain_text(self): + event = build_tool_result_event("checkout", "MISSING_INFO: recipient phone, delivery date") + self.assertNotIn("order", event) + if __name__ == "__main__": unittest.main() diff --git a/docs/feature-checklists/03-pay-link-card.md b/docs/feature-checklists/03-pay-link-card.md index 552be11..908a9a1 100644 --- a/docs/feature-checklists/03-pay-link-card.md +++ b/docs/feature-checklists/03-pay-link-card.md @@ -6,20 +6,23 @@ Upgrade the checkout completion experience so successful order placement produce ## Checklist -- [ ] Review candidate pay-link and order-summary implementations -- [ ] Confirm current `checkout` tool result shape from the MCP -- [ ] Add structured order payload support to backend tool-result events -- [ ] Add a dedicated pay-link or order-summary card component -- [ ] Surface price lock / payment guidance clearly in the UI -- [ ] Preserve the existing cart and checkout drawer flow -- [ ] Verify behavior for success and incomplete-checkout cases -- [ ] Run backend tests relevant to order payload parsing -- [ ] Run `npm run lint` -- [ ] Run `npm run build` -- [ ] Verify the flow in a live browser session +- [x] Review candidate pay-link and order-summary implementations +- [x] Confirm current `checkout` tool result shape from the MCP +- [x] Add structured order payload support to backend tool-result events +- [x] Add a dedicated pay-link or order-summary card component +- [x] Surface price lock / payment guidance clearly in the UI +- [x] Preserve the existing cart and checkout drawer flow +- [x] Verify behavior for success and incomplete-checkout cases +- [x] Run backend tests relevant to order payload parsing +- [x] Run `npm run lint` +- [x] Run `npm run build` +- [x] Verify the flow in a live browser session - [ ] Commit, push branch, and open draft PR ## Notes - Candidate reference: `tmp_compare/kavi-kapruka/components/checkout/PayLinkCard.tsx` - Candidate reference: `tmp_compare/Kapruka-Ai-Shopping-Agent/components/OrderSummary.js` +- Success card parsing is verified with a representative `checkout` tool-result test instead of placing a real Kapruka order. +- Live browser verification used `http://localhost:3000` with a Playwright fallback because the in-app Browser tool was not callable in this thread. +- Incomplete checkout browser check: "Place my order now" stayed text-only and did not render a payment card. diff --git a/frontend/src/components/ChatMessage.tsx b/frontend/src/components/ChatMessage.tsx index 63488eb..5d635b6 100644 --- a/frontend/src/components/ChatMessage.tsx +++ b/frontend/src/components/ChatMessage.tsx @@ -1,9 +1,10 @@ "use client"; +import PayLinkCard from "@/components/PayLinkCard"; import ProductCard from "@/components/ProductCard"; import TrackingCard from "@/components/TrackingCard"; import { Message } from "@/hooks/useChat"; -import type { ProductSummary, TrackingSummary } from "@/lib/api"; +import type { OrderSummary, ProductSummary, TrackingSummary } from "@/lib/api"; function ToolBadge({ tool, args }: { tool: string; args: Record }) { const labels: Record = { @@ -63,6 +64,11 @@ function ToolTracking({ tracking }: { tracking: TrackingSummary | null }) { return ; } +function ToolOrder({ order }: { order: OrderSummary | null }) { + if (!order) return null; + return ; +} + function normalizeLine(line: string, hasProducts: boolean): string | null { const trimmed = line.trim(); if (!trimmed) return null; @@ -155,6 +161,8 @@ export default function ChatMessage({ const hasProducts = !isUser && productResults.length > 0; const trackingResult = message.toolResults?.map((entry) => entry.tracking).find((entry): entry is TrackingSummary => Boolean(entry)) || null; + const orderResult = + message.toolResults?.map((entry) => entry.order).find((entry): entry is OrderSummary => Boolean(entry)) || null; return (
@@ -184,6 +192,7 @@ export default function ChatMessage({ )} {!hasProducts && trackingResult ? : null} + {!hasProducts && !trackingResult && orderResult ? : null} {!message.content && message.toolCalls && message.toolCalls.length > 0 && !productResults.length ? (
diff --git a/frontend/src/components/PayLinkCard.tsx b/frontend/src/components/PayLinkCard.tsx new file mode 100644 index 0000000..b3b174a --- /dev/null +++ b/frontend/src/components/PayLinkCard.tsx @@ -0,0 +1,50 @@ +"use client"; + +import type { OrderSummary } from "@/lib/api"; + +function formatTotal(total?: number | null, currency = "LKR") { + if (total == null) return null; + return `${currency} ${total.toLocaleString("en-LK")}`; +} + +export default function PayLinkCard({ order }: { order: OrderSummary }) { + if (!order.payment_url && !order.order_number) return null; + + const total = formatTotal(order.total, order.currency); + + return ( +
+
+

Order ready

+

+ {order.order_number ? `#${order.order_number}` : "Payment link created"} +

+

Prices are typically held for 60 minutes. Complete payment before the link expires.

+
+ +
+ {total ? ( +
+ Total + {total} +
+ ) : null} + + {order.payment_url ? ( + + Complete Payment + + ) : ( +
+ Payment link not included in the response. Please check the latest assistant message for next steps. +
+ )} +
+
+ ); +} diff --git a/frontend/src/hooks/useChat.ts b/frontend/src/hooks/useChat.ts index 31fff60..6d143a9 100644 --- a/frontend/src/hooks/useChat.ts +++ b/frontend/src/hooks/useChat.ts @@ -1,14 +1,14 @@ "use client"; import { useState, useCallback, useRef } from "react"; -import { streamChat, type ProductSummary, type TrackingSummary } from "@/lib/api"; +import { streamChat, type OrderSummary, type ProductSummary, type TrackingSummary } from "@/lib/api"; export interface Message { id: string; role: "user" | "assistant" | "system"; content: string; toolCalls?: { tool: string; args: Record }[]; - toolResults?: { tool: string; result?: string; raw?: string; product?: ProductSummary; products?: ProductSummary[]; tracking?: TrackingSummary }[]; + toolResults?: { tool: string; result?: string; raw?: string; product?: ProductSummary; products?: ProductSummary[]; tracking?: TrackingSummary; order?: OrderSummary }[]; timestamp: number; } @@ -34,7 +34,7 @@ export function useChat(sessionId: string) { let assistantContent = ""; const toolCalls: { tool: string; args: Record }[] = []; - const toolResults: { tool: string; result?: string; raw?: string; product?: ProductSummary; products?: ProductSummary[]; tracking?: TrackingSummary }[] = []; + const toolResults: { tool: string; result?: string; raw?: string; product?: ProductSummary; products?: ProductSummary[]; tracking?: TrackingSummary; order?: OrderSummary }[] = []; const assistantId = `assistant-${++idCounter.current}`; try { @@ -70,6 +70,7 @@ export function useChat(sessionId: string) { product: event.product, products: event.products, tracking: event.tracking, + order: event.order, }); setMessages((prev) => { const existing = prev.find((m) => m.id === assistantId); diff --git a/frontend/src/lib/api.ts b/frontend/src/lib/api.ts index d37944d..7caac6a 100644 --- a/frontend/src/lib/api.ts +++ b/frontend/src/lib/api.ts @@ -62,6 +62,15 @@ export interface TrackingSummary { raw?: string; } +export interface OrderSummary { + payment_url: string; + order_number: string; + total?: number | null; + currency: string; + expires_at: string; + raw?: string; +} + export interface CheckoutInfoPayload { recipient: { name: string; @@ -97,6 +106,7 @@ export interface ChatEvent { product?: ProductSummary; products?: ProductSummary[]; tracking?: TrackingSummary; + order?: OrderSummary; } export async function* streamChat(