PaymentDetails.tsx 10 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373374375376377378379380381382383384385386387388389390391392393394395396397398399400401402403404405406407408409410411412
  1. import { useState, useEffect, useCallback } from "react";
  2. import type { Spec } from "@json-render/react";
  3. import {
  4. Box,
  5. ContextView,
  6. Button,
  7. TextField,
  8. Spinner,
  9. } from "@stripe/ui-extension-sdk/ui";
  10. import type { ExtensionContextValue } from "@stripe/ui-extension-sdk/context";
  11. import BrandIcon from "./brand_icon.svg";
  12. import { stripeCatalog, StripeRenderer } from "../lib/render";
  13. import { executeAction } from "../lib/render/catalog/actions";
  14. // =============================================================================
  15. // Dynamic Spec Creation
  16. // =============================================================================
  17. function createPaymentDetailSpec(
  18. data: Record<string, unknown>,
  19. paymentId: string,
  20. ): Spec {
  21. const payments = data.payments as
  22. | {
  23. data?: Array<{
  24. id: string;
  25. amount: number;
  26. currency: string;
  27. status: string;
  28. description: string;
  29. created: string;
  30. formattedAmount: string;
  31. customerId?: string;
  32. }>;
  33. }
  34. | undefined;
  35. const payment = payments?.data?.find((p) => p.id === paymentId);
  36. const refunds = data.paymentRefunds as
  37. | {
  38. data?: Array<{
  39. id: string;
  40. amount: number;
  41. status: string;
  42. reason: string;
  43. created: string;
  44. formattedAmount: string;
  45. }>;
  46. total?: number;
  47. }
  48. | undefined;
  49. const refundsList = refunds?.data ?? [];
  50. const elements: Spec["elements"] = {
  51. root: {
  52. key: "root",
  53. type: "Stack",
  54. props: { direction: "vertical", gap: "large" },
  55. children: ["header", "details", "actions", "refundsSection"],
  56. parentKey: null,
  57. },
  58. header: {
  59. key: "header",
  60. type: "Stack",
  61. props: {
  62. direction: "horizontal",
  63. gap: "medium",
  64. distribute: "space-between",
  65. },
  66. children: ["paymentInfo", "statusBadge"],
  67. parentKey: "root",
  68. },
  69. paymentInfo: {
  70. key: "paymentInfo",
  71. type: "Stack",
  72. props: { direction: "vertical", gap: "xsmall" },
  73. children: ["paymentAmount", "paymentId"],
  74. parentKey: "header",
  75. },
  76. paymentAmount: {
  77. key: "paymentAmount",
  78. type: "Heading",
  79. props: { text: payment?.formattedAmount ?? "$0.00", size: "xlarge" },
  80. children: [],
  81. parentKey: "paymentInfo",
  82. },
  83. paymentId: {
  84. key: "paymentId",
  85. type: "Text",
  86. props: { content: paymentId, color: "secondary", size: "small" },
  87. children: [],
  88. parentKey: "paymentInfo",
  89. },
  90. statusBadge: {
  91. key: "statusBadge",
  92. type: "Badge",
  93. props: {
  94. label: payment?.status ?? "unknown",
  95. type:
  96. payment?.status === "succeeded"
  97. ? "positive"
  98. : payment?.status === "pending"
  99. ? "warning"
  100. : "negative",
  101. },
  102. children: [],
  103. parentKey: "header",
  104. },
  105. details: {
  106. key: "details",
  107. type: "PropertyList",
  108. props: { orientation: "vertical" },
  109. children: [
  110. "detailDescription",
  111. "detailCreated",
  112. "detailCurrency",
  113. "detailCustomer",
  114. ],
  115. parentKey: "root",
  116. },
  117. detailDescription: {
  118. key: "detailDescription",
  119. type: "PropertyListItem",
  120. props: {
  121. label: "Description",
  122. value: payment?.description ?? "No description",
  123. },
  124. children: [],
  125. parentKey: "details",
  126. },
  127. detailCreated: {
  128. key: "detailCreated",
  129. type: "PropertyListItem",
  130. props: { label: "Created", value: payment?.created ?? "Unknown" },
  131. children: [],
  132. parentKey: "details",
  133. },
  134. detailCurrency: {
  135. key: "detailCurrency",
  136. type: "PropertyListItem",
  137. props: {
  138. label: "Currency",
  139. value: (payment?.currency ?? "usd").toUpperCase(),
  140. },
  141. children: [],
  142. parentKey: "details",
  143. },
  144. detailCustomer: {
  145. key: "detailCustomer",
  146. type: "PropertyListItem",
  147. props: { label: "Customer", value: payment?.customerId ?? "Guest" },
  148. children: [],
  149. parentKey: "details",
  150. },
  151. actions: {
  152. key: "actions",
  153. type: "Stack",
  154. props: { direction: "horizontal", gap: "small" },
  155. children:
  156. payment?.status === "succeeded"
  157. ? ["refundBtn", "viewBtn"]
  158. : ["viewBtn"],
  159. parentKey: "root",
  160. },
  161. refundBtn: {
  162. key: "refundBtn",
  163. type: "Button",
  164. props: {
  165. label: "Refund Payment",
  166. action: "refundPayment",
  167. actionParams: { paymentId },
  168. type: "destructive",
  169. },
  170. children: [],
  171. parentKey: "actions",
  172. },
  173. viewBtn: {
  174. key: "viewBtn",
  175. type: "Button",
  176. props: {
  177. label: "View in Dashboard",
  178. action: "viewPayment",
  179. actionParams: { paymentId },
  180. type: "secondary",
  181. },
  182. children: [],
  183. parentKey: "actions",
  184. },
  185. refundsSection: {
  186. key: "refundsSection",
  187. type: "Accordion",
  188. props: {},
  189. children: ["refundsAccordion"],
  190. parentKey: "root",
  191. },
  192. refundsAccordion: {
  193. key: "refundsAccordion",
  194. type: "AccordionItem",
  195. props: {
  196. title: `Refunds (${refunds?.total ?? 0})`,
  197. defaultOpen: refundsList.length > 0,
  198. },
  199. children: refundsList.length > 0 ? ["refundsList"] : ["noRefunds"],
  200. parentKey: "refundsSection",
  201. },
  202. refundsList: {
  203. key: "refundsList",
  204. type: "Stack",
  205. props: { direction: "vertical", gap: "small" },
  206. children: refundsList.slice(0, 5).map((_, i) => `refund${i}`),
  207. parentKey: "refundsAccordion",
  208. },
  209. noRefunds: {
  210. key: "noRefunds",
  211. type: "Text",
  212. props: { content: "No refunds", color: "secondary" },
  213. children: [],
  214. parentKey: "refundsAccordion",
  215. },
  216. };
  217. refundsList.slice(0, 5).forEach((r, i) => {
  218. elements[`refund${i}`] = {
  219. key: `refund${i}`,
  220. type: "RefundCard",
  221. props: {
  222. amount: r.amount,
  223. currency: "usd",
  224. status: r.status as "pending" | "succeeded" | "failed" | "canceled",
  225. reason: r.reason,
  226. },
  227. children: [],
  228. parentKey: "refundsList",
  229. };
  230. });
  231. return { root: "root", elements };
  232. }
  233. // =============================================================================
  234. // Component
  235. // =============================================================================
  236. const PaymentDetails = ({ environment }: ExtensionContextValue) => {
  237. const [data, setData] = useState<Record<string, unknown>>({});
  238. const [spec, setSpec] = useState<Spec | null>(null);
  239. const [loading, setLoading] = useState(true);
  240. const [prompt, setPrompt] = useState("");
  241. const [generating, setGenerating] = useState(false);
  242. const paymentId = environment?.objectContext?.id ?? "";
  243. const handleSetData = useCallback(
  244. (updater: (prev: Record<string, unknown>) => Record<string, unknown>) => {
  245. setData((prev) => updater(prev));
  246. },
  247. [],
  248. );
  249. useEffect(() => {
  250. const loadData = async () => {
  251. if (!paymentId) {
  252. setLoading(false);
  253. return;
  254. }
  255. setLoading(true);
  256. await Promise.all([
  257. executeAction("fetchPayments", { limit: 100 }, handleSetData, {}),
  258. executeAction(
  259. "fetchRefunds",
  260. { paymentIntentId: paymentId, limit: 10 },
  261. (updater) => {
  262. setData((prev) => {
  263. const next = updater(prev);
  264. return { ...prev, paymentRefunds: next.refunds };
  265. });
  266. },
  267. {},
  268. ),
  269. ]);
  270. setLoading(false);
  271. };
  272. loadData();
  273. }, [paymentId, handleSetData]);
  274. useEffect(() => {
  275. if (!loading && paymentId) {
  276. setSpec(createPaymentDetailSpec(data, paymentId));
  277. }
  278. }, [data, loading, paymentId]);
  279. const handleGenerate = async () => {
  280. if (!prompt.trim()) return;
  281. setGenerating(true);
  282. const systemPrompt = stripeCatalog.prompt();
  283. try {
  284. const response = await fetch("/api/generate", {
  285. method: "POST",
  286. headers: { "Content-Type": "application/json" },
  287. body: JSON.stringify({
  288. prompt: `For payment ${paymentId}: ${prompt}`,
  289. systemPrompt,
  290. }),
  291. });
  292. const result = await response.json();
  293. if (result.spec) {
  294. setSpec(result.spec);
  295. }
  296. } catch (error) {
  297. console.error("Generation error:", error);
  298. setSpec(createPaymentDetailSpec(data, paymentId));
  299. }
  300. setGenerating(false);
  301. };
  302. if (!paymentId) {
  303. return (
  304. <ContextView
  305. title="Payment Details"
  306. brandColor="#635BFF"
  307. brandIcon={BrandIcon}
  308. >
  309. <Box css={{ color: "secondary", padding: "large" }}>
  310. No payment selected. Please select a payment from the list.
  311. </Box>
  312. </ContextView>
  313. );
  314. }
  315. if (loading) {
  316. return (
  317. <ContextView
  318. title="Payment Details"
  319. brandColor="#635BFF"
  320. brandIcon={BrandIcon}
  321. >
  322. <Box
  323. css={{
  324. stack: "y",
  325. gap: "medium",
  326. alignX: "center",
  327. paddingY: "xlarge",
  328. }}
  329. >
  330. <Spinner size="large" />
  331. <Box css={{ color: "secondary" }}>Loading payment details...</Box>
  332. </Box>
  333. </ContextView>
  334. );
  335. }
  336. return (
  337. <ContextView
  338. title="Payment Details"
  339. brandColor="#635BFF"
  340. brandIcon={BrandIcon}
  341. actions={
  342. <Button
  343. type="primary"
  344. onPress={() =>
  345. executeAction("viewPayment", { paymentId }, handleSetData, data)
  346. }
  347. >
  348. View in Dashboard
  349. </Button>
  350. }
  351. >
  352. <Box css={{ stack: "y", gap: "medium" }}>
  353. <Box css={{ stack: "x", gap: "small" }}>
  354. <Box css={{ width: "fill" }}>
  355. <TextField
  356. label=""
  357. placeholder="Describe the payment view you want..."
  358. value={prompt}
  359. onChange={(e) => setPrompt(e.target.value)}
  360. />
  361. </Box>
  362. <Box css={{ alignSelfY: "center" }}>
  363. <Button
  364. type="primary"
  365. onPress={handleGenerate}
  366. disabled={generating || !prompt.trim()}
  367. >
  368. {generating ? "Generating..." : "Generate"}
  369. </Button>
  370. </Box>
  371. </Box>
  372. {spec && (
  373. <StripeRenderer
  374. spec={spec}
  375. data={data}
  376. setData={handleSetData}
  377. loading={generating}
  378. />
  379. )}
  380. </Box>
  381. </ContextView>
  382. );
  383. };
  384. export default PaymentDetails;