mirror of
https://github.com/vercel-labs/json-render.git
synced 2026-10-03 04:18:15 +08:00
Compare commits
12
Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
b28fbc3bb9 | ||
|
|
b6e4ca0584 | ||
|
|
d6207dc093 | ||
|
|
1a36ccf7ae | ||
|
|
de81bf56ff | ||
|
|
51d02822c5 | ||
|
|
91ca8008dc | ||
|
|
c86e5455e3 | ||
|
|
ca68112032 | ||
|
|
3a448f6366 | ||
|
|
c869713cad | ||
|
|
7f4684c562 |
@@ -44,3 +44,8 @@ yarn-error.log*
|
||||
# opensrc - source code for packages
|
||||
opensrc/
|
||||
.env*.local
|
||||
|
||||
# Stripe apps (generated from template + build artifacts)
|
||||
examples/stripe-app/*/stripe-app.json
|
||||
examples/stripe-app/*/.build
|
||||
examples/stripe-app/*/yarn.lock
|
||||
|
||||
@@ -1,7 +0,0 @@
|
||||
# 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_...
|
||||
@@ -1,99 +1,49 @@
|
||||
# Stripe App Example
|
||||
# Stripe App Examples
|
||||
|
||||
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.
|
||||
[Stripe Apps](https://stripe.com/docs/stripe-apps) examples demonstrating how to use json-render to build dynamic, AI-generated UI within the Stripe Dashboard.
|
||||
|
||||
## Overview
|
||||
## Structure
|
||||
|
||||
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:
|
||||
| Folder | Description |
|
||||
|--------|-------------|
|
||||
| [api/](./api) | Next.js server providing the `/api/generate` endpoint for AI-powered UI generation |
|
||||
| [drawer-app/](./drawer-app) | Standard Stripe App that renders in the Dashboard drawer (sidebar) |
|
||||
| [fullpage-app/](./fullpage-app) | Full-page Stripe App using `FullPageView` (requires alpha access) |
|
||||
|
||||
- **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
|
||||
## Quick Start
|
||||
|
||||
## 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
|
||||
### 1. Start the API server
|
||||
|
||||
```bash
|
||||
# Install dependencies
|
||||
cd api
|
||||
pnpm install
|
||||
cp .env.example .env # Set AI_GATEWAY_API_KEY
|
||||
pnpm dev
|
||||
```
|
||||
|
||||
# Start the Stripe App in development mode
|
||||
### 2. Start a Stripe App
|
||||
|
||||
For the standard drawer app:
|
||||
|
||||
```bash
|
||||
cd drawer-app
|
||||
pnpm install
|
||||
pnpm setup
|
||||
stripe apps start
|
||||
```
|
||||
|
||||
### Development
|
||||
|
||||
The app uses the Stripe UI Extension SDK which handles authentication automatically - no API keys required in your environment.
|
||||
For the full-page app (requires alpha access):
|
||||
|
||||
```bash
|
||||
# Run linting
|
||||
pnpm lint
|
||||
|
||||
# Run tests
|
||||
pnpm test
|
||||
cd fullpage-app
|
||||
pnpm install
|
||||
cp .env.example .env # Set STRIPE_APP_ID
|
||||
pnpm setup
|
||||
stripe apps start
|
||||
# Navigate to https://dashboard.stripe.com/test/app/<your-app-id>
|
||||
```
|
||||
|
||||
## How It Works
|
||||
## Notes
|
||||
|
||||
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: {
|
||||
type: "Stack",
|
||||
props: { direction: "vertical", gap: "medium" },
|
||||
children: ["heading", "metric"],
|
||||
},
|
||||
heading: {
|
||||
type: "Heading",
|
||||
props: { text: "Revenue", size: "large" },
|
||||
children: [],
|
||||
},
|
||||
metric: {
|
||||
type: "Metric",
|
||||
props: { label: "Total", value: "$12,345", format: "currency" },
|
||||
children: [],
|
||||
},
|
||||
},
|
||||
};
|
||||
|
||||
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)
|
||||
- The AI generation feature requires the API server to be running. Without it, the apps fall back to locally generated specs using real Stripe data.
|
||||
- The `fullpage-app` uses Stripe's full-page apps **private developer preview**. You need Stripe to enable the feature flag for your app and account. See [fullpage-app/README.md](./fullpage-app/README.md) for details.
|
||||
|
||||
@@ -0,0 +1,3 @@
|
||||
# AI Gateway API Key (required for AI generation)
|
||||
# Get your key from Vercel AI Gateway dashboard
|
||||
AI_GATEWAY_API_KEY=
|
||||
@@ -0,0 +1,26 @@
|
||||
# Stripe App API
|
||||
|
||||
A lightweight Next.js server that provides the `/api/generate` endpoint for AI-powered UI generation in the Stripe App examples.
|
||||
|
||||
## Setup
|
||||
|
||||
```bash
|
||||
pnpm install
|
||||
|
||||
cp .env.example .env
|
||||
# Set AI_GATEWAY_API_KEY
|
||||
```
|
||||
|
||||
## Running
|
||||
|
||||
```bash
|
||||
pnpm dev
|
||||
```
|
||||
|
||||
Runs on port 3001. The Stripe apps (`drawer-app/` and `fullpage-app/`) expect this server to be running for AI generation features.
|
||||
|
||||
## API
|
||||
|
||||
### POST /api/generate
|
||||
|
||||
Accepts `{ prompt, systemPrompt }` and returns a streamed text response with a json-render spec.
|
||||
@@ -0,0 +1,50 @@
|
||||
import { streamText } from "ai";
|
||||
import { gateway } from "@ai-sdk/gateway";
|
||||
|
||||
export const maxDuration = 60;
|
||||
|
||||
const DEFAULT_MODEL = "anthropic/claude-haiku-4.5";
|
||||
|
||||
const CORS_HEADERS: Record<string, string> = {
|
||||
"Access-Control-Allow-Origin": "*",
|
||||
"Access-Control-Allow-Methods": "POST, OPTIONS",
|
||||
"Access-Control-Allow-Headers": "Content-Type",
|
||||
};
|
||||
|
||||
export async function POST(req: Request) {
|
||||
const { prompt, systemPrompt } = await req.json();
|
||||
|
||||
if (!prompt) {
|
||||
return new Response(JSON.stringify({ error: "prompt is required" }), {
|
||||
status: 400,
|
||||
headers: { "Content-Type": "application/json", ...CORS_HEADERS },
|
||||
});
|
||||
}
|
||||
|
||||
try {
|
||||
const result = streamText({
|
||||
model: gateway(DEFAULT_MODEL),
|
||||
system: systemPrompt ?? "You are a helpful UI builder.",
|
||||
prompt,
|
||||
temperature: 0.7,
|
||||
});
|
||||
|
||||
const response = result.toTextStreamResponse();
|
||||
|
||||
for (const [key, value] of Object.entries(CORS_HEADERS)) {
|
||||
response.headers.set(key, value);
|
||||
}
|
||||
|
||||
return response;
|
||||
} catch (err) {
|
||||
const message = err instanceof Error ? err.message : "Generation failed";
|
||||
return new Response(JSON.stringify({ error: message }), {
|
||||
status: 500,
|
||||
headers: { "Content-Type": "application/json", ...CORS_HEADERS },
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
export async function OPTIONS() {
|
||||
return new Response(null, { headers: CORS_HEADERS });
|
||||
}
|
||||
@@ -0,0 +1,15 @@
|
||||
export const metadata = {
|
||||
title: "Stripe App API",
|
||||
};
|
||||
|
||||
export default function RootLayout({
|
||||
children,
|
||||
}: {
|
||||
children: React.ReactNode;
|
||||
}) {
|
||||
return (
|
||||
<html lang="en">
|
||||
<body>{children}</body>
|
||||
</html>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,8 @@
|
||||
export default function Home() {
|
||||
return (
|
||||
<main style={{ padding: "2rem", fontFamily: "system-ui, sans-serif" }}>
|
||||
<h1>Stripe App API</h1>
|
||||
<p>POST /api/generate to generate UI specs.</p>
|
||||
</main>
|
||||
);
|
||||
}
|
||||
+6
@@ -0,0 +1,6 @@
|
||||
/// <reference types="next" />
|
||||
/// <reference types="next/image-types/global" />
|
||||
import "./.next/dev/types/routes.d.ts";
|
||||
|
||||
// NOTE: This file should not be edited
|
||||
// see https://nextjs.org/docs/app/api-reference/config/typescript for more information.
|
||||
@@ -0,0 +1,5 @@
|
||||
import type { NextConfig } from "next";
|
||||
|
||||
const nextConfig: NextConfig = {};
|
||||
|
||||
export default nextConfig;
|
||||
@@ -0,0 +1,23 @@
|
||||
{
|
||||
"name": "stripe-app-api",
|
||||
"version": "0.1.0",
|
||||
"private": true,
|
||||
"type": "module",
|
||||
"scripts": {
|
||||
"dev": "next dev --port 3001",
|
||||
"build": "next build",
|
||||
"start": "next start --port 3001"
|
||||
},
|
||||
"dependencies": {
|
||||
"@ai-sdk/gateway": "^3.0.50",
|
||||
"ai": "^6.0.91",
|
||||
"next": "^16.1.6",
|
||||
"react": "^19.1.0",
|
||||
"react-dom": "^19.1.0"
|
||||
},
|
||||
"devDependencies": {
|
||||
"@types/node": "^22.0.0",
|
||||
"@types/react": "^19.1.0",
|
||||
"typescript": "^5.4.5"
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,41 @@
|
||||
{
|
||||
"compilerOptions": {
|
||||
"target": "ES2017",
|
||||
"lib": [
|
||||
"dom",
|
||||
"dom.iterable",
|
||||
"esnext"
|
||||
],
|
||||
"allowJs": true,
|
||||
"skipLibCheck": true,
|
||||
"strict": true,
|
||||
"noEmit": true,
|
||||
"esModuleInterop": true,
|
||||
"module": "esnext",
|
||||
"moduleResolution": "bundler",
|
||||
"resolveJsonModule": true,
|
||||
"isolatedModules": true,
|
||||
"jsx": "react-jsx",
|
||||
"incremental": true,
|
||||
"plugins": [
|
||||
{
|
||||
"name": "next"
|
||||
}
|
||||
],
|
||||
"paths": {
|
||||
"@/*": [
|
||||
"./*"
|
||||
]
|
||||
}
|
||||
},
|
||||
"include": [
|
||||
"next-env.d.ts",
|
||||
"**/*.ts",
|
||||
"**/*.tsx",
|
||||
".next/types/**/*.ts",
|
||||
".next/dev/types/**/*.ts"
|
||||
],
|
||||
"exclude": [
|
||||
"node_modules"
|
||||
]
|
||||
}
|
||||
@@ -0,0 +1,14 @@
|
||||
# Stripe App ID (optional - overrides the template default)
|
||||
# Get this from your Stripe Apps dashboard after uploading
|
||||
# STRIPE_APP_ID=com.example.your-app-id
|
||||
|
||||
# Stripe App Name (optional - overrides the template default)
|
||||
# STRIPE_APP_NAME=My App Name
|
||||
|
||||
# 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,45 @@
|
||||
# Stripe App (Drawer)
|
||||
|
||||
A [Stripe App](https://stripe.com/docs/stripe-apps) example demonstrating how to use json-render to build dynamic, AI-generated UI in the Stripe Dashboard drawer.
|
||||
|
||||
## Views
|
||||
|
||||
- **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
|
||||
|
||||
## Setup
|
||||
|
||||
```bash
|
||||
pnpm install
|
||||
|
||||
cp .env.example .env
|
||||
# Optionally set STRIPE_APP_ID to your own app ID
|
||||
|
||||
pnpm setup
|
||||
```
|
||||
|
||||
## Running
|
||||
|
||||
Start the API server first (from `../api`):
|
||||
|
||||
```bash
|
||||
cd ../api && pnpm dev
|
||||
```
|
||||
|
||||
Then start the Stripe app:
|
||||
|
||||
```bash
|
||||
stripe apps start
|
||||
```
|
||||
|
||||
## How It Works
|
||||
|
||||
1. **Component Catalog** - Maps json-render component types to Stripe UIXT components
|
||||
2. **Action Handlers** - Stripe operations (refunds, subscriptions, etc.)
|
||||
3. **StripeRenderer** - Custom renderer connecting json-render specs to Stripe components
|
||||
4. **Views** - Each view fetches data from the Stripe API and renders it using json-render specs
|
||||
@@ -9,12 +9,14 @@
|
||||
"@json-render/core": "workspace:*",
|
||||
"@json-render/react": "workspace:*",
|
||||
"@stripe/ui-extension-sdk": "^9.1.0",
|
||||
"stripe": "^13.11.0"
|
||||
"stripe": "^13.11.0",
|
||||
"zod": "^4.0.0"
|
||||
},
|
||||
"engines": {
|
||||
"node": ">=14"
|
||||
},
|
||||
"scripts": {
|
||||
"setup": "node scripts/setup.mjs",
|
||||
"lint": "eslint src --max-warnings 0",
|
||||
"test": "jest"
|
||||
},
|
||||
@@ -0,0 +1,42 @@
|
||||
import { readFileSync, writeFileSync, existsSync } from "fs";
|
||||
import { resolve, dirname } from "path";
|
||||
import { fileURLToPath } from "url";
|
||||
|
||||
const __dirname = dirname(fileURLToPath(import.meta.url));
|
||||
const root = resolve(__dirname, "..");
|
||||
|
||||
function loadEnv() {
|
||||
const envPath = resolve(root, ".env");
|
||||
if (!existsSync(envPath)) return {};
|
||||
const env = {};
|
||||
for (const line of readFileSync(envPath, "utf-8").split("\n")) {
|
||||
const trimmed = line.trim();
|
||||
if (!trimmed || trimmed.startsWith("#")) continue;
|
||||
const idx = trimmed.indexOf("=");
|
||||
if (idx === -1) continue;
|
||||
const key = trimmed.slice(0, idx).trim();
|
||||
const val = trimmed.slice(idx + 1).trim();
|
||||
env[key] = val;
|
||||
}
|
||||
return env;
|
||||
}
|
||||
|
||||
const env = loadEnv();
|
||||
const templatePath = resolve(root, "stripe-app.template.json");
|
||||
const outputPath = resolve(root, "stripe-app.json");
|
||||
|
||||
const template = JSON.parse(readFileSync(templatePath, "utf-8"));
|
||||
|
||||
if (env.STRIPE_APP_ID) {
|
||||
template.id = env.STRIPE_APP_ID;
|
||||
console.log(`Using app ID from .env: ${env.STRIPE_APP_ID}`);
|
||||
} else {
|
||||
console.log(`No STRIPE_APP_ID in .env, using template default: ${template.id}`);
|
||||
}
|
||||
|
||||
if (env.STRIPE_APP_NAME) {
|
||||
template.name = env.STRIPE_APP_NAME;
|
||||
}
|
||||
|
||||
writeFileSync(outputPath, JSON.stringify(template, null, 4) + "\n");
|
||||
console.log("Generated stripe-app.json");
|
||||
@@ -0,0 +1 @@
|
||||
export const API_GENERATE_URL = "http://localhost:3001/api/generate";
|
||||
File diff suppressed because it is too large
Load Diff
+87
-22
@@ -62,6 +62,7 @@ type ExtendedRenderProps<P = Record<string, unknown>> =
|
||||
ComponentRenderProps<P> & {
|
||||
state?: Record<string, unknown>;
|
||||
getValue?: (path: string) => unknown;
|
||||
onAction?: (actionName: string, params?: Record<string, unknown>) => void;
|
||||
};
|
||||
|
||||
// NOTE: All interactive components use `emit` to fire named events.
|
||||
@@ -254,23 +255,47 @@ export const Metric: FunctionComponent<ExtendedRenderProps> = ({ element }) => {
|
||||
);
|
||||
};
|
||||
|
||||
const BADGE_TYPES = new Set([
|
||||
"neutral",
|
||||
"urgent",
|
||||
"warning",
|
||||
"negative",
|
||||
"positive",
|
||||
"info",
|
||||
]);
|
||||
const BADGE_ALIAS: Record<string, string> = {
|
||||
success: "positive",
|
||||
error: "negative",
|
||||
danger: "negative",
|
||||
critical: "urgent",
|
||||
default: "neutral",
|
||||
primary: "info",
|
||||
};
|
||||
|
||||
function coerceBadgeType(
|
||||
raw: unknown,
|
||||
): "neutral" | "urgent" | "warning" | "negative" | "positive" | "info" {
|
||||
const s = String(raw ?? "neutral");
|
||||
if (BADGE_TYPES.has(s))
|
||||
return s as
|
||||
| "neutral"
|
||||
| "urgent"
|
||||
| "warning"
|
||||
| "negative"
|
||||
| "positive"
|
||||
| "info";
|
||||
return (BADGE_ALIAS[s] ?? "neutral") as
|
||||
| "neutral"
|
||||
| "urgent"
|
||||
| "warning"
|
||||
| "negative"
|
||||
| "positive"
|
||||
| "info";
|
||||
}
|
||||
|
||||
export const Badge: FunctionComponent<ExtendedRenderProps> = ({ element }) => {
|
||||
const { label, type = "neutral" } = element.props as Record<string, unknown>;
|
||||
return (
|
||||
<UIBadge
|
||||
type={
|
||||
type as
|
||||
| "neutral"
|
||||
| "urgent"
|
||||
| "warning"
|
||||
| "negative"
|
||||
| "positive"
|
||||
| "info"
|
||||
}
|
||||
>
|
||||
{String(label || "")}
|
||||
</UIBadge>
|
||||
);
|
||||
return <UIBadge type={coerceBadgeType(type)}>{String(label || "")}</UIBadge>;
|
||||
};
|
||||
|
||||
export const Icon: FunctionComponent<ExtendedRenderProps> = ({ element }) => {
|
||||
@@ -309,6 +334,21 @@ export const Spinner: FunctionComponent<ExtendedRenderProps> = ({
|
||||
// =========================================================================
|
||||
// Feedback Components
|
||||
// =========================================================================
|
||||
const BANNER_TYPES = new Set(["default", "caution", "critical"]);
|
||||
const BANNER_ALIAS: Record<string, string> = {
|
||||
info: "default",
|
||||
warning: "caution",
|
||||
error: "critical",
|
||||
danger: "critical",
|
||||
success: "default",
|
||||
};
|
||||
|
||||
function coerceBannerType(raw: unknown): "default" | "caution" | "critical" {
|
||||
const s = String(raw ?? "default");
|
||||
if (BANNER_TYPES.has(s)) return s as "default" | "caution" | "critical";
|
||||
return (BANNER_ALIAS[s] ?? "default") as "default" | "caution" | "critical";
|
||||
}
|
||||
|
||||
export const Banner: FunctionComponent<ExtendedRenderProps> = ({ element }) => {
|
||||
const {
|
||||
title,
|
||||
@@ -319,7 +359,7 @@ export const Banner: FunctionComponent<ExtendedRenderProps> = ({ element }) => {
|
||||
<UIBanner
|
||||
title={title ? String(title) : undefined}
|
||||
description={description ? String(description) : undefined}
|
||||
type={type as "default" | "caution" | "critical"}
|
||||
type={coerceBannerType(type)}
|
||||
/>
|
||||
);
|
||||
};
|
||||
@@ -634,11 +674,12 @@ export const DateField: FunctionComponent<ExtendedRenderProps> = ({
|
||||
export const Button: FunctionComponent<ExtendedRenderProps> = ({
|
||||
element,
|
||||
emit,
|
||||
onAction,
|
||||
}) => {
|
||||
const {
|
||||
label,
|
||||
action,
|
||||
actionParams: _actionParams,
|
||||
actionParams,
|
||||
type = "primary",
|
||||
size = "medium",
|
||||
disabled,
|
||||
@@ -646,6 +687,18 @@ export const Button: FunctionComponent<ExtendedRenderProps> = ({
|
||||
href,
|
||||
} = element.props as Record<string, unknown>;
|
||||
|
||||
const handlePress = action
|
||||
? () => {
|
||||
emit("press");
|
||||
if (onAction) {
|
||||
onAction(
|
||||
String(action),
|
||||
actionParams as Record<string, unknown> | undefined,
|
||||
);
|
||||
}
|
||||
}
|
||||
: undefined;
|
||||
|
||||
return (
|
||||
<UIButton
|
||||
type={type as "primary" | "secondary" | "destructive"}
|
||||
@@ -653,7 +706,7 @@ export const Button: FunctionComponent<ExtendedRenderProps> = ({
|
||||
disabled={Boolean(disabled) || undefined}
|
||||
pending={Boolean(pending) || undefined}
|
||||
href={href ? String(href) : undefined}
|
||||
onPress={action ? () => emit("press") : undefined}
|
||||
onPress={handlePress}
|
||||
>
|
||||
{String(label || "")}
|
||||
</UIButton>
|
||||
@@ -715,14 +768,26 @@ export const BarChart: FunctionComponent<ExtendedRenderProps> = ({
|
||||
x={String(xKey || "x")}
|
||||
y={String(yKey || "y")}
|
||||
color={colorKey ? String(colorKey) : undefined}
|
||||
axis={showAxis as "x" | "y" | "both" | "none"}
|
||||
grid={showGrid as "x" | "y" | "both" | "none"}
|
||||
axis={coerceAxisGrid(showAxis, "both")}
|
||||
grid={coerceAxisGrid(showGrid, "none")}
|
||||
legend={Boolean(showLegend) || undefined}
|
||||
tooltip={Boolean(showTooltip)}
|
||||
/>
|
||||
);
|
||||
};
|
||||
|
||||
function coerceAxisGrid(
|
||||
val: unknown,
|
||||
fallback: string,
|
||||
): "x" | "y" | "both" | "none" {
|
||||
if (val === true) return "both";
|
||||
if (val === false) return "none";
|
||||
const s = String(val ?? fallback);
|
||||
if (["x", "y", "both", "none"].includes(s))
|
||||
return s as "x" | "y" | "both" | "none";
|
||||
return fallback as "x" | "y" | "both" | "none";
|
||||
}
|
||||
|
||||
export const LineChart: FunctionComponent<ExtendedRenderProps> = ({
|
||||
element,
|
||||
}) => {
|
||||
@@ -751,8 +816,8 @@ export const LineChart: FunctionComponent<ExtendedRenderProps> = ({
|
||||
x={String(xKey || "x")}
|
||||
y={String(yKey || "y")}
|
||||
color={colorKey ? String(colorKey) : undefined}
|
||||
axis={showAxis as "x" | "y" | "both" | "none"}
|
||||
grid={showGrid as "x" | "y" | "both" | "none"}
|
||||
axis={coerceAxisGrid(showAxis, "both")}
|
||||
grid={coerceAxisGrid(showGrid, "none")}
|
||||
legend={Boolean(showLegend) || undefined}
|
||||
tooltip={Boolean(showTooltip)}
|
||||
/>
|
||||
+13
-1
@@ -9,6 +9,7 @@ import {
|
||||
} from "@json-render/react";
|
||||
|
||||
import { components, Fallback } from "./catalog/components";
|
||||
import { executeAction } from "./catalog/actions";
|
||||
|
||||
// =============================================================================
|
||||
// Types
|
||||
@@ -72,6 +73,12 @@ function buildRegistry(
|
||||
}
|
||||
return current;
|
||||
},
|
||||
onAction: (actionName: string, params?: Record<string, unknown>) => {
|
||||
const setState = setDataRef.current;
|
||||
if (setState) {
|
||||
executeAction(actionName, params, setState, dataRef.current);
|
||||
}
|
||||
},
|
||||
});
|
||||
}
|
||||
|
||||
@@ -114,10 +121,15 @@ export function StripeRenderer({
|
||||
[loading],
|
||||
);
|
||||
|
||||
const mergedState = useMemo(
|
||||
() => (spec?.state ? { ...data, ...spec.state } : data),
|
||||
[data, spec?.state],
|
||||
);
|
||||
|
||||
if (!spec) return null;
|
||||
|
||||
return (
|
||||
<StateProvider initialState={data} onStateChange={onStateChange}>
|
||||
<StateProvider initialState={mergedState} onStateChange={onStateChange}>
|
||||
<VisibilityProvider>
|
||||
<ActionProvider>
|
||||
<Renderer
|
||||
@@ -0,0 +1,146 @@
|
||||
import type { Spec } from "@json-render/react";
|
||||
|
||||
interface JsonPatch {
|
||||
op: string;
|
||||
path: string;
|
||||
value?: unknown;
|
||||
from?: string;
|
||||
}
|
||||
|
||||
function setDeep(
|
||||
obj: Record<string, unknown>,
|
||||
segments: string[],
|
||||
value: unknown,
|
||||
): void {
|
||||
let current: unknown = obj;
|
||||
for (let i = 0; i < segments.length - 1; i++) {
|
||||
const seg = segments[i];
|
||||
const next = (current as Record<string, unknown>)[seg];
|
||||
if (next && typeof next === "object") {
|
||||
current = next;
|
||||
} else {
|
||||
const container = /^\d+$/.test(segments[i + 1]) ? [] : {};
|
||||
(current as Record<string, unknown>)[seg] = container;
|
||||
current = container;
|
||||
}
|
||||
}
|
||||
const last = segments[segments.length - 1];
|
||||
if (Array.isArray(current)) {
|
||||
(current as unknown[])[Number(last)] = value;
|
||||
} else {
|
||||
(current as Record<string, unknown>)[last] = value;
|
||||
}
|
||||
}
|
||||
|
||||
function setSpecValue(spec: Spec, path: string, value: unknown): void {
|
||||
if (path === "/root") {
|
||||
(spec as Record<string, unknown>).root = value as string;
|
||||
return;
|
||||
}
|
||||
if (path === "/state") {
|
||||
(spec as Record<string, unknown>).state = value;
|
||||
return;
|
||||
}
|
||||
if (path.startsWith("/state/")) {
|
||||
if (!spec.state) (spec as Record<string, unknown>).state = {};
|
||||
const segments = path.slice("/state/".length).split("/");
|
||||
setDeep(spec.state as Record<string, unknown>, segments, value);
|
||||
return;
|
||||
}
|
||||
const elemMatch = path.match(/^\/elements\/(.+)/);
|
||||
if (elemMatch) {
|
||||
spec.elements[elemMatch[1]] = value as Spec["elements"][string];
|
||||
}
|
||||
}
|
||||
|
||||
function applyPatch(spec: Spec, patch: JsonPatch): Spec {
|
||||
const next: Spec = {
|
||||
...spec,
|
||||
elements: { ...spec.elements },
|
||||
...(spec.state ? { state: { ...spec.state } } : {}),
|
||||
};
|
||||
switch (patch.op) {
|
||||
case "add":
|
||||
case "replace":
|
||||
setSpecValue(next, patch.path, patch.value);
|
||||
break;
|
||||
case "remove":
|
||||
if (patch.path.startsWith("/elements/")) {
|
||||
const key = patch.path.slice("/elements/".length);
|
||||
delete next.elements[key];
|
||||
}
|
||||
break;
|
||||
}
|
||||
return next;
|
||||
}
|
||||
|
||||
/**
|
||||
* Fetch an AI generation endpoint that returns a JSONL patch stream,
|
||||
* applying patches progressively via onPatch.
|
||||
*/
|
||||
export async function streamSpec(
|
||||
url: string,
|
||||
body: Record<string, unknown>,
|
||||
onPatch: (spec: Spec) => void,
|
||||
): Promise<Spec> {
|
||||
const response = await fetch(url, {
|
||||
method: "POST",
|
||||
headers: { "Content-Type": "application/json" },
|
||||
body: JSON.stringify(body),
|
||||
});
|
||||
|
||||
if (!response.ok) {
|
||||
let msg = `API error: ${response.status}`;
|
||||
try {
|
||||
const errData = await response.json();
|
||||
if (errData.error) msg = errData.error;
|
||||
} catch {
|
||||
// use default
|
||||
}
|
||||
throw new Error(msg);
|
||||
}
|
||||
|
||||
const reader = response.body?.getReader();
|
||||
if (!reader) throw new Error("No response body");
|
||||
|
||||
const decoder = new TextDecoder();
|
||||
let buffer = "";
|
||||
let spec: Spec = { root: "", elements: {} };
|
||||
|
||||
while (true) {
|
||||
const { done, value } = await reader.read();
|
||||
if (done) break;
|
||||
|
||||
buffer += decoder.decode(value, { stream: true });
|
||||
const lines = buffer.split("\n");
|
||||
buffer = lines.pop() ?? "";
|
||||
|
||||
for (const line of lines) {
|
||||
const trimmed = line.trim();
|
||||
if (!trimmed || trimmed.startsWith("//")) continue;
|
||||
try {
|
||||
const parsed = JSON.parse(trimmed) as JsonPatch;
|
||||
if (parsed.op) {
|
||||
spec = applyPatch(spec, parsed);
|
||||
onPatch(spec);
|
||||
}
|
||||
} catch {
|
||||
// skip non-JSON lines
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
if (buffer.trim()) {
|
||||
try {
|
||||
const parsed = JSON.parse(buffer.trim()) as JsonPatch;
|
||||
if (parsed.op) {
|
||||
spec = applyPatch(spec, parsed);
|
||||
onPatch(spec);
|
||||
}
|
||||
} catch {
|
||||
// skip
|
||||
}
|
||||
}
|
||||
|
||||
return spec;
|
||||
}
|
||||
+2
-1
@@ -10,6 +10,7 @@ import {
|
||||
import type { ExtensionContextValue } from "@stripe/ui-extension-sdk/context";
|
||||
|
||||
import BrandIcon from "./brand_icon.svg";
|
||||
import { API_GENERATE_URL } from "../lib/config";
|
||||
import { stripeCatalog, StripeRenderer } from "../lib/render";
|
||||
import { executeAction } from "../lib/render/catalog/actions";
|
||||
|
||||
@@ -373,7 +374,7 @@ const CustomerDetails = ({ environment }: ExtensionContextValue) => {
|
||||
const systemPrompt = stripeCatalog.prompt();
|
||||
|
||||
try {
|
||||
const response = await fetch("/api/generate", {
|
||||
const response = await fetch(API_GENERATE_URL, {
|
||||
method: "POST",
|
||||
headers: { "Content-Type": "application/json" },
|
||||
body: JSON.stringify({
|
||||
+2
-1
@@ -10,6 +10,7 @@ import {
|
||||
import type { ExtensionContextValue } from "@stripe/ui-extension-sdk/context";
|
||||
|
||||
import BrandIcon from "./brand_icon.svg";
|
||||
import { API_GENERATE_URL } from "../lib/config";
|
||||
import { stripeCatalog, StripeRenderer } from "../lib/render";
|
||||
import { executeAction } from "../lib/render/catalog/actions";
|
||||
|
||||
@@ -172,7 +173,7 @@ const Customers = (_props: ExtensionContextValue) => {
|
||||
const systemPrompt = stripeCatalog.prompt();
|
||||
|
||||
try {
|
||||
const response = await fetch("/api/generate", {
|
||||
const response = await fetch(API_GENERATE_URL, {
|
||||
method: "POST",
|
||||
headers: { "Content-Type": "application/json" },
|
||||
body: JSON.stringify({ prompt, systemPrompt }),
|
||||
+55
-41
@@ -1,10 +1,12 @@
|
||||
import { useState, useCallback, useEffect } from "react";
|
||||
import type { Spec } from "@json-render/react";
|
||||
import {
|
||||
Banner,
|
||||
Box,
|
||||
ContextView,
|
||||
Divider,
|
||||
Button,
|
||||
Link,
|
||||
TextField,
|
||||
Tabs,
|
||||
TabList,
|
||||
@@ -20,6 +22,8 @@ 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";
|
||||
import { API_GENERATE_URL } from "../lib/config";
|
||||
import { streamSpec } from "../lib/stream-spec";
|
||||
|
||||
// =============================================================================
|
||||
// Dynamic Specs (use real data from context)
|
||||
@@ -503,34 +507,23 @@ const Home = (_props: ExtensionContextValue) => {
|
||||
setError(null);
|
||||
|
||||
try {
|
||||
const response = await fetch("http://localhost:3000/api/generate", {
|
||||
method: "POST",
|
||||
headers: { "Content-Type": "application/json" },
|
||||
body: JSON.stringify({
|
||||
prompt,
|
||||
const dataSnapshot = JSON.stringify(data, null, 2);
|
||||
|
||||
await streamSpec(
|
||||
API_GENERATE_URL,
|
||||
{
|
||||
prompt: `${prompt}\n\nAVAILABLE STRIPE DATA (use this real data, do NOT invent fake numbers):\n${dataSnapshot}`,
|
||||
systemPrompt: stripeCatalog.prompt({
|
||||
system:
|
||||
"You are a Stripe dashboard widget builder. Generate UI specs for displaying Stripe data.",
|
||||
customRules: [
|
||||
'LAYOUT CONSTRAINT: This UI renders inside a narrow Stripe dashboard drawer (~320px wide). NEVER place more than 2 small items side-by-side horizontally. Prefer vertical (stacked) layouts. Use direction:"horizontal" sparingly and only for very compact items like a label+value pair.',
|
||||
'DATA: The user prompt includes real Stripe data under "AVAILABLE STRIPE DATA". You MUST use these real values when setting /state. NEVER invent or hallucinate numbers. If the data you need is not available, say so in a Text element rather than making up data. The data keys are: customers (data[], total), payments (data[], total, totalVolume, successRate), subscriptions (data[], total, active, trialing, pastDue, canceled), invoices (data[], total, totalAmount, paid, open, overdue).',
|
||||
],
|
||||
}),
|
||||
}),
|
||||
});
|
||||
|
||||
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");
|
||||
},
|
||||
(spec) => setCurrentSpec(spec),
|
||||
);
|
||||
} catch (err) {
|
||||
// Use dynamic specs with real data as fallback
|
||||
const lowerPrompt = prompt.toLowerCase();
|
||||
@@ -625,26 +618,47 @@ const Home = (_props: ExtensionContextValue) => {
|
||||
{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={handleSetState}
|
||||
loading={isGenerating}
|
||||
/>
|
||||
</Box>
|
||||
<StripeRenderer
|
||||
spec={currentSpec}
|
||||
data={data}
|
||||
setData={handleSetState}
|
||||
loading={isGenerating}
|
||||
/>
|
||||
</>
|
||||
)}
|
||||
|
||||
{(
|
||||
data._actionResult as
|
||||
| { message?: string; url?: string }
|
||||
| undefined
|
||||
)?.message && (
|
||||
<Banner
|
||||
title={(data._actionResult as { message: string }).message}
|
||||
description={
|
||||
(data._actionResult as { url?: string }).url
|
||||
? "Open in Stripe Dashboard"
|
||||
: undefined
|
||||
}
|
||||
actions={
|
||||
(data._actionResult as { url?: string }).url ? (
|
||||
<Link
|
||||
href={(data._actionResult as { url: string }).url}
|
||||
external
|
||||
>
|
||||
Open
|
||||
</Link>
|
||||
) : undefined
|
||||
}
|
||||
onDismiss={() =>
|
||||
setState((prev) => {
|
||||
const next = { ...prev };
|
||||
delete next._actionResult;
|
||||
return next;
|
||||
})
|
||||
}
|
||||
/>
|
||||
)}
|
||||
|
||||
<Divider />
|
||||
<Box css={{ font: "caption", color: "secondary" }}>
|
||||
Try: "Show revenue metrics" • "Show recent
|
||||
+2
-1
@@ -10,6 +10,7 @@ import {
|
||||
import type { ExtensionContextValue } from "@stripe/ui-extension-sdk/context";
|
||||
|
||||
import BrandIcon from "./brand_icon.svg";
|
||||
import { API_GENERATE_URL } from "../lib/config";
|
||||
import { stripeCatalog, StripeRenderer } from "../lib/render";
|
||||
import { executeAction } from "../lib/render/catalog/actions";
|
||||
|
||||
@@ -215,7 +216,7 @@ const Invoices = (_props: ExtensionContextValue) => {
|
||||
const systemPrompt = stripeCatalog.prompt();
|
||||
|
||||
try {
|
||||
const response = await fetch("/api/generate", {
|
||||
const response = await fetch(API_GENERATE_URL, {
|
||||
method: "POST",
|
||||
headers: { "Content-Type": "application/json" },
|
||||
body: JSON.stringify({ prompt, systemPrompt }),
|
||||
+2
-1
@@ -10,6 +10,7 @@ import {
|
||||
import type { ExtensionContextValue } from "@stripe/ui-extension-sdk/context";
|
||||
|
||||
import BrandIcon from "./brand_icon.svg";
|
||||
import { API_GENERATE_URL } from "../lib/config";
|
||||
import { stripeCatalog, StripeRenderer } from "../lib/render";
|
||||
import { executeAction } from "../lib/render/catalog/actions";
|
||||
|
||||
@@ -264,7 +265,7 @@ const PaymentDetails = ({ environment }: ExtensionContextValue) => {
|
||||
const systemPrompt = stripeCatalog.prompt();
|
||||
|
||||
try {
|
||||
const response = await fetch("/api/generate", {
|
||||
const response = await fetch(API_GENERATE_URL, {
|
||||
method: "POST",
|
||||
headers: { "Content-Type": "application/json" },
|
||||
body: JSON.stringify({
|
||||
+2
-1
@@ -10,6 +10,7 @@ import {
|
||||
import type { ExtensionContextValue } from "@stripe/ui-extension-sdk/context";
|
||||
|
||||
import BrandIcon from "./brand_icon.svg";
|
||||
import { API_GENERATE_URL } from "../lib/config";
|
||||
import { stripeCatalog, StripeRenderer } from "../lib/render";
|
||||
import { executeAction } from "../lib/render/catalog/actions";
|
||||
|
||||
@@ -181,7 +182,7 @@ const Payments = (_props: ExtensionContextValue) => {
|
||||
const systemPrompt = stripeCatalog.prompt();
|
||||
|
||||
try {
|
||||
const response = await fetch("/api/generate", {
|
||||
const response = await fetch(API_GENERATE_URL, {
|
||||
method: "POST",
|
||||
headers: { "Content-Type": "application/json" },
|
||||
body: JSON.stringify({ prompt, systemPrompt }),
|
||||
+2
-1
@@ -10,6 +10,7 @@ import {
|
||||
import type { ExtensionContextValue } from "@stripe/ui-extension-sdk/context";
|
||||
|
||||
import BrandIcon from "./brand_icon.svg";
|
||||
import { API_GENERATE_URL } from "../lib/config";
|
||||
import { stripeCatalog, StripeRenderer } from "../lib/render";
|
||||
import { executeAction } from "../lib/render/catalog/actions";
|
||||
|
||||
@@ -232,7 +233,7 @@ const Products = (_props: ExtensionContextValue) => {
|
||||
const systemPrompt = stripeCatalog.prompt();
|
||||
|
||||
try {
|
||||
const response = await fetch("/api/generate", {
|
||||
const response = await fetch(API_GENERATE_URL, {
|
||||
method: "POST",
|
||||
headers: { "Content-Type": "application/json" },
|
||||
body: JSON.stringify({ prompt, systemPrompt }),
|
||||
+2
-1
@@ -10,6 +10,7 @@ import {
|
||||
import type { ExtensionContextValue } from "@stripe/ui-extension-sdk/context";
|
||||
|
||||
import BrandIcon from "./brand_icon.svg";
|
||||
import { API_GENERATE_URL } from "../lib/config";
|
||||
import { stripeCatalog, StripeRenderer } from "../lib/render";
|
||||
import { executeAction } from "../lib/render/catalog/actions";
|
||||
|
||||
@@ -230,7 +231,7 @@ const Subscriptions = (_props: ExtensionContextValue) => {
|
||||
const systemPrompt = stripeCatalog.prompt();
|
||||
|
||||
try {
|
||||
const response = await fetch("/api/generate", {
|
||||
const response = await fetch(API_GENERATE_URL, {
|
||||
method: "POST",
|
||||
headers: { "Content-Type": "application/json" },
|
||||
body: JSON.stringify({ prompt, systemPrompt }),
|
||||
|
Before Width: | Height: | Size: 881 B After Width: | Height: | Size: 881 B |
+4
-4
@@ -37,8 +37,8 @@
|
||||
"purpose": "Send and manage invoices"
|
||||
},
|
||||
{
|
||||
"permission": "refund_write",
|
||||
"purpose": "Process refunds"
|
||||
"permission": "charge_write",
|
||||
"purpose": "Process charges and refunds"
|
||||
},
|
||||
{
|
||||
"permission": "product_read",
|
||||
@@ -49,11 +49,11 @@
|
||||
"purpose": "Create and manage products"
|
||||
},
|
||||
{
|
||||
"permission": "price_read",
|
||||
"permission": "plan_read",
|
||||
"purpose": "Display pricing information"
|
||||
},
|
||||
{
|
||||
"permission": "price_write",
|
||||
"permission": "plan_write",
|
||||
"purpose": "Create and manage prices"
|
||||
},
|
||||
{
|
||||
@@ -0,0 +1,14 @@
|
||||
# Stripe App ID (optional - overrides the template default)
|
||||
# Get this from your Stripe Apps dashboard after uploading
|
||||
# STRIPE_APP_ID=com.example.your-app-id
|
||||
|
||||
# Stripe App Name (optional - overrides the template default)
|
||||
# STRIPE_APP_NAME=My App Name
|
||||
|
||||
# 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,49 @@
|
||||
# Full Page Stripe App (Alpha)
|
||||
|
||||
A full-page [Stripe App](https://stripe.com/docs/stripe-apps) example using json-render. This uses the `FullPageView` component and the `stripe.dashboard.fullpage` viewport, which are part of Stripe's **private developer preview**.
|
||||
|
||||
## Prerequisites
|
||||
|
||||
- [Stripe CLI](https://stripe.com/docs/stripe-cli) with the apps plugin
|
||||
- A Stripe account with **full-page apps alpha access** enabled by Stripe
|
||||
- Both your `app_id` and `account_id` must be flagged in by Stripe
|
||||
|
||||
## Setup
|
||||
|
||||
```bash
|
||||
pnpm install
|
||||
|
||||
cp .env.example .env
|
||||
# Set STRIPE_APP_ID to your app ID
|
||||
|
||||
pnpm setup
|
||||
```
|
||||
|
||||
## Running
|
||||
|
||||
Start the API server first (from `../api`):
|
||||
|
||||
```bash
|
||||
cd ../api && pnpm dev
|
||||
```
|
||||
|
||||
Then start the Stripe app:
|
||||
|
||||
```bash
|
||||
stripe apps start
|
||||
```
|
||||
|
||||
Navigate directly to your full-page app URL (it will not appear in the left navigation):
|
||||
|
||||
```
|
||||
https://dashboard.stripe.com/test/app/<your-app-id>
|
||||
```
|
||||
|
||||
## Troubleshooting
|
||||
|
||||
If you are redirected to the Dashboard home page, the feature flag is not enabled for your account. Contact your Stripe partner to have both your `app_id` and `account_id` flagged in.
|
||||
|
||||
## Resources
|
||||
|
||||
- [FullPageView Component](https://docs.stripe.com/stripe-apps/components/fullpageview?app-sdk-version=9)
|
||||
- [FullPageTabs Component](https://docs.stripe.com/stripe-apps/components/fullpagetabs?app-sdk-version=9)
|
||||
@@ -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,28 @@
|
||||
{
|
||||
"name": "com.example.json-render-fullpage-demo",
|
||||
"version": "0.0.1",
|
||||
"description": "Full-page Stripe App example (alpha)",
|
||||
"private": true,
|
||||
"license": "~~proprietary~~",
|
||||
"type": "module",
|
||||
"dependencies": {
|
||||
"@json-render/core": "workspace:*",
|
||||
"@json-render/react": "workspace:*",
|
||||
"@stripe/ui-extension-sdk": "9.2.0-alpha.0",
|
||||
"stripe": "^13.11.0",
|
||||
"zod": "^4.0.0"
|
||||
},
|
||||
"engines": {
|
||||
"node": ">=14"
|
||||
},
|
||||
"scripts": {
|
||||
"setup": "node scripts/setup.mjs",
|
||||
"lint": "eslint src --max-warnings 0",
|
||||
"test": "jest"
|
||||
},
|
||||
"devDependencies": {
|
||||
"@repo/eslint-config": "workspace:*",
|
||||
"@stripe/ui-extension-tools": "^0.0.1",
|
||||
"eslint": "^9.39.0"
|
||||
}
|
||||
}
|
||||
+5270
File diff suppressed because it is too large
Load Diff
@@ -0,0 +1,42 @@
|
||||
import { readFileSync, writeFileSync, existsSync } from "fs";
|
||||
import { resolve, dirname } from "path";
|
||||
import { fileURLToPath } from "url";
|
||||
|
||||
const __dirname = dirname(fileURLToPath(import.meta.url));
|
||||
const root = resolve(__dirname, "..");
|
||||
|
||||
function loadEnv() {
|
||||
const envPath = resolve(root, ".env");
|
||||
if (!existsSync(envPath)) return {};
|
||||
const env = {};
|
||||
for (const line of readFileSync(envPath, "utf-8").split("\n")) {
|
||||
const trimmed = line.trim();
|
||||
if (!trimmed || trimmed.startsWith("#")) continue;
|
||||
const idx = trimmed.indexOf("=");
|
||||
if (idx === -1) continue;
|
||||
const key = trimmed.slice(0, idx).trim();
|
||||
const val = trimmed.slice(idx + 1).trim();
|
||||
env[key] = val;
|
||||
}
|
||||
return env;
|
||||
}
|
||||
|
||||
const env = loadEnv();
|
||||
const templatePath = resolve(root, "stripe-app.template.json");
|
||||
const outputPath = resolve(root, "stripe-app.json");
|
||||
|
||||
const template = JSON.parse(readFileSync(templatePath, "utf-8"));
|
||||
|
||||
if (env.STRIPE_APP_ID) {
|
||||
template.id = env.STRIPE_APP_ID;
|
||||
console.log(`Using app ID from .env: ${env.STRIPE_APP_ID}`);
|
||||
} else {
|
||||
console.log(`No STRIPE_APP_ID in .env, using template default: ${template.id}`);
|
||||
}
|
||||
|
||||
if (env.STRIPE_APP_NAME) {
|
||||
template.name = env.STRIPE_APP_NAME;
|
||||
}
|
||||
|
||||
writeFileSync(outputPath, JSON.stringify(template, null, 4) + "\n");
|
||||
console.log("Generated stripe-app.json");
|
||||
@@ -0,0 +1 @@
|
||||
export const API_GENERATE_URL = "http://localhost:3001/api/generate";
|
||||
File diff suppressed because it is too large
Load Diff
+14
-20
@@ -1374,16 +1374,16 @@ export const actionHandlers: Record<
|
||||
await actionHandlers.fetchCustomers({}, setState, {});
|
||||
},
|
||||
|
||||
refreshPayments: async (_) => {
|
||||
await actionHandlers.fetchPayments({}, setData, {});
|
||||
refreshPayments: async (_, setState) => {
|
||||
await actionHandlers.fetchPayments({}, setState, {});
|
||||
},
|
||||
|
||||
refreshSubscriptions: async (_) => {
|
||||
await actionHandlers.fetchSubscriptions({}, setData, {});
|
||||
refreshSubscriptions: async (_, setState) => {
|
||||
await actionHandlers.fetchSubscriptions({}, setState, {});
|
||||
},
|
||||
|
||||
refreshInvoices: async (_) => {
|
||||
await actionHandlers.fetchInvoices({}, setData, {});
|
||||
refreshInvoices: async (_, setState) => {
|
||||
await actionHandlers.fetchInvoices({}, setState, {});
|
||||
},
|
||||
|
||||
exportData: async (params, _, data) => {
|
||||
@@ -1431,11 +1431,8 @@ export const actionHandlers: Record<
|
||||
// ===========================================================================
|
||||
// Navigation Actions
|
||||
// ===========================================================================
|
||||
navigate: async (params) => {
|
||||
if (params?.statePath) {
|
||||
console.log("Navigate to:", params.statePath);
|
||||
// In a real app, this would use the router
|
||||
}
|
||||
navigate: async (_params) => {
|
||||
// In a real app, this would use the router
|
||||
},
|
||||
|
||||
openDashboard: async (params) => {
|
||||
@@ -1463,8 +1460,7 @@ export const actionHandlers: Record<
|
||||
// ===========================================================================
|
||||
// Form Actions
|
||||
// ===========================================================================
|
||||
submitForm: async (params, _, data) => {
|
||||
console.log("Submit form:", params?.formId, data);
|
||||
submitForm: async (_params, _, _data) => {
|
||||
// Implementation depends on form handling logic
|
||||
},
|
||||
|
||||
@@ -1475,9 +1471,8 @@ export const actionHandlers: Record<
|
||||
}
|
||||
},
|
||||
|
||||
validateForm: async (params, _, data) => {
|
||||
console.log("Validate form:", params?.formId, data);
|
||||
return;
|
||||
validateForm: async (_params, _, _data) => {
|
||||
// Implementation depends on form validation logic
|
||||
},
|
||||
|
||||
setFormValue: async (params, setState) => {
|
||||
@@ -1492,9 +1487,8 @@ export const actionHandlers: Record<
|
||||
// ===========================================================================
|
||||
// UI Actions
|
||||
// ===========================================================================
|
||||
showToast: async (params) => {
|
||||
// In UIXT, you would use the showToast API from the SDK
|
||||
console.log("Toast:", params?.type, params?.message);
|
||||
showToast: async (_params) => {
|
||||
// In a real app, use showToast from @stripe/ui-extension-sdk/utils
|
||||
},
|
||||
|
||||
copyToClipboard: async (params) => {
|
||||
@@ -1567,6 +1561,6 @@ export async function executeAction(
|
||||
if (handler) {
|
||||
await handler(params, setState, data);
|
||||
} else {
|
||||
console.log("Unknown action:", actionName, params);
|
||||
console.warn("Unknown action:", actionName);
|
||||
}
|
||||
}
|
||||
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,145 @@
|
||||
import { useMemo, useRef, type ReactNode } from "react";
|
||||
import {
|
||||
Renderer,
|
||||
type ComponentRegistry,
|
||||
type Spec,
|
||||
StateProvider,
|
||||
VisibilityProvider,
|
||||
ActionProvider,
|
||||
} from "@json-render/react";
|
||||
|
||||
import { components, Fallback } from "./catalog/components";
|
||||
import { executeAction } from "./catalog/actions";
|
||||
|
||||
// =============================================================================
|
||||
// Types
|
||||
// =============================================================================
|
||||
|
||||
type SetState = (
|
||||
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?: SetState;
|
||||
/** Callback when data changes */
|
||||
onStateChange?: (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<SetState | undefined>,
|
||||
loading?: boolean,
|
||||
): ComponentRegistry {
|
||||
const registry: ComponentRegistry = {};
|
||||
|
||||
for (const [name, Component] of Object.entries(components)) {
|
||||
const noop = () => {};
|
||||
registry[name] = (renderProps: {
|
||||
element: { type: string; props: Record<string, unknown> };
|
||||
children?: ReactNode;
|
||||
emit?: (event: string) => void;
|
||||
}) =>
|
||||
Component({
|
||||
element: renderProps.element,
|
||||
children: renderProps.children,
|
||||
emit: renderProps.emit ?? noop,
|
||||
loading,
|
||||
state: 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;
|
||||
},
|
||||
onAction: (actionName: string, params?: Record<string, unknown>) => {
|
||||
const setState = setDataRef.current;
|
||||
if (setState) {
|
||||
executeAction(actionName, params, setState, dataRef.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,
|
||||
onStateChange,
|
||||
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],
|
||||
);
|
||||
|
||||
const mergedState = useMemo(
|
||||
() => (spec?.state ? { ...data, ...spec.state } : data),
|
||||
[data, spec?.state],
|
||||
);
|
||||
|
||||
if (!spec) return null;
|
||||
|
||||
return (
|
||||
<StateProvider initialState={mergedState} onStateChange={onStateChange}>
|
||||
<VisibilityProvider>
|
||||
<ActionProvider>
|
||||
<Renderer
|
||||
spec={spec}
|
||||
registry={registry}
|
||||
fallback={fallbackRegistry}
|
||||
loading={loading}
|
||||
/>
|
||||
</ActionProvider>
|
||||
</VisibilityProvider>
|
||||
</StateProvider>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,146 @@
|
||||
import type { Spec } from "@json-render/react";
|
||||
|
||||
interface JsonPatch {
|
||||
op: string;
|
||||
path: string;
|
||||
value?: unknown;
|
||||
from?: string;
|
||||
}
|
||||
|
||||
function setDeep(
|
||||
obj: Record<string, unknown>,
|
||||
segments: string[],
|
||||
value: unknown,
|
||||
): void {
|
||||
let current: unknown = obj;
|
||||
for (let i = 0; i < segments.length - 1; i++) {
|
||||
const seg = segments[i];
|
||||
const next = (current as Record<string, unknown>)[seg];
|
||||
if (next && typeof next === "object") {
|
||||
current = next;
|
||||
} else {
|
||||
const container = /^\d+$/.test(segments[i + 1]) ? [] : {};
|
||||
(current as Record<string, unknown>)[seg] = container;
|
||||
current = container;
|
||||
}
|
||||
}
|
||||
const last = segments[segments.length - 1];
|
||||
if (Array.isArray(current)) {
|
||||
(current as unknown[])[Number(last)] = value;
|
||||
} else {
|
||||
(current as Record<string, unknown>)[last] = value;
|
||||
}
|
||||
}
|
||||
|
||||
function setSpecValue(spec: Spec, path: string, value: unknown): void {
|
||||
if (path === "/root") {
|
||||
(spec as Record<string, unknown>).root = value as string;
|
||||
return;
|
||||
}
|
||||
if (path === "/state") {
|
||||
(spec as Record<string, unknown>).state = value;
|
||||
return;
|
||||
}
|
||||
if (path.startsWith("/state/")) {
|
||||
if (!spec.state) (spec as Record<string, unknown>).state = {};
|
||||
const segments = path.slice("/state/".length).split("/");
|
||||
setDeep(spec.state as Record<string, unknown>, segments, value);
|
||||
return;
|
||||
}
|
||||
const elemMatch = path.match(/^\/elements\/(.+)/);
|
||||
if (elemMatch) {
|
||||
spec.elements[elemMatch[1]] = value as Spec["elements"][string];
|
||||
}
|
||||
}
|
||||
|
||||
function applyPatch(spec: Spec, patch: JsonPatch): Spec {
|
||||
const next: Spec = {
|
||||
...spec,
|
||||
elements: { ...spec.elements },
|
||||
...(spec.state ? { state: { ...spec.state } } : {}),
|
||||
};
|
||||
switch (patch.op) {
|
||||
case "add":
|
||||
case "replace":
|
||||
setSpecValue(next, patch.path, patch.value);
|
||||
break;
|
||||
case "remove":
|
||||
if (patch.path.startsWith("/elements/")) {
|
||||
const key = patch.path.slice("/elements/".length);
|
||||
delete next.elements[key];
|
||||
}
|
||||
break;
|
||||
}
|
||||
return next;
|
||||
}
|
||||
|
||||
/**
|
||||
* Fetch an AI generation endpoint that returns a JSONL patch stream,
|
||||
* applying patches progressively via onPatch.
|
||||
*/
|
||||
export async function streamSpec(
|
||||
url: string,
|
||||
body: Record<string, unknown>,
|
||||
onPatch: (spec: Spec) => void,
|
||||
): Promise<Spec> {
|
||||
const response = await fetch(url, {
|
||||
method: "POST",
|
||||
headers: { "Content-Type": "application/json" },
|
||||
body: JSON.stringify(body),
|
||||
});
|
||||
|
||||
if (!response.ok) {
|
||||
let msg = `API error: ${response.status}`;
|
||||
try {
|
||||
const errData = await response.json();
|
||||
if (errData.error) msg = errData.error;
|
||||
} catch {
|
||||
// use default
|
||||
}
|
||||
throw new Error(msg);
|
||||
}
|
||||
|
||||
const reader = response.body?.getReader();
|
||||
if (!reader) throw new Error("No response body");
|
||||
|
||||
const decoder = new TextDecoder();
|
||||
let buffer = "";
|
||||
let spec: Spec = { root: "", elements: {} };
|
||||
|
||||
while (true) {
|
||||
const { done, value } = await reader.read();
|
||||
if (done) break;
|
||||
|
||||
buffer += decoder.decode(value, { stream: true });
|
||||
const lines = buffer.split("\n");
|
||||
buffer = lines.pop() ?? "";
|
||||
|
||||
for (const line of lines) {
|
||||
const trimmed = line.trim();
|
||||
if (!trimmed || trimmed.startsWith("//")) continue;
|
||||
try {
|
||||
const parsed = JSON.parse(trimmed) as JsonPatch;
|
||||
if (parsed.op) {
|
||||
spec = applyPatch(spec, parsed);
|
||||
onPatch(spec);
|
||||
}
|
||||
} catch {
|
||||
// skip non-JSON lines
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
if (buffer.trim()) {
|
||||
try {
|
||||
const parsed = JSON.parse(buffer.trim()) as JsonPatch;
|
||||
if (parsed.op) {
|
||||
spec = applyPatch(spec, parsed);
|
||||
onPatch(spec);
|
||||
}
|
||||
} catch {
|
||||
// skip
|
||||
}
|
||||
}
|
||||
|
||||
return spec;
|
||||
}
|
||||
@@ -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,308 @@
|
||||
import { useState, useCallback, useEffect } from "react";
|
||||
import type { Spec } from "@json-render/react";
|
||||
import {
|
||||
Box,
|
||||
FullPageView,
|
||||
FullPageTabs,
|
||||
FullPageTab,
|
||||
Button,
|
||||
TextField,
|
||||
Divider,
|
||||
List,
|
||||
ListItem,
|
||||
Spinner,
|
||||
} from "@stripe/ui-extension-sdk/ui";
|
||||
import type { ExtensionContextValue } from "@stripe/ui-extension-sdk/context";
|
||||
|
||||
import { stripeCatalog, StripeRenderer } from "../lib/render";
|
||||
import { executeAction } from "../lib/render/catalog/actions";
|
||||
import { API_GENERATE_URL } from "../lib/config";
|
||||
import { streamSpec } from "../lib/stream-spec";
|
||||
|
||||
function createOverviewSpec(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: {
|
||||
type: "Stack",
|
||||
props: { direction: "vertical", gap: "large" },
|
||||
children: ["metrics", "divider", "refresh"],
|
||||
},
|
||||
metrics: {
|
||||
type: "Stack",
|
||||
props: { direction: "horizontal", gap: "medium" },
|
||||
children: ["m1", "m2", "m3"],
|
||||
},
|
||||
m1: {
|
||||
type: "Metric",
|
||||
props: {
|
||||
label: "Payment Volume",
|
||||
value: payments?.totalVolume ?? "$0",
|
||||
change: payments?.successRate
|
||||
? `${payments.successRate} success`
|
||||
: null,
|
||||
changeType: "positive",
|
||||
},
|
||||
children: [],
|
||||
},
|
||||
m2: {
|
||||
type: "Metric",
|
||||
props: {
|
||||
label: "Active Subscriptions",
|
||||
value: String(subscriptions?.active ?? 0),
|
||||
change: subscriptions?.trialing
|
||||
? `+${subscriptions.trialing} trialing`
|
||||
: null,
|
||||
changeType: "positive",
|
||||
},
|
||||
children: [],
|
||||
},
|
||||
m3: {
|
||||
type: "Metric",
|
||||
props: {
|
||||
label: "Total Customers",
|
||||
value: String(customers?.total ?? 0),
|
||||
changeType: "neutral",
|
||||
},
|
||||
children: [],
|
||||
},
|
||||
divider: { type: "Divider", props: {}, children: [] },
|
||||
refresh: {
|
||||
type: "Button",
|
||||
props: {
|
||||
label: "Refresh Data",
|
||||
action: "refreshData",
|
||||
type: "secondary",
|
||||
},
|
||||
children: [],
|
||||
},
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
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,
|
||||
}),
|
||||
);
|
||||
|
||||
const FullPage = (_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, setState] = useState<Record<string, unknown>>({});
|
||||
const [error, setError] = useState<string | null>(null);
|
||||
|
||||
const handleSetState = useCallback(
|
||||
(updater: (prev: Record<string, unknown>) => Record<string, unknown>) => {
|
||||
setState((prev) => updater(prev));
|
||||
},
|
||||
[],
|
||||
);
|
||||
|
||||
useEffect(() => {
|
||||
async function loadData() {
|
||||
setIsLoading(true);
|
||||
try {
|
||||
await executeAction("refreshData", {}, handleSetState, {});
|
||||
} catch (err) {
|
||||
console.error("Failed to load data:", err);
|
||||
setError("Failed to load Stripe data");
|
||||
} finally {
|
||||
setIsLoading(false);
|
||||
}
|
||||
}
|
||||
loadData();
|
||||
}, [handleSetState]);
|
||||
|
||||
const handleGenerate = async () => {
|
||||
if (!prompt.trim()) return;
|
||||
setIsGenerating(true);
|
||||
setError(null);
|
||||
|
||||
try {
|
||||
await streamSpec(
|
||||
API_GENERATE_URL,
|
||||
{
|
||||
prompt,
|
||||
systemPrompt: stripeCatalog.prompt({
|
||||
system:
|
||||
"You are a Stripe dashboard builder. Generate UI specs for displaying Stripe data in a full-page layout.",
|
||||
}),
|
||||
},
|
||||
(spec) => setCurrentSpec(spec),
|
||||
);
|
||||
} catch (err) {
|
||||
setCurrentSpec(createOverviewSpec(data));
|
||||
setError(
|
||||
`Using local generation (${err instanceof Error ? err.message : "API unavailable"})`,
|
||||
);
|
||||
} finally {
|
||||
setIsGenerating(false);
|
||||
}
|
||||
};
|
||||
|
||||
if (isLoading) {
|
||||
return (
|
||||
<FullPageView>
|
||||
<FullPageTabs>
|
||||
<FullPageTab
|
||||
id="overview"
|
||||
label="Overview"
|
||||
content={
|
||||
<Box
|
||||
css={{
|
||||
stack: "y",
|
||||
gap: "medium",
|
||||
alignX: "center",
|
||||
paddingY: "xlarge",
|
||||
}}
|
||||
>
|
||||
<Spinner size="large" />
|
||||
<Box css={{ font: "body", color: "secondary" }}>
|
||||
Loading Stripe data...
|
||||
</Box>
|
||||
</Box>
|
||||
}
|
||||
/>
|
||||
</FullPageTabs>
|
||||
</FullPageView>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<FullPageView
|
||||
pageAction={{
|
||||
label: "Refresh Data",
|
||||
onPress: () => executeAction("refreshData", {}, handleSetState, {}),
|
||||
}}
|
||||
>
|
||||
<FullPageTabs>
|
||||
<FullPageTab
|
||||
id="overview"
|
||||
label="Overview"
|
||||
content={
|
||||
<Box css={{ stack: "y", gap: "large", padding: "large" }}>
|
||||
<StripeRenderer
|
||||
spec={createOverviewSpec(data)}
|
||||
data={data}
|
||||
setData={handleSetState}
|
||||
loading={false}
|
||||
/>
|
||||
</Box>
|
||||
}
|
||||
/>
|
||||
|
||||
<FullPageTab
|
||||
id="generate"
|
||||
label="Generate"
|
||||
content={
|
||||
<Box css={{ stack: "y", gap: "medium", padding: "large" }}>
|
||||
<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={handleSetState}
|
||||
loading={isGenerating}
|
||||
/>
|
||||
</Box>
|
||||
</>
|
||||
)}
|
||||
</Box>
|
||||
}
|
||||
/>
|
||||
|
||||
<FullPageTab
|
||||
id="components"
|
||||
label="Components"
|
||||
content={
|
||||
<Box css={{ padding: "large" }}>
|
||||
<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>
|
||||
}
|
||||
/>
|
||||
|
||||
<FullPageTab
|
||||
id="actions"
|
||||
label="Actions"
|
||||
content={
|
||||
<Box css={{ padding: "large" }}>
|
||||
<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>
|
||||
}
|
||||
/>
|
||||
</FullPageTabs>
|
||||
</FullPageView>
|
||||
);
|
||||
};
|
||||
|
||||
export default FullPage;
|
||||
@@ -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,97 @@
|
||||
{
|
||||
"id": "com.example.json-render-demo",
|
||||
"version": "0.0.1",
|
||||
"name": "json-render Full Page 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": "charge_write",
|
||||
"purpose": "Process charges and refunds"
|
||||
},
|
||||
{
|
||||
"permission": "product_read",
|
||||
"purpose": "Display product catalog"
|
||||
},
|
||||
{
|
||||
"permission": "product_write",
|
||||
"purpose": "Create and manage products"
|
||||
},
|
||||
{
|
||||
"permission": "plan_read",
|
||||
"purpose": "Display pricing information"
|
||||
},
|
||||
{
|
||||
"permission": "plan_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.fullpage",
|
||||
"component": "FullPage"
|
||||
}
|
||||
],
|
||||
"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"
|
||||
}
|
||||
@@ -0,0 +1 @@
|
||||
/// <reference types="@stripe/ui-extension-tools" />
|
||||
Generated
+155
-8
@@ -560,24 +560,83 @@ importers:
|
||||
specifier: ^5.7.2
|
||||
version: 5.9.2
|
||||
|
||||
examples/stripe-app:
|
||||
examples/stripe-app/api:
|
||||
dependencies:
|
||||
'@ai-sdk/gateway':
|
||||
specifier: ^3.0.50
|
||||
version: 3.0.50(zod@4.3.5)
|
||||
ai:
|
||||
specifier: ^6.0.91
|
||||
version: 6.0.91(zod@4.3.5)
|
||||
next:
|
||||
specifier: ^16.1.6
|
||||
version: 16.1.6(@opentelemetry/api@1.9.0)(babel-plugin-react-compiler@1.0.0)(react-dom@19.2.4(react@19.2.4))(react@19.2.4)
|
||||
react:
|
||||
specifier: ^19.1.0
|
||||
version: 19.2.4
|
||||
react-dom:
|
||||
specifier: ^19.1.0
|
||||
version: 19.2.4(react@19.2.4)
|
||||
devDependencies:
|
||||
'@types/node':
|
||||
specifier: ^22.0.0
|
||||
version: 22.19.6
|
||||
'@types/react':
|
||||
specifier: ^19.1.0
|
||||
version: 19.2.3
|
||||
typescript:
|
||||
specifier: ^5.4.5
|
||||
version: 5.9.2
|
||||
|
||||
examples/stripe-app/drawer-app:
|
||||
dependencies:
|
||||
'@json-render/core':
|
||||
specifier: workspace:*
|
||||
version: link:../../packages/core
|
||||
version: link:../../../packages/core
|
||||
'@json-render/react':
|
||||
specifier: workspace:*
|
||||
version: link:../../packages/react
|
||||
version: link:../../../packages/react
|
||||
'@stripe/ui-extension-sdk':
|
||||
specifier: ^9.1.0
|
||||
version: 9.1.0(stripe@13.11.0)
|
||||
stripe:
|
||||
specifier: ^13.11.0
|
||||
version: 13.11.0
|
||||
zod:
|
||||
specifier: ^4.0.0
|
||||
version: 4.3.5
|
||||
devDependencies:
|
||||
'@repo/eslint-config':
|
||||
specifier: workspace:*
|
||||
version: link:../../packages/eslint-config
|
||||
version: link:../../../packages/eslint-config
|
||||
'@stripe/ui-extension-tools':
|
||||
specifier: ^0.0.1
|
||||
version: 0.0.1(@babel/core@7.29.0)(babel-jest@27.5.1(@babel/core@7.29.0))
|
||||
eslint:
|
||||
specifier: ^9.39.0
|
||||
version: 9.39.2(jiti@2.6.1)
|
||||
|
||||
examples/stripe-app/fullpage-app:
|
||||
dependencies:
|
||||
'@json-render/core':
|
||||
specifier: workspace:*
|
||||
version: link:../../../packages/core
|
||||
'@json-render/react':
|
||||
specifier: workspace:*
|
||||
version: link:../../../packages/react
|
||||
'@stripe/ui-extension-sdk':
|
||||
specifier: 9.2.0-alpha.0
|
||||
version: 9.2.0-alpha.0(stripe@13.11.0)
|
||||
stripe:
|
||||
specifier: ^13.11.0
|
||||
version: 13.11.0
|
||||
zod:
|
||||
specifier: ^4.0.0
|
||||
version: 4.3.5
|
||||
devDependencies:
|
||||
'@repo/eslint-config':
|
||||
specifier: workspace:*
|
||||
version: link:../../../packages/eslint-config
|
||||
'@stripe/ui-extension-tools':
|
||||
specifier: ^0.0.1
|
||||
version: 0.0.1(@babel/core@7.29.0)(babel-jest@27.5.1(@babel/core@7.29.0))
|
||||
@@ -856,6 +915,12 @@ packages:
|
||||
peerDependencies:
|
||||
zod: ^3.25.76 || ^4.1.8
|
||||
|
||||
'@ai-sdk/gateway@3.0.50':
|
||||
resolution: {integrity: sha512-Jdd1a8VgbD7l7r+COj0h5SuaYRfPvOJ/AO6l0OrmTPEcI2MUQPr3C4JttfpNkcheEN+gOdy0CtZWuG17bW2fjw==}
|
||||
engines: {node: '>=18'}
|
||||
peerDependencies:
|
||||
zod: ^3.25.76 || ^4.1.8
|
||||
|
||||
'@ai-sdk/provider-utils@4.0.13':
|
||||
resolution: {integrity: sha512-HHG72BN4d+OWTcq2NwTxOm/2qvk1duYsnhCDtsbYwn/h/4zeqURu1S0+Cn0nY2Ysq9a9HGKvrYuMn9bgFhR2Og==}
|
||||
engines: {node: '>=18'}
|
||||
@@ -868,6 +933,12 @@ packages:
|
||||
peerDependencies:
|
||||
zod: ^3.25.76 || ^4.1.8
|
||||
|
||||
'@ai-sdk/provider-utils@4.0.15':
|
||||
resolution: {integrity: sha512-8XiKWbemmCbvNN0CLR9u3PQiet4gtEVIrX4zzLxnCj06AwsEDJwJVBbKrEI4t6qE8XRSIvU2irka0dcpziKW6w==}
|
||||
engines: {node: '>=18'}
|
||||
peerDependencies:
|
||||
zod: ^3.25.76 || ^4.1.8
|
||||
|
||||
'@ai-sdk/provider-utils@4.0.5':
|
||||
resolution: {integrity: sha512-Ow/X/SEkeExTTc1x+nYLB9ZHK2WUId8+9TlkamAx7Tl9vxU+cKzWx2dwjgMHeCN6twrgwkLrrtqckQeO4mxgVA==}
|
||||
engines: {node: '>=18'}
|
||||
@@ -4097,6 +4168,11 @@ packages:
|
||||
peerDependencies:
|
||||
stripe: '>= 8.195.0'
|
||||
|
||||
'@stripe/ui-extension-sdk@9.2.0-alpha.0':
|
||||
resolution: {integrity: sha512-VMW2jE/y/engb/ygosJy7ZlmikX2b/wMLU+MEusj9n7SHvlVVHjQOzQA53M17n8Hq9urxhyV8BNv6S6YYsi7VA==}
|
||||
peerDependencies:
|
||||
stripe: '>= 8.195.0'
|
||||
|
||||
'@stripe/ui-extension-tools@0.0.1':
|
||||
resolution: {integrity: sha512-0pOgQ3AuEUeypAgAhcJbyC9QxMaMW1OqzzxkCO4a+5ALDyIFEA6M4jDQ3H9KayNwqQ23qq+PQ0rlYY7dRACNgA==}
|
||||
|
||||
@@ -4757,6 +4833,12 @@ packages:
|
||||
peerDependencies:
|
||||
zod: ^3.25.76 || ^4.1.8
|
||||
|
||||
ai@6.0.91:
|
||||
resolution: {integrity: sha512-k1/8BusZMhYVxxLZt0BUZzm9HVDCCh117nyWfWUx5xjR2+tWisJbXgysL7EBMq2lgyHwgpA1jDR3tVjWSdWZXw==}
|
||||
engines: {node: '>=18'}
|
||||
peerDependencies:
|
||||
zod: ^3.25.76 || ^4.1.8
|
||||
|
||||
ajv-formats@2.1.1:
|
||||
resolution: {integrity: sha512-Wx0Kx52hxE7C18hkMEggYlEifqWZtYaRgouJor+WMdPnQyEK13vgEWyVNup7SoeeoLMsr4kf5h6dOW11I15MUA==}
|
||||
peerDependencies:
|
||||
@@ -10037,6 +10119,13 @@ snapshots:
|
||||
'@vercel/oidc': 3.1.0
|
||||
zod: 4.3.5
|
||||
|
||||
'@ai-sdk/gateway@3.0.50(zod@4.3.5)':
|
||||
dependencies:
|
||||
'@ai-sdk/provider': 3.0.8
|
||||
'@ai-sdk/provider-utils': 4.0.15(zod@4.3.5)
|
||||
'@vercel/oidc': 3.1.0
|
||||
zod: 4.3.5
|
||||
|
||||
'@ai-sdk/provider-utils@4.0.13(zod@4.3.5)':
|
||||
dependencies:
|
||||
'@ai-sdk/provider': 3.0.7
|
||||
@@ -10051,6 +10140,13 @@ snapshots:
|
||||
eventsource-parser: 3.0.6
|
||||
zod: 4.3.5
|
||||
|
||||
'@ai-sdk/provider-utils@4.0.15(zod@4.3.5)':
|
||||
dependencies:
|
||||
'@ai-sdk/provider': 3.0.8
|
||||
'@standard-schema/spec': 1.1.0
|
||||
eventsource-parser: 3.0.6
|
||||
zod: 4.3.5
|
||||
|
||||
'@ai-sdk/provider-utils@4.0.5(zod@4.3.5)':
|
||||
dependencies:
|
||||
'@ai-sdk/provider': 3.0.2
|
||||
@@ -14448,14 +14544,14 @@ snapshots:
|
||||
|
||||
'@remote-ui/core@2.2.5':
|
||||
dependencies:
|
||||
'@remote-ui/rpc': 1.4.5
|
||||
'@remote-ui/rpc': 1.4.7
|
||||
'@remote-ui/types': 1.1.3
|
||||
|
||||
'@remote-ui/react@5.0.5(react-reconciler@0.29.0(react@18.3.1))(react@18.3.1)':
|
||||
dependencies:
|
||||
'@remote-ui/async-subscription': 2.1.18
|
||||
'@remote-ui/core': 2.2.5
|
||||
'@remote-ui/rpc': 1.4.5
|
||||
'@remote-ui/rpc': 1.4.7
|
||||
'@types/react': 18.3.28
|
||||
'@types/react-reconciler': 0.28.9(@types/react@18.3.28)
|
||||
react: 18.3.1
|
||||
@@ -14757,10 +14853,22 @@ snapshots:
|
||||
react-reconciler: 0.29.0(react@18.3.1)
|
||||
stripe: 13.11.0
|
||||
|
||||
'@stripe/ui-extension-sdk@9.2.0-alpha.0(stripe@13.11.0)':
|
||||
dependencies:
|
||||
'@remote-ui/core': 2.2.5
|
||||
'@remote-ui/react': 5.0.5(react-reconciler@0.29.0(react@18.3.1))(react@18.3.1)
|
||||
'@remote-ui/rpc': 1.4.5
|
||||
'@remote-ui/testing': 1.4.3
|
||||
'@types/stylis': 4.2.6
|
||||
invariant: 2.2.4
|
||||
react: 18.3.1
|
||||
react-reconciler: 0.29.0(react@18.3.1)
|
||||
stripe: 13.11.0
|
||||
|
||||
'@stripe/ui-extension-tools@0.0.1(@babel/core@7.29.0)(babel-jest@27.5.1(@babel/core@7.29.0))':
|
||||
dependencies:
|
||||
'@types/jest': 28.1.8
|
||||
'@typescript-eslint/eslint-plugin': 5.62.0(@typescript-eslint/parser@5.62.0(eslint@9.39.2(jiti@2.6.1))(typescript@4.9.5))(eslint@8.57.1)(typescript@4.9.5)
|
||||
'@typescript-eslint/eslint-plugin': 5.62.0(@typescript-eslint/parser@5.62.0(eslint@8.57.1)(typescript@4.9.5))(eslint@8.57.1)(typescript@4.9.5)
|
||||
'@typescript-eslint/parser': 5.62.0(eslint@8.57.1)(typescript@4.9.5)
|
||||
eslint: 8.57.1
|
||||
eslint-plugin-react: 7.37.5(eslint@8.57.1)
|
||||
@@ -15087,7 +15195,7 @@ snapshots:
|
||||
'@types/node': 22.19.6
|
||||
optional: true
|
||||
|
||||
'@typescript-eslint/eslint-plugin@5.62.0(@typescript-eslint/parser@5.62.0(eslint@9.39.2(jiti@2.6.1))(typescript@4.9.5))(eslint@8.57.1)(typescript@4.9.5)':
|
||||
'@typescript-eslint/eslint-plugin@5.62.0(@typescript-eslint/parser@5.62.0(eslint@8.57.1)(typescript@4.9.5))(eslint@8.57.1)(typescript@4.9.5)':
|
||||
dependencies:
|
||||
'@eslint-community/regexpp': 4.12.2
|
||||
'@typescript-eslint/parser': 5.62.0(eslint@8.57.1)(typescript@4.9.5)
|
||||
@@ -15497,6 +15605,14 @@ snapshots:
|
||||
'@opentelemetry/api': 1.9.0
|
||||
zod: 4.3.5
|
||||
|
||||
ai@6.0.91(zod@4.3.5):
|
||||
dependencies:
|
||||
'@ai-sdk/gateway': 3.0.50(zod@4.3.5)
|
||||
'@ai-sdk/provider': 3.0.8
|
||||
'@ai-sdk/provider-utils': 4.0.15(zod@4.3.5)
|
||||
'@opentelemetry/api': 1.9.0
|
||||
zod: 4.3.5
|
||||
|
||||
ajv-formats@2.1.1(ajv@8.17.1):
|
||||
optionalDependencies:
|
||||
ajv: 8.17.1
|
||||
@@ -19667,6 +19783,32 @@ snapshots:
|
||||
- '@babel/core'
|
||||
- babel-plugin-macros
|
||||
|
||||
next@16.1.6(@opentelemetry/api@1.9.0)(babel-plugin-react-compiler@1.0.0)(react-dom@19.2.4(react@19.2.4))(react@19.2.4):
|
||||
dependencies:
|
||||
'@next/env': 16.1.6
|
||||
'@swc/helpers': 0.5.15
|
||||
baseline-browser-mapping: 2.9.14
|
||||
caniuse-lite: 1.0.30001764
|
||||
postcss: 8.4.31
|
||||
react: 19.2.4
|
||||
react-dom: 19.2.4(react@19.2.4)
|
||||
styled-jsx: 5.1.6(react@19.2.4)
|
||||
optionalDependencies:
|
||||
'@next/swc-darwin-arm64': 16.1.6
|
||||
'@next/swc-darwin-x64': 16.1.6
|
||||
'@next/swc-linux-arm64-gnu': 16.1.6
|
||||
'@next/swc-linux-arm64-musl': 16.1.6
|
||||
'@next/swc-linux-x64-gnu': 16.1.6
|
||||
'@next/swc-linux-x64-musl': 16.1.6
|
||||
'@next/swc-win32-arm64-msvc': 16.1.6
|
||||
'@next/swc-win32-x64-msvc': 16.1.6
|
||||
'@opentelemetry/api': 1.9.0
|
||||
babel-plugin-react-compiler: 1.0.0
|
||||
sharp: 0.34.5
|
||||
transitivePeerDependencies:
|
||||
- '@babel/core'
|
||||
- babel-plugin-macros
|
||||
|
||||
node-abi@3.87.0:
|
||||
dependencies:
|
||||
semver: 7.7.3
|
||||
@@ -21510,6 +21652,11 @@ snapshots:
|
||||
client-only: 0.0.1
|
||||
react: 19.2.3
|
||||
|
||||
styled-jsx@5.1.6(react@19.2.4):
|
||||
dependencies:
|
||||
client-only: 0.0.1
|
||||
react: 19.2.4
|
||||
|
||||
sucrase@3.35.1:
|
||||
dependencies:
|
||||
'@jridgewell/gen-mapping': 0.3.13
|
||||
|
||||
@@ -1,4 +1,5 @@
|
||||
packages:
|
||||
- "apps/*"
|
||||
- "examples/*"
|
||||
- "examples/stripe-app/*"
|
||||
- "packages/*"
|
||||
|
||||
Reference in New Issue
Block a user