mirror of
https://github.com/vercel-labs/json-render.git
synced 2026-10-03 04:18:15 +08:00
Compare commits
4
Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
660a543974 | ||
|
|
f632c7b259 | ||
|
|
3c51db2da2 | ||
|
|
1efacbdf0d |
@@ -0,0 +1,7 @@
|
||||
# AI Gateway API Key
|
||||
# Get your key from Vercel AI Gateway dashboard
|
||||
AI_GATEWAY_API_KEY=
|
||||
|
||||
# Optional: Stripe API keys if you want to use real data
|
||||
# STRIPE_SECRET_KEY=sk_test_...
|
||||
# STRIPE_PUBLISHABLE_KEY=pk_test_...
|
||||
@@ -0,0 +1,105 @@
|
||||
# Stripe App Example
|
||||
|
||||
A [Stripe App](https://stripe.com/docs/stripe-apps) example demonstrating how to use json-render to build dynamic, AI-generated UI within the Stripe Dashboard.
|
||||
|
||||
## Overview
|
||||
|
||||
This example shows how to integrate json-render with Stripe's UI Extension SDK to create dashboard views that can be dynamically generated from prompts. The app includes:
|
||||
|
||||
- **Home** - Revenue dashboard with AI-powered UI generation
|
||||
- **Customers** - Customer list and management
|
||||
- **Customer Details** - Individual customer view
|
||||
- **Payments** - Payment list and details
|
||||
- **Subscriptions** - Subscription management
|
||||
- **Invoices** - Invoice list
|
||||
- **Products** - Product catalog
|
||||
|
||||
## Features
|
||||
|
||||
- Full Stripe UIXT component catalog mapped to json-render
|
||||
- Dynamic spec generation from natural language prompts
|
||||
- Real-time data binding with Stripe API
|
||||
- Action handlers for Stripe operations (refunds, subscriptions, etc.)
|
||||
|
||||
## Getting Started
|
||||
|
||||
### Prerequisites
|
||||
|
||||
- [Stripe CLI](https://stripe.com/docs/stripe-cli) installed
|
||||
- A Stripe account with app development enabled
|
||||
|
||||
### Installation
|
||||
|
||||
```bash
|
||||
# Install dependencies
|
||||
pnpm install
|
||||
|
||||
# Start the Stripe App in development mode
|
||||
stripe apps start
|
||||
```
|
||||
|
||||
### Development
|
||||
|
||||
The app uses the Stripe UI Extension SDK which handles authentication automatically - no API keys required in your environment.
|
||||
|
||||
```bash
|
||||
# Run linting
|
||||
pnpm lint
|
||||
|
||||
# Run tests
|
||||
pnpm test
|
||||
```
|
||||
|
||||
## How It Works
|
||||
|
||||
1. **Component Catalog**: Maps json-render component types to Stripe UIXT components (`Box`, `Button`, `Badge`, etc.)
|
||||
|
||||
2. **Action Handlers**: Defines actions that can be triggered from the UI (e.g., `refundPayment`, `cancelSubscription`)
|
||||
|
||||
3. **StripeRenderer**: A custom renderer that uses the Stripe component catalog to render json-render specs
|
||||
|
||||
4. **Views**: Each view fetches data from the Stripe API and renders it using json-render specs
|
||||
|
||||
## Example Usage
|
||||
|
||||
```tsx
|
||||
import { StripeRenderer, stripeCatalog } from "./lib/render";
|
||||
import type { Spec } from "@json-render/react";
|
||||
|
||||
const spec: Spec = {
|
||||
root: "container",
|
||||
elements: {
|
||||
container: {
|
||||
key: "container",
|
||||
type: "Stack",
|
||||
props: { direction: "vertical", gap: "medium" },
|
||||
children: ["heading", "metric"],
|
||||
parentKey: null,
|
||||
},
|
||||
heading: {
|
||||
key: "heading",
|
||||
type: "Heading",
|
||||
props: { text: "Revenue", size: "large" },
|
||||
children: [],
|
||||
parentKey: "container",
|
||||
},
|
||||
metric: {
|
||||
key: "metric",
|
||||
type: "Metric",
|
||||
props: { label: "Total", value: "$12,345", format: "currency" },
|
||||
children: [],
|
||||
parentKey: "container",
|
||||
},
|
||||
},
|
||||
};
|
||||
|
||||
function MyView() {
|
||||
return <StripeRenderer spec={spec} catalog={stripeCatalog} />;
|
||||
}
|
||||
```
|
||||
|
||||
## Learn More
|
||||
|
||||
- [json-render Documentation](https://json-render.com/docs)
|
||||
- [Stripe Apps Documentation](https://stripe.com/docs/stripe-apps)
|
||||
- [Stripe UI Extension SDK](https://stripe.com/docs/stripe-apps/reference/ui-extension-sdk)
|
||||
@@ -0,0 +1,17 @@
|
||||
import { config as reactConfig } from "@repo/eslint-config/react-internal";
|
||||
|
||||
/** @type {import("eslint").Linter.Config[]} */
|
||||
export default [
|
||||
...reactConfig,
|
||||
{
|
||||
rules: {
|
||||
// Disable prop-types - we use TypeScript for type checking
|
||||
"react/prop-types": "off",
|
||||
// Allow underscore-prefixed unused variables
|
||||
"@typescript-eslint/no-unused-vars": [
|
||||
"warn",
|
||||
{ argsIgnorePattern: "^_", varsIgnorePattern: "^_" },
|
||||
],
|
||||
},
|
||||
},
|
||||
];
|
||||
@@ -0,0 +1,7 @@
|
||||
/* eslint-env node */
|
||||
/* eslint-disable @typescript-eslint/no-var-requires */
|
||||
const UIExtensionsConfig = require("@stripe/ui-extension-tools/jest.config.ui-extension");
|
||||
|
||||
module.exports = {
|
||||
...UIExtensionsConfig,
|
||||
};
|
||||
@@ -0,0 +1,26 @@
|
||||
{
|
||||
"name": "com.example.json-render-demo",
|
||||
"version": "0.0.1",
|
||||
"description": "Test",
|
||||
"private": true,
|
||||
"license": "~~proprietary~~",
|
||||
"type": "module",
|
||||
"dependencies": {
|
||||
"@json-render/core": "^0.4.0",
|
||||
"@json-render/react": "^0.4.0",
|
||||
"@stripe/ui-extension-sdk": "^9.1.0",
|
||||
"stripe": "^13.11.0"
|
||||
},
|
||||
"engines": {
|
||||
"node": ">=14"
|
||||
},
|
||||
"scripts": {
|
||||
"lint": "eslint src --max-warnings 0",
|
||||
"test": "jest"
|
||||
},
|
||||
"devDependencies": {
|
||||
"@repo/eslint-config": "workspace:*",
|
||||
"@stripe/ui-extension-tools": "^0.0.1",
|
||||
"eslint": "^9.39.0"
|
||||
}
|
||||
}
|
||||
Generated
+5270
File diff suppressed because it is too large
Load Diff
File diff suppressed because it is too large
Load Diff
File diff suppressed because it is too large
Load Diff
File diff suppressed because it is too large
Load Diff
@@ -0,0 +1,11 @@
|
||||
// Catalog
|
||||
export { stripeCatalog } from "./catalog";
|
||||
|
||||
// Components
|
||||
export { components, Fallback } from "./catalog/components";
|
||||
|
||||
// Actions
|
||||
export { actionHandlers, executeAction } from "./catalog/actions";
|
||||
|
||||
// Renderer
|
||||
export { StripeRenderer, type StripeRendererProps } from "./renderer";
|
||||
@@ -0,0 +1,142 @@
|
||||
import { useMemo, useRef, type ReactNode } from "react";
|
||||
import {
|
||||
Renderer,
|
||||
type ComponentRegistry,
|
||||
type Spec,
|
||||
DataProvider,
|
||||
VisibilityProvider,
|
||||
ActionProvider,
|
||||
} from "@json-render/react";
|
||||
|
||||
import { components, Fallback } from "./catalog/components";
|
||||
import { executeAction } from "./catalog/actions";
|
||||
|
||||
// =============================================================================
|
||||
// Types
|
||||
// =============================================================================
|
||||
|
||||
type SetData = (
|
||||
updater: (prev: Record<string, unknown>) => Record<string, unknown>,
|
||||
) => void;
|
||||
|
||||
export interface StripeRendererProps {
|
||||
/** The UI spec to render (from json-render) */
|
||||
spec: Spec | null;
|
||||
/** Data context for components */
|
||||
data?: Record<string, unknown>;
|
||||
/** Function to update data */
|
||||
setData?: SetData;
|
||||
/** Callback when data changes */
|
||||
onDataChange?: (path: string, value: unknown) => void;
|
||||
/** Whether the spec is currently loading/streaming */
|
||||
loading?: boolean;
|
||||
}
|
||||
|
||||
// =============================================================================
|
||||
// Build Registry
|
||||
// =============================================================================
|
||||
|
||||
/**
|
||||
* Build a component registry from our components map.
|
||||
* Uses refs to avoid recreating on data changes.
|
||||
*/
|
||||
function buildRegistry(
|
||||
dataRef: React.RefObject<Record<string, unknown>>,
|
||||
setDataRef: React.RefObject<SetData | undefined>,
|
||||
loading?: boolean,
|
||||
): ComponentRegistry {
|
||||
const registry: ComponentRegistry = {};
|
||||
|
||||
for (const [name, Component] of Object.entries(components)) {
|
||||
registry[name] = (renderProps: {
|
||||
element: { type: string; props: Record<string, unknown> };
|
||||
children?: ReactNode;
|
||||
onAction?: (action: {
|
||||
name: string;
|
||||
params?: Record<string, unknown>;
|
||||
}) => void;
|
||||
}) =>
|
||||
Component({
|
||||
element: renderProps.element,
|
||||
children: renderProps.children,
|
||||
onAction: (action) => {
|
||||
const setData = setDataRef.current;
|
||||
const data = dataRef.current;
|
||||
if (setData) {
|
||||
executeAction(action.name, action.params, setData, data);
|
||||
}
|
||||
},
|
||||
loading,
|
||||
data: dataRef.current,
|
||||
getValue: (path: string) => {
|
||||
const data = dataRef.current;
|
||||
const parts = path.replace(/^\//, "").split("/");
|
||||
let current: unknown = data;
|
||||
for (const part of parts) {
|
||||
if (current && typeof current === "object" && part in current) {
|
||||
current = (current as Record<string, unknown>)[part];
|
||||
} else {
|
||||
return undefined;
|
||||
}
|
||||
}
|
||||
return current;
|
||||
},
|
||||
});
|
||||
}
|
||||
|
||||
return registry;
|
||||
}
|
||||
|
||||
/**
|
||||
* Fallback component for unknown types
|
||||
*/
|
||||
const fallbackRegistry = (renderProps: {
|
||||
element: { type: string; props: Record<string, unknown> };
|
||||
}) => <Fallback element={renderProps.element} />;
|
||||
|
||||
// =============================================================================
|
||||
// StripeRenderer Component
|
||||
// =============================================================================
|
||||
|
||||
/**
|
||||
* Main renderer component for Stripe UIXT.
|
||||
*
|
||||
* Wraps the json-render Renderer with all necessary providers
|
||||
* and connects it to the Stripe component implementations.
|
||||
*/
|
||||
export function StripeRenderer({
|
||||
spec,
|
||||
data = {},
|
||||
setData,
|
||||
onDataChange,
|
||||
loading,
|
||||
}: StripeRendererProps): ReactNode {
|
||||
// Use refs to keep registry stable while still accessing latest data/setData
|
||||
const dataRef = useRef(data);
|
||||
const setDataRef = useRef(setData);
|
||||
dataRef.current = data;
|
||||
setDataRef.current = setData;
|
||||
|
||||
// Memoize registry - only changes when loading changes
|
||||
const registry = useMemo(
|
||||
() => buildRegistry(dataRef, setDataRef, loading),
|
||||
[loading],
|
||||
);
|
||||
|
||||
if (!spec) return null;
|
||||
|
||||
return (
|
||||
<DataProvider initialData={data} onDataChange={onDataChange}>
|
||||
<VisibilityProvider>
|
||||
<ActionProvider>
|
||||
<Renderer
|
||||
spec={spec}
|
||||
registry={registry}
|
||||
fallback={fallbackRegistry}
|
||||
loading={loading}
|
||||
/>
|
||||
</ActionProvider>
|
||||
</VisibilityProvider>
|
||||
</DataProvider>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,37 @@
|
||||
import Stripe from "stripe";
|
||||
import {
|
||||
createHttpClient,
|
||||
STRIPE_API_KEY,
|
||||
} from "@stripe/ui-extension-sdk/http_client";
|
||||
|
||||
/**
|
||||
* Create a Stripe client for use in Stripe Apps.
|
||||
*
|
||||
* The Stripe UI Extension SDK handles authentication automatically
|
||||
* through the httpClient - no real API key is needed.
|
||||
*/
|
||||
export const stripe = new Stripe(STRIPE_API_KEY, {
|
||||
httpClient: createHttpClient(),
|
||||
apiVersion: "2024-12-18.acacia",
|
||||
});
|
||||
|
||||
/**
|
||||
* Format amount for display (converts cents to dollars)
|
||||
*/
|
||||
export function formatAmount(amount: number, currency = "usd"): string {
|
||||
return new Intl.NumberFormat("en-US", {
|
||||
style: "currency",
|
||||
currency: currency.toUpperCase(),
|
||||
}).format(amount / 100);
|
||||
}
|
||||
|
||||
/**
|
||||
* Format date for display
|
||||
*/
|
||||
export function formatDate(timestamp: number): string {
|
||||
return new Date(timestamp * 1000).toLocaleDateString("en-US", {
|
||||
year: "numeric",
|
||||
month: "short",
|
||||
day: "numeric",
|
||||
});
|
||||
}
|
||||
@@ -0,0 +1,12 @@
|
||||
import { render, getMockContextProps } from "@stripe/ui-extension-sdk/testing";
|
||||
import { ContextView } from "@stripe/ui-extension-sdk/ui";
|
||||
|
||||
import CustomerDetails from "./CustomerDetails";
|
||||
|
||||
describe("CustomerDetailsView", () => {
|
||||
it("renders ContextView", () => {
|
||||
const { wrapper } = render(<CustomerDetails {...getMockContextProps()} />);
|
||||
|
||||
expect(wrapper.find(ContextView)).toContainText("save to reload this view");
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,535 @@
|
||||
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 { stripeCatalog, StripeRenderer } from "../lib/render";
|
||||
import { executeAction } from "../lib/render/catalog/actions";
|
||||
|
||||
// =============================================================================
|
||||
// Dynamic Spec Creation
|
||||
// =============================================================================
|
||||
|
||||
function createCustomerDetailSpec(
|
||||
data: Record<string, unknown>,
|
||||
customerId: string,
|
||||
): Spec {
|
||||
const customers = data.customers as
|
||||
| {
|
||||
data?: Array<{
|
||||
id: string;
|
||||
name: string;
|
||||
email: string;
|
||||
phone?: string;
|
||||
status: string;
|
||||
created: string;
|
||||
balance?: number;
|
||||
currency?: string;
|
||||
}>;
|
||||
}
|
||||
| undefined;
|
||||
|
||||
const customer = customers?.data?.find((c) => c.id === customerId);
|
||||
|
||||
const payments = data.customerPayments as
|
||||
| {
|
||||
data?: Array<{
|
||||
id: string;
|
||||
formattedAmount: string;
|
||||
status: string;
|
||||
description: string;
|
||||
created: string;
|
||||
}>;
|
||||
total?: number;
|
||||
}
|
||||
| undefined;
|
||||
|
||||
const subscriptions = data.customerSubscriptions as
|
||||
| {
|
||||
data?: Array<{
|
||||
id: string;
|
||||
planName: string;
|
||||
status: string;
|
||||
amount: number;
|
||||
interval: string;
|
||||
currentPeriodEnd: string;
|
||||
}>;
|
||||
total?: number;
|
||||
}
|
||||
| undefined;
|
||||
|
||||
const invoices = data.customerInvoices as
|
||||
| {
|
||||
data?: Array<{
|
||||
id: string;
|
||||
invoiceNumber: string;
|
||||
amount: number;
|
||||
status: string;
|
||||
dueDate: string;
|
||||
}>;
|
||||
total?: number;
|
||||
}
|
||||
| undefined;
|
||||
|
||||
const paymentsList = payments?.data ?? [];
|
||||
const subscriptionsList = subscriptions?.data ?? [];
|
||||
const invoicesList = invoices?.data ?? [];
|
||||
|
||||
const elements: Spec["elements"] = {
|
||||
root: {
|
||||
key: "root",
|
||||
type: "Stack",
|
||||
props: { direction: "vertical", gap: "large" },
|
||||
children: ["header", "details", "sections"],
|
||||
parentKey: null,
|
||||
},
|
||||
header: {
|
||||
key: "header",
|
||||
type: "Stack",
|
||||
props: {
|
||||
direction: "horizontal",
|
||||
gap: "medium",
|
||||
distribute: "space-between",
|
||||
},
|
||||
children: ["customerInfo", "actions"],
|
||||
parentKey: "root",
|
||||
},
|
||||
customerInfo: {
|
||||
key: "customerInfo",
|
||||
type: "Stack",
|
||||
props: { direction: "vertical", gap: "xsmall" },
|
||||
children: ["customerName", "customerEmail"],
|
||||
parentKey: "header",
|
||||
},
|
||||
customerName: {
|
||||
key: "customerName",
|
||||
type: "Heading",
|
||||
props: { text: customer?.name ?? "Unknown Customer", size: "xlarge" },
|
||||
children: [],
|
||||
parentKey: "customerInfo",
|
||||
},
|
||||
customerEmail: {
|
||||
key: "customerEmail",
|
||||
type: "Text",
|
||||
props: { content: customer?.email ?? "", color: "secondary" },
|
||||
children: [],
|
||||
parentKey: "customerInfo",
|
||||
},
|
||||
actions: {
|
||||
key: "actions",
|
||||
type: "Stack",
|
||||
props: { direction: "horizontal", gap: "small" },
|
||||
children: ["editBtn", "portalBtn"],
|
||||
parentKey: "header",
|
||||
},
|
||||
editBtn: {
|
||||
key: "editBtn",
|
||||
type: "Button",
|
||||
props: {
|
||||
label: "Edit",
|
||||
action: "viewCustomer",
|
||||
actionParams: { customerId },
|
||||
type: "secondary",
|
||||
},
|
||||
children: [],
|
||||
parentKey: "actions",
|
||||
},
|
||||
portalBtn: {
|
||||
key: "portalBtn",
|
||||
type: "Button",
|
||||
props: {
|
||||
label: "Billing Portal",
|
||||
action: "createBillingPortalSession",
|
||||
actionParams: { customerId, returnUrl: window.location.href },
|
||||
type: "primary",
|
||||
},
|
||||
children: [],
|
||||
parentKey: "actions",
|
||||
},
|
||||
details: {
|
||||
key: "details",
|
||||
type: "PropertyList",
|
||||
props: { orientation: "horizontal" },
|
||||
children: ["detailStatus", "detailCreated", "detailBalance"],
|
||||
parentKey: "root",
|
||||
},
|
||||
detailStatus: {
|
||||
key: "detailStatus",
|
||||
type: "PropertyListItem",
|
||||
props: { label: "Status", value: customer?.status ?? "Unknown" },
|
||||
children: [],
|
||||
parentKey: "details",
|
||||
},
|
||||
detailCreated: {
|
||||
key: "detailCreated",
|
||||
type: "PropertyListItem",
|
||||
props: { label: "Created", value: customer?.created ?? "Unknown" },
|
||||
children: [],
|
||||
parentKey: "details",
|
||||
},
|
||||
detailBalance: {
|
||||
key: "detailBalance",
|
||||
type: "PropertyListItem",
|
||||
props: {
|
||||
label: "Balance",
|
||||
value: customer?.balance
|
||||
? `$${(customer.balance / 100).toFixed(2)}`
|
||||
: "$0.00",
|
||||
},
|
||||
children: [],
|
||||
parentKey: "details",
|
||||
},
|
||||
sections: {
|
||||
key: "sections",
|
||||
type: "Accordion",
|
||||
props: {},
|
||||
children: ["paymentsSection", "subscriptionsSection", "invoicesSection"],
|
||||
parentKey: "root",
|
||||
},
|
||||
|
||||
// Payments Section
|
||||
paymentsSection: {
|
||||
key: "paymentsSection",
|
||||
type: "AccordionItem",
|
||||
props: { title: `Payments (${payments?.total ?? 0})`, defaultOpen: true },
|
||||
children: paymentsList.length > 0 ? ["paymentsList"] : ["noPayments"],
|
||||
parentKey: "sections",
|
||||
},
|
||||
paymentsList: {
|
||||
key: "paymentsList",
|
||||
type: "Stack",
|
||||
props: { direction: "vertical", gap: "small" },
|
||||
children: paymentsList.slice(0, 5).map((_, i) => `payment${i}`),
|
||||
parentKey: "paymentsSection",
|
||||
},
|
||||
noPayments: {
|
||||
key: "noPayments",
|
||||
type: "Text",
|
||||
props: { content: "No payments found", color: "secondary" },
|
||||
children: [],
|
||||
parentKey: "paymentsSection",
|
||||
},
|
||||
|
||||
// Subscriptions Section
|
||||
subscriptionsSection: {
|
||||
key: "subscriptionsSection",
|
||||
type: "AccordionItem",
|
||||
props: {
|
||||
title: `Subscriptions (${subscriptions?.total ?? 0})`,
|
||||
defaultOpen: false,
|
||||
},
|
||||
children:
|
||||
subscriptionsList.length > 0
|
||||
? ["subscriptionsList"]
|
||||
: ["noSubscriptions"],
|
||||
parentKey: "sections",
|
||||
},
|
||||
subscriptionsList: {
|
||||
key: "subscriptionsList",
|
||||
type: "Stack",
|
||||
props: { direction: "vertical", gap: "small" },
|
||||
children: subscriptionsList.slice(0, 5).map((_, i) => `subscription${i}`),
|
||||
parentKey: "subscriptionsSection",
|
||||
},
|
||||
noSubscriptions: {
|
||||
key: "noSubscriptions",
|
||||
type: "Text",
|
||||
props: { content: "No subscriptions found", color: "secondary" },
|
||||
children: [],
|
||||
parentKey: "subscriptionsSection",
|
||||
},
|
||||
|
||||
// Invoices Section
|
||||
invoicesSection: {
|
||||
key: "invoicesSection",
|
||||
type: "AccordionItem",
|
||||
props: {
|
||||
title: `Invoices (${invoices?.total ?? 0})`,
|
||||
defaultOpen: false,
|
||||
},
|
||||
children: invoicesList.length > 0 ? ["invoicesList"] : ["noInvoices"],
|
||||
parentKey: "sections",
|
||||
},
|
||||
invoicesList: {
|
||||
key: "invoicesList",
|
||||
type: "Stack",
|
||||
props: { direction: "vertical", gap: "small" },
|
||||
children: invoicesList.slice(0, 5).map((_, i) => `invoice${i}`),
|
||||
parentKey: "invoicesSection",
|
||||
},
|
||||
noInvoices: {
|
||||
key: "noInvoices",
|
||||
type: "Text",
|
||||
props: { content: "No invoices found", color: "secondary" },
|
||||
children: [],
|
||||
parentKey: "invoicesSection",
|
||||
},
|
||||
};
|
||||
|
||||
// Add payment cards
|
||||
paymentsList.slice(0, 5).forEach((p, i) => {
|
||||
elements[`payment${i}`] = {
|
||||
key: `payment${i}`,
|
||||
type: "PaymentCard",
|
||||
props: {
|
||||
amount: 0,
|
||||
currency: "usd",
|
||||
status: p.status as "succeeded" | "pending" | "failed",
|
||||
description: `${p.formattedAmount} - ${p.description}`,
|
||||
paymentId: p.id,
|
||||
},
|
||||
children: [],
|
||||
parentKey: "paymentsList",
|
||||
};
|
||||
});
|
||||
|
||||
// Add subscription cards
|
||||
subscriptionsList.slice(0, 5).forEach((s, i) => {
|
||||
elements[`subscription${i}`] = {
|
||||
key: `subscription${i}`,
|
||||
type: "SubscriptionCard",
|
||||
props: {
|
||||
planName: s.planName,
|
||||
status: s.status as "active" | "trialing" | "past_due" | "canceled",
|
||||
amount: s.amount,
|
||||
currency: "usd",
|
||||
interval: s.interval as "month" | "year",
|
||||
currentPeriodEnd: s.currentPeriodEnd,
|
||||
},
|
||||
children: [],
|
||||
parentKey: "subscriptionsList",
|
||||
};
|
||||
});
|
||||
|
||||
// Add invoice cards
|
||||
invoicesList.slice(0, 5).forEach((inv, i) => {
|
||||
elements[`invoice${i}`] = {
|
||||
key: `invoice${i}`,
|
||||
type: "InvoiceCard",
|
||||
props: {
|
||||
invoiceNumber: inv.invoiceNumber,
|
||||
amount: inv.amount,
|
||||
currency: "usd",
|
||||
status: inv.status as "draft" | "open" | "paid" | "void",
|
||||
dueDate: inv.dueDate,
|
||||
},
|
||||
children: [],
|
||||
parentKey: "invoicesList",
|
||||
};
|
||||
});
|
||||
|
||||
return { root: "root", elements };
|
||||
}
|
||||
|
||||
// =============================================================================
|
||||
// Component
|
||||
// =============================================================================
|
||||
|
||||
const CustomerDetails = ({ environment }: ExtensionContextValue) => {
|
||||
const [data, setData] = 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);
|
||||
|
||||
// Get customer ID from context
|
||||
const customerId = environment?.objectContext?.id ?? "";
|
||||
|
||||
// Wrap setData for action handlers
|
||||
const handleSetData = useCallback(
|
||||
(updater: (prev: Record<string, unknown>) => Record<string, unknown>) => {
|
||||
setData((prev) => updater(prev));
|
||||
},
|
||||
[],
|
||||
);
|
||||
|
||||
// Load customer data
|
||||
useEffect(() => {
|
||||
const loadData = async () => {
|
||||
if (!customerId) {
|
||||
setLoading(false);
|
||||
return;
|
||||
}
|
||||
|
||||
setLoading(true);
|
||||
|
||||
// Fetch customer and related data
|
||||
await Promise.all([
|
||||
executeAction(
|
||||
"fetchCustomers",
|
||||
{ email: null, limit: 100 },
|
||||
handleSetData,
|
||||
{},
|
||||
),
|
||||
executeAction(
|
||||
"fetchPayments",
|
||||
{ customerId, limit: 10 },
|
||||
(updater) => {
|
||||
setData((prev) => {
|
||||
const next = updater(prev);
|
||||
return { ...prev, customerPayments: next.payments };
|
||||
});
|
||||
},
|
||||
{},
|
||||
),
|
||||
executeAction(
|
||||
"fetchSubscriptions",
|
||||
{ customerId, limit: 10 },
|
||||
(updater) => {
|
||||
setData((prev) => {
|
||||
const next = updater(prev);
|
||||
return { ...prev, customerSubscriptions: next.subscriptions };
|
||||
});
|
||||
},
|
||||
{},
|
||||
),
|
||||
executeAction(
|
||||
"fetchInvoices",
|
||||
{ customerId, limit: 10 },
|
||||
(updater) => {
|
||||
setData((prev) => {
|
||||
const next = updater(prev);
|
||||
return { ...prev, customerInvoices: next.invoices };
|
||||
});
|
||||
},
|
||||
{},
|
||||
),
|
||||
]);
|
||||
|
||||
setLoading(false);
|
||||
};
|
||||
loadData();
|
||||
}, [customerId, handleSetData]);
|
||||
|
||||
// Update spec when data changes
|
||||
useEffect(() => {
|
||||
if (!loading && customerId) {
|
||||
setSpec(createCustomerDetailSpec(data, customerId));
|
||||
}
|
||||
}, [data, loading, customerId]);
|
||||
|
||||
// Generate custom UI
|
||||
const handleGenerate = async () => {
|
||||
if (!prompt.trim()) return;
|
||||
setGenerating(true);
|
||||
|
||||
const systemPrompt = stripeCatalog.prompt();
|
||||
|
||||
try {
|
||||
const response = await fetch("/api/generate", {
|
||||
method: "POST",
|
||||
headers: { "Content-Type": "application/json" },
|
||||
body: JSON.stringify({
|
||||
prompt: `For customer ${customerId}: ${prompt}`,
|
||||
systemPrompt,
|
||||
}),
|
||||
});
|
||||
|
||||
const result = await response.json();
|
||||
if (result.spec) {
|
||||
setSpec(result.spec);
|
||||
}
|
||||
} catch (error) {
|
||||
console.error("Generation error:", error);
|
||||
setSpec(createCustomerDetailSpec(data, customerId));
|
||||
}
|
||||
|
||||
setGenerating(false);
|
||||
};
|
||||
|
||||
if (!customerId) {
|
||||
return (
|
||||
<ContextView
|
||||
title="Customer Details"
|
||||
brandColor="#635BFF"
|
||||
brandIcon={BrandIcon}
|
||||
>
|
||||
<Box css={{ color: "secondary", padding: "large" }}>
|
||||
No customer selected. Please select a customer from the list.
|
||||
</Box>
|
||||
</ContextView>
|
||||
);
|
||||
}
|
||||
|
||||
if (loading) {
|
||||
return (
|
||||
<ContextView
|
||||
title="Customer Details"
|
||||
brandColor="#635BFF"
|
||||
brandIcon={BrandIcon}
|
||||
>
|
||||
<Box
|
||||
css={{
|
||||
stack: "y",
|
||||
gap: "medium",
|
||||
alignX: "center",
|
||||
paddingY: "xlarge",
|
||||
}}
|
||||
>
|
||||
<Spinner size="large" />
|
||||
<Box css={{ color: "secondary" }}>Loading customer details...</Box>
|
||||
</Box>
|
||||
</ContextView>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<ContextView
|
||||
title="Customer Details"
|
||||
brandColor="#635BFF"
|
||||
brandIcon={BrandIcon}
|
||||
actions={
|
||||
<Button
|
||||
type="primary"
|
||||
onPress={() =>
|
||||
executeAction("viewCustomer", { customerId }, handleSetData, data)
|
||||
}
|
||||
>
|
||||
View in Dashboard
|
||||
</Button>
|
||||
}
|
||||
>
|
||||
<Box css={{ stack: "y", gap: "medium" }}>
|
||||
{/* AI Generation Input */}
|
||||
<Box css={{ stack: "x", gap: "small" }}>
|
||||
<Box css={{ width: "fill" }}>
|
||||
<TextField
|
||||
label=""
|
||||
placeholder="Describe the customer 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>
|
||||
|
||||
{/* Rendered Spec */}
|
||||
{spec && (
|
||||
<StripeRenderer
|
||||
spec={spec}
|
||||
data={data}
|
||||
setData={handleSetData}
|
||||
loading={generating}
|
||||
/>
|
||||
)}
|
||||
</Box>
|
||||
</ContextView>
|
||||
);
|
||||
};
|
||||
|
||||
export default CustomerDetails;
|
||||
@@ -0,0 +1,12 @@
|
||||
import { render, getMockContextProps } from "@stripe/ui-extension-sdk/testing";
|
||||
import { ContextView } from "@stripe/ui-extension-sdk/ui";
|
||||
|
||||
import Customers from "./Customers";
|
||||
|
||||
describe("CustomersView", () => {
|
||||
it("renders ContextView", () => {
|
||||
const { wrapper } = render(<Customers {...getMockContextProps()} />);
|
||||
|
||||
expect(wrapper.find(ContextView)).toContainText("save to reload this view");
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,295 @@
|
||||
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 { stripeCatalog, StripeRenderer } from "../lib/render";
|
||||
import { executeAction } from "../lib/render/catalog/actions";
|
||||
|
||||
// =============================================================================
|
||||
// Dynamic Spec Creation
|
||||
// =============================================================================
|
||||
|
||||
function createCustomersListSpec(data: Record<string, unknown>): Spec {
|
||||
const customers = data.customers as
|
||||
| {
|
||||
data?: Array<{
|
||||
id: string;
|
||||
name: string;
|
||||
email: string;
|
||||
status: string;
|
||||
created: string;
|
||||
}>;
|
||||
total?: number;
|
||||
hasMore?: boolean;
|
||||
}
|
||||
| undefined;
|
||||
|
||||
const customerList = customers?.data ?? [];
|
||||
|
||||
const elements: Spec["elements"] = {
|
||||
root: {
|
||||
key: "root",
|
||||
type: "Stack",
|
||||
props: { direction: "vertical", gap: "large" },
|
||||
children: ["heading", "stats", "actions", "list", "pagination"],
|
||||
parentKey: null,
|
||||
},
|
||||
heading: {
|
||||
key: "heading",
|
||||
type: "Heading",
|
||||
props: { text: "Customer Directory", size: "xlarge" },
|
||||
children: [],
|
||||
parentKey: "root",
|
||||
},
|
||||
stats: {
|
||||
key: "stats",
|
||||
type: "Stack",
|
||||
props: { direction: "horizontal", gap: "medium" },
|
||||
children: ["totalMetric"],
|
||||
parentKey: "root",
|
||||
},
|
||||
totalMetric: {
|
||||
key: "totalMetric",
|
||||
type: "Metric",
|
||||
props: {
|
||||
label: "Total Customers",
|
||||
value: String(customers?.total ?? 0),
|
||||
changeType: "neutral",
|
||||
},
|
||||
children: [],
|
||||
parentKey: "stats",
|
||||
},
|
||||
actions: {
|
||||
key: "actions",
|
||||
type: "Stack",
|
||||
props: { direction: "horizontal", gap: "small" },
|
||||
children: ["refreshBtn", "exportBtn"],
|
||||
parentKey: "root",
|
||||
},
|
||||
refreshBtn: {
|
||||
key: "refreshBtn",
|
||||
type: "Button",
|
||||
props: {
|
||||
label: "Refresh",
|
||||
action: "refreshCustomers",
|
||||
type: "secondary",
|
||||
},
|
||||
children: [],
|
||||
parentKey: "actions",
|
||||
},
|
||||
exportBtn: {
|
||||
key: "exportBtn",
|
||||
type: "Button",
|
||||
props: {
|
||||
label: "Export CSV",
|
||||
action: "exportData",
|
||||
actionParams: { format: "csv", dataType: "customers" },
|
||||
type: "secondary",
|
||||
},
|
||||
children: [],
|
||||
parentKey: "actions",
|
||||
},
|
||||
list: {
|
||||
key: "list",
|
||||
type: "Stack",
|
||||
props: { direction: "vertical", gap: "small" },
|
||||
children: customerList.slice(0, 10).map((_, i) => `customer${i}`),
|
||||
parentKey: "root",
|
||||
},
|
||||
pagination: {
|
||||
key: "pagination",
|
||||
type: "Stack",
|
||||
props: { direction: "horizontal", gap: "small" },
|
||||
children: customers?.hasMore ? ["loadMore"] : [],
|
||||
parentKey: "root",
|
||||
},
|
||||
loadMore: {
|
||||
key: "loadMore",
|
||||
type: "Button",
|
||||
props: {
|
||||
label: "Load More",
|
||||
action: "fetchCustomers",
|
||||
actionParams: {
|
||||
limit: 10,
|
||||
startingAfter: customerList[customerList.length - 1]?.id,
|
||||
},
|
||||
type: "secondary",
|
||||
},
|
||||
children: [],
|
||||
parentKey: "pagination",
|
||||
},
|
||||
};
|
||||
|
||||
customerList.slice(0, 10).forEach((c, i) => {
|
||||
elements[`customer${i}`] = {
|
||||
key: `customer${i}`,
|
||||
type: "CustomerCard",
|
||||
props: {
|
||||
name: c.name,
|
||||
email: c.email,
|
||||
status: c.status as "active" | "inactive",
|
||||
customerId: c.id,
|
||||
},
|
||||
children: [],
|
||||
parentKey: "list",
|
||||
};
|
||||
});
|
||||
|
||||
return { root: "root", elements };
|
||||
}
|
||||
|
||||
// =============================================================================
|
||||
// Component
|
||||
// =============================================================================
|
||||
|
||||
const Customers = (_props: ExtensionContextValue) => {
|
||||
const [data, setData] = 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);
|
||||
|
||||
// Wrap setData for action handlers
|
||||
const handleSetData = useCallback(
|
||||
(updater: (prev: Record<string, unknown>) => Record<string, unknown>) => {
|
||||
setData((prev) => {
|
||||
const next = updater(prev);
|
||||
return next;
|
||||
});
|
||||
},
|
||||
[],
|
||||
);
|
||||
|
||||
// Load initial data
|
||||
useEffect(() => {
|
||||
const loadData = async () => {
|
||||
setLoading(true);
|
||||
await executeAction("fetchCustomers", { limit: 10 }, handleSetData, {});
|
||||
setLoading(false);
|
||||
};
|
||||
loadData();
|
||||
}, [handleSetData]);
|
||||
|
||||
// Update spec when data changes
|
||||
useEffect(() => {
|
||||
if (!loading && Object.keys(data).length > 0) {
|
||||
setSpec(createCustomersListSpec(data));
|
||||
}
|
||||
}, [data, loading]);
|
||||
|
||||
// Generate custom UI
|
||||
const handleGenerate = async () => {
|
||||
if (!prompt.trim()) return;
|
||||
setGenerating(true);
|
||||
|
||||
// Use catalog prompt for system context
|
||||
const systemPrompt = stripeCatalog.prompt();
|
||||
|
||||
try {
|
||||
const response = await fetch("/api/generate", {
|
||||
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);
|
||||
// Fallback to data-driven spec
|
||||
setSpec(createCustomersListSpec(data));
|
||||
}
|
||||
|
||||
setGenerating(false);
|
||||
};
|
||||
|
||||
if (loading) {
|
||||
return (
|
||||
<ContextView
|
||||
title="Customer Directory"
|
||||
brandColor="#635BFF"
|
||||
brandIcon={BrandIcon}
|
||||
>
|
||||
<Box
|
||||
css={{
|
||||
stack: "y",
|
||||
gap: "medium",
|
||||
alignX: "center",
|
||||
paddingY: "xlarge",
|
||||
}}
|
||||
>
|
||||
<Spinner size="large" />
|
||||
<Box css={{ color: "secondary" }}>Loading customers...</Box>
|
||||
</Box>
|
||||
</ContextView>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<ContextView
|
||||
title="Customer Directory"
|
||||
brandColor="#635BFF"
|
||||
brandIcon={BrandIcon}
|
||||
actions={
|
||||
<Button
|
||||
type="primary"
|
||||
onPress={() =>
|
||||
executeAction(
|
||||
"openDashboard",
|
||||
{ page: "customers" },
|
||||
handleSetData,
|
||||
data,
|
||||
)
|
||||
}
|
||||
>
|
||||
Open in Dashboard
|
||||
</Button>
|
||||
}
|
||||
>
|
||||
<Box css={{ stack: "y", gap: "medium" }}>
|
||||
{/* AI Generation Input */}
|
||||
<Box css={{ stack: "x", gap: "small" }}>
|
||||
<Box css={{ width: "fill" }}>
|
||||
<TextField
|
||||
label=""
|
||||
placeholder="Describe the customer 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>
|
||||
|
||||
{/* Rendered Spec */}
|
||||
{spec && (
|
||||
<StripeRenderer
|
||||
spec={spec}
|
||||
data={data}
|
||||
setData={handleSetData}
|
||||
loading={generating}
|
||||
/>
|
||||
)}
|
||||
</Box>
|
||||
</ContextView>
|
||||
);
|
||||
};
|
||||
|
||||
export default Customers;
|
||||
@@ -0,0 +1,12 @@
|
||||
import { render, getMockContextProps } from "@stripe/ui-extension-sdk/testing";
|
||||
import { ContextView } from "@stripe/ui-extension-sdk/ui";
|
||||
|
||||
import Home from "./Home";
|
||||
|
||||
describe("DashboardHomepageView", () => {
|
||||
it("renders ContextView", () => {
|
||||
const { wrapper } = render(<Home {...getMockContextProps()} />);
|
||||
|
||||
expect(wrapper.find(ContextView)).toContainText("save to reload this view");
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,781 @@
|
||||
import { useState, useCallback, useEffect } from "react";
|
||||
import type { Spec } from "@json-render/react";
|
||||
import {
|
||||
Box,
|
||||
ContextView,
|
||||
Divider,
|
||||
Button,
|
||||
TextField,
|
||||
Tabs,
|
||||
TabList,
|
||||
Tab,
|
||||
TabPanels,
|
||||
TabPanel,
|
||||
List,
|
||||
ListItem,
|
||||
Spinner,
|
||||
} from "@stripe/ui-extension-sdk/ui";
|
||||
import type { ExtensionContextValue } from "@stripe/ui-extension-sdk/context";
|
||||
|
||||
import BrandIcon from "./brand_icon.svg";
|
||||
import { stripeCatalog, StripeRenderer } from "../lib/render";
|
||||
import { executeAction } from "../lib/render/catalog/actions";
|
||||
|
||||
// =============================================================================
|
||||
// Dynamic Specs (use real data from context)
|
||||
// =============================================================================
|
||||
|
||||
function createRevenueSpec(data: Record<string, unknown>): Spec {
|
||||
const customers = data.customers as { total?: number } | undefined;
|
||||
const payments = data.payments as
|
||||
| { totalVolume?: string; successRate?: string }
|
||||
| undefined;
|
||||
const subscriptions = data.subscriptions as
|
||||
| { active?: number; trialing?: number; pastDue?: number }
|
||||
| undefined;
|
||||
|
||||
return {
|
||||
root: "root",
|
||||
elements: {
|
||||
root: {
|
||||
key: "root",
|
||||
type: "Stack",
|
||||
props: { direction: "vertical", gap: "large" },
|
||||
children: ["heading", "metrics", "refresh"],
|
||||
parentKey: null,
|
||||
},
|
||||
heading: {
|
||||
key: "heading",
|
||||
type: "Heading",
|
||||
props: { text: "Revenue Dashboard", size: "xlarge" },
|
||||
children: [],
|
||||
parentKey: "root",
|
||||
},
|
||||
metrics: {
|
||||
key: "metrics",
|
||||
type: "Stack",
|
||||
props: { direction: "horizontal", gap: "medium" },
|
||||
children: ["m1", "m2", "m3"],
|
||||
parentKey: "root",
|
||||
},
|
||||
m1: {
|
||||
key: "m1",
|
||||
type: "Metric",
|
||||
props: {
|
||||
label: "Payment Volume",
|
||||
value: payments?.totalVolume ?? "$0",
|
||||
change: payments?.successRate
|
||||
? `${payments.successRate} success`
|
||||
: null,
|
||||
changeType: "positive",
|
||||
},
|
||||
children: [],
|
||||
parentKey: "metrics",
|
||||
},
|
||||
m2: {
|
||||
key: "m2",
|
||||
type: "Metric",
|
||||
props: {
|
||||
label: "Active Subscriptions",
|
||||
value: String(subscriptions?.active ?? 0),
|
||||
change: subscriptions?.trialing
|
||||
? `+${subscriptions.trialing} trialing`
|
||||
: null,
|
||||
changeType: "positive",
|
||||
},
|
||||
children: [],
|
||||
parentKey: "metrics",
|
||||
},
|
||||
m3: {
|
||||
key: "m3",
|
||||
type: "Metric",
|
||||
props: {
|
||||
label: "Total Customers",
|
||||
value: String(customers?.total ?? 0),
|
||||
changeType: "neutral",
|
||||
},
|
||||
children: [],
|
||||
parentKey: "metrics",
|
||||
},
|
||||
refresh: {
|
||||
key: "refresh",
|
||||
type: "Button",
|
||||
props: {
|
||||
label: "Refresh Data",
|
||||
action: "refreshData",
|
||||
type: "secondary",
|
||||
},
|
||||
children: [],
|
||||
parentKey: "root",
|
||||
},
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
function createPaymentsSpec(data: Record<string, unknown>): Spec {
|
||||
const payments = data.payments as
|
||||
| {
|
||||
data?: Array<{
|
||||
id: string;
|
||||
formattedAmount: string;
|
||||
status: string;
|
||||
description: string;
|
||||
}>;
|
||||
totalVolume?: string;
|
||||
successRate?: string;
|
||||
}
|
||||
| undefined;
|
||||
const paymentsList = payments?.data ?? [];
|
||||
|
||||
const elements: Spec["elements"] = {
|
||||
root: {
|
||||
key: "root",
|
||||
type: "Stack",
|
||||
props: { direction: "vertical", gap: "large" },
|
||||
children: ["heading", "stats", "payments", "refresh"],
|
||||
parentKey: null,
|
||||
},
|
||||
heading: {
|
||||
key: "heading",
|
||||
type: "Heading",
|
||||
props: { text: "Recent Payments", size: "xlarge" },
|
||||
children: [],
|
||||
parentKey: "root",
|
||||
},
|
||||
stats: {
|
||||
key: "stats",
|
||||
type: "Stack",
|
||||
props: { direction: "horizontal", gap: "medium" },
|
||||
children: ["s1", "s2"],
|
||||
parentKey: "root",
|
||||
},
|
||||
s1: {
|
||||
key: "s1",
|
||||
type: "Metric",
|
||||
props: {
|
||||
label: "Total Volume",
|
||||
value: payments?.totalVolume ?? "$0",
|
||||
changeType: "positive",
|
||||
},
|
||||
children: [],
|
||||
parentKey: "stats",
|
||||
},
|
||||
s2: {
|
||||
key: "s2",
|
||||
type: "Metric",
|
||||
props: {
|
||||
label: "Success Rate",
|
||||
value: payments?.successRate ?? "0%",
|
||||
changeType: "positive",
|
||||
},
|
||||
children: [],
|
||||
parentKey: "stats",
|
||||
},
|
||||
payments: {
|
||||
key: "payments",
|
||||
type: "Stack",
|
||||
props: { direction: "vertical", gap: "small" },
|
||||
children: paymentsList.slice(0, 5).map((_, i) => `p${i}`),
|
||||
parentKey: "root",
|
||||
},
|
||||
refresh: {
|
||||
key: "refresh",
|
||||
type: "Button",
|
||||
props: {
|
||||
label: "Refresh Payments",
|
||||
action: "fetchPayments",
|
||||
type: "secondary",
|
||||
},
|
||||
children: [],
|
||||
parentKey: "root",
|
||||
},
|
||||
};
|
||||
|
||||
paymentsList.slice(0, 5).forEach((p, i) => {
|
||||
elements[`p${i}`] = {
|
||||
key: `p${i}`,
|
||||
type: "PaymentCard",
|
||||
props: {
|
||||
amount: 0, // We'll use formattedAmount in description
|
||||
currency: "usd",
|
||||
status: p.status as "succeeded" | "pending" | "failed",
|
||||
description: `${p.formattedAmount} - ${p.description}`,
|
||||
paymentId: p.id,
|
||||
},
|
||||
children: [],
|
||||
parentKey: "payments",
|
||||
};
|
||||
});
|
||||
|
||||
return { root: "root", elements };
|
||||
}
|
||||
|
||||
function createSubscriptionsSpec(data: Record<string, unknown>): Spec {
|
||||
const subscriptions = data.subscriptions as
|
||||
| {
|
||||
data?: Array<{
|
||||
id: string;
|
||||
planName: string;
|
||||
status: string;
|
||||
amount: number;
|
||||
interval: string;
|
||||
}>;
|
||||
active?: number;
|
||||
trialing?: number;
|
||||
pastDue?: number;
|
||||
}
|
||||
| undefined;
|
||||
const subsList = subscriptions?.data ?? [];
|
||||
|
||||
const elements: Spec["elements"] = {
|
||||
root: {
|
||||
key: "root",
|
||||
type: "Stack",
|
||||
props: { direction: "vertical", gap: "large" },
|
||||
children: ["heading", "metrics", "alert", "subs", "refresh"],
|
||||
parentKey: null,
|
||||
},
|
||||
heading: {
|
||||
key: "heading",
|
||||
type: "Heading",
|
||||
props: { text: "Subscriptions Overview", size: "xlarge" },
|
||||
children: [],
|
||||
parentKey: "root",
|
||||
},
|
||||
metrics: {
|
||||
key: "metrics",
|
||||
type: "Stack",
|
||||
props: { direction: "horizontal", gap: "medium" },
|
||||
children: ["m1", "m2", "m3"],
|
||||
parentKey: "root",
|
||||
},
|
||||
m1: {
|
||||
key: "m1",
|
||||
type: "Metric",
|
||||
props: {
|
||||
label: "Active",
|
||||
value: String(subscriptions?.active ?? 0),
|
||||
changeType: "positive",
|
||||
},
|
||||
children: [],
|
||||
parentKey: "metrics",
|
||||
},
|
||||
m2: {
|
||||
key: "m2",
|
||||
type: "Metric",
|
||||
props: {
|
||||
label: "Trialing",
|
||||
value: String(subscriptions?.trialing ?? 0),
|
||||
changeType: "positive",
|
||||
},
|
||||
children: [],
|
||||
parentKey: "metrics",
|
||||
},
|
||||
m3: {
|
||||
key: "m3",
|
||||
type: "Metric",
|
||||
props: {
|
||||
label: "Past Due",
|
||||
value: String(subscriptions?.pastDue ?? 0),
|
||||
changeType: subscriptions?.pastDue ? "negative" : "neutral",
|
||||
},
|
||||
children: [],
|
||||
parentKey: "metrics",
|
||||
},
|
||||
alert: {
|
||||
key: "alert",
|
||||
type: "Banner",
|
||||
props: {
|
||||
title: `${subscriptions?.trialing ?? 0} subscriptions currently trialing`,
|
||||
type: "default",
|
||||
},
|
||||
children: [],
|
||||
parentKey: "root",
|
||||
},
|
||||
subs: {
|
||||
key: "subs",
|
||||
type: "Stack",
|
||||
props: { direction: "vertical", gap: "small" },
|
||||
children: subsList.slice(0, 5).map((_, i) => `sub${i}`),
|
||||
parentKey: "root",
|
||||
},
|
||||
refresh: {
|
||||
key: "refresh",
|
||||
type: "Button",
|
||||
props: {
|
||||
label: "Refresh Subscriptions",
|
||||
action: "fetchSubscriptions",
|
||||
type: "secondary",
|
||||
},
|
||||
children: [],
|
||||
parentKey: "root",
|
||||
},
|
||||
};
|
||||
|
||||
subsList.slice(0, 5).forEach((s, i) => {
|
||||
elements[`sub${i}`] = {
|
||||
key: `sub${i}`,
|
||||
type: "SubscriptionCard",
|
||||
props: {
|
||||
planName: s.planName,
|
||||
status: s.status as "active" | "trialing" | "past_due" | "canceled",
|
||||
amount: s.amount,
|
||||
interval: (s.interval as "month" | "year") ?? "month",
|
||||
},
|
||||
children: [],
|
||||
parentKey: "subs",
|
||||
};
|
||||
});
|
||||
|
||||
return { root: "root", elements };
|
||||
}
|
||||
|
||||
function createCustomersSpec(data: Record<string, unknown>): Spec {
|
||||
const customers = data.customers as
|
||||
| {
|
||||
data?: Array<{
|
||||
id: string;
|
||||
name: string;
|
||||
email: string;
|
||||
status: string;
|
||||
}>;
|
||||
total?: number;
|
||||
}
|
||||
| undefined;
|
||||
const customersList = customers?.data ?? [];
|
||||
|
||||
const elements: Spec["elements"] = {
|
||||
root: {
|
||||
key: "root",
|
||||
type: "Stack",
|
||||
props: { direction: "vertical", gap: "large" },
|
||||
children: ["heading", "stats", "customers", "refresh"],
|
||||
parentKey: null,
|
||||
},
|
||||
heading: {
|
||||
key: "heading",
|
||||
type: "Heading",
|
||||
props: { text: "Customer Directory", size: "xlarge" },
|
||||
children: [],
|
||||
parentKey: "root",
|
||||
},
|
||||
stats: {
|
||||
key: "stats",
|
||||
type: "Stack",
|
||||
props: { direction: "horizontal", gap: "medium" },
|
||||
children: ["s1"],
|
||||
parentKey: "root",
|
||||
},
|
||||
s1: {
|
||||
key: "s1",
|
||||
type: "Metric",
|
||||
props: {
|
||||
label: "Total Customers",
|
||||
value: String(customers?.total ?? 0),
|
||||
changeType: "neutral",
|
||||
},
|
||||
children: [],
|
||||
parentKey: "stats",
|
||||
},
|
||||
customers: {
|
||||
key: "customers",
|
||||
type: "Stack",
|
||||
props: { direction: "vertical", gap: "small" },
|
||||
children: customersList.slice(0, 5).map((_, i) => `c${i}`),
|
||||
parentKey: "root",
|
||||
},
|
||||
refresh: {
|
||||
key: "refresh",
|
||||
type: "Button",
|
||||
props: {
|
||||
label: "Refresh Customers",
|
||||
action: "fetchCustomers",
|
||||
type: "secondary",
|
||||
},
|
||||
children: [],
|
||||
parentKey: "root",
|
||||
},
|
||||
};
|
||||
|
||||
customersList.slice(0, 5).forEach((c, i) => {
|
||||
elements[`c${i}`] = {
|
||||
key: `c${i}`,
|
||||
type: "CustomerCard",
|
||||
props: {
|
||||
name: c.name,
|
||||
email: c.email,
|
||||
status: c.status as "active" | "inactive",
|
||||
customerId: c.id,
|
||||
},
|
||||
children: [],
|
||||
parentKey: "customers",
|
||||
};
|
||||
});
|
||||
|
||||
return { root: "root", elements };
|
||||
}
|
||||
|
||||
function createInvoicesSpec(data: Record<string, unknown>): Spec {
|
||||
const invoices = data.invoices as
|
||||
| {
|
||||
data?: Array<{
|
||||
id: string;
|
||||
invoiceNumber: string;
|
||||
amount: number;
|
||||
status: string;
|
||||
dueDate: string | null;
|
||||
}>;
|
||||
outstanding?: string;
|
||||
paid?: string;
|
||||
overdue?: string;
|
||||
}
|
||||
| undefined;
|
||||
const invoicesList = invoices?.data ?? [];
|
||||
|
||||
const elements: Spec["elements"] = {
|
||||
root: {
|
||||
key: "root",
|
||||
type: "Stack",
|
||||
props: { direction: "vertical", gap: "large" },
|
||||
children: ["heading", "stats", "invoices", "refresh"],
|
||||
parentKey: null,
|
||||
},
|
||||
heading: {
|
||||
key: "heading",
|
||||
type: "Heading",
|
||||
props: { text: "Invoice Management", size: "xlarge" },
|
||||
children: [],
|
||||
parentKey: "root",
|
||||
},
|
||||
stats: {
|
||||
key: "stats",
|
||||
type: "Stack",
|
||||
props: { direction: "horizontal", gap: "medium" },
|
||||
children: ["s1", "s2", "s3"],
|
||||
parentKey: "root",
|
||||
},
|
||||
s1: {
|
||||
key: "s1",
|
||||
type: "Metric",
|
||||
props: {
|
||||
label: "Outstanding",
|
||||
value: invoices?.outstanding ?? "$0",
|
||||
changeType: "neutral",
|
||||
},
|
||||
children: [],
|
||||
parentKey: "stats",
|
||||
},
|
||||
s2: {
|
||||
key: "s2",
|
||||
type: "Metric",
|
||||
props: {
|
||||
label: "Paid",
|
||||
value: invoices?.paid ?? "$0",
|
||||
changeType: "positive",
|
||||
},
|
||||
children: [],
|
||||
parentKey: "stats",
|
||||
},
|
||||
s3: {
|
||||
key: "s3",
|
||||
type: "Metric",
|
||||
props: {
|
||||
label: "Overdue",
|
||||
value: invoices?.overdue ?? "$0",
|
||||
changeType: invoices?.overdue !== "$0.00" ? "negative" : "neutral",
|
||||
},
|
||||
children: [],
|
||||
parentKey: "stats",
|
||||
},
|
||||
invoices: {
|
||||
key: "invoices",
|
||||
type: "Stack",
|
||||
props: { direction: "vertical", gap: "small" },
|
||||
children: invoicesList.slice(0, 5).map((_, i) => `inv${i}`),
|
||||
parentKey: "root",
|
||||
},
|
||||
refresh: {
|
||||
key: "refresh",
|
||||
type: "Button",
|
||||
props: {
|
||||
label: "Refresh Invoices",
|
||||
action: "fetchInvoices",
|
||||
type: "secondary",
|
||||
},
|
||||
children: [],
|
||||
parentKey: "root",
|
||||
},
|
||||
};
|
||||
|
||||
invoicesList.slice(0, 5).forEach((inv, i) => {
|
||||
elements[`inv${i}`] = {
|
||||
key: `inv${i}`,
|
||||
type: "InvoiceCard",
|
||||
props: {
|
||||
invoiceNumber: inv.invoiceNumber,
|
||||
amount: inv.amount,
|
||||
status: inv.status as "draft" | "open" | "paid" | "void",
|
||||
dueDate: inv.dueDate,
|
||||
},
|
||||
children: [],
|
||||
parentKey: "invoices",
|
||||
};
|
||||
});
|
||||
|
||||
return { root: "root", elements };
|
||||
}
|
||||
|
||||
// =============================================================================
|
||||
// Catalog Info for Display
|
||||
// =============================================================================
|
||||
|
||||
const componentCatalogInfo = Object.entries(stripeCatalog.data.components).map(
|
||||
([name, def]) => ({
|
||||
name,
|
||||
description: def.description,
|
||||
}),
|
||||
);
|
||||
|
||||
const actionsCatalogInfo = Object.entries(stripeCatalog.data.actions).map(
|
||||
([name, def]) => ({
|
||||
name,
|
||||
description: def.description,
|
||||
}),
|
||||
);
|
||||
|
||||
// =============================================================================
|
||||
// Home Component
|
||||
// =============================================================================
|
||||
|
||||
const Home = (_props: ExtensionContextValue) => {
|
||||
const [prompt, setPrompt] = useState("");
|
||||
const [isGenerating, setIsGenerating] = useState(false);
|
||||
const [isLoading, setIsLoading] = useState(true);
|
||||
const [currentSpec, setCurrentSpec] = useState<Spec | null>(null);
|
||||
const [data, setData] = useState<Record<string, unknown>>({});
|
||||
const [error, setError] = useState<string | null>(null);
|
||||
|
||||
const handleSetData = useCallback(
|
||||
(updater: (prev: Record<string, unknown>) => Record<string, unknown>) => {
|
||||
setData((prev) => updater(prev));
|
||||
},
|
||||
[],
|
||||
);
|
||||
|
||||
// Load initial data
|
||||
useEffect(() => {
|
||||
async function loadData() {
|
||||
setIsLoading(true);
|
||||
try {
|
||||
await executeAction("refreshData", {}, handleSetData, {});
|
||||
} catch (err) {
|
||||
console.error("Failed to load data:", err);
|
||||
setError("Failed to load Stripe data");
|
||||
} finally {
|
||||
setIsLoading(false);
|
||||
}
|
||||
}
|
||||
loadData();
|
||||
}, [handleSetData]);
|
||||
|
||||
const handleGenerate = async () => {
|
||||
if (!prompt.trim()) return;
|
||||
|
||||
setIsGenerating(true);
|
||||
setError(null);
|
||||
|
||||
try {
|
||||
const response = await fetch("http://localhost:3000/api/generate", {
|
||||
method: "POST",
|
||||
headers: { "Content-Type": "application/json" },
|
||||
body: JSON.stringify({
|
||||
prompt,
|
||||
systemPrompt: stripeCatalog.prompt({
|
||||
system:
|
||||
"You are a Stripe dashboard widget builder. Generate UI specs for displaying Stripe data.",
|
||||
}),
|
||||
}),
|
||||
});
|
||||
|
||||
if (!response.ok) {
|
||||
throw new Error(`API error: ${response.status}`);
|
||||
}
|
||||
|
||||
const contentType = response.headers.get("content-type") || "";
|
||||
|
||||
if (contentType.includes("application/json")) {
|
||||
const responseData = await response.json();
|
||||
if (responseData.spec) {
|
||||
setCurrentSpec(responseData.spec);
|
||||
return;
|
||||
} else if (responseData.error) {
|
||||
throw new Error(responseData.error);
|
||||
}
|
||||
}
|
||||
throw new Error("Invalid response");
|
||||
} catch (err) {
|
||||
// Use dynamic specs with real data as fallback
|
||||
const lowerPrompt = prompt.toLowerCase();
|
||||
|
||||
if (
|
||||
lowerPrompt.includes("payment") ||
|
||||
lowerPrompt.includes("transaction")
|
||||
) {
|
||||
setCurrentSpec(createPaymentsSpec(data));
|
||||
} else if (
|
||||
lowerPrompt.includes("subscription") ||
|
||||
lowerPrompt.includes("recurring")
|
||||
) {
|
||||
setCurrentSpec(createSubscriptionsSpec(data));
|
||||
} else if (
|
||||
lowerPrompt.includes("customer") ||
|
||||
lowerPrompt.includes("user")
|
||||
) {
|
||||
setCurrentSpec(createCustomersSpec(data));
|
||||
} else if (
|
||||
lowerPrompt.includes("invoice") ||
|
||||
lowerPrompt.includes("billing")
|
||||
) {
|
||||
setCurrentSpec(createInvoicesSpec(data));
|
||||
} else {
|
||||
setCurrentSpec(createRevenueSpec(data));
|
||||
}
|
||||
|
||||
setError(
|
||||
`Using local generation (${err instanceof Error ? err.message : "API unavailable"})`,
|
||||
);
|
||||
} finally {
|
||||
setIsGenerating(false);
|
||||
}
|
||||
};
|
||||
|
||||
return (
|
||||
<ContextView
|
||||
title="json-render Demo"
|
||||
brandColor="#635BFF"
|
||||
brandIcon={BrandIcon}
|
||||
externalLink={{
|
||||
label: "json-render docs",
|
||||
href: "https://github.com/vercel-labs/json-render",
|
||||
}}
|
||||
>
|
||||
{isLoading ? (
|
||||
<Box
|
||||
css={{
|
||||
stack: "y",
|
||||
gap: "medium",
|
||||
alignX: "center",
|
||||
paddingY: "xlarge",
|
||||
}}
|
||||
>
|
||||
<Spinner size="large" />
|
||||
<Box css={{ font: "body", color: "secondary" }}>
|
||||
Loading Stripe data...
|
||||
</Box>
|
||||
</Box>
|
||||
) : (
|
||||
<Tabs fitted>
|
||||
<TabList>
|
||||
<Tab id="generate">Generate</Tab>
|
||||
<Tab id="components">Components</Tab>
|
||||
<Tab id="actions">Actions</Tab>
|
||||
</TabList>
|
||||
<TabPanels>
|
||||
{/* Generate Tab */}
|
||||
<TabPanel id="generate">
|
||||
<Box css={{ stack: "y", gap: "medium", paddingY: "medium" }}>
|
||||
<TextField
|
||||
label="Describe the dashboard you want"
|
||||
placeholder="e.g., Show MRR, payments, or subscriptions"
|
||||
value={prompt}
|
||||
onChange={(e) => setPrompt(e.target.value)}
|
||||
/>
|
||||
<Button
|
||||
type="primary"
|
||||
onPress={handleGenerate}
|
||||
disabled={isGenerating || !prompt.trim()}
|
||||
>
|
||||
{isGenerating ? "Generating..." : "Generate"}
|
||||
</Button>
|
||||
|
||||
{error && (
|
||||
<Box css={{ color: "secondary", font: "caption" }}>
|
||||
{error}
|
||||
</Box>
|
||||
)}
|
||||
|
||||
{currentSpec && (
|
||||
<>
|
||||
<Divider />
|
||||
<Box css={{ font: "subheading" }}>
|
||||
Preview (with real Stripe data)
|
||||
</Box>
|
||||
<Box
|
||||
css={{
|
||||
padding: "medium",
|
||||
borderRadius: "medium",
|
||||
keyline: "neutral",
|
||||
}}
|
||||
>
|
||||
<StripeRenderer
|
||||
spec={currentSpec}
|
||||
data={data}
|
||||
setData={handleSetData}
|
||||
loading={isGenerating}
|
||||
/>
|
||||
</Box>
|
||||
</>
|
||||
)}
|
||||
|
||||
<Divider />
|
||||
<Box css={{ font: "caption", color: "secondary" }}>
|
||||
Try: "Show revenue metrics" • "Show recent
|
||||
payments" • "Show subscriptions" •
|
||||
"Show customers" • "Show invoices"
|
||||
</Box>
|
||||
</Box>
|
||||
</TabPanel>
|
||||
|
||||
{/* Components Catalog Tab */}
|
||||
<TabPanel id="components">
|
||||
<Box css={{ paddingY: "medium" }}>
|
||||
<Box css={{ font: "subheading", marginBottom: "medium" }}>
|
||||
Available Components ({componentCatalogInfo.length})
|
||||
</Box>
|
||||
<List>
|
||||
{componentCatalogInfo.map((comp) => (
|
||||
<ListItem
|
||||
key={comp.name}
|
||||
title={comp.name}
|
||||
secondaryTitle={comp.description}
|
||||
/>
|
||||
))}
|
||||
</List>
|
||||
</Box>
|
||||
</TabPanel>
|
||||
|
||||
{/* Actions Catalog Tab */}
|
||||
<TabPanel id="actions">
|
||||
<Box css={{ paddingY: "medium" }}>
|
||||
<Box css={{ font: "subheading", marginBottom: "medium" }}>
|
||||
Available Actions ({actionsCatalogInfo.length})
|
||||
</Box>
|
||||
<List>
|
||||
{actionsCatalogInfo.map((action) => (
|
||||
<ListItem
|
||||
key={action.name}
|
||||
title={action.name}
|
||||
secondaryTitle={action.description}
|
||||
/>
|
||||
))}
|
||||
</List>
|
||||
</Box>
|
||||
</TabPanel>
|
||||
</TabPanels>
|
||||
</Tabs>
|
||||
)}
|
||||
</ContextView>
|
||||
);
|
||||
};
|
||||
|
||||
export default Home;
|
||||
@@ -0,0 +1,339 @@
|
||||
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 { stripeCatalog, StripeRenderer } from "../lib/render";
|
||||
import { executeAction } from "../lib/render/catalog/actions";
|
||||
|
||||
// =============================================================================
|
||||
// Dynamic Spec Creation
|
||||
// =============================================================================
|
||||
|
||||
function createInvoicesSpec(data: Record<string, unknown>): Spec {
|
||||
const invoices = data.invoices as
|
||||
| {
|
||||
data?: Array<{
|
||||
id: string;
|
||||
invoiceNumber: string;
|
||||
amount: number;
|
||||
status: string;
|
||||
dueDate: string;
|
||||
customerEmail: string;
|
||||
formattedAmount: string;
|
||||
}>;
|
||||
total?: number;
|
||||
outstanding?: string;
|
||||
paid?: string;
|
||||
overdue?: string;
|
||||
hasMore?: boolean;
|
||||
}
|
||||
| undefined;
|
||||
|
||||
const invoicesList = invoices?.data ?? [];
|
||||
|
||||
const elements: Spec["elements"] = {
|
||||
root: {
|
||||
key: "root",
|
||||
type: "Stack",
|
||||
props: { direction: "vertical", gap: "large" },
|
||||
children: [
|
||||
"heading",
|
||||
"metrics",
|
||||
"alert",
|
||||
"filters",
|
||||
"list",
|
||||
"pagination",
|
||||
],
|
||||
parentKey: null,
|
||||
},
|
||||
heading: {
|
||||
key: "heading",
|
||||
type: "Heading",
|
||||
props: { text: "Invoices", size: "xlarge" },
|
||||
children: [],
|
||||
parentKey: "root",
|
||||
},
|
||||
metrics: {
|
||||
key: "metrics",
|
||||
type: "Stack",
|
||||
props: { direction: "horizontal", gap: "medium" },
|
||||
children: ["outstandingMetric", "paidMetric", "overdueMetric"],
|
||||
parentKey: "root",
|
||||
},
|
||||
outstandingMetric: {
|
||||
key: "outstandingMetric",
|
||||
type: "Metric",
|
||||
props: {
|
||||
label: "Outstanding",
|
||||
value: invoices?.outstanding ?? "$0",
|
||||
changeType: "neutral",
|
||||
},
|
||||
children: [],
|
||||
parentKey: "metrics",
|
||||
},
|
||||
paidMetric: {
|
||||
key: "paidMetric",
|
||||
type: "Metric",
|
||||
props: {
|
||||
label: "Paid",
|
||||
value: invoices?.paid ?? "$0",
|
||||
changeType: "positive",
|
||||
},
|
||||
children: [],
|
||||
parentKey: "metrics",
|
||||
},
|
||||
overdueMetric: {
|
||||
key: "overdueMetric",
|
||||
type: "Metric",
|
||||
props: {
|
||||
label: "Overdue",
|
||||
value: invoices?.overdue ?? "$0",
|
||||
changeType: invoices?.overdue !== "$0.00" ? "negative" : "neutral",
|
||||
},
|
||||
children: [],
|
||||
parentKey: "metrics",
|
||||
},
|
||||
alert: {
|
||||
key: "alert",
|
||||
type: "Banner",
|
||||
props: {
|
||||
title:
|
||||
invoices?.overdue !== "$0.00"
|
||||
? `You have ${invoices?.overdue} in overdue invoices`
|
||||
: "All invoices are up to date",
|
||||
type: invoices?.overdue !== "$0.00" ? "caution" : "default",
|
||||
},
|
||||
children: [],
|
||||
parentKey: "root",
|
||||
},
|
||||
filters: {
|
||||
key: "filters",
|
||||
type: "Stack",
|
||||
props: { direction: "horizontal", gap: "small" },
|
||||
children: ["refreshBtn", "createBtn", "exportBtn"],
|
||||
parentKey: "root",
|
||||
},
|
||||
refreshBtn: {
|
||||
key: "refreshBtn",
|
||||
type: "Button",
|
||||
props: { label: "Refresh", action: "refreshInvoices", type: "secondary" },
|
||||
children: [],
|
||||
parentKey: "filters",
|
||||
},
|
||||
createBtn: {
|
||||
key: "createBtn",
|
||||
type: "Button",
|
||||
props: {
|
||||
label: "Create Invoice",
|
||||
action: "openDashboard",
|
||||
actionParams: { page: "invoices" },
|
||||
type: "primary",
|
||||
},
|
||||
children: [],
|
||||
parentKey: "filters",
|
||||
},
|
||||
exportBtn: {
|
||||
key: "exportBtn",
|
||||
type: "Button",
|
||||
props: {
|
||||
label: "Export CSV",
|
||||
action: "exportData",
|
||||
actionParams: { format: "csv", dataType: "invoices" },
|
||||
type: "secondary",
|
||||
},
|
||||
children: [],
|
||||
parentKey: "filters",
|
||||
},
|
||||
list: {
|
||||
key: "list",
|
||||
type: "Stack",
|
||||
props: { direction: "vertical", gap: "small" },
|
||||
children: invoicesList.slice(0, 10).map((_, i) => `invoice${i}`),
|
||||
parentKey: "root",
|
||||
},
|
||||
pagination: {
|
||||
key: "pagination",
|
||||
type: "Stack",
|
||||
props: { direction: "horizontal", gap: "small" },
|
||||
children: invoices?.hasMore ? ["loadMore"] : [],
|
||||
parentKey: "root",
|
||||
},
|
||||
loadMore: {
|
||||
key: "loadMore",
|
||||
type: "Button",
|
||||
props: {
|
||||
label: "Load More",
|
||||
action: "fetchInvoices",
|
||||
actionParams: { limit: 10 },
|
||||
type: "secondary",
|
||||
},
|
||||
children: [],
|
||||
parentKey: "pagination",
|
||||
},
|
||||
};
|
||||
|
||||
invoicesList.slice(0, 10).forEach((inv, i) => {
|
||||
elements[`invoice${i}`] = {
|
||||
key: `invoice${i}`,
|
||||
type: "InvoiceCard",
|
||||
props: {
|
||||
invoiceNumber: inv.invoiceNumber,
|
||||
amount: inv.amount,
|
||||
currency: "usd",
|
||||
status: inv.status as
|
||||
| "draft"
|
||||
| "open"
|
||||
| "paid"
|
||||
| "void"
|
||||
| "uncollectible",
|
||||
dueDate: inv.dueDate,
|
||||
customerEmail: inv.customerEmail,
|
||||
},
|
||||
children: [],
|
||||
parentKey: "list",
|
||||
};
|
||||
});
|
||||
|
||||
return { root: "root", elements };
|
||||
}
|
||||
|
||||
// =============================================================================
|
||||
// Component
|
||||
// =============================================================================
|
||||
|
||||
const Invoices = (_props: ExtensionContextValue) => {
|
||||
const [data, setData] = 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 handleSetData = useCallback(
|
||||
(updater: (prev: Record<string, unknown>) => Record<string, unknown>) => {
|
||||
setData((prev) => updater(prev));
|
||||
},
|
||||
[],
|
||||
);
|
||||
|
||||
useEffect(() => {
|
||||
const loadData = async () => {
|
||||
setLoading(true);
|
||||
await executeAction("fetchInvoices", { limit: 10 }, handleSetData, {});
|
||||
setLoading(false);
|
||||
};
|
||||
loadData();
|
||||
}, [handleSetData]);
|
||||
|
||||
useEffect(() => {
|
||||
if (!loading && Object.keys(data).length > 0) {
|
||||
setSpec(createInvoicesSpec(data));
|
||||
}
|
||||
}, [data, loading]);
|
||||
|
||||
const handleGenerate = async () => {
|
||||
if (!prompt.trim()) return;
|
||||
setGenerating(true);
|
||||
|
||||
const systemPrompt = stripeCatalog.prompt();
|
||||
|
||||
try {
|
||||
const response = await fetch("/api/generate", {
|
||||
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(createInvoicesSpec(data));
|
||||
}
|
||||
|
||||
setGenerating(false);
|
||||
};
|
||||
|
||||
if (loading) {
|
||||
return (
|
||||
<ContextView title="Invoices" brandColor="#635BFF" brandIcon={BrandIcon}>
|
||||
<Box
|
||||
css={{
|
||||
stack: "y",
|
||||
gap: "medium",
|
||||
alignX: "center",
|
||||
paddingY: "xlarge",
|
||||
}}
|
||||
>
|
||||
<Spinner size="large" />
|
||||
<Box css={{ color: "secondary" }}>Loading invoices...</Box>
|
||||
</Box>
|
||||
</ContextView>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<ContextView
|
||||
title="Invoices"
|
||||
brandColor="#635BFF"
|
||||
brandIcon={BrandIcon}
|
||||
actions={
|
||||
<Button
|
||||
type="primary"
|
||||
onPress={() =>
|
||||
executeAction(
|
||||
"openDashboard",
|
||||
{ page: "invoices" },
|
||||
handleSetData,
|
||||
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 invoices 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={handleSetData}
|
||||
loading={generating}
|
||||
/>
|
||||
)}
|
||||
</Box>
|
||||
</ContextView>
|
||||
);
|
||||
};
|
||||
|
||||
export default Invoices;
|
||||
@@ -0,0 +1,412 @@
|
||||
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 { stripeCatalog, StripeRenderer } from "../lib/render";
|
||||
import { executeAction } from "../lib/render/catalog/actions";
|
||||
|
||||
// =============================================================================
|
||||
// Dynamic Spec Creation
|
||||
// =============================================================================
|
||||
|
||||
function createPaymentDetailSpec(
|
||||
data: Record<string, unknown>,
|
||||
paymentId: string,
|
||||
): Spec {
|
||||
const payments = data.payments as
|
||||
| {
|
||||
data?: Array<{
|
||||
id: string;
|
||||
amount: number;
|
||||
currency: string;
|
||||
status: string;
|
||||
description: string;
|
||||
created: string;
|
||||
formattedAmount: string;
|
||||
customerId?: string;
|
||||
}>;
|
||||
}
|
||||
| undefined;
|
||||
|
||||
const payment = payments?.data?.find((p) => p.id === paymentId);
|
||||
|
||||
const refunds = data.paymentRefunds as
|
||||
| {
|
||||
data?: Array<{
|
||||
id: string;
|
||||
amount: number;
|
||||
status: string;
|
||||
reason: string;
|
||||
created: string;
|
||||
formattedAmount: string;
|
||||
}>;
|
||||
total?: number;
|
||||
}
|
||||
| undefined;
|
||||
|
||||
const refundsList = refunds?.data ?? [];
|
||||
|
||||
const elements: Spec["elements"] = {
|
||||
root: {
|
||||
key: "root",
|
||||
type: "Stack",
|
||||
props: { direction: "vertical", gap: "large" },
|
||||
children: ["header", "details", "actions", "refundsSection"],
|
||||
parentKey: null,
|
||||
},
|
||||
header: {
|
||||
key: "header",
|
||||
type: "Stack",
|
||||
props: {
|
||||
direction: "horizontal",
|
||||
gap: "medium",
|
||||
distribute: "space-between",
|
||||
},
|
||||
children: ["paymentInfo", "statusBadge"],
|
||||
parentKey: "root",
|
||||
},
|
||||
paymentInfo: {
|
||||
key: "paymentInfo",
|
||||
type: "Stack",
|
||||
props: { direction: "vertical", gap: "xsmall" },
|
||||
children: ["paymentAmount", "paymentId"],
|
||||
parentKey: "header",
|
||||
},
|
||||
paymentAmount: {
|
||||
key: "paymentAmount",
|
||||
type: "Heading",
|
||||
props: { text: payment?.formattedAmount ?? "$0.00", size: "xlarge" },
|
||||
children: [],
|
||||
parentKey: "paymentInfo",
|
||||
},
|
||||
paymentId: {
|
||||
key: "paymentId",
|
||||
type: "Text",
|
||||
props: { content: paymentId, color: "secondary", size: "small" },
|
||||
children: [],
|
||||
parentKey: "paymentInfo",
|
||||
},
|
||||
statusBadge: {
|
||||
key: "statusBadge",
|
||||
type: "Badge",
|
||||
props: {
|
||||
label: payment?.status ?? "unknown",
|
||||
type:
|
||||
payment?.status === "succeeded"
|
||||
? "positive"
|
||||
: payment?.status === "pending"
|
||||
? "warning"
|
||||
: "negative",
|
||||
},
|
||||
children: [],
|
||||
parentKey: "header",
|
||||
},
|
||||
details: {
|
||||
key: "details",
|
||||
type: "PropertyList",
|
||||
props: { orientation: "vertical" },
|
||||
children: [
|
||||
"detailDescription",
|
||||
"detailCreated",
|
||||
"detailCurrency",
|
||||
"detailCustomer",
|
||||
],
|
||||
parentKey: "root",
|
||||
},
|
||||
detailDescription: {
|
||||
key: "detailDescription",
|
||||
type: "PropertyListItem",
|
||||
props: {
|
||||
label: "Description",
|
||||
value: payment?.description ?? "No description",
|
||||
},
|
||||
children: [],
|
||||
parentKey: "details",
|
||||
},
|
||||
detailCreated: {
|
||||
key: "detailCreated",
|
||||
type: "PropertyListItem",
|
||||
props: { label: "Created", value: payment?.created ?? "Unknown" },
|
||||
children: [],
|
||||
parentKey: "details",
|
||||
},
|
||||
detailCurrency: {
|
||||
key: "detailCurrency",
|
||||
type: "PropertyListItem",
|
||||
props: {
|
||||
label: "Currency",
|
||||
value: (payment?.currency ?? "usd").toUpperCase(),
|
||||
},
|
||||
children: [],
|
||||
parentKey: "details",
|
||||
},
|
||||
detailCustomer: {
|
||||
key: "detailCustomer",
|
||||
type: "PropertyListItem",
|
||||
props: { label: "Customer", value: payment?.customerId ?? "Guest" },
|
||||
children: [],
|
||||
parentKey: "details",
|
||||
},
|
||||
actions: {
|
||||
key: "actions",
|
||||
type: "Stack",
|
||||
props: { direction: "horizontal", gap: "small" },
|
||||
children:
|
||||
payment?.status === "succeeded"
|
||||
? ["refundBtn", "viewBtn"]
|
||||
: ["viewBtn"],
|
||||
parentKey: "root",
|
||||
},
|
||||
refundBtn: {
|
||||
key: "refundBtn",
|
||||
type: "Button",
|
||||
props: {
|
||||
label: "Refund Payment",
|
||||
action: "refundPayment",
|
||||
actionParams: { paymentId },
|
||||
type: "destructive",
|
||||
},
|
||||
children: [],
|
||||
parentKey: "actions",
|
||||
},
|
||||
viewBtn: {
|
||||
key: "viewBtn",
|
||||
type: "Button",
|
||||
props: {
|
||||
label: "View in Dashboard",
|
||||
action: "viewPayment",
|
||||
actionParams: { paymentId },
|
||||
type: "secondary",
|
||||
},
|
||||
children: [],
|
||||
parentKey: "actions",
|
||||
},
|
||||
refundsSection: {
|
||||
key: "refundsSection",
|
||||
type: "Accordion",
|
||||
props: {},
|
||||
children: ["refundsAccordion"],
|
||||
parentKey: "root",
|
||||
},
|
||||
refundsAccordion: {
|
||||
key: "refundsAccordion",
|
||||
type: "AccordionItem",
|
||||
props: {
|
||||
title: `Refunds (${refunds?.total ?? 0})`,
|
||||
defaultOpen: refundsList.length > 0,
|
||||
},
|
||||
children: refundsList.length > 0 ? ["refundsList"] : ["noRefunds"],
|
||||
parentKey: "refundsSection",
|
||||
},
|
||||
refundsList: {
|
||||
key: "refundsList",
|
||||
type: "Stack",
|
||||
props: { direction: "vertical", gap: "small" },
|
||||
children: refundsList.slice(0, 5).map((_, i) => `refund${i}`),
|
||||
parentKey: "refundsAccordion",
|
||||
},
|
||||
noRefunds: {
|
||||
key: "noRefunds",
|
||||
type: "Text",
|
||||
props: { content: "No refunds", color: "secondary" },
|
||||
children: [],
|
||||
parentKey: "refundsAccordion",
|
||||
},
|
||||
};
|
||||
|
||||
refundsList.slice(0, 5).forEach((r, i) => {
|
||||
elements[`refund${i}`] = {
|
||||
key: `refund${i}`,
|
||||
type: "RefundCard",
|
||||
props: {
|
||||
amount: r.amount,
|
||||
currency: "usd",
|
||||
status: r.status as "pending" | "succeeded" | "failed" | "canceled",
|
||||
reason: r.reason,
|
||||
},
|
||||
children: [],
|
||||
parentKey: "refundsList",
|
||||
};
|
||||
});
|
||||
|
||||
return { root: "root", elements };
|
||||
}
|
||||
|
||||
// =============================================================================
|
||||
// Component
|
||||
// =============================================================================
|
||||
|
||||
const PaymentDetails = ({ environment }: ExtensionContextValue) => {
|
||||
const [data, setData] = 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 paymentId = environment?.objectContext?.id ?? "";
|
||||
|
||||
const handleSetData = useCallback(
|
||||
(updater: (prev: Record<string, unknown>) => Record<string, unknown>) => {
|
||||
setData((prev) => updater(prev));
|
||||
},
|
||||
[],
|
||||
);
|
||||
|
||||
useEffect(() => {
|
||||
const loadData = async () => {
|
||||
if (!paymentId) {
|
||||
setLoading(false);
|
||||
return;
|
||||
}
|
||||
|
||||
setLoading(true);
|
||||
|
||||
await Promise.all([
|
||||
executeAction("fetchPayments", { limit: 100 }, handleSetData, {}),
|
||||
executeAction(
|
||||
"fetchRefunds",
|
||||
{ paymentIntentId: paymentId, limit: 10 },
|
||||
(updater) => {
|
||||
setData((prev) => {
|
||||
const next = updater(prev);
|
||||
return { ...prev, paymentRefunds: next.refunds };
|
||||
});
|
||||
},
|
||||
{},
|
||||
),
|
||||
]);
|
||||
|
||||
setLoading(false);
|
||||
};
|
||||
loadData();
|
||||
}, [paymentId, handleSetData]);
|
||||
|
||||
useEffect(() => {
|
||||
if (!loading && paymentId) {
|
||||
setSpec(createPaymentDetailSpec(data, paymentId));
|
||||
}
|
||||
}, [data, loading, paymentId]);
|
||||
|
||||
const handleGenerate = async () => {
|
||||
if (!prompt.trim()) return;
|
||||
setGenerating(true);
|
||||
|
||||
const systemPrompt = stripeCatalog.prompt();
|
||||
|
||||
try {
|
||||
const response = await fetch("/api/generate", {
|
||||
method: "POST",
|
||||
headers: { "Content-Type": "application/json" },
|
||||
body: JSON.stringify({
|
||||
prompt: `For payment ${paymentId}: ${prompt}`,
|
||||
systemPrompt,
|
||||
}),
|
||||
});
|
||||
|
||||
const result = await response.json();
|
||||
if (result.spec) {
|
||||
setSpec(result.spec);
|
||||
}
|
||||
} catch (error) {
|
||||
console.error("Generation error:", error);
|
||||
setSpec(createPaymentDetailSpec(data, paymentId));
|
||||
}
|
||||
|
||||
setGenerating(false);
|
||||
};
|
||||
|
||||
if (!paymentId) {
|
||||
return (
|
||||
<ContextView
|
||||
title="Payment Details"
|
||||
brandColor="#635BFF"
|
||||
brandIcon={BrandIcon}
|
||||
>
|
||||
<Box css={{ color: "secondary", padding: "large" }}>
|
||||
No payment selected. Please select a payment from the list.
|
||||
</Box>
|
||||
</ContextView>
|
||||
);
|
||||
}
|
||||
|
||||
if (loading) {
|
||||
return (
|
||||
<ContextView
|
||||
title="Payment Details"
|
||||
brandColor="#635BFF"
|
||||
brandIcon={BrandIcon}
|
||||
>
|
||||
<Box
|
||||
css={{
|
||||
stack: "y",
|
||||
gap: "medium",
|
||||
alignX: "center",
|
||||
paddingY: "xlarge",
|
||||
}}
|
||||
>
|
||||
<Spinner size="large" />
|
||||
<Box css={{ color: "secondary" }}>Loading payment details...</Box>
|
||||
</Box>
|
||||
</ContextView>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<ContextView
|
||||
title="Payment Details"
|
||||
brandColor="#635BFF"
|
||||
brandIcon={BrandIcon}
|
||||
actions={
|
||||
<Button
|
||||
type="primary"
|
||||
onPress={() =>
|
||||
executeAction("viewPayment", { paymentId }, handleSetData, data)
|
||||
}
|
||||
>
|
||||
View in Dashboard
|
||||
</Button>
|
||||
}
|
||||
>
|
||||
<Box css={{ stack: "y", gap: "medium" }}>
|
||||
<Box css={{ stack: "x", gap: "small" }}>
|
||||
<Box css={{ width: "fill" }}>
|
||||
<TextField
|
||||
label=""
|
||||
placeholder="Describe the payment 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={handleSetData}
|
||||
loading={generating}
|
||||
/>
|
||||
)}
|
||||
</Box>
|
||||
</ContextView>
|
||||
);
|
||||
};
|
||||
|
||||
export default PaymentDetails;
|
||||
@@ -0,0 +1,301 @@
|
||||
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 { stripeCatalog, StripeRenderer } from "../lib/render";
|
||||
import { executeAction } from "../lib/render/catalog/actions";
|
||||
|
||||
// =============================================================================
|
||||
// Dynamic Spec Creation
|
||||
// =============================================================================
|
||||
|
||||
function createPaymentsSpec(data: Record<string, unknown>): Spec {
|
||||
const payments = data.payments as
|
||||
| {
|
||||
data?: Array<{
|
||||
id: string;
|
||||
formattedAmount: string;
|
||||
status: string;
|
||||
description: string;
|
||||
created: string;
|
||||
}>;
|
||||
total?: number;
|
||||
totalVolume?: string;
|
||||
successRate?: string;
|
||||
hasMore?: boolean;
|
||||
}
|
||||
| undefined;
|
||||
|
||||
const paymentsList = payments?.data ?? [];
|
||||
|
||||
const elements: Spec["elements"] = {
|
||||
root: {
|
||||
key: "root",
|
||||
type: "Stack",
|
||||
props: { direction: "vertical", gap: "large" },
|
||||
children: ["heading", "metrics", "filters", "list", "pagination"],
|
||||
parentKey: null,
|
||||
},
|
||||
heading: {
|
||||
key: "heading",
|
||||
type: "Heading",
|
||||
props: { text: "Payments", size: "xlarge" },
|
||||
children: [],
|
||||
parentKey: "root",
|
||||
},
|
||||
metrics: {
|
||||
key: "metrics",
|
||||
type: "Stack",
|
||||
props: { direction: "horizontal", gap: "medium" },
|
||||
children: ["volumeMetric", "rateMetric", "countMetric"],
|
||||
parentKey: "root",
|
||||
},
|
||||
volumeMetric: {
|
||||
key: "volumeMetric",
|
||||
type: "Metric",
|
||||
props: {
|
||||
label: "Total Volume",
|
||||
value: payments?.totalVolume ?? "$0",
|
||||
changeType: "positive",
|
||||
},
|
||||
children: [],
|
||||
parentKey: "metrics",
|
||||
},
|
||||
rateMetric: {
|
||||
key: "rateMetric",
|
||||
type: "Metric",
|
||||
props: {
|
||||
label: "Success Rate",
|
||||
value: payments?.successRate ?? "0%",
|
||||
changeType: "positive",
|
||||
},
|
||||
children: [],
|
||||
parentKey: "metrics",
|
||||
},
|
||||
countMetric: {
|
||||
key: "countMetric",
|
||||
type: "Metric",
|
||||
props: {
|
||||
label: "Total Payments",
|
||||
value: String(payments?.total ?? 0),
|
||||
changeType: "neutral",
|
||||
},
|
||||
children: [],
|
||||
parentKey: "metrics",
|
||||
},
|
||||
filters: {
|
||||
key: "filters",
|
||||
type: "Stack",
|
||||
props: { direction: "horizontal", gap: "small" },
|
||||
children: ["refreshBtn", "exportBtn"],
|
||||
parentKey: "root",
|
||||
},
|
||||
refreshBtn: {
|
||||
key: "refreshBtn",
|
||||
type: "Button",
|
||||
props: { label: "Refresh", action: "refreshPayments", type: "secondary" },
|
||||
children: [],
|
||||
parentKey: "filters",
|
||||
},
|
||||
exportBtn: {
|
||||
key: "exportBtn",
|
||||
type: "Button",
|
||||
props: {
|
||||
label: "Export CSV",
|
||||
action: "exportData",
|
||||
actionParams: { format: "csv", dataType: "payments" },
|
||||
type: "secondary",
|
||||
},
|
||||
children: [],
|
||||
parentKey: "filters",
|
||||
},
|
||||
list: {
|
||||
key: "list",
|
||||
type: "Stack",
|
||||
props: { direction: "vertical", gap: "small" },
|
||||
children: paymentsList.slice(0, 10).map((_, i) => `payment${i}`),
|
||||
parentKey: "root",
|
||||
},
|
||||
pagination: {
|
||||
key: "pagination",
|
||||
type: "Stack",
|
||||
props: { direction: "horizontal", gap: "small" },
|
||||
children: payments?.hasMore ? ["loadMore"] : [],
|
||||
parentKey: "root",
|
||||
},
|
||||
loadMore: {
|
||||
key: "loadMore",
|
||||
type: "Button",
|
||||
props: {
|
||||
label: "Load More",
|
||||
action: "fetchPayments",
|
||||
actionParams: {
|
||||
limit: 10,
|
||||
startingAfter: paymentsList[paymentsList.length - 1]?.id,
|
||||
},
|
||||
type: "secondary",
|
||||
},
|
||||
children: [],
|
||||
parentKey: "pagination",
|
||||
},
|
||||
};
|
||||
|
||||
paymentsList.slice(0, 10).forEach((p, i) => {
|
||||
elements[`payment${i}`] = {
|
||||
key: `payment${i}`,
|
||||
type: "PaymentCard",
|
||||
props: {
|
||||
amount: 0,
|
||||
currency: "usd",
|
||||
status: p.status as "succeeded" | "pending" | "failed" | "canceled",
|
||||
description: `${p.formattedAmount} - ${p.description}`,
|
||||
paymentId: p.id,
|
||||
},
|
||||
children: [],
|
||||
parentKey: "list",
|
||||
};
|
||||
});
|
||||
|
||||
return { root: "root", elements };
|
||||
}
|
||||
|
||||
// =============================================================================
|
||||
// Component
|
||||
// =============================================================================
|
||||
|
||||
const Payments = (_props: ExtensionContextValue) => {
|
||||
const [data, setData] = 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 handleSetData = useCallback(
|
||||
(updater: (prev: Record<string, unknown>) => Record<string, unknown>) => {
|
||||
setData((prev) => updater(prev));
|
||||
},
|
||||
[],
|
||||
);
|
||||
|
||||
useEffect(() => {
|
||||
const loadData = async () => {
|
||||
setLoading(true);
|
||||
await executeAction("fetchPayments", { limit: 10 }, handleSetData, {});
|
||||
setLoading(false);
|
||||
};
|
||||
loadData();
|
||||
}, [handleSetData]);
|
||||
|
||||
useEffect(() => {
|
||||
if (!loading && Object.keys(data).length > 0) {
|
||||
setSpec(createPaymentsSpec(data));
|
||||
}
|
||||
}, [data, loading]);
|
||||
|
||||
const handleGenerate = async () => {
|
||||
if (!prompt.trim()) return;
|
||||
setGenerating(true);
|
||||
|
||||
const systemPrompt = stripeCatalog.prompt();
|
||||
|
||||
try {
|
||||
const response = await fetch("/api/generate", {
|
||||
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(createPaymentsSpec(data));
|
||||
}
|
||||
|
||||
setGenerating(false);
|
||||
};
|
||||
|
||||
if (loading) {
|
||||
return (
|
||||
<ContextView title="Payments" brandColor="#635BFF" brandIcon={BrandIcon}>
|
||||
<Box
|
||||
css={{
|
||||
stack: "y",
|
||||
gap: "medium",
|
||||
alignX: "center",
|
||||
paddingY: "xlarge",
|
||||
}}
|
||||
>
|
||||
<Spinner size="large" />
|
||||
<Box css={{ color: "secondary" }}>Loading payments...</Box>
|
||||
</Box>
|
||||
</ContextView>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<ContextView
|
||||
title="Payments"
|
||||
brandColor="#635BFF"
|
||||
brandIcon={BrandIcon}
|
||||
actions={
|
||||
<Button
|
||||
type="primary"
|
||||
onPress={() =>
|
||||
executeAction(
|
||||
"openDashboard",
|
||||
{ page: "payments" },
|
||||
handleSetData,
|
||||
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 payments 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={handleSetData}
|
||||
loading={generating}
|
||||
/>
|
||||
)}
|
||||
</Box>
|
||||
</ContextView>
|
||||
);
|
||||
};
|
||||
|
||||
export default Payments;
|
||||
@@ -0,0 +1,362 @@
|
||||
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 { 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: {
|
||||
key: "root",
|
||||
type: "Stack",
|
||||
props: { direction: "vertical", gap: "large" },
|
||||
children: ["heading", "metrics", "filters", "list", "pagination"],
|
||||
parentKey: null,
|
||||
},
|
||||
heading: {
|
||||
key: "heading",
|
||||
type: "Heading",
|
||||
props: { text: "Products & Prices", size: "xlarge" },
|
||||
children: [],
|
||||
parentKey: "root",
|
||||
},
|
||||
metrics: {
|
||||
key: "metrics",
|
||||
type: "Stack",
|
||||
props: { direction: "horizontal", gap: "medium" },
|
||||
children: ["productsMetric", "pricesMetric", "activeMetric"],
|
||||
parentKey: "root",
|
||||
},
|
||||
productsMetric: {
|
||||
key: "productsMetric",
|
||||
type: "Metric",
|
||||
props: {
|
||||
label: "Total Products",
|
||||
value: String(products?.total ?? 0),
|
||||
changeType: "neutral",
|
||||
},
|
||||
children: [],
|
||||
parentKey: "metrics",
|
||||
},
|
||||
pricesMetric: {
|
||||
key: "pricesMetric",
|
||||
type: "Metric",
|
||||
props: {
|
||||
label: "Total Prices",
|
||||
value: String(pricesList.length ?? 0),
|
||||
changeType: "neutral",
|
||||
},
|
||||
children: [],
|
||||
parentKey: "metrics",
|
||||
},
|
||||
activeMetric: {
|
||||
key: "activeMetric",
|
||||
type: "Metric",
|
||||
props: {
|
||||
label: "Active Products",
|
||||
value: String(productsList.filter((p) => p.active).length ?? 0),
|
||||
changeType: "positive",
|
||||
},
|
||||
children: [],
|
||||
parentKey: "metrics",
|
||||
},
|
||||
filters: {
|
||||
key: "filters",
|
||||
type: "Stack",
|
||||
props: { direction: "horizontal", gap: "small" },
|
||||
children: ["refreshBtn", "createBtn"],
|
||||
parentKey: "root",
|
||||
},
|
||||
refreshBtn: {
|
||||
key: "refreshBtn",
|
||||
type: "Button",
|
||||
props: { label: "Refresh", action: "fetchProducts", type: "secondary" },
|
||||
children: [],
|
||||
parentKey: "filters",
|
||||
},
|
||||
createBtn: {
|
||||
key: "createBtn",
|
||||
type: "Button",
|
||||
props: {
|
||||
label: "Create Product",
|
||||
action: "openDashboard",
|
||||
actionParams: { page: "products" },
|
||||
type: "primary",
|
||||
},
|
||||
children: [],
|
||||
parentKey: "filters",
|
||||
},
|
||||
list: {
|
||||
key: "list",
|
||||
type: "Stack",
|
||||
props: { direction: "vertical", gap: "small" },
|
||||
children: productsList.slice(0, 10).map((_, i) => `product${i}`),
|
||||
parentKey: "root",
|
||||
},
|
||||
pagination: {
|
||||
key: "pagination",
|
||||
type: "Stack",
|
||||
props: { direction: "horizontal", gap: "small" },
|
||||
children: products?.hasMore ? ["loadMore"] : [],
|
||||
parentKey: "root",
|
||||
},
|
||||
loadMore: {
|
||||
key: "loadMore",
|
||||
type: "Button",
|
||||
props: {
|
||||
label: "Load More",
|
||||
action: "fetchProducts",
|
||||
actionParams: { limit: 10 },
|
||||
type: "secondary",
|
||||
},
|
||||
children: [],
|
||||
parentKey: "pagination",
|
||||
},
|
||||
};
|
||||
|
||||
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}`] = {
|
||||
key: `product${i}`,
|
||||
type: "Stack",
|
||||
props: { direction: "vertical", gap: "small" },
|
||||
children: [`productCard${i}`],
|
||||
parentKey: "list",
|
||||
};
|
||||
|
||||
elements[`productCard${i}`] = {
|
||||
key: `productCard${i}`,
|
||||
type: "PropertyList",
|
||||
props: { orientation: "vertical" },
|
||||
children: [
|
||||
`productName${i}`,
|
||||
`productDesc${i}`,
|
||||
`productPrice${i}`,
|
||||
`productStatus${i}`,
|
||||
],
|
||||
parentKey: `product${i}`,
|
||||
};
|
||||
|
||||
elements[`productName${i}`] = {
|
||||
key: `productName${i}`,
|
||||
type: "PropertyListItem",
|
||||
props: { label: "Name", value: p.name },
|
||||
children: [],
|
||||
parentKey: `productCard${i}`,
|
||||
};
|
||||
|
||||
elements[`productDesc${i}`] = {
|
||||
key: `productDesc${i}`,
|
||||
type: "PropertyListItem",
|
||||
props: { label: "Description", value: p.description || "No description" },
|
||||
children: [],
|
||||
parentKey: `productCard${i}`,
|
||||
};
|
||||
|
||||
elements[`productPrice${i}`] = {
|
||||
key: `productPrice${i}`,
|
||||
type: "PropertyListItem",
|
||||
props: { label: "Prices", value: priceDisplay },
|
||||
children: [],
|
||||
parentKey: `productCard${i}`,
|
||||
};
|
||||
|
||||
elements[`productStatus${i}`] = {
|
||||
key: `productStatus${i}`,
|
||||
type: "PropertyListItem",
|
||||
props: { label: "Status", value: p.active ? "Active" : "Archived" },
|
||||
children: [],
|
||||
parentKey: `productCard${i}`,
|
||||
};
|
||||
});
|
||||
|
||||
return { root: "root", elements };
|
||||
}
|
||||
|
||||
// =============================================================================
|
||||
// Component
|
||||
// =============================================================================
|
||||
|
||||
const Products = (_props: ExtensionContextValue) => {
|
||||
const [data, setData] = 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 handleSetData = useCallback(
|
||||
(updater: (prev: Record<string, unknown>) => Record<string, unknown>) => {
|
||||
setData((prev) => updater(prev));
|
||||
},
|
||||
[],
|
||||
);
|
||||
|
||||
useEffect(() => {
|
||||
const loadData = async () => {
|
||||
setLoading(true);
|
||||
await Promise.all([
|
||||
executeAction("fetchProducts", { limit: 10 }, handleSetData, {}),
|
||||
executeAction("fetchPrices", { limit: 50 }, handleSetData, {}),
|
||||
]);
|
||||
setLoading(false);
|
||||
};
|
||||
loadData();
|
||||
}, [handleSetData]);
|
||||
|
||||
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", {
|
||||
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" },
|
||||
handleSetData,
|
||||
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={handleSetData}
|
||||
loading={generating}
|
||||
/>
|
||||
)}
|
||||
</Box>
|
||||
</ContextView>
|
||||
);
|
||||
};
|
||||
|
||||
export default Products;
|
||||
@@ -0,0 +1,358 @@
|
||||
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 { stripeCatalog, StripeRenderer } from "../lib/render";
|
||||
import { executeAction } from "../lib/render/catalog/actions";
|
||||
|
||||
// =============================================================================
|
||||
// Dynamic Spec Creation
|
||||
// =============================================================================
|
||||
|
||||
function createSubscriptionsSpec(data: Record<string, unknown>): Spec {
|
||||
const subscriptions = data.subscriptions as
|
||||
| {
|
||||
data?: Array<{
|
||||
id: string;
|
||||
planName: string;
|
||||
status: string;
|
||||
amount: number;
|
||||
interval: string;
|
||||
currentPeriodEnd: string;
|
||||
customerId: string;
|
||||
}>;
|
||||
total?: number;
|
||||
active?: number;
|
||||
trialing?: number;
|
||||
pastDue?: number;
|
||||
canceled?: number;
|
||||
hasMore?: boolean;
|
||||
}
|
||||
| undefined;
|
||||
|
||||
const subscriptionsList = subscriptions?.data ?? [];
|
||||
|
||||
const elements: Spec["elements"] = {
|
||||
root: {
|
||||
key: "root",
|
||||
type: "Stack",
|
||||
props: { direction: "vertical", gap: "large" },
|
||||
children: [
|
||||
"heading",
|
||||
"metrics",
|
||||
"alert",
|
||||
"filters",
|
||||
"list",
|
||||
"pagination",
|
||||
],
|
||||
parentKey: null,
|
||||
},
|
||||
heading: {
|
||||
key: "heading",
|
||||
type: "Heading",
|
||||
props: { text: "Subscriptions", size: "xlarge" },
|
||||
children: [],
|
||||
parentKey: "root",
|
||||
},
|
||||
metrics: {
|
||||
key: "metrics",
|
||||
type: "Stack",
|
||||
props: { direction: "horizontal", gap: "medium" },
|
||||
children: [
|
||||
"activeMetric",
|
||||
"trialingMetric",
|
||||
"pastDueMetric",
|
||||
"canceledMetric",
|
||||
],
|
||||
parentKey: "root",
|
||||
},
|
||||
activeMetric: {
|
||||
key: "activeMetric",
|
||||
type: "Metric",
|
||||
props: {
|
||||
label: "Active",
|
||||
value: String(subscriptions?.active ?? 0),
|
||||
changeType: "positive",
|
||||
},
|
||||
children: [],
|
||||
parentKey: "metrics",
|
||||
},
|
||||
trialingMetric: {
|
||||
key: "trialingMetric",
|
||||
type: "Metric",
|
||||
props: {
|
||||
label: "Trialing",
|
||||
value: String(subscriptions?.trialing ?? 0),
|
||||
changeType: "positive",
|
||||
},
|
||||
children: [],
|
||||
parentKey: "metrics",
|
||||
},
|
||||
pastDueMetric: {
|
||||
key: "pastDueMetric",
|
||||
type: "Metric",
|
||||
props: {
|
||||
label: "Past Due",
|
||||
value: String(subscriptions?.pastDue ?? 0),
|
||||
changeType: subscriptions?.pastDue ? "negative" : "neutral",
|
||||
},
|
||||
children: [],
|
||||
parentKey: "metrics",
|
||||
},
|
||||
canceledMetric: {
|
||||
key: "canceledMetric",
|
||||
type: "Metric",
|
||||
props: {
|
||||
label: "Canceled",
|
||||
value: String(subscriptions?.canceled ?? 0),
|
||||
changeType: "neutral",
|
||||
},
|
||||
children: [],
|
||||
parentKey: "metrics",
|
||||
},
|
||||
alert: {
|
||||
key: "alert",
|
||||
type: "Banner",
|
||||
props: {
|
||||
title: subscriptions?.pastDue
|
||||
? `${subscriptions.pastDue} subscriptions need attention`
|
||||
: `${subscriptions?.trialing ?? 0} subscriptions trialing`,
|
||||
type: subscriptions?.pastDue ? "caution" : "default",
|
||||
},
|
||||
children: [],
|
||||
parentKey: "root",
|
||||
},
|
||||
filters: {
|
||||
key: "filters",
|
||||
type: "Stack",
|
||||
props: { direction: "horizontal", gap: "small" },
|
||||
children: ["refreshBtn", "exportBtn"],
|
||||
parentKey: "root",
|
||||
},
|
||||
refreshBtn: {
|
||||
key: "refreshBtn",
|
||||
type: "Button",
|
||||
props: {
|
||||
label: "Refresh",
|
||||
action: "refreshSubscriptions",
|
||||
type: "secondary",
|
||||
},
|
||||
children: [],
|
||||
parentKey: "filters",
|
||||
},
|
||||
exportBtn: {
|
||||
key: "exportBtn",
|
||||
type: "Button",
|
||||
props: {
|
||||
label: "Export CSV",
|
||||
action: "exportData",
|
||||
actionParams: { format: "csv", dataType: "subscriptions" },
|
||||
type: "secondary",
|
||||
},
|
||||
children: [],
|
||||
parentKey: "filters",
|
||||
},
|
||||
list: {
|
||||
key: "list",
|
||||
type: "Stack",
|
||||
props: { direction: "vertical", gap: "small" },
|
||||
children: subscriptionsList
|
||||
.slice(0, 10)
|
||||
.map((_, i) => `subscription${i}`),
|
||||
parentKey: "root",
|
||||
},
|
||||
pagination: {
|
||||
key: "pagination",
|
||||
type: "Stack",
|
||||
props: { direction: "horizontal", gap: "small" },
|
||||
children: subscriptions?.hasMore ? ["loadMore"] : [],
|
||||
parentKey: "root",
|
||||
},
|
||||
loadMore: {
|
||||
key: "loadMore",
|
||||
type: "Button",
|
||||
props: {
|
||||
label: "Load More",
|
||||
action: "fetchSubscriptions",
|
||||
actionParams: { limit: 10 },
|
||||
type: "secondary",
|
||||
},
|
||||
children: [],
|
||||
parentKey: "pagination",
|
||||
},
|
||||
};
|
||||
|
||||
subscriptionsList.slice(0, 10).forEach((s, i) => {
|
||||
elements[`subscription${i}`] = {
|
||||
key: `subscription${i}`,
|
||||
type: "SubscriptionCard",
|
||||
props: {
|
||||
planName: s.planName,
|
||||
status: s.status as
|
||||
| "active"
|
||||
| "trialing"
|
||||
| "past_due"
|
||||
| "canceled"
|
||||
| "unpaid",
|
||||
amount: s.amount,
|
||||
currency: "usd",
|
||||
interval: s.interval as "month" | "year",
|
||||
currentPeriodEnd: s.currentPeriodEnd,
|
||||
},
|
||||
children: [],
|
||||
parentKey: "list",
|
||||
};
|
||||
});
|
||||
|
||||
return { root: "root", elements };
|
||||
}
|
||||
|
||||
// =============================================================================
|
||||
// Component
|
||||
// =============================================================================
|
||||
|
||||
const Subscriptions = (_props: ExtensionContextValue) => {
|
||||
const [data, setData] = 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 handleSetData = useCallback(
|
||||
(updater: (prev: Record<string, unknown>) => Record<string, unknown>) => {
|
||||
setData((prev) => updater(prev));
|
||||
},
|
||||
[],
|
||||
);
|
||||
|
||||
useEffect(() => {
|
||||
const loadData = async () => {
|
||||
setLoading(true);
|
||||
await executeAction(
|
||||
"fetchSubscriptions",
|
||||
{ limit: 10 },
|
||||
handleSetData,
|
||||
{},
|
||||
);
|
||||
setLoading(false);
|
||||
};
|
||||
loadData();
|
||||
}, [handleSetData]);
|
||||
|
||||
useEffect(() => {
|
||||
if (!loading && Object.keys(data).length > 0) {
|
||||
setSpec(createSubscriptionsSpec(data));
|
||||
}
|
||||
}, [data, loading]);
|
||||
|
||||
const handleGenerate = async () => {
|
||||
if (!prompt.trim()) return;
|
||||
setGenerating(true);
|
||||
|
||||
const systemPrompt = stripeCatalog.prompt();
|
||||
|
||||
try {
|
||||
const response = await fetch("/api/generate", {
|
||||
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(createSubscriptionsSpec(data));
|
||||
}
|
||||
|
||||
setGenerating(false);
|
||||
};
|
||||
|
||||
if (loading) {
|
||||
return (
|
||||
<ContextView
|
||||
title="Subscriptions"
|
||||
brandColor="#635BFF"
|
||||
brandIcon={BrandIcon}
|
||||
>
|
||||
<Box
|
||||
css={{
|
||||
stack: "y",
|
||||
gap: "medium",
|
||||
alignX: "center",
|
||||
paddingY: "xlarge",
|
||||
}}
|
||||
>
|
||||
<Spinner size="large" />
|
||||
<Box css={{ color: "secondary" }}>Loading subscriptions...</Box>
|
||||
</Box>
|
||||
</ContextView>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<ContextView
|
||||
title="Subscriptions"
|
||||
brandColor="#635BFF"
|
||||
brandIcon={BrandIcon}
|
||||
actions={
|
||||
<Button
|
||||
type="primary"
|
||||
onPress={() =>
|
||||
executeAction(
|
||||
"openDashboard",
|
||||
{ page: "subscriptions" },
|
||||
handleSetData,
|
||||
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 subscriptions 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={handleSetData}
|
||||
loading={generating}
|
||||
/>
|
||||
)}
|
||||
</Box>
|
||||
</ContextView>
|
||||
);
|
||||
};
|
||||
|
||||
export default Subscriptions;
|
||||
@@ -0,0 +1,4 @@
|
||||
<svg width="32" height="32" viewBox="0 0 32 32" fill="none" xmlns="http://www.w3.org/2000/svg">
|
||||
<path d="M24.218 9.10285C24.4008 8.99422 24.5577 8.84887 24.682 8.67797L17.5155 4.41809C16.5777 3.86064 15.4223 3.86064 14.4845 4.41809L7.31798 8.67797C7.44229 8.84887 7.59924 8.99422 7.78199 9.10285L15.2509 13.5425C15.7163 13.8192 16.2837 13.8192 16.7491 13.5425L24.218 9.10285Z" fill="#FFFFFF"/>
|
||||
<path d="M26.5 11.5603C26.5 11.4215 26.491 11.2841 26.4736 11.1486C26.2529 11.3479 26.0113 11.5268 25.7509 11.6817L18.282 16.1213C16.8718 16.9596 15.1282 16.9596 13.718 16.1213L6.2491 11.6817C5.98866 11.5268 5.7471 11.3479 5.52644 11.1486C5.50895 11.2841 5.5 11.4215 5.5 11.5603V20.4397C5.5 21.5546 6.07772 22.5848 7.01554 23.1423L14.4845 27.5819C15.4223 28.1394 16.5777 28.1394 17.5155 27.5819L24.9845 23.1423C25.9223 22.5848 26.5 21.5546 26.5 20.4397V11.5603Z" fill="#FFFFFF"/>
|
||||
</svg>
|
||||
|
After Width: | Height: | Size: 881 B |
@@ -0,0 +1,125 @@
|
||||
{
|
||||
"id": "com.example.json-render-demo",
|
||||
"version": "0.0.1",
|
||||
"name": "json-render Demo",
|
||||
"icon": "",
|
||||
"permissions": [
|
||||
{
|
||||
"permission": "customer_read",
|
||||
"purpose": "Display customer information in dashboards"
|
||||
},
|
||||
{
|
||||
"permission": "customer_write",
|
||||
"purpose": "Create and manage customers"
|
||||
},
|
||||
{
|
||||
"permission": "payment_intent_read",
|
||||
"purpose": "Display payment information in dashboards"
|
||||
},
|
||||
{
|
||||
"permission": "charge_read",
|
||||
"purpose": "Display charge information and details"
|
||||
},
|
||||
{
|
||||
"permission": "subscription_read",
|
||||
"purpose": "Display subscription information in dashboards"
|
||||
},
|
||||
{
|
||||
"permission": "subscription_write",
|
||||
"purpose": "Manage subscriptions"
|
||||
},
|
||||
{
|
||||
"permission": "invoice_read",
|
||||
"purpose": "Display invoice information in dashboards"
|
||||
},
|
||||
{
|
||||
"permission": "invoice_write",
|
||||
"purpose": "Send and manage invoices"
|
||||
},
|
||||
{
|
||||
"permission": "refund_write",
|
||||
"purpose": "Process refunds"
|
||||
},
|
||||
{
|
||||
"permission": "product_read",
|
||||
"purpose": "Display product catalog"
|
||||
},
|
||||
{
|
||||
"permission": "product_write",
|
||||
"purpose": "Create and manage products"
|
||||
},
|
||||
{
|
||||
"permission": "price_read",
|
||||
"purpose": "Display pricing information"
|
||||
},
|
||||
{
|
||||
"permission": "price_write",
|
||||
"purpose": "Create and manage prices"
|
||||
},
|
||||
{
|
||||
"permission": "balance_read",
|
||||
"purpose": "Display account balance"
|
||||
},
|
||||
{
|
||||
"permission": "payout_read",
|
||||
"purpose": "Display payout information"
|
||||
},
|
||||
{
|
||||
"permission": "event_read",
|
||||
"purpose": "Display webhook events"
|
||||
},
|
||||
{
|
||||
"permission": "dispute_read",
|
||||
"purpose": "Display dispute information"
|
||||
},
|
||||
{
|
||||
"permission": "coupon_read",
|
||||
"purpose": "Display coupon information"
|
||||
},
|
||||
{
|
||||
"permission": "coupon_write",
|
||||
"purpose": "Create and manage coupons"
|
||||
}
|
||||
],
|
||||
"ui_extension": {
|
||||
"views": [
|
||||
{
|
||||
"viewport": "stripe.dashboard.home.overview",
|
||||
"component": "Home"
|
||||
},
|
||||
{
|
||||
"viewport": "stripe.dashboard.customer.list",
|
||||
"component": "Customers"
|
||||
},
|
||||
{
|
||||
"viewport": "stripe.dashboard.customer.detail",
|
||||
"component": "CustomerDetails"
|
||||
},
|
||||
{
|
||||
"viewport": "stripe.dashboard.payment.list",
|
||||
"component": "Payments"
|
||||
},
|
||||
{
|
||||
"viewport": "stripe.dashboard.payment.detail",
|
||||
"component": "PaymentDetails"
|
||||
},
|
||||
{
|
||||
"viewport": "stripe.dashboard.subscription.list",
|
||||
"component": "Subscriptions"
|
||||
},
|
||||
{
|
||||
"viewport": "stripe.dashboard.invoice.list",
|
||||
"component": "Invoices"
|
||||
},
|
||||
{
|
||||
"viewport": "stripe.dashboard.product.list",
|
||||
"component": "Products"
|
||||
}
|
||||
],
|
||||
"content_security_policy": {
|
||||
"connect-src": null,
|
||||
"image-src": null,
|
||||
"purpose": "No external connections needed for core functionality"
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,3 @@
|
||||
{
|
||||
"extends": "@stripe/ui-extension-tools/tsconfig.ui-extension"
|
||||
}
|
||||
+1
@@ -0,0 +1 @@
|
||||
/// <reference types="@stripe/ui-extension-tools" />
|
||||
Generated
+3131
File diff suppressed because it is too large
Load Diff
Reference in New Issue
Block a user