Products.tsx 8.5 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327
  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 { API_GENERATE_URL } from "../lib/config";
  13. import { stripeCatalog, StripeRenderer } from "../lib/render";
  14. import { executeAction } from "../lib/render/catalog/actions";
  15. // =============================================================================
  16. // Dynamic Spec Creation
  17. // =============================================================================
  18. function createProductsSpec(data: Record<string, unknown>): Spec {
  19. const products = data.products as
  20. | {
  21. data?: Array<{
  22. id: string;
  23. name: string;
  24. description: string;
  25. active: boolean;
  26. created: string;
  27. }>;
  28. total?: number;
  29. hasMore?: boolean;
  30. }
  31. | undefined;
  32. const prices = data.prices as
  33. | {
  34. data?: Array<{
  35. id: string;
  36. productId: string;
  37. formattedAmount: string;
  38. type: string;
  39. recurring?: { interval: string };
  40. }>;
  41. }
  42. | undefined;
  43. const productsList = products?.data ?? [];
  44. const pricesList = prices?.data ?? [];
  45. const elements: Spec["elements"] = {
  46. root: {
  47. type: "Stack",
  48. props: { direction: "vertical", gap: "large" },
  49. children: ["heading", "metrics", "filters", "list", "pagination"],
  50. },
  51. heading: {
  52. type: "Heading",
  53. props: { text: "Products & Prices", size: "xlarge" },
  54. children: [],
  55. },
  56. metrics: {
  57. type: "Stack",
  58. props: { direction: "horizontal", gap: "medium" },
  59. children: ["productsMetric", "pricesMetric", "activeMetric"],
  60. },
  61. productsMetric: {
  62. type: "Metric",
  63. props: {
  64. label: "Total Products",
  65. value: String(products?.total ?? 0),
  66. changeType: "neutral",
  67. },
  68. children: [],
  69. },
  70. pricesMetric: {
  71. type: "Metric",
  72. props: {
  73. label: "Total Prices",
  74. value: String(pricesList.length ?? 0),
  75. changeType: "neutral",
  76. },
  77. children: [],
  78. },
  79. activeMetric: {
  80. type: "Metric",
  81. props: {
  82. label: "Active Products",
  83. value: String(productsList.filter((p) => p.active).length ?? 0),
  84. changeType: "positive",
  85. },
  86. children: [],
  87. },
  88. filters: {
  89. type: "Stack",
  90. props: { direction: "horizontal", gap: "small" },
  91. children: ["refreshBtn", "createBtn"],
  92. },
  93. refreshBtn: {
  94. type: "Button",
  95. props: { label: "Refresh", action: "fetchProducts", type: "secondary" },
  96. children: [],
  97. },
  98. createBtn: {
  99. type: "Button",
  100. props: {
  101. label: "Create Product",
  102. action: "openDashboard",
  103. actionParams: { page: "products" },
  104. type: "primary",
  105. },
  106. children: [],
  107. },
  108. list: {
  109. type: "Stack",
  110. props: { direction: "vertical", gap: "small" },
  111. children: productsList.slice(0, 10).map((_, i) => `product${i}`),
  112. },
  113. pagination: {
  114. type: "Stack",
  115. props: { direction: "horizontal", gap: "small" },
  116. children: products?.hasMore ? ["loadMore"] : [],
  117. },
  118. loadMore: {
  119. type: "Button",
  120. props: {
  121. label: "Load More",
  122. action: "fetchProducts",
  123. actionParams: { limit: 10 },
  124. type: "secondary",
  125. },
  126. children: [],
  127. },
  128. };
  129. productsList.slice(0, 10).forEach((p, i) => {
  130. const productPrices = pricesList.filter((pr) => pr.productId === p.id);
  131. const priceDisplay =
  132. productPrices.length > 0
  133. ? productPrices
  134. .map(
  135. (pr) =>
  136. `${pr.formattedAmount}${pr.recurring ? `/${pr.recurring.interval}` : ""}`,
  137. )
  138. .join(", ")
  139. : "No prices";
  140. elements[`product${i}`] = {
  141. type: "Stack",
  142. props: { direction: "vertical", gap: "small" },
  143. children: [`productCard${i}`],
  144. };
  145. elements[`productCard${i}`] = {
  146. type: "PropertyList",
  147. props: { orientation: "vertical" },
  148. children: [
  149. `productName${i}`,
  150. `productDesc${i}`,
  151. `productPrice${i}`,
  152. `productStatus${i}`,
  153. ],
  154. };
  155. elements[`productName${i}`] = {
  156. type: "PropertyListItem",
  157. props: { label: "Name", value: p.name },
  158. children: [],
  159. };
  160. elements[`productDesc${i}`] = {
  161. type: "PropertyListItem",
  162. props: { label: "Description", value: p.description || "No description" },
  163. children: [],
  164. };
  165. elements[`productPrice${i}`] = {
  166. type: "PropertyListItem",
  167. props: { label: "Prices", value: priceDisplay },
  168. children: [],
  169. };
  170. elements[`productStatus${i}`] = {
  171. type: "PropertyListItem",
  172. props: { label: "Status", value: p.active ? "Active" : "Archived" },
  173. children: [],
  174. };
  175. });
  176. return { root: "root", elements };
  177. }
  178. // =============================================================================
  179. // Component
  180. // =============================================================================
  181. const Products = (_props: ExtensionContextValue) => {
  182. const [data, setState] = useState<Record<string, unknown>>({});
  183. const [spec, setSpec] = useState<Spec | null>(null);
  184. const [loading, setLoading] = useState(true);
  185. const [prompt, setPrompt] = useState("");
  186. const [generating, setGenerating] = useState(false);
  187. const handleSetState = useCallback(
  188. (updater: (prev: Record<string, unknown>) => Record<string, unknown>) => {
  189. setState((prev) => updater(prev));
  190. },
  191. [],
  192. );
  193. useEffect(() => {
  194. const loadData = async () => {
  195. setLoading(true);
  196. await Promise.all([
  197. executeAction("fetchProducts", { limit: 10 }, handleSetState, {}),
  198. executeAction("fetchPrices", { limit: 50 }, handleSetState, {}),
  199. ]);
  200. setLoading(false);
  201. };
  202. loadData();
  203. }, [handleSetState]);
  204. useEffect(() => {
  205. if (!loading && Object.keys(data).length > 0) {
  206. setSpec(createProductsSpec(data));
  207. }
  208. }, [data, loading]);
  209. const handleGenerate = async () => {
  210. if (!prompt.trim()) return;
  211. setGenerating(true);
  212. const systemPrompt = stripeCatalog.prompt();
  213. try {
  214. const response = await fetch(API_GENERATE_URL, {
  215. method: "POST",
  216. headers: { "Content-Type": "application/json" },
  217. body: JSON.stringify({ prompt, systemPrompt }),
  218. });
  219. const result = await response.json();
  220. if (result.spec) {
  221. setSpec(result.spec);
  222. }
  223. } catch (error) {
  224. console.error("Generation error:", error);
  225. setSpec(createProductsSpec(data));
  226. }
  227. setGenerating(false);
  228. };
  229. if (loading) {
  230. return (
  231. <ContextView title="Products" brandColor="#635BFF" brandIcon={BrandIcon}>
  232. <Box
  233. css={{
  234. stack: "y",
  235. gap: "medium",
  236. alignX: "center",
  237. paddingY: "xlarge",
  238. }}
  239. >
  240. <Spinner size="large" />
  241. <Box css={{ color: "secondary" }}>Loading products...</Box>
  242. </Box>
  243. </ContextView>
  244. );
  245. }
  246. return (
  247. <ContextView
  248. title="Products & Prices"
  249. brandColor="#635BFF"
  250. brandIcon={BrandIcon}
  251. actions={
  252. <Button
  253. type="primary"
  254. onPress={() =>
  255. executeAction(
  256. "openDashboard",
  257. { page: "products" },
  258. handleSetState,
  259. data,
  260. )
  261. }
  262. >
  263. Open in Dashboard
  264. </Button>
  265. }
  266. >
  267. <Box css={{ stack: "y", gap: "medium" }}>
  268. <Box css={{ stack: "x", gap: "small" }}>
  269. <Box css={{ width: "fill" }}>
  270. <TextField
  271. label=""
  272. placeholder="Describe the products view you want..."
  273. value={prompt}
  274. onChange={(e) => setPrompt(e.target.value)}
  275. />
  276. </Box>
  277. <Box css={{ alignSelfY: "center" }}>
  278. <Button
  279. type="primary"
  280. onPress={handleGenerate}
  281. disabled={generating || !prompt.trim()}
  282. >
  283. {generating ? "Generating..." : "Generate"}
  284. </Button>
  285. </Box>
  286. </Box>
  287. {spec && (
  288. <StripeRenderer
  289. spec={spec}
  290. data={data}
  291. setData={handleSetState}
  292. loading={generating}
  293. />
  294. )}
  295. </Box>
  296. </ContextView>
  297. );
  298. };
  299. export default Products;