| 123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327 |
- import { useState, useEffect, useCallback } from "react";
- import type { Spec } from "@json-render/react";
- import {
- Box,
- ContextView,
- Button,
- TextField,
- Spinner,
- } from "@stripe/ui-extension-sdk/ui";
- import type { ExtensionContextValue } from "@stripe/ui-extension-sdk/context";
- import BrandIcon from "./brand_icon.svg";
- import { API_GENERATE_URL } from "../lib/config";
- import { stripeCatalog, StripeRenderer } from "../lib/render";
- import { executeAction } from "../lib/render/catalog/actions";
- // =============================================================================
- // Dynamic Spec Creation
- // =============================================================================
- function createProductsSpec(data: Record<string, unknown>): Spec {
- const products = data.products as
- | {
- data?: Array<{
- id: string;
- name: string;
- description: string;
- active: boolean;
- created: string;
- }>;
- total?: number;
- hasMore?: boolean;
- }
- | undefined;
- const prices = data.prices as
- | {
- data?: Array<{
- id: string;
- productId: string;
- formattedAmount: string;
- type: string;
- recurring?: { interval: string };
- }>;
- }
- | undefined;
- const productsList = products?.data ?? [];
- const pricesList = prices?.data ?? [];
- const elements: Spec["elements"] = {
- root: {
- type: "Stack",
- props: { direction: "vertical", gap: "large" },
- children: ["heading", "metrics", "filters", "list", "pagination"],
- },
- heading: {
- type: "Heading",
- props: { text: "Products & Prices", size: "xlarge" },
- children: [],
- },
- metrics: {
- type: "Stack",
- props: { direction: "horizontal", gap: "medium" },
- children: ["productsMetric", "pricesMetric", "activeMetric"],
- },
- productsMetric: {
- type: "Metric",
- props: {
- label: "Total Products",
- value: String(products?.total ?? 0),
- changeType: "neutral",
- },
- children: [],
- },
- pricesMetric: {
- type: "Metric",
- props: {
- label: "Total Prices",
- value: String(pricesList.length ?? 0),
- changeType: "neutral",
- },
- children: [],
- },
- activeMetric: {
- type: "Metric",
- props: {
- label: "Active Products",
- value: String(productsList.filter((p) => p.active).length ?? 0),
- changeType: "positive",
- },
- children: [],
- },
- filters: {
- type: "Stack",
- props: { direction: "horizontal", gap: "small" },
- children: ["refreshBtn", "createBtn"],
- },
- refreshBtn: {
- type: "Button",
- props: { label: "Refresh", action: "fetchProducts", type: "secondary" },
- children: [],
- },
- createBtn: {
- type: "Button",
- props: {
- label: "Create Product",
- action: "openDashboard",
- actionParams: { page: "products" },
- type: "primary",
- },
- children: [],
- },
- list: {
- type: "Stack",
- props: { direction: "vertical", gap: "small" },
- children: productsList.slice(0, 10).map((_, i) => `product${i}`),
- },
- pagination: {
- type: "Stack",
- props: { direction: "horizontal", gap: "small" },
- children: products?.hasMore ? ["loadMore"] : [],
- },
- loadMore: {
- type: "Button",
- props: {
- label: "Load More",
- action: "fetchProducts",
- actionParams: { limit: 10 },
- type: "secondary",
- },
- children: [],
- },
- };
- productsList.slice(0, 10).forEach((p, i) => {
- const productPrices = pricesList.filter((pr) => pr.productId === p.id);
- const priceDisplay =
- productPrices.length > 0
- ? productPrices
- .map(
- (pr) =>
- `${pr.formattedAmount}${pr.recurring ? `/${pr.recurring.interval}` : ""}`,
- )
- .join(", ")
- : "No prices";
- elements[`product${i}`] = {
- type: "Stack",
- props: { direction: "vertical", gap: "small" },
- children: [`productCard${i}`],
- };
- elements[`productCard${i}`] = {
- type: "PropertyList",
- props: { orientation: "vertical" },
- children: [
- `productName${i}`,
- `productDesc${i}`,
- `productPrice${i}`,
- `productStatus${i}`,
- ],
- };
- elements[`productName${i}`] = {
- type: "PropertyListItem",
- props: { label: "Name", value: p.name },
- children: [],
- };
- elements[`productDesc${i}`] = {
- type: "PropertyListItem",
- props: { label: "Description", value: p.description || "No description" },
- children: [],
- };
- elements[`productPrice${i}`] = {
- type: "PropertyListItem",
- props: { label: "Prices", value: priceDisplay },
- children: [],
- };
- elements[`productStatus${i}`] = {
- type: "PropertyListItem",
- props: { label: "Status", value: p.active ? "Active" : "Archived" },
- children: [],
- };
- });
- return { root: "root", elements };
- }
- // =============================================================================
- // Component
- // =============================================================================
- const Products = (_props: ExtensionContextValue) => {
- const [data, setState] = useState<Record<string, unknown>>({});
- const [spec, setSpec] = useState<Spec | null>(null);
- const [loading, setLoading] = useState(true);
- const [prompt, setPrompt] = useState("");
- const [generating, setGenerating] = useState(false);
- const handleSetState = useCallback(
- (updater: (prev: Record<string, unknown>) => Record<string, unknown>) => {
- setState((prev) => updater(prev));
- },
- [],
- );
- useEffect(() => {
- const loadData = async () => {
- setLoading(true);
- await Promise.all([
- executeAction("fetchProducts", { limit: 10 }, handleSetState, {}),
- executeAction("fetchPrices", { limit: 50 }, handleSetState, {}),
- ]);
- setLoading(false);
- };
- loadData();
- }, [handleSetState]);
- useEffect(() => {
- if (!loading && Object.keys(data).length > 0) {
- setSpec(createProductsSpec(data));
- }
- }, [data, loading]);
- const handleGenerate = async () => {
- if (!prompt.trim()) return;
- setGenerating(true);
- const systemPrompt = stripeCatalog.prompt();
- try {
- const response = await fetch(API_GENERATE_URL, {
- method: "POST",
- headers: { "Content-Type": "application/json" },
- body: JSON.stringify({ prompt, systemPrompt }),
- });
- const result = await response.json();
- if (result.spec) {
- setSpec(result.spec);
- }
- } catch (error) {
- console.error("Generation error:", error);
- setSpec(createProductsSpec(data));
- }
- setGenerating(false);
- };
- if (loading) {
- return (
- <ContextView title="Products" brandColor="#635BFF" brandIcon={BrandIcon}>
- <Box
- css={{
- stack: "y",
- gap: "medium",
- alignX: "center",
- paddingY: "xlarge",
- }}
- >
- <Spinner size="large" />
- <Box css={{ color: "secondary" }}>Loading products...</Box>
- </Box>
- </ContextView>
- );
- }
- return (
- <ContextView
- title="Products & Prices"
- brandColor="#635BFF"
- brandIcon={BrandIcon}
- actions={
- <Button
- type="primary"
- onPress={() =>
- executeAction(
- "openDashboard",
- { page: "products" },
- handleSetState,
- data,
- )
- }
- >
- Open in Dashboard
- </Button>
- }
- >
- <Box css={{ stack: "y", gap: "medium" }}>
- <Box css={{ stack: "x", gap: "small" }}>
- <Box css={{ width: "fill" }}>
- <TextField
- label=""
- placeholder="Describe the products view you want..."
- value={prompt}
- onChange={(e) => setPrompt(e.target.value)}
- />
- </Box>
- <Box css={{ alignSelfY: "center" }}>
- <Button
- type="primary"
- onPress={handleGenerate}
- disabled={generating || !prompt.trim()}
- >
- {generating ? "Generating..." : "Generate"}
- </Button>
- </Box>
- </Box>
- {spec && (
- <StripeRenderer
- spec={spec}
- data={data}
- setData={handleSetState}
- loading={generating}
- />
- )}
- </Box>
- </ContextView>
- );
- };
- export default Products;
|