Compare commits

...
Author SHA1 Message Date
github-actions[bot] f3611860a7 chore: version packages (#75)
Co-authored-by: github-actions[bot] <41898282+github-actions[bot]@users.noreply.github.com>
2026-02-06 18:02:12 -06:00
Chris Tate 61ee8e5291 changeset: include remove op in system prompt (#74) 2026-02-06 17:58:50 -06:00
Chris Tate d38b281e5e include remove op in system prompt (#73) 2026-02-06 17:56:03 -06:00
Chris Tate 39fcc192e9 fix stream docs (#72) 2026-02-06 14:48:32 -06:00
github-actions[bot] 96c7a452b3 chore: version packages (#71)
Co-authored-by: github-actions[bot] <41898282+github-actions[bot]@users.noreply.github.com>
2026-02-06 14:33:12 -06:00
Chris Tate 844f18d1ee fix deps (#70) 2026-02-06 14:32:10 -06:00
Chris Tate 54bce097a5 add defineRegistry function (#67) 2026-02-06 14:30:06 -06:00
Chris Tate e2cb239c88 fix version (#69) 2026-02-06 14:28:38 -06:00
Chris Tate 90c59c674d update registry page (#68) 2026-02-06 14:27:07 -06:00
Chris Tate bba38727e8 ignore private packages (#66)
* ignore private packages

* fix ignore

* better ignore
2026-02-06 14:19:34 -06:00
Chris Tate 1d547b579c changeset (#65) 2026-02-06 14:12:00 -06:00
Chris Tate 795cdaac56 defineRegistry (#64)
* defineRegistry

* update dashboard example
2026-02-06 13:56:48 -06:00
Chris Tate 4ae049f218 stripe app (#62)
* stripe-app example

* fix lint

* add readme

* fix lint
2026-02-05 12:53:58 -06:00
Chris Tate f004caf95e combine docs nav (#61) 2026-02-05 10:22:51 -06:00
Chris Tate 72d6468e70 redirect for /docs/registry (#60) 2026-02-05 10:13:26 -06:00
Chris Tate e6f2b1e9b8 chore: bump @json-render/remotion to v0.4.1 (#59) 2026-02-05 05:05:21 -06:00
Chris Tate 85f82590a8 add motion to remotion (#58)
* add motion to remotion

* update example
2026-02-05 05:03:28 -06:00
73 changed files with 20845 additions and 1543 deletions
+13
View File
@@ -0,0 +1,13 @@
# Changesets
Hello and welcome! This folder has been automatically generated by `@changesets/cli`, a build tool that works
with multi-package repos, or single-package repos to help you version and publish your code. You can
find the full documentation for it [in the repository](https://github.com/changesets/changesets).
## Adding a changeset
To add a changeset, run `pnpm changeset` in the root of the repository. This will prompt you to select
which packages have changed and what type of version bump (major, minor, or patch) should be applied.
All `@json-render/*` packages are versioned together -- a changeset for any one of them will bump all
packages to the same version.
+21
View File
@@ -0,0 +1,21 @@
{
"$schema": "https://unpkg.com/@changesets/config@3.1.1/schema.json",
"changelog": "@changesets/cli/changelog",
"commit": false,
"fixed": [
[
"@json-render/core",
"@json-render/react",
"@json-render/remotion",
"@json-render/codegen"
]
],
"linked": [],
"access": "public",
"baseBranch": "main",
"updateInternalDependencies": "patch",
"privatePackages": {
"version": false,
"tag": false
}
}
+47
View File
@@ -0,0 +1,47 @@
name: Release
on:
push:
branches:
- main
workflow_dispatch:
concurrency: ${{ github.workflow }}-${{ github.ref }}
permissions:
contents: write
pull-requests: write
jobs:
release:
name: Release
runs-on: ubuntu-latest
steps:
- name: Checkout repository
uses: actions/checkout@v4
with:
fetch-depth: 0
- name: Install pnpm
uses: pnpm/action-setup@v4
- name: Setup Node.js
uses: actions/setup-node@v4
with:
node-version: 20
cache: pnpm
registry-url: "https://registry.npmjs.org"
- name: Install dependencies
run: pnpm install --frozen-lockfile
- name: Create Release Pull Request or Publish
uses: changesets/action@v1
with:
version: pnpm ci:version
publish: pnpm ci:publish
title: "chore: version packages"
commit: "chore: version packages"
env:
GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }}
NODE_AUTH_TOKEN: ${{ secrets.NPM_VERCEL_TOKEN_ELEVATED }}
+27 -31
View File
@@ -56,45 +56,39 @@ const catalog = defineCatalog(schema, {
});
```
### 2. Register Component Implementations
### 2. Define Your Components
```tsx
import { defineComponents } from "@json-render/react";
import { defineRegistry, Renderer } from "@json-render/react";
const components = defineComponents(catalog, {
Card: ({ props, children }) => (
<div className="card">
<h3>{props.title}</h3>
{children}
</div>
),
Metric: ({ props }) => (
<div className="metric">
<span>{props.label}</span>
<span>{format(props.value, props.format)}</span>
</div>
),
Button: ({ props, onAction }) => (
<button onClick={() => onAction?.(props.action)}>
{props.label}
</button>
),
const { registry } = defineRegistry(catalog, {
components: {
Card: ({ props, children }) => (
<div className="card">
<h3>{props.title}</h3>
{children}
</div>
),
Metric: ({ props }) => (
<div className="metric">
<span>{props.label}</span>
<span>{format(props.value, props.format)}</span>
</div>
),
Button: ({ props, onAction }) => (
<button onClick={() => onAction?.({ name: props.action })}>
{props.label}
</button>
),
},
});
```
### 3. Render AI-Generated Specs
```tsx
import { Renderer } from "@json-render/react";
function Dashboard({ spec }) {
return (
<Renderer
spec={spec}
catalog={catalog}
components={components}
/>
);
return <Renderer spec={spec} registry={registry} />;
}
```
@@ -115,7 +109,7 @@ function Dashboard({ spec }) {
### React (UI)
```tsx
import { Renderer } from "@json-render/react";
import { defineRegistry, Renderer } from "@json-render/react";
import { schema } from "@json-render/react";
// Element tree spec format
@@ -129,7 +123,9 @@ const spec = {
}
};
<Renderer spec={spec} catalog={catalog} components={components} />
// defineRegistry creates a type-safe component registry
const { registry } = defineRegistry(catalog, { components });
<Renderer spec={spec} registry={registry} />
```
### Remotion (Video)
+35 -20
View File
@@ -43,51 +43,66 @@ interface AuthState {
type ValidatorFn = (value: unknown, args?: object) => boolean | Promise<boolean>;`}</Code>
<h2 className="text-xl font-semibold mt-12 mb-4">createRenderer</h2>
<h2 className="text-xl font-semibold mt-12 mb-4">defineRegistry</h2>
<p className="text-sm text-muted-foreground mb-4">
Factory function to create a pre-configured renderer component.
Create a type-safe component registry from a catalog. Components receive{" "}
<code className="text-foreground">props</code>,{" "}
<code className="text-foreground">children</code>,{" "}
<code className="text-foreground">onAction</code>, and{" "}
<code className="text-foreground">loading</code> with catalog-inferred
types.
</p>
<Code lang="tsx">{`import { createRenderer } from '@json-render/react';
<Code lang="tsx">{`import { defineRegistry } from '@json-render/react';
const MyRenderer = createRenderer({
registry: componentRegistry,
data?: initialData,
actionHandlers?: actionHandlerMap,
auth?: authState,
const { registry } = defineRegistry(catalog, {
components: {
Card: ({ props, children }) => <div>{props.title}{children}</div>,
Button: ({ props, onAction }) => (
<button onClick={() => onAction?.({ name: props.action })}>
{props.label}
</button>
),
},
});
// Usage
<MyRenderer spec={spec} loading={isStreaming} />`}</Code>
// Pass to <Renderer>
<Renderer spec={spec} registry={registry} />`}</Code>
<h2 className="text-xl font-semibold mt-12 mb-4">Components</h2>
<h3 className="text-lg font-semibold mt-8 mb-4">Renderer</h3>
<Code lang="tsx">{`<Renderer
spec={Spec} // The UI spec to render
registry={Registry} // Component registry
registry={Registry} // Component registry (from defineRegistry)
loading={boolean} // Optional loading state
fallback={Component} // Optional fallback for unknown types
/>
type Registry = Record<string, React.ComponentType<ComponentProps>>;
type Registry = Record<string, React.ComponentType<ComponentRenderProps>>;`}</Code>
interface ComponentProps<T = Record<string, unknown>> {
props: T; // Component props from spec
<h3 className="text-lg font-semibold mt-8 mb-4">
Component Props (via defineRegistry)
</h3>
<Code lang="tsx">{`interface ComponentContext<P> {
props: P; // Typed props from catalog
children?: React.ReactNode; // Rendered children (for slot components)
onAction?: (action: { name: string; params?: object }) => void;
loading?: boolean;
}`}</Code>
<h2 className="text-xl font-semibold mt-12 mb-4">Hooks</h2>
<h3 className="text-lg font-semibold mt-8 mb-4">useUIStream</h3>
<Code lang="typescript">{`const {
spec, // Spec - current UI state
spec, // Spec | null - current UI state
isStreaming, // boolean - true while streaming
error, // Error | null
send, // (prompt: string) => void
abort, // () => void
send, // (prompt: string, context?: Record<string, unknown>) => Promise<void>
clear, // () => void - reset spec and error
} = useUIStream({
api: string, // API endpoint URL
onChunk?: (chunk: string) => void, // Called for each chunk
onFinish?: (spec: Spec) => void, // Called when streaming completes
api: string, // API endpoint URL
onComplete?: (spec: Spec) => void, // Called when streaming completes
onError?: (error: Error) => void, // Called when an error occurs
});`}</Code>
<h3 className="text-lg font-semibold mt-8 mb-4">useData</h3>
+3 -6
View File
@@ -116,13 +116,10 @@ const customPrompt = catalog.prompt({
<h2 className="text-xl font-semibold mt-12 mb-4">Next</h2>
<p className="text-sm text-muted-foreground">
Learn how to{" "}
<Link
href="/docs/components"
className="text-foreground hover:underline"
>
register React components
<Link href="/docs/registry" className="text-foreground hover:underline">
register components
</Link>{" "}
for your catalog.
in your registry.
</p>
</article>
);
+34 -31
View File
@@ -61,39 +61,42 @@ export const catalog = defineCatalog(schema, {
});`}</Code>
<h2 className="text-xl font-semibold mt-12 mb-4">
2. Create your components and actions
2. Define your components
</h2>
<p className="text-sm text-muted-foreground mb-4">
Define React components that render each catalog type. Each component
receives <code className="text-foreground">props</code>,{" "}
Use <code className="text-foreground">defineRegistry</code> to map
catalog types to React components. Each component receives type-safe{" "}
<code className="text-foreground">props</code>,{" "}
<code className="text-foreground">children</code>, and{" "}
<code className="text-foreground">onAction</code>:
</p>
<Code lang="tsx">{`// lib/components.tsx
import { defineComponents } from '@json-render/react';
<Code lang="tsx">{`// lib/registry.tsx
import { defineRegistry } from '@json-render/react';
import { catalog } from './catalog';
export const components = defineComponents(catalog, {
Card: ({ props, children }) => (
<div className="p-4 border rounded-lg">
<h2 className="font-bold">{props.title}</h2>
{props.description && (
<p className="text-gray-600">{props.description}</p>
)}
{children}
</div>
),
Button: ({ props, onAction }) => (
<button
className="px-4 py-2 bg-blue-500 text-white rounded"
onClick={() => onAction?.(props.action)}
>
{props.label}
</button>
),
Text: ({ props }) => (
<p>{props.content}</p>
),
export const { registry } = defineRegistry(catalog, {
components: {
Card: ({ props, children }) => (
<div className="p-4 border rounded-lg">
<h2 className="font-bold">{props.title}</h2>
{props.description && (
<p className="text-gray-600">{props.description}</p>
)}
{children}
</div>
),
Button: ({ props, onAction }) => (
<button
className="px-4 py-2 bg-blue-500 text-white rounded"
onClick={() => onAction?.({ name: props.action })}
>
{props.label}
</button>
),
Text: ({ props }) => (
<p>{props.content}</p>
),
},
});`}</Code>
<h2 className="text-xl font-semibold mt-12 mb-4">
@@ -123,14 +126,14 @@ export async function POST(req: Request) {
<h2 className="text-xl font-semibold mt-12 mb-4">4. Render the UI</h2>
<p className="text-sm text-muted-foreground mb-4">
Use the providers and renderer to display AI-generated UI:
Use providers and the <code className="text-foreground">Renderer</code>{" "}
with your registry to display AI-generated UI:
</p>
<Code lang="tsx">{`// app/page.tsx
'use client';
import { DataProvider, ActionProvider, VisibilityProvider, Renderer, useUIStream } from '@json-render/react';
import { catalog } from '@/lib/catalog';
import { components } from '@/lib/components';
import { Renderer, DataProvider, ActionProvider, VisibilityProvider, useUIStream } from '@json-render/react';
import { registry } from '@/lib/registry';
export default function Page() {
const { spec, isStreaming, send } = useUIStream({
@@ -162,7 +165,7 @@ export default function Page() {
</form>
<div className="mt-8">
<Renderer spec={spec} catalog={catalog} components={components} loading={isStreaming} />
<Renderer spec={spec} registry={registry} loading={isStreaming} />
</div>
</ActionProvider>
</VisibilityProvider>
+139 -112
View File
@@ -14,82 +14,86 @@ export default function RegistryPage() {
life.
</p>
{/* Components Section */}
<h2 className="text-xl font-semibold mt-12 mb-4">Component Registry</h2>
{/* defineRegistry */}
<h2 className="text-xl font-semibold mt-12 mb-4">defineRegistry</h2>
<p className="text-sm text-muted-foreground mb-4">
Create a registry that maps catalog component types to React components:
Use <code>defineRegistry</code> to create a type-safe registry from your
catalog. Pass your components, actions, or both in a single call:
</p>
<Code lang="tsx">{`import { useAction } from '@json-render/react';
<Code lang="tsx">{`import { defineRegistry } from '@json-render/react';
import { myCatalog } from './catalog';
const registry = {
Card: ({ props, children }) => (
<div className="card">
<h2>{props.title}</h2>
{props.description && <p>{props.description}</p>}
{children}
</div>
),
Button: ({ props }) => {
const executeAction = useAction(props.action);
return (
<button onClick={() => executeAction({})}>
export const { registry, handlers, executeAction } = defineRegistry(myCatalog, {
components: {
Card: ({ props, children }) => (
<div className="card">
<h2>{props.title}</h2>
{props.description && <p>{props.description}</p>}
{children}
</div>
),
Button: ({ props, onAction }) => (
<button onClick={() => onAction?.({ name: props.action })}>
{props.label}
</button>
);
),
},
};`}</Code>
actions: {
submit_form: async (params, setData) => {
const res = await fetch('/api/submit', {
method: 'POST',
body: JSON.stringify(params),
});
const result = await res.json();
setData((prev) => ({ ...prev, formResult: result }));
},
export_data: async (params) => {
const blob = await generateExport(params.format);
downloadBlob(blob, \`export.\${params.format}\`);
},
},
});`}</Code>
<p className="text-sm text-muted-foreground mt-4 mb-4">
The returned object contains:
</p>
<ul className="list-disc list-inside text-sm text-muted-foreground mb-4 space-y-1">
<li>
<code>registry</code> - component registry for{" "}
<code>{"<Renderer />"}</code>
</li>
<li>
<code>handlers</code> - factory for ActionProvider-compatible handlers
</li>
<li>
<code>executeAction</code> - imperative action dispatch (for use
outside the React tree)
</li>
</ul>
{/* Component Props */}
<h2 className="text-xl font-semibold mt-12 mb-4">Component Props</h2>
<p className="text-sm text-muted-foreground mb-4">
Each component receives these props:
Each component in the registry receives a <code>ComponentContext</code>{" "}
object:
</p>
<Code lang="typescript">{`interface ComponentProps<T = Record<string, unknown>> {
props: T; // Component props from the spec
<Code lang="typescript">{`interface ComponentContext {
props: T; // Type-safe props from your catalog
children?: React.ReactNode; // Rendered children (for slot components)
}
onAction?: (action: ActionTrigger) => void; // Dispatch an action
loading?: boolean; // Whether the renderer is in a loading state
}`}</Code>
// Type-safe props by extracting from your catalog
type CardProps = ComponentProps<{
title: string;
description: string | null;
}>;
// Use hooks for actions and data within components
import { useAction, useDataValue } from '@json-render/react';`}</Code>
<h2 className="text-xl font-semibold mt-12 mb-4">Using Data Binding</h2>
<p className="text-sm text-muted-foreground mb-4">
Use hooks to read and write data:
<p className="text-sm text-muted-foreground mt-4 mb-4">
Props are automatically inferred from your catalog, so{" "}
<code>props.title</code> is typed as <code>string</code> if your catalog
defines it that way.
</p>
<Code lang="tsx">{`import { useDataValue, useDataBinding } from '@json-render/react';
const Metric = ({ props }) => {
// Read-only value from data context
const value = useDataValue(props.valuePath);
return (
<div className="metric">
<span className="label">{props.label}</span>
<span className="value">{formatValue(value)}</span>
</div>
);
};
const TextField = ({ props }) => {
// Two-way binding to data context
const [value, setValue] = useDataBinding(props.valuePath);
return (
<input
value={value || ''}
onChange={(e) => setValue(e.target.value)}
placeholder={props.placeholder}
/>
);
};`}</Code>
{/* Actions Section */}
{/* Action Handlers */}
<h2 className="text-xl font-semibold mt-12 mb-4">Action Handlers</h2>
<p className="text-sm text-muted-foreground mb-4">
Instead of AI generating arbitrary code, it declares <em>intent</em> by
@@ -117,9 +121,6 @@ const catalog = defineCatalog(schema, {
export_data: {
params: z.object({
format: z.enum(['csv', 'pdf', 'json']),
filters: z.object({
dateRange: z.string().nullable(),
}).nullable(),
}),
},
navigate: {
@@ -130,78 +131,104 @@ const catalog = defineCatalog(schema, {
},
});`}</Code>
<h3 className="text-lg font-medium mt-8 mb-3">ActionProvider</h3>
<h3 className="text-lg font-medium mt-8 mb-3">
Implementing Action Handlers
</h3>
<p className="text-sm text-muted-foreground mb-4">
Provide action handlers to your app:
Action handlers receive <code>(params, setData, data)</code> and are
defined inside <code>defineRegistry</code>:
</p>
<Code lang="tsx">{`import { ActionProvider } from '@json-render/react';
function App() {
const handlers = {
submit_form: async (params) => {
<Code lang="tsx">{`export const { handlers, executeAction } = defineRegistry(catalog, {
actions: {
submit_form: async (params, setData) => {
const response = await fetch('/api/submit', {
method: 'POST',
body: JSON.stringify({ formId: params.formId }),
});
return response.json();
const result = await response.json();
setData((prev) => ({ ...prev, formResult: result }));
},
export_data: async (params) => {
const blob = await generateExport(params.format, params.filters);
const blob = await generateExport(params.format);
downloadBlob(blob, \`export.\${params.format}\`);
},
navigate: (params) => {
window.location.href = params.url;
},
};
},
});`}</Code>
{/* Using Data Binding */}
<h2 className="text-xl font-semibold mt-12 mb-4">Using Data Binding</h2>
<p className="text-sm text-muted-foreground mb-4">
Use hooks inside your registry components to read and write data:
</p>
<Code lang="tsx">{`import { useData } from '@json-render/react';
import { getByPath } from '@json-render/core';
// Inside defineRegistry components:
Metric: ({ props }) => {
const { data } = useData();
const value = getByPath(data, props.valuePath);
return (
<ActionProvider handlers={handlers}>
{/* Your UI */}
</ActionProvider>
<div className="metric">
<span className="label">{props.label}</span>
<span className="value">{formatValue(value)}</span>
</div>
);
}`}</Code>
},
<h3 className="text-lg font-medium mt-8 mb-3">
Using Actions in Components
</h3>
<Code lang="tsx">{`import { useAction } from '@json-render/react';
TextField: ({ props }) => {
const { data, set } = useData();
const value = getByPath(data, props.valuePath) as string;
// Using the useAction hook (recommended)
const Button = ({ props }) => {
const executeAction = useAction(props.action);
return (
<button onClick={() => executeAction({})}>
{props.label}
</button>
<input
value={value || ''}
onChange={(e) => set(props.valuePath, e.target.value)}
placeholder={props.placeholder}
/>
);
};
// Or for standalone use
function SubmitButton() {
const submitForm = useAction('submit_form');
return (
<button onClick={() => submitForm({ formId: 'contact' })}>
Submit
</button>
);
}`}</Code>
},`}</Code>
{/* Renderer Section */}
<h2 className="text-xl font-semibold mt-12 mb-4">Using the Renderer</h2>
<Code lang="tsx">{`import { Renderer, ActionProvider } from '@json-render/react';
<p className="text-sm text-muted-foreground mb-4">
Wire everything together with providers and the{" "}
<code>{"<Renderer />"}</code> component:
</p>
<Code lang="tsx">{`import { useMemo, useRef } from 'react';
import {
Renderer,
DataProvider,
VisibilityProvider,
ActionProvider,
} from '@json-render/react';
import { registry, handlers } from './registry';
function App({ spec, data, setData }) {
const dataRef = useRef(data);
const setDataRef = useRef(setData);
dataRef.current = data;
setDataRef.current = setData;
const actionHandlers = useMemo(
() => handlers(() => setDataRef.current, () => dataRef.current),
[],
);
function App() {
return (
<ActionProvider handlers={actionHandlers}>
<Renderer
spec={uiSpec}
registry={registry}
/>
</ActionProvider>
<DataProvider initialData={data}>
<VisibilityProvider>
<ActionProvider handlers={actionHandlers}>
<Renderer spec={spec} registry={registry} />
</ActionProvider>
</VisibilityProvider>
</DataProvider>
);
}`}</Code>
+11 -7
View File
@@ -34,11 +34,11 @@ function App() {
isStreaming, // True while streaming
error, // Any error that occurred
send, // Function to start generation
abort, // Function to cancel streaming
clear, // Function to reset spec and error
} = useUIStream({
api: '/api/generate',
onChunk: (chunk) => {}, // Optional: called for each chunk
onFinish: (spec) => {}, // Optional: called when complete
onComplete: (spec) => {}, // Optional: called when streaming completes
onError: (error) => {}, // Optional: called when an error occurs
});
}`}</Code>
@@ -112,8 +112,14 @@ export async function POST(req: Request) {
}`}</Code>
<h2 className="text-xl font-semibold mt-12 mb-4">Aborting Streams</h2>
<p className="text-sm text-muted-foreground mb-4">
Calling <code className="text-foreground">send</code> again
automatically aborts the previous request. Use{" "}
<code className="text-foreground">clear</code> to reset the spec and
error state:
</p>
<Code lang="tsx">{`function App() {
const { isStreaming, send, abort } = useUIStream({
const { isStreaming, send, clear } = useUIStream({
api: '/api/generate',
});
@@ -122,9 +128,7 @@ export async function POST(req: Request) {
<button onClick={() => send('Create dashboard')}>
Generate
</button>
{isStreaming && (
<button onClick={abort}>Cancel</button>
)}
<button onClick={clear}>Reset</button>
</div>
);
}`}</Code>
+41 -59
View File
@@ -10,55 +10,15 @@ import {
SheetContent,
SheetTitle,
} from "@/components/ui/sheet";
const navigation = [
{
title: "Getting Started",
items: [
{ title: "Introduction", href: "/docs" },
{ title: "Installation", href: "/docs/installation" },
{ title: "Quick Start", href: "/docs/quick-start" },
],
},
{
title: "Core Concepts",
items: [
{ title: "Catalog", href: "/docs/catalog" },
{ title: "Components", href: "/docs/components" },
{ title: "Data Binding", href: "/docs/data-binding" },
{ title: "Actions", href: "/docs/actions" },
{ title: "Visibility", href: "/docs/visibility" },
{ title: "Validation", href: "/docs/validation" },
],
},
{
title: "Guides",
items: [
{ title: "AI SDK Integration", href: "/docs/ai-sdk" },
{ title: "Streaming", href: "/docs/streaming" },
{ title: "Code Export", href: "/docs/code-export" },
],
},
{
title: "API Reference",
items: [
{ title: "@json-render/core", href: "/docs/api/core" },
{ title: "@json-render/react", href: "/docs/api/react" },
{ title: "@json-render/codegen", href: "/docs/api/codegen" },
],
},
];
// Flatten all pages for current page lookup
const allPages = navigation.flatMap((section) => section.items);
import { docsNavigation, allDocsPages } from "@/lib/docs-navigation";
export function DocsMobileNav() {
const [open, setOpen] = useState(false);
const pathname = usePathname();
const currentPage = useMemo(() => {
const page = allPages.find((page) => page.href === pathname);
return page ?? allPages[0];
const page = allDocsPages.find((page) => page.href === pathname);
return page ?? allDocsPages[0];
}, [pathname]);
return (
@@ -72,27 +32,49 @@ export function DocsMobileNav() {
<SheetContent className="overflow-y-auto p-6">
<SheetTitle className="mb-6">Table of Contents</SheetTitle>
<nav className="space-y-6">
{navigation.map((section) => (
{docsNavigation.map((section) => (
<div key={section.title}>
<h4 className="text-xs font-medium text-muted-foreground uppercase tracking-wider mb-2">
{section.title}
</h4>
<ul className="space-y-1">
{section.items.map((item) => (
<li key={item.href}>
<Link
href={item.href}
onClick={() => setOpen(false)}
className={`text-sm block py-2 transition-colors ${
pathname === item.href
? "text-primary font-medium"
: "text-muted-foreground hover:text-foreground"
}`}
>
{item.title}
</Link>
</li>
))}
{section.items.map((item) => {
const isExternal = item.external;
return (
<li key={item.href}>
<Link
href={item.href}
onClick={() => setOpen(false)}
{...(isExternal && {
target: "_blank",
rel: "noopener noreferrer",
})}
className={`text-sm block py-2 transition-colors ${
pathname === item.href
? "text-primary font-medium"
: "text-muted-foreground hover:text-foreground"
} ${isExternal ? "inline-flex items-center gap-1" : ""}`}
>
{item.title}
{isExternal && (
<svg
className="w-3 h-3"
fill="none"
stroke="currentColor"
viewBox="0 0 24 24"
>
<path
strokeLinecap="round"
strokeLinejoin="round"
strokeWidth={2}
d="M10 6H6a2 2 0 00-2 2v10a2 2 0 002 2h10a2 2 0 002-2v-4M14 4h6m0 0v6m0-6L10 14"
/>
</svg>
)}
</Link>
</li>
);
})}
</ul>
</div>
))}
+3 -68
View File
@@ -3,79 +3,14 @@
import Link from "next/link";
import { usePathname } from "next/navigation";
import { cn } from "@/lib/utils";
const navigation = [
{
title: "Getting Started",
items: [
{ title: "Introduction", href: "/docs" },
{ title: "Installation", href: "/docs/installation" },
{ title: "Quick Start", href: "/docs/quick-start" },
{ title: "Changelog", href: "/docs/changelog" },
],
},
{
title: "Core Concepts",
items: [
{ title: "Specs", href: "/docs/specs" },
{ title: "Schemas", href: "/docs/schemas" },
{ title: "Catalog", href: "/docs/catalog" },
{ title: "Registry", href: "/docs/registry" },
{ title: "Data Binding", href: "/docs/data-binding" },
{ title: "Visibility", href: "/docs/visibility" },
{ title: "Validation", href: "/docs/validation" },
],
},
{
title: "Examples",
items: [
{
title: "Dashboard",
href: "https://github.com/vercel-labs/json-render/tree/main/examples/dashboard",
external: true,
},
{
title: "Remotion",
href: "https://github.com/vercel-labs/json-render/tree/main/examples/remotion",
external: true,
},
],
},
{
title: "Guides",
items: [
{ title: "Custom Schema", href: "/docs/custom-schema" },
{ title: "Streaming", href: "/docs/streaming" },
{ title: "Code Export", href: "/docs/code-export" },
],
},
{
title: "Integrations",
items: [
{ title: "AI SDK", href: "/docs/ai-sdk" },
{ title: "A2UI", href: "/docs/a2ui" },
{ title: "Adaptive Cards", href: "/docs/adaptive-cards" },
{ title: "AG-UI", href: "/docs/ag-ui" },
{ title: "OpenAPI", href: "/docs/openapi" },
],
},
{
title: "API Reference",
items: [
{ title: "@json-render/core", href: "/docs/api/core" },
{ title: "@json-render/react", href: "/docs/api/react" },
{ title: "@json-render/remotion", href: "/docs/api/remotion" },
{ title: "@json-render/codegen", href: "/docs/api/codegen" },
],
},
];
import { docsNavigation } from "@/lib/docs-navigation";
export function DocsSidebar() {
const pathname = usePathname();
return (
<nav className="space-y-6 pb-8">
{navigation.map((section) => (
{docsNavigation.map((section) => (
<div key={section.title}>
<h4 className="text-xs font-normal text-muted-foreground/50 uppercase tracking-wider mb-2">
{section.title}
@@ -83,7 +18,7 @@ export function DocsSidebar() {
<ul className="space-y-1">
{section.items.map((item) => {
const isActive = pathname === item.href;
const isExternal = "external" in item && item.external;
const isExternal = item.external;
return (
<li key={item.href}>
<Link
+81
View File
@@ -0,0 +1,81 @@
export type NavItem = {
title: string;
href: string;
external?: boolean;
};
export type NavSection = {
title: string;
items: NavItem[];
};
export const docsNavigation: NavSection[] = [
{
title: "Getting Started",
items: [
{ title: "Introduction", href: "/docs" },
{ title: "Installation", href: "/docs/installation" },
{ title: "Quick Start", href: "/docs/quick-start" },
{ title: "Changelog", href: "/docs/changelog" },
],
},
{
title: "Core Concepts",
items: [
{ title: "Specs", href: "/docs/specs" },
{ title: "Schemas", href: "/docs/schemas" },
{ title: "Catalog", href: "/docs/catalog" },
{ title: "Registry", href: "/docs/registry" },
{ title: "Data Binding", href: "/docs/data-binding" },
{ title: "Visibility", href: "/docs/visibility" },
{ title: "Validation", href: "/docs/validation" },
],
},
{
title: "Examples",
items: [
{
title: "Dashboard",
href: "https://github.com/vercel-labs/json-render/tree/main/examples/dashboard",
external: true,
},
{
title: "Remotion",
href: "https://github.com/vercel-labs/json-render/tree/main/examples/remotion",
external: true,
},
],
},
{
title: "Guides",
items: [
{ title: "Custom Schema", href: "/docs/custom-schema" },
{ title: "Streaming", href: "/docs/streaming" },
{ title: "Code Export", href: "/docs/code-export" },
],
},
{
title: "Integrations",
items: [
{ title: "AI SDK", href: "/docs/ai-sdk" },
{ title: "A2UI", href: "/docs/a2ui" },
{ title: "Adaptive Cards", href: "/docs/adaptive-cards" },
{ title: "AG-UI", href: "/docs/ag-ui" },
{ title: "OpenAPI", href: "/docs/openapi" },
],
},
{
title: "API Reference",
items: [
{ title: "@json-render/core", href: "/docs/api/core" },
{ title: "@json-render/react", href: "/docs/api/react" },
{ title: "@json-render/remotion", href: "/docs/api/remotion" },
{ title: "@json-render/codegen", href: "/docs/api/codegen" },
],
},
];
// Flatten all pages for current page lookup (excludes external links)
export const allDocsPages = docsNavigation.flatMap((section) =>
section.items.filter((item) => !item.external),
);
+11 -1
View File
@@ -1,4 +1,14 @@
/** @type {import('next').NextConfig} */
const nextConfig = {};
const nextConfig = {
async redirects() {
return [
{
source: "/docs/components",
destination: "/docs/registry",
permanent: true,
},
];
},
};
export default nextConfig;
+1 -1
View File
@@ -13,7 +13,7 @@ import {
} from "lucide-react";
import { useUIStream, type Spec } from "@json-render/react";
import { DashboardRenderer } from "@/lib/render/renderer";
import { executeAction } from "@/lib/render/catalog/actions";
import { executeAction } from "@/lib/render/registry";
import { CodeHighlight } from "@/components/code-highlight";
import { Button } from "@/components/ui/button";
import { Input } from "@/components/ui/input";
@@ -1,255 +0,0 @@
import { toast } from "sonner";
import { findFormValue } from "@json-render/core";
import {
type Actions,
type ActionFn,
type SetData,
type DataModel,
} from "@json-render/react";
import { dashboardCatalog } from "../catalog";
// =============================================================================
// Action Handlers - Type-safe with Catalog
// =============================================================================
export const actionHandlers: Actions<typeof dashboardCatalog> = {
viewCustomers: async (params, setData) => {
const queryParams = new URLSearchParams();
if (params?.limit) queryParams.set("limit", String(params.limit));
if (params?.sort) queryParams.set("sort", String(params.sort));
if (params?.status) queryParams.set("status", String(params.status));
const url = `/api/v1/customers${queryParams.toString() ? `?${queryParams}` : ""}`;
const res = await fetch(url);
const customers = await res.json();
setData((prev) => ({ ...prev, customers }));
},
refreshCustomers: async (params, setData) => {
const queryParams = new URLSearchParams();
if (params?.limit) queryParams.set("limit", String(params.limit));
if (params?.sort) queryParams.set("sort", String(params.sort));
const url = `/api/v1/customers${queryParams.toString() ? `?${queryParams}` : ""}`;
const res = await fetch(url);
const customers = await res.json();
setData((prev) => ({ ...prev, customers }));
},
createCustomer: async (params, setData, data) => {
const name = findFormValue("name", params, data) as string;
const email =
(findFormValue("email", params, data) as string) ||
`${name?.toLowerCase().replace(/\s+/g, ".")}@example.com`;
const phone = findFormValue("phone", params, data) as string | undefined;
if (!name) {
toast.error("Customer name is required");
return;
}
try {
const res = await fetch("/api/v1/customers", {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({ name, email, phone }),
});
const customer = await res.json();
if (res.ok) {
toast.success(`Customer "${customer.name}" created`);
const listRes = await fetch("/api/v1/customers");
const customers = await listRes.json();
setData((prev) => ({ ...prev, customers }));
} else {
toast.error(customer.error || "Failed to create customer");
}
} catch (err) {
console.error("Failed to create customer:", err);
toast.error("Failed to create customer");
}
},
deleteCustomer: async (params, setData, data) => {
const customerId =
findFormValue("customerId", params, data) ||
findFormValue("id", params, data);
if (!customerId) {
toast.error("Customer ID required");
return;
}
try {
const res = await fetch(`/api/v1/customers/${customerId}`, {
method: "DELETE",
});
if (res.ok) {
toast.success("Customer deleted");
const listRes = await fetch("/api/v1/customers");
const customers = await listRes.json();
setData((prev) => ({ ...prev, customers }));
} else {
const err = await res.json();
toast.error(err.error || "Failed to delete customer");
}
} catch (err) {
console.error("Failed to delete customer:", err);
toast.error("Failed to delete customer");
}
},
viewInvoices: async (params, setData) => {
const queryParams = new URLSearchParams();
if (params?.status) queryParams.set("status", String(params.status));
const url = `/api/v1/invoices${queryParams.toString() ? `?${queryParams}` : ""}`;
const res = await fetch(url);
const invoices = await res.json();
setData((prev) => ({ ...prev, invoices }));
},
refreshInvoices: async (_params, setData) => {
const res = await fetch("/api/v1/invoices");
const invoices = await res.json();
setData((prev) => ({ ...prev, invoices }));
},
createInvoice: async (params, setData) => {
if (!params?.customerId || !params?.dueDate) {
toast.error("Customer ID and due date required");
return;
}
try {
const res = await fetch("/api/v1/invoices", {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify(params),
});
const invoice = await res.json();
if (res.ok) {
toast.success("Invoice created");
const listRes = await fetch("/api/v1/invoices");
const invoices = await listRes.json();
setData((prev) => ({ ...prev, invoices }));
} else {
toast.error(invoice.error || "Failed to create invoice");
}
} catch (err) {
console.error("Failed to create invoice:", err);
toast.error("Failed to create invoice");
}
},
sendInvoice: async (params) => {
if (!params?.invoiceId) {
toast.error("Invoice ID required");
return;
}
const res = await fetch(`/api/v1/invoices/${params.invoiceId}/send`, {
method: "POST",
});
const result = await res.json();
if (res.ok) {
toast.success(result.message || "Invoice sent");
} else {
toast.error(result.error || "Failed to send invoice");
}
},
markInvoicePaid: async (params) => {
if (!params?.invoiceId) {
toast.error("Invoice ID required");
return;
}
const res = await fetch(`/api/v1/invoices/${params.invoiceId}/mark-paid`, {
method: "POST",
});
const result = await res.json();
if (res.ok) {
toast.success(result.message || "Invoice marked paid");
} else {
toast.error(result.error || "Failed to mark invoice paid");
}
},
viewExpenses: async (params, setData) => {
const queryParams = new URLSearchParams();
if (params?.status) queryParams.set("status", String(params.status));
const url = `/api/v1/expenses${queryParams.toString() ? `?${queryParams}` : ""}`;
const res = await fetch(url);
const expenses = await res.json();
setData((prev) => ({ ...prev, expenses }));
},
refreshExpenses: async (_params, setData) => {
const res = await fetch("/api/v1/expenses");
const expenses = await res.json();
setData((prev) => ({ ...prev, expenses }));
},
createExpense: async (params, setData) => {
if (!params?.vendor || !params?.category || params?.amount === undefined) {
toast.error("Vendor, category, and amount required");
return;
}
try {
const res = await fetch("/api/v1/expenses", {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify(params),
});
const expense = await res.json();
if (res.ok) {
toast.success("Expense created");
const listRes = await fetch("/api/v1/expenses");
const expenses = await listRes.json();
setData((prev) => ({ ...prev, expenses }));
} else {
toast.error(expense.error || "Failed to create expense");
}
} catch (err) {
console.error("Failed to create expense:", err);
toast.error("Failed to create expense");
}
},
approveExpense: async (params) => {
if (!params?.expenseId) {
toast.error("Expense ID required");
return;
}
const res = await fetch(`/api/v1/expenses/${params.expenseId}/approve`, {
method: "POST",
});
const result = await res.json();
if (res.ok) {
toast.success(result.message || "Expense approved");
} else {
toast.error(result.error || "Failed to approve expense");
}
},
};
// =============================================================================
// Execute Action
// =============================================================================
type Catalog = typeof dashboardCatalog;
type CatalogActions = Catalog["data"]["actions"];
/**
* Execute an action by name with the given parameters.
*/
export async function executeAction(
actionName: string,
params: Record<string, unknown> | undefined,
setData: SetData,
data: DataModel = {},
): Promise<void> {
const handler = actionHandlers[actionName as keyof CatalogActions];
if (handler) {
await (handler as ActionFn<Catalog, keyof CatalogActions>)(
params as never,
setData,
data,
);
} else {
console.log("Unknown action:", actionName, params);
toast(`Action: ${actionName}`);
}
}
@@ -1,771 +0,0 @@
"use client";
import { useData, type Components } from "@json-render/react";
import { getByPath } from "@json-render/core";
import {
Bar,
BarChart as RechartsBarChart,
CartesianGrid,
Line,
LineChart as RechartsLineChart,
XAxis,
} from "recharts";
import {
ChartContainer,
ChartTooltip,
ChartTooltipContent,
type ChartConfig,
} from "@/components/ui/chart";
// shadcn components
import { Button } from "@/components/ui/button";
import { Input } from "@/components/ui/input";
import { Label } from "@/components/ui/label";
import { Badge } from "@/components/ui/badge";
import { Alert, AlertTitle, AlertDescription } from "@/components/ui/alert";
import { Separator } from "@/components/ui/separator";
import {
Accordion,
AccordionItem,
AccordionTrigger,
AccordionContent,
} from "@/components/ui/accordion";
import {
Table,
TableHeader,
TableBody,
TableHead,
TableRow,
TableCell,
} from "@/components/ui/table";
import { Avatar, AvatarImage, AvatarFallback } from "@/components/ui/avatar";
import { Checkbox } from "@/components/ui/checkbox";
import {
Dialog,
DialogTrigger,
DialogContent,
DialogHeader,
DialogTitle,
DialogDescription,
} from "@/components/ui/dialog";
import {
Drawer,
DrawerTrigger,
DrawerContent,
DrawerHeader,
DrawerTitle,
DrawerDescription,
} from "@/components/ui/drawer";
import {
DropdownMenu,
DropdownMenuTrigger,
DropdownMenuContent,
DropdownMenuItem,
} from "@/components/ui/dropdown-menu";
import {
Pagination,
PaginationContent,
PaginationItem,
PaginationPrevious,
PaginationNext,
PaginationLink,
} from "@/components/ui/pagination";
import {
Popover,
PopoverTrigger,
PopoverContent,
} from "@/components/ui/popover";
import { Progress } from "@/components/ui/progress";
import { RadioGroup, RadioGroupItem } from "@/components/ui/radio-group";
import {
Select,
SelectTrigger,
SelectValue,
SelectContent,
SelectItem,
} from "@/components/ui/select";
import { Skeleton } from "@/components/ui/skeleton";
import { Switch } from "@/components/ui/switch";
import { Tabs, TabsList, TabsTrigger, TabsContent } from "@/components/ui/tabs";
import { Textarea } from "@/components/ui/textarea";
import {
Tooltip,
TooltipTrigger,
TooltipContent,
TooltipProvider,
} from "@/components/ui/tooltip";
import { dashboardCatalog } from "../catalog";
// =============================================================================
// Components - Type-safe with Catalog
// =============================================================================
export const components: Components<typeof dashboardCatalog> = {
Stack: ({ props, children }) => {
const gapClass =
{ sm: "gap-2", md: "gap-4", lg: "gap-6" }[props.gap ?? "md"] ?? "gap-4";
return (
<div
className={`flex ${props.direction === "horizontal" ? "flex-row" : "flex-col"} ${gapClass}`}
>
{children}
</div>
);
},
Accordion: ({ props, children }) => (
<Accordion type={props.type ?? "single"} collapsible>
{children}
</Accordion>
),
AccordionItem: ({ props, children }) => (
<AccordionItem value={props.value}>
<AccordionTrigger>{props.title}</AccordionTrigger>
<AccordionContent>{children}</AccordionContent>
</AccordionItem>
),
Button: ({ props, onAction, loading }) => (
<Button
variant={props.variant ?? "default"}
disabled={loading || (props.disabled ?? false)}
onClick={() =>
onAction?.({
name: props.action,
params: props.actionParams ?? undefined,
})
}
>
{loading ? "..." : props.label}
</Button>
),
Input: ({ props }) => {
const { data, set } = useData();
return (
<div className="flex flex-col gap-2">
{props.label ? <Label>{props.label}</Label> : null}
<Input
type={props.type ?? "text"}
value={(getByPath(data, props.valuePath) as string) ?? ""}
placeholder={props.placeholder ?? ""}
onChange={(e) => set(props.valuePath, e.target.value)}
/>
</div>
);
},
Form: ({ props, children, onAction }) => (
<form
onSubmit={(e) => {
e.preventDefault();
onAction?.({
name: props.submitAction,
params: props.submitActionParams ?? undefined,
});
}}
className="flex flex-col gap-4"
>
{children}
</form>
),
Badge: ({ props }) => (
<Badge variant={props.variant ?? "default"}>{props.text}</Badge>
),
Alert: ({ props }) => (
<Alert variant={props.variant ?? "default"}>
<AlertTitle>{props.title}</AlertTitle>
{props.description ? (
<AlertDescription>{props.description}</AlertDescription>
) : null}
</Alert>
),
Separator: () => <Separator />,
Avatar: ({ props }) => (
<Avatar>
{props.src ? <AvatarImage src={props.src} alt={props.alt ?? ""} /> : null}
<AvatarFallback>{props.fallback}</AvatarFallback>
</Avatar>
),
Checkbox: ({ props }) => {
const { data, set } = useData();
const checked =
(getByPath(data, props.valuePath) as boolean) ??
props.defaultChecked ??
false;
return (
<div className="flex items-center gap-2">
<Checkbox
id={props.valuePath}
checked={checked}
onCheckedChange={(value) => set(props.valuePath, value)}
/>
{props.label ? (
<Label htmlFor={props.valuePath}>{props.label}</Label>
) : null}
</div>
);
},
Dialog: ({ props, children }) => (
<Dialog>
<DialogTrigger asChild>
<Button variant="outline">{props.trigger}</Button>
</DialogTrigger>
<DialogContent>
<DialogHeader>
<DialogTitle>{props.title}</DialogTitle>
{props.description ? (
<DialogDescription>{props.description}</DialogDescription>
) : null}
</DialogHeader>
{children}
</DialogContent>
</Dialog>
),
Drawer: ({ props, children }) => (
<Drawer>
<DrawerTrigger asChild>
<Button variant="outline">{props.trigger}</Button>
</DrawerTrigger>
<DrawerContent>
<DrawerHeader>
<DrawerTitle>{props.title}</DrawerTitle>
{props.description ? (
<DrawerDescription>{props.description}</DrawerDescription>
) : null}
</DrawerHeader>
<div className="p-4">{children}</div>
</DrawerContent>
</Drawer>
),
DropdownMenu: ({ props, onAction }) => (
<DropdownMenu>
<DropdownMenuTrigger asChild>
<Button variant="outline">{props.trigger}</Button>
</DropdownMenuTrigger>
<DropdownMenuContent>
{props.items.map((item, i) => (
<DropdownMenuItem
key={i}
onClick={() =>
item.action
? onAction?.({
name: item.action,
params: item.actionParams ?? undefined,
})
: undefined
}
>
{item.label}
</DropdownMenuItem>
))}
</DropdownMenuContent>
</DropdownMenu>
),
Label: ({ props }) => (
<Label htmlFor={props.htmlFor ?? undefined}>{props.text}</Label>
),
Pagination: ({ props }) => {
const pages = Array.from({ length: props.totalPages }, (_, i) => i + 1);
return (
<Pagination>
<PaginationContent>
<PaginationItem>
<PaginationPrevious href="#" />
</PaginationItem>
{pages.map((page) => (
<PaginationItem key={page}>
<PaginationLink href="#" isActive={page === props.currentPage}>
{page}
</PaginationLink>
</PaginationItem>
))}
<PaginationItem>
<PaginationNext href="#" />
</PaginationItem>
</PaginationContent>
</Pagination>
);
},
Popover: ({ props, children }) => (
<Popover>
<PopoverTrigger asChild>
<Button variant="outline">{props.trigger}</Button>
</PopoverTrigger>
<PopoverContent>{children}</PopoverContent>
</Popover>
),
Progress: ({ props }) => (
<Progress value={props.value} max={props.max ?? 100} />
),
RadioGroup: ({ props }) => {
const { data, set } = useData();
const value =
(getByPath(data, props.valuePath) as string) ?? props.defaultValue ?? "";
return (
<RadioGroup value={value} onValueChange={(v) => set(props.valuePath, v)}>
{props.options.map((option) => (
<div key={option.value} className="flex items-center gap-2">
<RadioGroupItem value={option.value} id={option.value} />
<Label htmlFor={option.value}>{option.label}</Label>
</div>
))}
</RadioGroup>
);
},
Select: ({ props }) => {
const { data, set } = useData();
const value = (getByPath(data, props.valuePath) as string) ?? "";
return (
<Select value={value} onValueChange={(v) => set(props.valuePath, v)}>
<SelectTrigger>
<SelectValue placeholder={props.placeholder ?? "Select..."} />
</SelectTrigger>
<SelectContent>
{props.options.map((option) => (
<SelectItem key={option.value} value={option.value}>
{option.label}
</SelectItem>
))}
</SelectContent>
</Select>
);
},
Skeleton: ({ props }) => (
<Skeleton
className={`${props.width ?? "w-full"} ${props.height ?? "h-4"}`}
/>
),
Spinner: ({ props }) => {
const sizes = { sm: "h-4 w-4", md: "h-6 w-6", lg: "h-8 w-8" };
const size = sizes[props.size ?? "md"];
return (
<div
className={`${size} animate-spin rounded-full border-2 border-muted border-t-primary`}
/>
);
},
Switch: ({ props }) => {
const { data, set } = useData();
const checked =
(getByPath(data, props.valuePath) as boolean) ??
props.defaultChecked ??
false;
return (
<div className="flex items-center gap-2">
<Switch
id={props.valuePath}
checked={checked}
onCheckedChange={(value) => set(props.valuePath, value)}
/>
{props.label ? (
<Label htmlFor={props.valuePath}>{props.label}</Label>
) : null}
</div>
);
},
Tabs: ({ props, children }) => (
<Tabs defaultValue={props.defaultValue ?? props.tabs[0]?.value}>
<TabsList>
{props.tabs.map((tab) => (
<TabsTrigger key={tab.value} value={tab.value}>
{tab.label}
</TabsTrigger>
))}
</TabsList>
{children}
</Tabs>
),
TabContent: ({ props, children }) => (
<TabsContent value={props.value}>{children}</TabsContent>
),
Textarea: ({ props }) => {
const { data, set } = useData();
return (
<div className="flex flex-col gap-2">
{props.label ? <Label>{props.label}</Label> : null}
<Textarea
value={(getByPath(data, props.valuePath) as string) ?? ""}
placeholder={props.placeholder ?? ""}
rows={props.rows ?? 3}
onChange={(e) => set(props.valuePath, e.target.value)}
/>
</div>
);
},
Tooltip: ({ props, children }) => (
<TooltipProvider>
<Tooltip>
<TooltipTrigger asChild>{children}</TooltipTrigger>
<TooltipContent>{props.content}</TooltipContent>
</Tooltip>
</TooltipProvider>
),
// Heading is intentionally not rendered - widgets already have a title bar
Heading: () => null,
Text: ({ props }) => (
<p className={props.muted ? "text-muted-foreground" : ""}>
{props.content}
</p>
),
Table: ({ props, onAction }) => {
const { data } = useData();
const path = props.dataPath.replace(/\./g, "/");
const rawData = getByPath(data, path);
const items: Array<Record<string, unknown>> = Array.isArray(rawData)
? rawData
: Array.isArray((rawData as Record<string, unknown>)?.data)
? ((rawData as Record<string, unknown>).data as Array<
Record<string, unknown>
>)
: Array.isArray((rawData as Record<string, unknown>)?.items)
? ((rawData as Record<string, unknown>).items as Array<
Record<string, unknown>
>)
: [];
if (items.length === 0) {
return (
<div className="text-center py-4 text-muted-foreground">
{props.emptyMessage ?? "No data"}
</div>
);
}
const hasRowActions = props.rowActions && props.rowActions.length > 0;
return (
<Table>
<TableHeader>
<TableRow>
{props.columns.map((col) => (
<TableHead key={col.key}>{col.label}</TableHead>
))}
{hasRowActions ? <TableHead>Actions</TableHead> : null}
</TableRow>
</TableHeader>
<TableBody>
{items.map((item, i) => (
<TableRow key={i}>
{props.columns.map((col) => (
<TableCell key={col.key}>
{String(item[col.key] ?? "")}
</TableCell>
))}
{hasRowActions ? (
<TableCell>
<div className="flex gap-1">
{props.rowActions!.map((rowAction) => (
<Button
key={rowAction.action}
variant={rowAction.variant ?? "ghost"}
size="sm"
onClick={() =>
onAction?.({
name: rowAction.action,
params: { id: item.id as string },
})
}
>
{rowAction.label}
</Button>
))}
</div>
</TableCell>
) : null}
</TableRow>
))}
</TableBody>
</Table>
);
},
BarChart: ({ props }) => {
const { data } = useData();
const path = props.dataPath.replace(/\./g, "/");
const rawData = getByPath(data, path);
const rawItems: Array<Record<string, unknown>> = Array.isArray(rawData)
? rawData
: Array.isArray((rawData as Record<string, unknown>)?.data)
? ((rawData as Record<string, unknown>).data as Array<
Record<string, unknown>
>)
: [];
// Process and aggregate data
const { items, valueKey } = processChartData(
rawItems,
props.xKey,
props.yKey,
props.aggregate,
);
const chartColor = props.color ?? "var(--chart-1)";
const chartConfig = {
[valueKey]: {
label: valueKey,
color: chartColor,
},
} satisfies ChartConfig;
if (items.length === 0) {
return (
<div className="w-full">
<div className="text-center py-4 text-muted-foreground">
No data available
</div>
</div>
);
}
return (
<div className="w-full">
<ChartContainer
config={chartConfig}
className="min-h-[200px] w-full"
style={{ height: props.height ?? 300 }}
>
<RechartsBarChart accessibilityLayer data={items}>
<CartesianGrid vertical={false} />
<XAxis
dataKey="label"
tickLine={false}
tickMargin={10}
axisLine={false}
/>
<ChartTooltip content={<ChartTooltipContent />} />
<Bar
dataKey={valueKey}
fill={`var(--color-${valueKey})`}
radius={4}
/>
</RechartsBarChart>
</ChartContainer>
</div>
);
},
LineChart: ({ props }) => {
const { data } = useData();
const path = props.dataPath.replace(/\./g, "/");
const rawData = getByPath(data, path);
const rawItems: Array<Record<string, unknown>> = Array.isArray(rawData)
? rawData
: Array.isArray((rawData as Record<string, unknown>)?.data)
? ((rawData as Record<string, unknown>).data as Array<
Record<string, unknown>
>)
: [];
// Process and aggregate data
const { items, valueKey } = processChartData(
rawItems,
props.xKey,
props.yKey,
props.aggregate,
);
const chartColor = props.color ?? "var(--chart-1)";
const chartConfig = {
[valueKey]: {
label: valueKey,
color: chartColor,
},
} satisfies ChartConfig;
if (items.length === 0) {
return (
<div className="w-full">
<div className="text-center py-4 text-muted-foreground">
No data available
</div>
</div>
);
}
return (
<div className="w-full">
<ChartContainer
config={chartConfig}
className="min-h-[200px] w-full"
style={{ height: props.height ?? 300 }}
>
<RechartsLineChart accessibilityLayer data={items}>
<CartesianGrid vertical={false} />
<XAxis
dataKey="label"
tickLine={false}
tickMargin={10}
axisLine={false}
/>
<ChartTooltip content={<ChartTooltipContent />} />
<Line
type="monotone"
dataKey={valueKey}
stroke={`var(--color-${valueKey})`}
strokeWidth={2}
dot={false}
/>
</RechartsLineChart>
</ChartContainer>
</div>
);
},
};
// =============================================================================
// Chart Helpers
// =============================================================================
function isISODate(value: unknown): boolean {
if (typeof value !== "string") return false;
// Check for ISO date format: YYYY-MM-DDTHH:mm:ss
return /^\d{4}-\d{2}-\d{2}T\d{2}:\d{2}:\d{2}/.test(value);
}
function formatDateLabel(value: string): string {
const date = new Date(value);
if (isNaN(date.getTime())) return value;
// Format as "Mon DD" (e.g., "Feb 04")
return date.toLocaleDateString("en-US", { month: "short", day: "2-digit" });
}
function processChartData(
items: Array<Record<string, unknown>>,
xKey: string,
yKey: string,
aggregate: "sum" | "count" | "avg" | null | undefined,
): { items: Array<Record<string, unknown>>; valueKey: string } {
if (items.length === 0) {
return { items: [], valueKey: yKey };
}
// Check if xKey contains date values
const firstXValue = items[0]?.[xKey];
const isDateKey = isISODate(firstXValue);
// If no aggregation, just format dates and return
if (!aggregate) {
const formatted = items.map((item) => {
const xValue = item[xKey];
return {
...item,
label:
isDateKey && typeof xValue === "string"
? formatDateLabel(xValue)
: String(xValue ?? ""),
};
});
return { items: formatted, valueKey: yKey };
}
// Group items by xKey (use date string for dates)
const groups = new Map<string, Array<Record<string, unknown>>>();
for (const item of items) {
const xValue = item[xKey];
let groupKey: string;
if (isDateKey && typeof xValue === "string") {
// Group by date (YYYY-MM-DD)
groupKey = xValue.split("T")[0] ?? xValue;
} else {
groupKey = String(xValue ?? "unknown");
}
const group = groups.get(groupKey) ?? [];
group.push(item);
groups.set(groupKey, group);
}
// Aggregate each group
const valueKey = aggregate === "count" ? "count" : yKey;
const aggregated: Array<Record<string, unknown>> = [];
// Sort keys (for dates, this will be chronological)
const sortedKeys = Array.from(groups.keys()).sort();
for (const key of sortedKeys) {
const group = groups.get(key)!;
let value: number;
if (aggregate === "count") {
value = group.length;
} else if (aggregate === "sum") {
value = group.reduce((sum, item) => {
const v = item[yKey];
return sum + (typeof v === "number" ? v : parseFloat(String(v)) || 0);
}, 0);
} else {
// avg
const sum = group.reduce((s, item) => {
const v = item[yKey];
return s + (typeof v === "number" ? v : parseFloat(String(v)) || 0);
}, 0);
value = group.length > 0 ? sum / group.length : 0;
}
// Format label
let label: string;
if (isDateKey) {
const date = new Date(key);
label = date.toLocaleDateString("en-US", {
month: "short",
day: "2-digit",
});
} else {
label = key;
}
aggregated.push({
label,
[valueKey]: value,
_groupKey: key,
});
}
return { items: aggregated, valueKey };
}
// =============================================================================
// Fallback Component
// =============================================================================
export function Fallback({ type }: { type: string }) {
return (
<div className="p-4 border border-dashed rounded-lg text-muted-foreground text-sm">
Unknown component: {type}
</div>
);
}
+998
View File
@@ -0,0 +1,998 @@
"use client";
import { toast } from "sonner";
import { findFormValue, getByPath } from "@json-render/core";
import { useData, defineRegistry } from "@json-render/react";
import {
Bar,
BarChart as RechartsBarChart,
CartesianGrid,
Line,
LineChart as RechartsLineChart,
XAxis,
} from "recharts";
import {
ChartContainer,
ChartTooltip,
ChartTooltipContent,
type ChartConfig,
} from "@/components/ui/chart";
// shadcn components
import { Button } from "@/components/ui/button";
import { Input } from "@/components/ui/input";
import { Label } from "@/components/ui/label";
import { Badge } from "@/components/ui/badge";
import { Alert, AlertTitle, AlertDescription } from "@/components/ui/alert";
import { Separator } from "@/components/ui/separator";
import {
Accordion,
AccordionItem,
AccordionTrigger,
AccordionContent,
} from "@/components/ui/accordion";
import {
Table,
TableHeader,
TableBody,
TableHead,
TableRow,
TableCell,
} from "@/components/ui/table";
import { Avatar, AvatarImage, AvatarFallback } from "@/components/ui/avatar";
import { Checkbox } from "@/components/ui/checkbox";
import {
Dialog,
DialogTrigger,
DialogContent,
DialogHeader,
DialogTitle,
DialogDescription,
} from "@/components/ui/dialog";
import {
Drawer,
DrawerTrigger,
DrawerContent,
DrawerHeader,
DrawerTitle,
DrawerDescription,
} from "@/components/ui/drawer";
import {
DropdownMenu,
DropdownMenuTrigger,
DropdownMenuContent,
DropdownMenuItem,
} from "@/components/ui/dropdown-menu";
import {
Pagination,
PaginationContent,
PaginationItem,
PaginationPrevious,
PaginationNext,
PaginationLink,
} from "@/components/ui/pagination";
import {
Popover,
PopoverTrigger,
PopoverContent,
} from "@/components/ui/popover";
import { Progress } from "@/components/ui/progress";
import { RadioGroup, RadioGroupItem } from "@/components/ui/radio-group";
import {
Select,
SelectTrigger,
SelectValue,
SelectContent,
SelectItem,
} from "@/components/ui/select";
import { Skeleton } from "@/components/ui/skeleton";
import { Switch } from "@/components/ui/switch";
import { Tabs, TabsList, TabsTrigger, TabsContent } from "@/components/ui/tabs";
import { Textarea } from "@/components/ui/textarea";
import {
Tooltip,
TooltipTrigger,
TooltipContent,
TooltipProvider,
} from "@/components/ui/tooltip";
import { dashboardCatalog } from "./catalog";
// =============================================================================
// Registry
// =============================================================================
export const { registry, handlers, executeAction } = defineRegistry(
dashboardCatalog,
{
components: {
Stack: ({ props, children }) => {
const gapClass =
{ sm: "gap-2", md: "gap-4", lg: "gap-6" }[props.gap ?? "md"] ??
"gap-4";
return (
<div
className={`flex ${props.direction === "horizontal" ? "flex-row" : "flex-col"} ${gapClass}`}
>
{children}
</div>
);
},
Accordion: ({ props, children }) => (
<Accordion type={props.type ?? "single"} collapsible>
{children}
</Accordion>
),
AccordionItem: ({ props, children }) => (
<AccordionItem value={props.value}>
<AccordionTrigger>{props.title}</AccordionTrigger>
<AccordionContent>{children}</AccordionContent>
</AccordionItem>
),
Button: ({ props, onAction, loading }) => (
<Button
variant={props.variant ?? "default"}
disabled={loading || (props.disabled ?? false)}
onClick={() =>
onAction?.({
name: props.action,
params: props.actionParams ?? undefined,
})
}
>
{loading ? "..." : props.label}
</Button>
),
Input: ({ props }) => {
const { data, set } = useData();
return (
<div className="flex flex-col gap-2">
{props.label ? <Label>{props.label}</Label> : null}
<Input
type={props.type ?? "text"}
value={(getByPath(data, props.valuePath) as string) ?? ""}
placeholder={props.placeholder ?? ""}
onChange={(e) => set(props.valuePath, e.target.value)}
/>
</div>
);
},
Form: ({ props, children, onAction }) => (
<form
onSubmit={(e) => {
e.preventDefault();
onAction?.({
name: props.submitAction,
params: props.submitActionParams ?? undefined,
});
}}
className="flex flex-col gap-4"
>
{children}
</form>
),
Badge: ({ props }) => (
<Badge variant={props.variant ?? "default"}>{props.text}</Badge>
),
Alert: ({ props }) => (
<Alert variant={props.variant ?? "default"}>
<AlertTitle>{props.title}</AlertTitle>
{props.description ? (
<AlertDescription>{props.description}</AlertDescription>
) : null}
</Alert>
),
Separator: () => <Separator />,
Avatar: ({ props }) => (
<Avatar>
{props.src ? (
<AvatarImage src={props.src} alt={props.alt ?? ""} />
) : null}
<AvatarFallback>{props.fallback}</AvatarFallback>
</Avatar>
),
Checkbox: ({ props }) => {
const { data, set } = useData();
const checked =
(getByPath(data, props.valuePath) as boolean) ??
props.defaultChecked ??
false;
return (
<div className="flex items-center gap-2">
<Checkbox
id={props.valuePath}
checked={checked}
onCheckedChange={(value) => set(props.valuePath, value)}
/>
{props.label ? (
<Label htmlFor={props.valuePath}>{props.label}</Label>
) : null}
</div>
);
},
Dialog: ({ props, children }) => (
<Dialog>
<DialogTrigger asChild>
<Button variant="outline">{props.trigger}</Button>
</DialogTrigger>
<DialogContent>
<DialogHeader>
<DialogTitle>{props.title}</DialogTitle>
{props.description ? (
<DialogDescription>{props.description}</DialogDescription>
) : null}
</DialogHeader>
{children}
</DialogContent>
</Dialog>
),
Drawer: ({ props, children }) => (
<Drawer>
<DrawerTrigger asChild>
<Button variant="outline">{props.trigger}</Button>
</DrawerTrigger>
<DrawerContent>
<DrawerHeader>
<DrawerTitle>{props.title}</DrawerTitle>
{props.description ? (
<DrawerDescription>{props.description}</DrawerDescription>
) : null}
</DrawerHeader>
<div className="p-4">{children}</div>
</DrawerContent>
</Drawer>
),
DropdownMenu: ({ props, onAction }) => (
<DropdownMenu>
<DropdownMenuTrigger asChild>
<Button variant="outline">{props.trigger}</Button>
</DropdownMenuTrigger>
<DropdownMenuContent>
{props.items.map((item, i) => (
<DropdownMenuItem
key={i}
onClick={() =>
item.action
? onAction?.({
name: item.action,
params: item.actionParams ?? undefined,
})
: undefined
}
>
{item.label}
</DropdownMenuItem>
))}
</DropdownMenuContent>
</DropdownMenu>
),
Label: ({ props }) => (
<Label htmlFor={props.htmlFor ?? undefined}>{props.text}</Label>
),
Pagination: ({ props }) => {
const pages = Array.from({ length: props.totalPages }, (_, i) => i + 1);
return (
<Pagination>
<PaginationContent>
<PaginationItem>
<PaginationPrevious href="#" />
</PaginationItem>
{pages.map((page) => (
<PaginationItem key={page}>
<PaginationLink
href="#"
isActive={page === props.currentPage}
>
{page}
</PaginationLink>
</PaginationItem>
))}
<PaginationItem>
<PaginationNext href="#" />
</PaginationItem>
</PaginationContent>
</Pagination>
);
},
Popover: ({ props, children }) => (
<Popover>
<PopoverTrigger asChild>
<Button variant="outline">{props.trigger}</Button>
</PopoverTrigger>
<PopoverContent>{children}</PopoverContent>
</Popover>
),
Progress: ({ props }) => (
<Progress value={props.value} max={props.max ?? 100} />
),
RadioGroup: ({ props }) => {
const { data, set } = useData();
const value =
(getByPath(data, props.valuePath) as string) ??
props.defaultValue ??
"";
return (
<RadioGroup
value={value}
onValueChange={(v) => set(props.valuePath, v)}
>
{props.options.map((option) => (
<div key={option.value} className="flex items-center gap-2">
<RadioGroupItem value={option.value} id={option.value} />
<Label htmlFor={option.value}>{option.label}</Label>
</div>
))}
</RadioGroup>
);
},
Select: ({ props }) => {
const { data, set } = useData();
const value = (getByPath(data, props.valuePath) as string) ?? "";
return (
<Select value={value} onValueChange={(v) => set(props.valuePath, v)}>
<SelectTrigger>
<SelectValue placeholder={props.placeholder ?? "Select..."} />
</SelectTrigger>
<SelectContent>
{props.options.map((option) => (
<SelectItem key={option.value} value={option.value}>
{option.label}
</SelectItem>
))}
</SelectContent>
</Select>
);
},
Skeleton: ({ props }) => (
<Skeleton
className={`${props.width ?? "w-full"} ${props.height ?? "h-4"}`}
/>
),
Spinner: ({ props }) => {
const sizes = { sm: "h-4 w-4", md: "h-6 w-6", lg: "h-8 w-8" };
const size = sizes[props.size ?? "md"];
return (
<div
className={`${size} animate-spin rounded-full border-2 border-muted border-t-primary`}
/>
);
},
Switch: ({ props }) => {
const { data, set } = useData();
const checked =
(getByPath(data, props.valuePath) as boolean) ??
props.defaultChecked ??
false;
return (
<div className="flex items-center gap-2">
<Switch
id={props.valuePath}
checked={checked}
onCheckedChange={(value) => set(props.valuePath, value)}
/>
{props.label ? (
<Label htmlFor={props.valuePath}>{props.label}</Label>
) : null}
</div>
);
},
Tabs: ({ props, children }) => (
<Tabs defaultValue={props.defaultValue ?? props.tabs[0]?.value}>
<TabsList>
{props.tabs.map((tab) => (
<TabsTrigger key={tab.value} value={tab.value}>
{tab.label}
</TabsTrigger>
))}
</TabsList>
{children}
</Tabs>
),
TabContent: ({ props, children }) => (
<TabsContent value={props.value}>{children}</TabsContent>
),
Textarea: ({ props }) => {
const { data, set } = useData();
return (
<div className="flex flex-col gap-2">
{props.label ? <Label>{props.label}</Label> : null}
<Textarea
value={(getByPath(data, props.valuePath) as string) ?? ""}
placeholder={props.placeholder ?? ""}
rows={props.rows ?? 3}
onChange={(e) => set(props.valuePath, e.target.value)}
/>
</div>
);
},
Tooltip: ({ props, children }) => (
<TooltipProvider>
<Tooltip>
<TooltipTrigger asChild>{children}</TooltipTrigger>
<TooltipContent>{props.content}</TooltipContent>
</Tooltip>
</TooltipProvider>
),
// Heading is intentionally not rendered - widgets already have a title bar
Heading: () => null,
Text: ({ props }) => (
<p className={props.muted ? "text-muted-foreground" : ""}>
{props.content}
</p>
),
Table: ({ props, onAction }) => {
const { data } = useData();
const path = props.dataPath.replace(/\./g, "/");
const rawData = getByPath(data, path);
const items: Array<Record<string, unknown>> = Array.isArray(rawData)
? rawData
: Array.isArray((rawData as Record<string, unknown>)?.data)
? ((rawData as Record<string, unknown>).data as Array<
Record<string, unknown>
>)
: Array.isArray((rawData as Record<string, unknown>)?.items)
? ((rawData as Record<string, unknown>).items as Array<
Record<string, unknown>
>)
: [];
if (items.length === 0) {
return (
<div className="text-center py-4 text-muted-foreground">
{props.emptyMessage ?? "No data"}
</div>
);
}
const hasRowActions = props.rowActions && props.rowActions.length > 0;
return (
<Table>
<TableHeader>
<TableRow>
{props.columns.map((col) => (
<TableHead key={col.key}>{col.label}</TableHead>
))}
{hasRowActions ? <TableHead>Actions</TableHead> : null}
</TableRow>
</TableHeader>
<TableBody>
{items.map((item, i) => (
<TableRow key={i}>
{props.columns.map((col) => (
<TableCell key={col.key}>
{String(item[col.key] ?? "")}
</TableCell>
))}
{hasRowActions ? (
<TableCell>
<div className="flex gap-1">
{props.rowActions!.map((rowAction) => (
<Button
key={rowAction.action}
variant={rowAction.variant ?? "ghost"}
size="sm"
onClick={() =>
onAction?.({
name: rowAction.action,
params: { id: item.id as string },
})
}
>
{rowAction.label}
</Button>
))}
</div>
</TableCell>
) : null}
</TableRow>
))}
</TableBody>
</Table>
);
},
BarChart: ({ props }) => {
const { data } = useData();
const path = props.dataPath.replace(/\./g, "/");
const rawData = getByPath(data, path);
const rawItems: Array<Record<string, unknown>> = Array.isArray(rawData)
? rawData
: Array.isArray((rawData as Record<string, unknown>)?.data)
? ((rawData as Record<string, unknown>).data as Array<
Record<string, unknown>
>)
: [];
// Process and aggregate data
const { items, valueKey } = processChartData(
rawItems,
props.xKey,
props.yKey,
props.aggregate,
);
const chartColor = props.color ?? "var(--chart-1)";
const chartConfig = {
[valueKey]: {
label: valueKey,
color: chartColor,
},
} satisfies ChartConfig;
if (items.length === 0) {
return (
<div className="w-full">
<div className="text-center py-4 text-muted-foreground">
No data available
</div>
</div>
);
}
return (
<div className="w-full">
<ChartContainer
config={chartConfig}
className="min-h-[200px] w-full"
style={{ height: props.height ?? 300 }}
>
<RechartsBarChart accessibilityLayer data={items}>
<CartesianGrid vertical={false} />
<XAxis
dataKey="label"
tickLine={false}
tickMargin={10}
axisLine={false}
/>
<ChartTooltip content={<ChartTooltipContent />} />
<Bar
dataKey={valueKey}
fill={`var(--color-${valueKey})`}
radius={4}
/>
</RechartsBarChart>
</ChartContainer>
</div>
);
},
LineChart: ({ props }) => {
const { data } = useData();
const path = props.dataPath.replace(/\./g, "/");
const rawData = getByPath(data, path);
const rawItems: Array<Record<string, unknown>> = Array.isArray(rawData)
? rawData
: Array.isArray((rawData as Record<string, unknown>)?.data)
? ((rawData as Record<string, unknown>).data as Array<
Record<string, unknown>
>)
: [];
// Process and aggregate data
const { items, valueKey } = processChartData(
rawItems,
props.xKey,
props.yKey,
props.aggregate,
);
const chartColor = props.color ?? "var(--chart-1)";
const chartConfig = {
[valueKey]: {
label: valueKey,
color: chartColor,
},
} satisfies ChartConfig;
if (items.length === 0) {
return (
<div className="w-full">
<div className="text-center py-4 text-muted-foreground">
No data available
</div>
</div>
);
}
return (
<div className="w-full">
<ChartContainer
config={chartConfig}
className="min-h-[200px] w-full"
style={{ height: props.height ?? 300 }}
>
<RechartsLineChart accessibilityLayer data={items}>
<CartesianGrid vertical={false} />
<XAxis
dataKey="label"
tickLine={false}
tickMargin={10}
axisLine={false}
/>
<ChartTooltip content={<ChartTooltipContent />} />
<Line
type="monotone"
dataKey={valueKey}
stroke={`var(--color-${valueKey})`}
strokeWidth={2}
dot={false}
/>
</RechartsLineChart>
</ChartContainer>
</div>
);
},
},
actions: {
viewCustomers: async (params, setData) => {
const queryParams = new URLSearchParams();
if (params?.limit) queryParams.set("limit", String(params.limit));
if (params?.sort) queryParams.set("sort", String(params.sort));
if (params?.status) queryParams.set("status", String(params.status));
const url = `/api/v1/customers${queryParams.toString() ? `?${queryParams}` : ""}`;
const res = await fetch(url);
const customers = await res.json();
setData((prev) => ({ ...prev, customers }));
},
refreshCustomers: async (params, setData) => {
const queryParams = new URLSearchParams();
if (params?.limit) queryParams.set("limit", String(params.limit));
if (params?.sort) queryParams.set("sort", String(params.sort));
const url = `/api/v1/customers${queryParams.toString() ? `?${queryParams}` : ""}`;
const res = await fetch(url);
const customers = await res.json();
setData((prev) => ({ ...prev, customers }));
},
createCustomer: async (params, setData, data) => {
const name = findFormValue("name", params, data) as string;
const email =
(findFormValue("email", params, data) as string) ||
`${name?.toLowerCase().replace(/\s+/g, ".")}@example.com`;
const phone = findFormValue("phone", params, data) as
| string
| undefined;
if (!name) {
toast.error("Customer name is required");
return;
}
try {
const res = await fetch("/api/v1/customers", {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({ name, email, phone }),
});
const customer = await res.json();
if (res.ok) {
toast.success(`Customer "${customer.name}" created`);
const listRes = await fetch("/api/v1/customers");
const customers = await listRes.json();
setData((prev) => ({ ...prev, customers }));
} else {
toast.error(customer.error || "Failed to create customer");
}
} catch (err) {
console.error("Failed to create customer:", err);
toast.error("Failed to create customer");
}
},
deleteCustomer: async (params, setData, data) => {
const customerId =
findFormValue("customerId", params, data) ||
findFormValue("id", params, data);
if (!customerId) {
toast.error("Customer ID required");
return;
}
try {
const res = await fetch(`/api/v1/customers/${customerId}`, {
method: "DELETE",
});
if (res.ok) {
toast.success("Customer deleted");
const listRes = await fetch("/api/v1/customers");
const customers = await listRes.json();
setData((prev) => ({ ...prev, customers }));
} else {
const err = await res.json();
toast.error(err.error || "Failed to delete customer");
}
} catch (err) {
console.error("Failed to delete customer:", err);
toast.error("Failed to delete customer");
}
},
viewInvoices: async (params, setData) => {
const queryParams = new URLSearchParams();
if (params?.status) queryParams.set("status", String(params.status));
const url = `/api/v1/invoices${queryParams.toString() ? `?${queryParams}` : ""}`;
const res = await fetch(url);
const invoices = await res.json();
setData((prev) => ({ ...prev, invoices }));
},
refreshInvoices: async (_params, setData) => {
const res = await fetch("/api/v1/invoices");
const invoices = await res.json();
setData((prev) => ({ ...prev, invoices }));
},
createInvoice: async (params, setData) => {
if (!params?.customerId || !params?.dueDate) {
toast.error("Customer ID and due date required");
return;
}
try {
const res = await fetch("/api/v1/invoices", {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify(params),
});
const invoice = await res.json();
if (res.ok) {
toast.success("Invoice created");
const listRes = await fetch("/api/v1/invoices");
const invoices = await listRes.json();
setData((prev) => ({ ...prev, invoices }));
} else {
toast.error(invoice.error || "Failed to create invoice");
}
} catch (err) {
console.error("Failed to create invoice:", err);
toast.error("Failed to create invoice");
}
},
sendInvoice: async (params) => {
if (!params?.invoiceId) {
toast.error("Invoice ID required");
return;
}
const res = await fetch(`/api/v1/invoices/${params.invoiceId}/send`, {
method: "POST",
});
const result = await res.json();
if (res.ok) {
toast.success(result.message || "Invoice sent");
} else {
toast.error(result.error || "Failed to send invoice");
}
},
markInvoicePaid: async (params) => {
if (!params?.invoiceId) {
toast.error("Invoice ID required");
return;
}
const res = await fetch(
`/api/v1/invoices/${params.invoiceId}/mark-paid`,
{ method: "POST" },
);
const result = await res.json();
if (res.ok) {
toast.success(result.message || "Invoice marked paid");
} else {
toast.error(result.error || "Failed to mark invoice paid");
}
},
viewExpenses: async (params, setData) => {
const queryParams = new URLSearchParams();
if (params?.status) queryParams.set("status", String(params.status));
const url = `/api/v1/expenses${queryParams.toString() ? `?${queryParams}` : ""}`;
const res = await fetch(url);
const expenses = await res.json();
setData((prev) => ({ ...prev, expenses }));
},
refreshExpenses: async (_params, setData) => {
const res = await fetch("/api/v1/expenses");
const expenses = await res.json();
setData((prev) => ({ ...prev, expenses }));
},
createExpense: async (params, setData) => {
if (
!params?.vendor ||
!params?.category ||
params?.amount === undefined
) {
toast.error("Vendor, category, and amount required");
return;
}
try {
const res = await fetch("/api/v1/expenses", {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify(params),
});
const expense = await res.json();
if (res.ok) {
toast.success("Expense created");
const listRes = await fetch("/api/v1/expenses");
const expenses = await listRes.json();
setData((prev) => ({ ...prev, expenses }));
} else {
toast.error(expense.error || "Failed to create expense");
}
} catch (err) {
console.error("Failed to create expense:", err);
toast.error("Failed to create expense");
}
},
approveExpense: async (params) => {
if (!params?.expenseId) {
toast.error("Expense ID required");
return;
}
const res = await fetch(
`/api/v1/expenses/${params.expenseId}/approve`,
{ method: "POST" },
);
const result = await res.json();
if (res.ok) {
toast.success(result.message || "Expense approved");
} else {
toast.error(result.error || "Failed to approve expense");
}
},
},
},
);
// =============================================================================
// Chart Helpers
// =============================================================================
function isISODate(value: unknown): boolean {
if (typeof value !== "string") return false;
return /^\d{4}-\d{2}-\d{2}T\d{2}:\d{2}:\d{2}/.test(value);
}
function formatDateLabel(value: string): string {
const date = new Date(value);
if (isNaN(date.getTime())) return value;
return date.toLocaleDateString("en-US", { month: "short", day: "2-digit" });
}
function processChartData(
items: Array<Record<string, unknown>>,
xKey: string,
yKey: string,
aggregate: "sum" | "count" | "avg" | null | undefined,
): { items: Array<Record<string, unknown>>; valueKey: string } {
if (items.length === 0) {
return { items: [], valueKey: yKey };
}
const firstXValue = items[0]?.[xKey];
const isDateKey = isISODate(firstXValue);
if (!aggregate) {
const formatted = items.map((item) => {
const xValue = item[xKey];
return {
...item,
label:
isDateKey && typeof xValue === "string"
? formatDateLabel(xValue)
: String(xValue ?? ""),
};
});
return { items: formatted, valueKey: yKey };
}
const groups = new Map<string, Array<Record<string, unknown>>>();
for (const item of items) {
const xValue = item[xKey];
let groupKey: string;
if (isDateKey && typeof xValue === "string") {
groupKey = xValue.split("T")[0] ?? xValue;
} else {
groupKey = String(xValue ?? "unknown");
}
const group = groups.get(groupKey) ?? [];
group.push(item);
groups.set(groupKey, group);
}
const valueKey = aggregate === "count" ? "count" : yKey;
const aggregated: Array<Record<string, unknown>> = [];
const sortedKeys = Array.from(groups.keys()).sort();
for (const key of sortedKeys) {
const group = groups.get(key)!;
let value: number;
if (aggregate === "count") {
value = group.length;
} else if (aggregate === "sum") {
value = group.reduce((sum, item) => {
const v = item[yKey];
return sum + (typeof v === "number" ? v : parseFloat(String(v)) || 0);
}, 0);
} else {
const sum = group.reduce((s, item) => {
const v = item[yKey];
return s + (typeof v === "number" ? v : parseFloat(String(v)) || 0);
}, 0);
value = group.length > 0 ? sum / group.length : 0;
}
let label: string;
if (isDateKey) {
const date = new Date(key);
label = date.toLocaleDateString("en-US", {
month: "short",
day: "2-digit",
});
} else {
label = key;
}
aggregated.push({
label,
[valueKey]: value,
_groupKey: key,
});
}
return { items: aggregated, valueKey };
}
// =============================================================================
// Fallback Component
// =============================================================================
export function Fallback({ type }: { type: string }) {
return (
<div className="p-4 border border-dashed rounded-lg text-muted-foreground text-sm">
Unknown component: {type}
</div>
);
}
+16 -42
View File
@@ -3,15 +3,14 @@
import { useMemo, useRef, type ReactNode } from "react";
import {
Renderer,
type ComponentRegistry,
type ComponentRenderer,
type Spec,
DataProvider,
VisibilityProvider,
ActionProvider,
} from "@json-render/react";
import { components, Fallback } from "./catalog/components";
import { executeAction } from "./catalog/actions";
import { registry, Fallback, handlers as createHandlers } from "./registry";
// =============================================================================
// DashboardRenderer
@@ -29,39 +28,9 @@ interface DashboardRendererProps {
loading?: boolean;
}
// Build registry - 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, componentFn] of Object.entries(components)) {
registry[name] = (renderProps: {
element: { props: Record<string, unknown> };
children?: ReactNode;
}) =>
componentFn({
props: renderProps.element.props as never,
children: renderProps.children,
onAction: (a) => {
const setData = setDataRef.current;
const data = dataRef.current;
if (setData) {
executeAction(a.name, a.params, setData, data);
}
},
loading,
});
}
return registry;
}
// Fallback component for unknown types
const fallbackRegistry = (renderProps: { element: { type: string } }) => (
<Fallback type={renderProps.element.type} />
const fallback: ComponentRenderer = ({ element }) => (
<Fallback type={element.type} />
);
export function DashboardRenderer({
@@ -71,16 +40,21 @@ export function DashboardRenderer({
onDataChange,
loading,
}: DashboardRendererProps): ReactNode {
// Use refs to keep registry stable while still accessing latest data/setData
// Use refs so action handlers always see the 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],
// Create ActionProvider-compatible handlers using getters so they
// always read the latest data/setData from refs
const actionHandlers = useMemo(
() =>
createHandlers(
() => setDataRef.current,
() => dataRef.current,
),
[],
);
if (!spec) return null;
@@ -88,11 +62,11 @@ export function DashboardRenderer({
return (
<DataProvider initialData={data} onDataChange={onDataChange}>
<VisibilityProvider>
<ActionProvider>
<ActionProvider handlers={actionHandlers}>
<Renderer
spec={spec}
registry={registry}
fallback={fallbackRegistry}
fallback={fallback}
loading={loading}
/>
</ActionProvider>
+3 -3
View File
@@ -1,10 +1,10 @@
import { streamText } from "ai";
import { videoCatalog } from "@/lib/catalog";
import { getVideoPrompt } from "@/lib/catalog";
export const maxDuration = 30;
// Generate prompt from catalog - uses Remotion schema's prompt template
const SYSTEM_PROMPT = videoCatalog.prompt();
// Generate prompt from catalog - uses Remotion schema's prompt template with custom rules
const SYSTEM_PROMPT = getVideoPrompt();
const MAX_PROMPT_LENGTH = 500;
const DEFAULT_MODEL = "anthropic/claude-haiku-4.5";
+176 -21
View File
@@ -1,9 +1,10 @@
"use client";
import { useState, useCallback, useRef } from "react";
import { useState, useCallback, useRef, useEffect } from "react";
import { createSpecStreamCompiler } from "@json-render/core";
import { Player, PlayerRef } from "@remotion/player";
import { Renderer, type TimelineSpec } from "@json-render/remotion";
import { createHighlighter, type Highlighter } from "shiki";
/**
* Check if spec is complete enough to render
@@ -17,11 +18,146 @@ function isSpecComplete(spec: TimelineSpec): spec is Required<TimelineSpec> {
);
}
/**
* Shiki theme (Vercel-inspired dark theme)
*/
const darkTheme = {
name: "custom-dark",
type: "dark" as const,
colors: {
"editor.background": "transparent",
"editor.foreground": "#EDEDED",
},
settings: [
{
scope: ["string", "string.quoted"],
settings: { foreground: "#50E3C2" },
},
{
scope: [
"constant.numeric",
"constant.language.boolean",
"constant.language.null",
],
settings: { foreground: "#50E3C2" },
},
{
scope: ["punctuation", "meta.brace", "meta.bracket"],
settings: { foreground: "#888888" },
},
{
scope: ["support.type.property-name", "entity.name.tag.json"],
settings: { foreground: "#EDEDED" },
},
],
};
// Preload highlighter
let highlighterPromise: Promise<Highlighter> | null = null;
function getHighlighter() {
if (!highlighterPromise) {
highlighterPromise = createHighlighter({
themes: [darkTheme],
langs: ["json"],
});
}
return highlighterPromise;
}
// Start loading immediately
if (typeof window !== "undefined") {
getHighlighter();
}
/**
* Code block with syntax highlighting
*/
function CodeBlock({ code }: { code: string }) {
const [html, setHtml] = useState<string>("");
useEffect(() => {
getHighlighter().then((highlighter) => {
setHtml(
highlighter.codeToHtml(code, {
lang: "json",
theme: "custom-dark",
}),
);
});
}, [code]);
if (!html) {
return (
<pre className="p-4 text-left">
<code className="text-muted-foreground">{code}</code>
</pre>
);
}
return (
<div
className="p-4 text-[13px] leading-relaxed [&_pre]:bg-transparent! [&_pre]:p-0! [&_pre]:m-0! [&_code]:bg-transparent!"
dangerouslySetInnerHTML={{ __html: html }}
/>
);
}
/**
* Copy button component
*/
function CopyButton({ text }: { text: string }) {
const [copied, setCopied] = useState(false);
const handleCopy = async () => {
await navigator.clipboard.writeText(text);
setCopied(true);
setTimeout(() => setCopied(false), 2000);
};
return (
<button
onClick={handleCopy}
className="p-1.5 rounded hover:bg-white/10 transition-colors text-muted-foreground hover:text-foreground"
aria-label="Copy JSON"
>
{copied ? (
<svg
width="14"
height="14"
viewBox="0 0 24 24"
fill="none"
stroke="currentColor"
strokeWidth="2"
strokeLinecap="round"
strokeLinejoin="round"
>
<polyline points="20 6 9 17 4 12" />
</svg>
) : (
<svg
width="14"
height="14"
viewBox="0 0 24 24"
fill="none"
stroke="currentColor"
strokeWidth="2"
strokeLinecap="round"
strokeLinejoin="round"
>
<rect x="9" y="9" width="13" height="13" rx="2" ry="2" />
<path d="M5 15H4a2 2 0 0 1-2-2V4a2 2 0 0 1 2-2h9a2 2 0 0 1 2 2v1" />
</svg>
)}
</button>
);
}
const EXAMPLE_PROMPTS = [
"Create a 10-second product intro with title cards",
"Make a social media promo video with stats",
"Build a testimonial video with quotes",
"Design a company intro with split screens",
"Create a 10-second product intro with title cards and images",
"Make a photo slideshow with 5 different images",
"Build a testimonial video with quotes and background images",
"Design a dynamic company intro with animated entrances",
];
export default function Home() {
@@ -98,9 +234,18 @@ export default function Home() {
setTimeout(() => inputRef.current?.focus(), 0);
};
const totalTime = spec?.composition
? spec.composition.durationInFrames / spec.composition.fps
: 0;
const handleExport = useCallback(() => {
if (!spec) return;
const blob = new Blob([JSON.stringify(spec, null, 2)], {
type: "application/json",
});
const url = URL.createObjectURL(blob);
const a = document.createElement("a");
a.href = url;
a.download = "timeline.json";
a.click();
URL.revokeObjectURL(url);
}, [spec]);
return (
<div className="min-h-screen">
@@ -193,7 +338,7 @@ export default function Home() {
<div className="text-left">
<div className="flex items-center gap-4 mb-2 h-6">
<span className="text-xs font-mono text-muted-foreground">
timeline.json
json
</span>
{isGenerating && (
<span className="text-xs text-muted-foreground animate-pulse">
@@ -201,13 +346,14 @@ export default function Home() {
</span>
)}
</div>
<div className="border border-border rounded bg-background font-mono text-xs h-[28rem] overflow-auto">
<div className="border border-border rounded bg-background font-mono text-xs h-[28rem] overflow-auto relative group">
{spec && (
<div className="absolute top-2 right-2 z-10 opacity-0 group-hover:opacity-100 transition-opacity">
<CopyButton text={JSON.stringify(spec, null, 2)} />
</div>
)}
{spec ? (
<pre className="p-4 text-left">
<code className="text-muted-foreground">
{JSON.stringify(spec, null, 2)}
</code>
</pre>
<CodeBlock code={JSON.stringify(spec, null, 2)} />
) : isGenerating ? (
<div className="p-4 text-muted-foreground/50 h-full flex items-center justify-center">
<div className="flex flex-col items-center gap-2">
@@ -233,13 +379,22 @@ export default function Home() {
<span className="text-xs font-mono text-muted-foreground">
preview
</span>
{spec?.composition && (
<span className="text-xs font-mono text-muted-foreground">
{totalTime.toFixed(1)}s
</span>
)}
<div className="flex items-center gap-2">
{spec && isSpecComplete(spec) && (
<button
onClick={handleExport}
className="text-xs px-2 py-1 rounded border border-border text-muted-foreground hover:text-foreground hover:border-foreground/50 transition-colors"
title="Export timeline JSON"
>
Export
</button>
)}
</div>
</div>
<div className="border border-border rounded bg-black h-[28rem] relative overflow-hidden">
<div
className="border border-border rounded bg-black h-[28rem] relative overflow-hidden"
data-player-container
>
{spec && isSpecComplete(spec) ? (
<Player
ref={playerRef}
+17
View File
@@ -6,6 +6,16 @@ import {
standardEffectDefinitions,
} from "@json-render/remotion/server";
/**
* Custom rules for the AI to follow when generating videos
*/
const customRules = [
// Image URLs using Picsum (free, no API key)
'ImageSlide props: { "src": "https://picsum.photos/1920/1080?random=N", "alt": "description" } - use "src" NOT "imageUrl"',
"Use different random numbers for each image to get different photos (e.g., ?random=1, ?random=2, ?random=3)",
"Picsum provides random professional stock photos - great for product shots, backgrounds, and visual content",
];
/**
* Remotion video catalog
*
@@ -35,3 +45,10 @@ export const videoCatalog = defineCatalog(schema, {
// Use all standard effects from the package
effects: standardEffectDefinitions,
});
/**
* Get the prompt with custom rules for image generation
*/
export function getVideoPrompt(): string {
return videoCatalog.prompt({ customRules });
}
+3
View File
@@ -13,7 +13,9 @@
"@ai-sdk/gateway": "^3.0.13",
"@json-render/core": "workspace:*",
"@json-render/remotion": "workspace:*",
"@remotion/bundler": "4.0.418",
"@remotion/player": "4.0.418",
"@remotion/renderer": "4.0.418",
"ai": "^6.0.70",
"class-variance-authority": "^0.7.1",
"clsx": "^2.1.1",
@@ -22,6 +24,7 @@
"react": "19.2.3",
"react-dom": "19.2.3",
"remotion": "4.0.418",
"shiki": "^3.21.0",
"tailwind-merge": "^3.4.0",
"zod": "^4.0.0"
},
File diff suppressed because one or more lines are too long
+7
View File
@@ -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_...
+105
View File
@@ -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)
+17
View File
@@ -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: "^_" },
],
},
},
];
+7
View File
@@ -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,
};
+26
View File
@@ -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": "workspace:*",
"@json-render/react": "workspace:*",
"@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"
}
}
+5270
View File
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>
);
}
+37
View File
@@ -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");
});
});
+295
View File
@@ -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");
});
});
+781
View File
@@ -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: &quot;Show revenue metrics&quot; &bull; &quot;Show recent
payments&quot; &bull; &quot;Show subscriptions&quot; &bull;
&quot;Show customers&quot; &bull; &quot;Show invoices&quot;
</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;
+339
View File
@@ -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;
+301
View File
@@ -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;
+362
View File
@@ -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

+125
View File
@@ -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"
}
}
}
+3
View File
@@ -0,0 +1,3 @@
{
"extends": "@stripe/ui-extension-tools/tsconfig.ui-extension"
}
+1
View File
@@ -0,0 +1 @@
/// <reference types="@stripe/ui-extension-tools" />
+5 -1
View File
@@ -20,9 +20,13 @@
"test": "vitest run",
"test:watch": "vitest",
"test:coverage": "vitest run --coverage",
"prepare": "husky"
"prepare": "husky",
"changeset": "changeset",
"ci:version": "changeset version && pnpm install --no-frozen-lockfile",
"ci:publish": "pnpm run build && changeset publish"
},
"devDependencies": {
"@changesets/cli": "2.29.8",
"@testing-library/dom": "^10.4.1",
"@testing-library/react": "^16.3.1",
"husky": "^9.1.7",
+17
View File
@@ -0,0 +1,17 @@
# @json-render/codegen
## 0.4.3
### Patch Changes
- 61ee8e5: include remove op in system prompt
- Updated dependencies [61ee8e5]
- @json-render/core@0.4.3
## 0.4.2
### Patch Changes
- 54bce09: add defineRegistry function
- Updated dependencies [54bce09]
- @json-render/core@0.4.2
+1 -1
View File
@@ -1,6 +1,6 @@
{
"name": "@json-render/codegen",
"version": "0.4.0",
"version": "0.4.3",
"license": "Apache-2.0",
"description": "Utilities for generating code from json-render UI trees",
"keywords": [
+13
View File
@@ -0,0 +1,13 @@
# @json-render/core
## 0.4.3
### Patch Changes
- 61ee8e5: include remove op in system prompt
## 0.4.2
### Patch Changes
- 54bce09: add defineRegistry function
+1 -1
View File
@@ -1,6 +1,6 @@
{
"name": "@json-render/core",
"version": "0.4.0",
"version": "0.4.3",
"license": "Apache-2.0",
"description": "JSON becomes real things. Define your catalog, register your components, let AI generate.",
"keywords": [
+2
View File
@@ -502,6 +502,7 @@ export function generateSystemPrompt<
lines.push(
'{"op":"add","path":"/elements/key","value":{"key":"...","type":"...","props":{...},"children":[...]}}',
);
lines.push('{"op":"remove","path":"/elements/key"}');
lines.push("");
// Rules
@@ -509,6 +510,7 @@ export function generateSystemPrompt<
const baseRules = [
"First line sets /root to root element key",
"Add elements with /elements/{key}",
"Remove elements with op:remove - also update the parent's children array to exclude the removed key",
"Children array contains string keys, not objects",
"Parent first, then children",
"Each element needs: key, type, props",
+17
View File
@@ -0,0 +1,17 @@
# @json-render/react
## 0.4.3
### Patch Changes
- 61ee8e5: include remove op in system prompt
- Updated dependencies [61ee8e5]
- @json-render/core@0.4.3
## 0.4.2
### Patch Changes
- 54bce09: add defineRegistry function
- Updated dependencies [54bce09]
- @json-render/core@0.4.2
+44 -55
View File
@@ -51,33 +51,36 @@ export const catalog = defineCatalog(schema, {
### 2. Define Component Implementations
```tsx
import { defineComponents, useData } from "@json-render/react";
import { defineRegistry, useData } from "@json-render/react";
import { catalog } from "./catalog";
export const components = defineComponents(catalog, {
Card: ({ props, children }) => (
<div className="card">
<h3>{props.title}</h3>
{props.description && <p>{props.description}</p>}
{children}
</div>
),
Button: ({ props, onAction }) => (
<button onClick={() => onAction?.(props.action)}>
{props.label}
</button>
),
Input: ({ props }) => {
const { get, set } = useData();
return (
<label>
export const { registry } = defineRegistry(catalog, {
components: {
Card: ({ props, children }) => (
<div className="card">
<h3>{props.title}</h3>
{props.description && <p>{props.description}</p>}
{children}
</div>
),
Button: ({ props, onAction }) => (
<button onClick={() => onAction?.({ name: props.action })}>
{props.label}
<input
placeholder={props.placeholder ?? ""}
value={get("/form/value") ?? ""}
onChange={(e) => set("/form/value", e.target.value)}
/>
</label>
);
</button>
),
Input: ({ props }) => {
const { get, set } = useData();
return (
<label>
{props.label}
<input
placeholder={props.placeholder ?? ""}
value={get("/form/value") ?? ""}
onChange={(e) => set("/form/value", e.target.value)}
/>
</label>
);
},
},
});
```
@@ -86,20 +89,15 @@ export const components = defineComponents(catalog, {
```tsx
import { Renderer, DataProvider, ActionProvider } from "@json-render/react";
import { registry } from "./registry";
function App({ spec }) {
const handleAction = (action: string) => {
console.log("Action triggered:", action);
};
return (
<DataProvider initialData={{ form: { value: "" } }}>
<ActionProvider onAction={handleAction}>
<Renderer
spec={spec}
catalog={catalog}
components={components}
/>
<ActionProvider handlers={{
submit: () => console.log("Submit"),
}}>
<Renderer spec={spec} registry={registry} />
</ActionProvider>
</DataProvider>
);
@@ -253,13 +251,14 @@ const { errors, validate } = useFieldValidation("/form/email", {
## Component Props
Components receive these props:
When using `defineRegistry`, components receive these props:
```typescript
interface ComponentProps<P> {
props: P; // Props from spec
interface ComponentContext<P> {
props: P; // Typed props from the catalog
children?: React.ReactNode; // Rendered children
onAction?: (action: string) => void;
onAction?: (action: { name: string; params?: Record<string, unknown> }) => void;
loading?: boolean; // Whether the parent is loading
}
```
@@ -274,13 +273,7 @@ const systemPrompt = catalog.prompt();
```tsx
import { defineCatalog } from "@json-render/core";
import {
schema,
defineComponents,
Renderer,
DataProvider,
ActionProvider,
} from "@json-render/react";
import { schema, defineRegistry, Renderer } from "@json-render/react";
import { z } from "zod";
const catalog = defineCatalog(schema, {
@@ -293,8 +286,10 @@ const catalog = defineCatalog(schema, {
actions: {},
});
const components = defineComponents(catalog, {
Greeting: ({ props }) => <h1>Hello, {props.name}!</h1>,
const { registry } = defineRegistry(catalog, {
components: {
Greeting: ({ props }) => <h1>Hello, {props.name}!</h1>,
},
});
const spec = {
@@ -305,12 +300,6 @@ const spec = {
};
function App() {
return (
<DataProvider initialData={{}}>
<ActionProvider onAction={() => {}}>
<Renderer spec={spec} catalog={catalog} components={components} />
</ActionProvider>
</DataProvider>
);
return <Renderer spec={spec} registry={registry} />;
}
```
+1 -1
View File
@@ -1,6 +1,6 @@
{
"name": "@json-render/react",
"version": "0.4.0",
"version": "0.4.3",
"license": "Apache-2.0",
"description": "React renderer for @json-render/core. JSON becomes React components.",
"keywords": [
+5 -2
View File
@@ -64,11 +64,14 @@ export type {
// Renderer
export {
// New API
// Registry
defineRegistry,
type DefineRegistryResult,
// createRenderer (higher-level, includes providers)
createRenderer,
type CreateRendererProps,
type ComponentMap,
// Legacy API
// Low-level
Renderer,
JSONUIProvider,
createRendererFromCatalog,
+154 -2
View File
@@ -5,11 +5,18 @@ import type {
UIElement,
Spec,
Action,
Catalog as NewCatalog,
Catalog,
SchemaDefinition,
LegacyCatalog,
ComponentDefinition,
} from "@json-render/core";
import type {
Components,
Actions,
ActionFn,
SetData,
DataModel,
} from "./catalog-types";
import { useIsVisible } from "./contexts/visibility";
import { useActions } from "./contexts/actions";
import { useData } from "./contexts/data";
@@ -232,6 +239,151 @@ export function createRendererFromCatalog<
};
}
// ============================================================================
// defineRegistry
// ============================================================================
/**
* Result returned by defineRegistry
*/
export interface DefineRegistryResult {
/** Component registry for `<Renderer registry={...} />` */
registry: ComponentRegistry;
/**
* Create ActionProvider-compatible handlers.
* Accepts getter functions so handlers always read the latest data/setData
* (e.g. from React refs).
*/
handlers: (
getSetData: () => SetData | undefined,
getData: () => DataModel,
) => Record<string, (params: Record<string, unknown>) => Promise<void>>;
/**
* Execute an action by name imperatively
* (for use outside the React tree, e.g. initial data loading).
*/
executeAction: (
actionName: string,
params: Record<string, unknown> | undefined,
setData: SetData,
data?: DataModel,
) => Promise<void>;
}
/**
* Create a registry from a catalog with components and/or actions.
*
* @example
* ```tsx
* // Components only
* const { registry } = defineRegistry(catalog, {
* components: {
* Card: ({ props, children }) => (
* <div className="card">{props.title}{children}</div>
* ),
* },
* });
*
* // Actions only
* const { handlers, executeAction } = defineRegistry(catalog, {
* actions: {
* viewCustomers: async (params, setData) => { ... },
* },
* });
*
* // Both
* const { registry, handlers, executeAction } = defineRegistry(catalog, {
* components: { ... },
* actions: { ... },
* });
* ```
*/
export function defineRegistry<C extends Catalog>(
_catalog: C,
options: {
components?: Components<C>;
actions?: Actions<C>;
},
): DefineRegistryResult {
// Build component registry
const registry: ComponentRegistry = {};
if (options.components) {
for (const [name, componentFn] of Object.entries(options.components)) {
registry[name] = ({
element,
children,
onAction,
loading,
}: ComponentRenderProps) => {
return (componentFn as DefineRegistryComponentFn)({
props: element.props,
children,
onAction,
loading,
});
};
}
}
// Build action helpers
const actionMap = options.actions
? (Object.entries(options.actions) as Array<
[string, DefineRegistryActionFn]
>)
: [];
const handlers = (
getSetData: () => SetData | undefined,
getData: () => DataModel,
): Record<string, (params: Record<string, unknown>) => Promise<void>> => {
const result: Record<
string,
(params: Record<string, unknown>) => Promise<void>
> = {};
for (const [name, actionFn] of actionMap) {
result[name] = async (params) => {
const setData = getSetData();
const data = getData();
if (setData) {
await actionFn(params, setData, data);
}
};
}
return result;
};
const executeAction = async (
actionName: string,
params: Record<string, unknown> | undefined,
setData: SetData,
data: DataModel = {},
): Promise<void> => {
const entry = actionMap.find(([name]) => name === actionName);
if (entry) {
await entry[1](params, setData, data);
} else {
console.warn(`Unknown action: ${actionName}`);
}
};
return { registry, handlers, executeAction };
}
/** @internal */
type DefineRegistryComponentFn = (ctx: {
props: unknown;
children?: React.ReactNode;
onAction?: (action: Action) => void;
loading?: boolean;
}) => React.ReactNode;
/** @internal */
type DefineRegistryActionFn = (
params: Record<string, unknown> | undefined,
setData: SetData,
data: DataModel,
) => Promise<void>;
// ============================================================================
// NEW API
// ============================================================================
@@ -289,7 +441,7 @@ export function createRenderer<
TDef extends SchemaDefinition,
TCatalog extends { components: Record<string, { props: unknown }> },
>(
catalog: NewCatalog<TDef, TCatalog>,
catalog: Catalog<TDef, TCatalog>,
components: ComponentMap<TCatalog["components"]>,
): ComponentType<CreateRendererProps> {
// Convert component map to registry
+17
View File
@@ -0,0 +1,17 @@
# @json-render/remotion
## 0.4.3
### Patch Changes
- 61ee8e5: include remove op in system prompt
- Updated dependencies [61ee8e5]
- @json-render/core@0.4.3
## 0.4.2
### Patch Changes
- 54bce09: add defineRegistry function
- Updated dependencies [54bce09]
- @json-render/core@0.4.2
+1 -1
View File
@@ -1,6 +1,6 @@
{
"name": "@json-render/remotion",
"version": "0.4.0",
"version": "0.4.3",
"license": "Apache-2.0",
"description": "Remotion renderer for @json-render/core. JSON becomes video compositions.",
"keywords": [
+4 -1
View File
@@ -54,10 +54,13 @@ export const standardComponentDefinitions = {
quote: z.string(),
author: z.string().nullable(),
backgroundColor: z.string().nullable(),
textColor: z.string().nullable(),
transparent: z.boolean().nullable(),
}),
type: "scene",
defaultDuration: 150,
description: "Quote display with attribution. Use for testimonials.",
description:
"Quote display with author. Props: quote, author, textColor, backgroundColor. Set transparent:true when using as overlay on images.",
},
StatCard: {
@@ -1,7 +1,7 @@
"use client";
import { AbsoluteFill, useCurrentFrame } from "remotion";
import { useTransition } from "./hooks";
import { useTransition, useMotion } from "./hooks";
import type { Clip } from "./types";
interface ClipWrapperProps {
@@ -10,22 +10,38 @@ interface ClipWrapperProps {
}
/**
* Wrapper component that applies transition animations to clips
* Wrapper component that applies transition and motion animations to clips
*
* Automatically handles transitionIn and transitionOut based on clip config.
* Automatically handles:
* - transitionIn/transitionOut: basic transition presets (fade, slide, zoom, etc.)
* - motion: declarative enter/exit/loop animations with spring physics
*
* Transitions and motion compose together:
* - Opacity: multiplied (both affect final opacity)
* - Transforms: added (both contribute to final position/scale/rotation)
*/
export function ClipWrapper({ clip, children }: ClipWrapperProps) {
const frame = useCurrentFrame();
const { opacity, translateX, translateY, scale } = useTransition(
clip,
frame + clip.from,
);
const absoluteFrame = frame + clip.from;
// Get transition styles (from transitionIn/transitionOut)
const transition = useTransition(clip, absoluteFrame);
// Get motion styles (from motion.enter/exit/loop)
const motion = useMotion(clip, absoluteFrame);
// Compose styles: multiply opacity, add transforms
const composedOpacity = transition.opacity * motion.opacity;
const composedTranslateX = transition.translateX + motion.translateX;
const composedTranslateY = transition.translateY + motion.translateY;
const composedScale = transition.scale * motion.scale;
const composedRotate = motion.rotate; // Only from motion (transitions don't rotate)
return (
<AbsoluteFill
style={{
opacity,
transform: `translateX(${translateX}%) translateY(${translateY}%) scale(${scale})`,
opacity: composedOpacity,
transform: `translateX(${composedTranslateX}%) translateY(${composedTranslateY}%) scale(${composedScale}) rotate(${composedRotate}deg)`,
}}
>
{children}
+169 -2
View File
@@ -1,7 +1,7 @@
"use client";
import { useVideoConfig, spring, interpolate } from "remotion";
import type { Clip, TransitionStyles } from "./types";
import { useVideoConfig, spring, interpolate, Easing } from "remotion";
import type { Clip, TransitionStyles, MotionStyles } from "./types";
/**
* Calculate transition styles based on clip configuration
@@ -102,3 +102,170 @@ export function useTransition(clip: Clip, frame: number): TransitionStyles {
return { opacity, translateX, translateY, scale };
}
/**
* Calculate motion styles based on clip's motion configuration
*
* Handles declarative motion:
* - enter: animate FROM values TO neutral
* - exit: animate FROM neutral TO values
* - loop: continuous animation during clip lifetime
* - spring: physics-based easing
*/
export function useMotion(clip: Clip, frame: number): MotionStyles {
const { fps } = useVideoConfig();
const relativeFrame = frame - clip.from;
const clipEnd = clip.durationInFrames;
// Default neutral values
let opacity = 1;
let translateX = 0;
let translateY = 0;
let scale = 1;
let rotate = 0;
const motion = clip.motion;
if (!motion) {
return { opacity, translateX, translateY, scale, rotate };
}
// Spring config with defaults
const springConfig = {
damping: motion.spring?.damping ?? 20,
stiffness: motion.spring?.stiffness ?? 100,
mass: motion.spring?.mass ?? 1,
};
// Enter animation
if (motion.enter) {
const enterDuration = motion.enter.duration ?? 20;
if (relativeFrame < enterDuration) {
const progress = spring({
frame: relativeFrame,
fps,
config: springConfig,
durationInFrames: enterDuration,
});
// Interpolate FROM enter values TO neutral (1, 0, 0, 1, 0)
if (motion.enter.opacity !== undefined) {
opacity = interpolate(progress, [0, 1], [motion.enter.opacity, 1]);
}
if (motion.enter.scale !== undefined) {
scale = interpolate(progress, [0, 1], [motion.enter.scale, 1]);
}
if (motion.enter.x !== undefined) {
translateX = interpolate(progress, [0, 1], [motion.enter.x, 0]);
}
if (motion.enter.y !== undefined) {
translateY = interpolate(progress, [0, 1], [motion.enter.y, 0]);
}
if (motion.enter.rotate !== undefined) {
rotate = interpolate(progress, [0, 1], [motion.enter.rotate, 0]);
}
}
}
// Exit animation
if (motion.exit) {
const exitDuration = motion.exit.duration ?? 20;
const exitStart = clipEnd - exitDuration;
if (relativeFrame >= exitStart) {
const exitFrame = relativeFrame - exitStart;
const progress = spring({
frame: exitFrame,
fps,
config: springConfig,
durationInFrames: exitDuration,
});
// Interpolate FROM neutral TO exit values
if (motion.exit.opacity !== undefined) {
const exitOpacity = interpolate(
progress,
[0, 1],
[1, motion.exit.opacity],
);
opacity = Math.min(opacity, exitOpacity);
}
if (motion.exit.scale !== undefined) {
const exitScale = interpolate(progress, [0, 1], [1, motion.exit.scale]);
// Multiply scales for composition
scale = scale * exitScale;
}
if (motion.exit.x !== undefined) {
const exitX = interpolate(progress, [0, 1], [0, motion.exit.x]);
translateX = translateX + exitX;
}
if (motion.exit.y !== undefined) {
const exitY = interpolate(progress, [0, 1], [0, motion.exit.y]);
translateY = translateY + exitY;
}
if (motion.exit.rotate !== undefined) {
const exitRotate = interpolate(
progress,
[0, 1],
[0, motion.exit.rotate],
);
rotate = rotate + exitRotate;
}
}
}
// Loop animation (continuous during clip)
if (motion.loop) {
const { property, from, to, duration, easing = "ease" } = motion.loop;
// Calculate loop progress (0-1 repeating)
const loopFrame = relativeFrame % duration;
let loopProgress: number;
switch (easing) {
case "linear":
loopProgress = loopFrame / duration;
break;
case "spring":
loopProgress = spring({
frame: loopFrame,
fps,
config: springConfig,
durationInFrames: duration,
});
break;
case "ease":
default:
// Sine ease in-out for smooth looping
loopProgress = interpolate(
loopFrame,
[0, duration / 2, duration],
[0, 1, 0],
{ extrapolateRight: "clamp" },
);
break;
}
const loopValue = interpolate(loopProgress, [0, 1], [from, to]);
switch (property) {
case "opacity":
opacity = opacity * loopValue;
break;
case "scale":
scale = scale * loopValue;
break;
case "x":
translateX = translateX + loopValue;
break;
case "y":
translateY = translateY + loopValue;
break;
case "rotate":
rotate = rotate + loopValue;
break;
}
}
return { opacity, translateX, translateY, scale, rotate };
}
+6 -1
View File
@@ -4,12 +4,17 @@ export type {
TimelineSpec,
AudioTrack,
TransitionStyles,
MotionStyles,
Motion,
MotionState,
MotionLoop,
SpringConfig,
ClipComponent,
ComponentRegistry,
} from "./types";
// Hooks
export { useTransition } from "./hooks";
export { useTransition, useMotion } from "./hooks";
// Wrapper
export { ClipWrapper } from "./ClipWrapper";
+27 -8
View File
@@ -151,18 +151,26 @@ export function SplitScreen({ clip }: { clip: Clip }) {
// =============================================================================
export function QuoteCard({ clip }: { clip: Clip }) {
const { quote, author, backgroundColor } = clip.props as {
quote: string;
author?: string;
backgroundColor?: string;
};
const { quote, author, backgroundColor, textColor, transparent } =
clip.props as {
quote: string;
author?: string;
backgroundColor?: string;
textColor?: string;
transparent?: boolean;
};
// Use transparent background for overlays, or specified color, or default dark
const bgColor = transparent ? "transparent" : backgroundColor || "#1a1a2e";
const color = textColor || "#ffffff";
return (
<ClipWrapper clip={clip}>
<AbsoluteFill
style={{
backgroundColor: backgroundColor || "#1a1a2e",
color: "#ffffff",
backgroundColor: bgColor,
color,
display: "flex",
flexDirection: "column",
alignItems: "center",
@@ -176,11 +184,22 @@ export function QuoteCard({ clip }: { clip: Clip }) {
fontStyle: "italic",
textAlign: "center",
marginBottom: 24,
textShadow: transparent ? "2px 2px 8px rgba(0,0,0,0.8)" : "none",
}}
>
&ldquo;{quote}&rdquo;
</div>
{author && <div style={{ fontSize: 28, opacity: 0.7 }}>- {author}</div>}
{author && (
<div
style={{
fontSize: 28,
opacity: 0.9,
textShadow: transparent ? "1px 1px 4px rgba(0,0,0,0.8)" : "none",
}}
>
- {author}
</div>
)}
</AbsoluteFill>
</ClipWrapper>
);
+74
View File
@@ -2,6 +2,78 @@
* Types for Remotion timeline components
*/
/**
* Motion state for enter/exit animations
* Values animate FROM these values TO neutral (enter) or FROM neutral TO these values (exit)
*/
export interface MotionState {
/** Opacity (0-1), default: 1 */
opacity?: number;
/** Scale multiplier (0.8 = 80%), default: 1 */
scale?: number;
/** Translate X in pixels, default: 0 */
x?: number;
/** Translate Y in pixels, default: 0 */
y?: number;
/** Rotation in degrees, default: 0 */
rotate?: number;
/** Duration in frames, default: 20 */
duration?: number;
}
/**
* Spring physics configuration
*/
export interface SpringConfig {
/** Damping coefficient, default: 20 */
damping?: number;
/** Stiffness, default: 100 */
stiffness?: number;
/** Mass, default: 1 */
mass?: number;
}
/**
* Continuous loop animation
*/
export interface MotionLoop {
/** Property to animate */
property: "scale" | "rotate" | "x" | "y" | "opacity";
/** Starting value */
from: number;
/** Ending value */
to: number;
/** Duration of one cycle in frames */
duration: number;
/** Easing type, default: "ease" */
easing?: "linear" | "ease" | "spring";
}
/**
* Declarative motion configuration for clips
*/
export interface Motion {
/** Enter animation - animates FROM these values TO neutral */
enter?: MotionState;
/** Exit animation - animates FROM neutral TO these values */
exit?: MotionState;
/** Spring physics config (applies to enter/exit) */
spring?: SpringConfig;
/** Continuous looping animation */
loop?: MotionLoop;
}
/**
* Motion styles calculated by useMotion hook
*/
export interface MotionStyles {
opacity: number;
translateX: number;
translateY: number;
scale: number;
rotate: number;
}
/**
* Clip data passed to components
*/
@@ -14,6 +86,8 @@ export interface Clip {
durationInFrames: number;
transitionIn?: { type: string; durationInFrames: number };
transitionOut?: { type: string; durationInFrames: number };
/** Declarative motion configuration */
motion?: Motion;
}
/**
+6 -1
View File
@@ -26,12 +26,17 @@ export type {
TimelineSpec,
AudioTrack,
TransitionStyles,
MotionStyles,
Motion,
MotionState,
MotionLoop,
SpringConfig,
ClipComponent,
ComponentRegistry,
} from "./components";
// Hooks and utilities
export { useTransition, ClipWrapper } from "./components";
export { useTransition, useMotion, ClipWrapper } from "./components";
// Standard components
export {
+91 -2
View File
@@ -27,8 +27,8 @@ function remotionPromptTemplate(context: PromptContext): string {
lines.push("");
lines.push(`{"op":"set","path":"/composition","value":{"id":"intro","fps":30,"width":1920,"height":1080,"durationInFrames":300}}
{"op":"set","path":"/tracks","value":[{"id":"main","name":"Main","type":"video","enabled":true},{"id":"overlay","name":"Overlay","type":"overlay","enabled":true}]}
{"op":"set","path":"/clips/0","value":{"id":"clip-1","trackId":"main","component":"TitleCard","props":{"title":"Welcome","subtitle":"Getting Started"},"from":0,"durationInFrames":90,"transitionIn":{"type":"fade","durationInFrames":15},"transitionOut":{"type":"fade","durationInFrames":15}}}
{"op":"set","path":"/clips/1","value":{"id":"clip-2","trackId":"main","component":"TitleCard","props":{"title":"Features"},"from":90,"durationInFrames":90}}
{"op":"set","path":"/clips/0","value":{"id":"clip-1","trackId":"main","component":"TitleCard","props":{"title":"Welcome","subtitle":"Getting Started"},"from":0,"durationInFrames":90,"transitionIn":{"type":"fade","durationInFrames":15},"transitionOut":{"type":"fade","durationInFrames":15},"motion":{"enter":{"opacity":0,"y":50,"scale":0.9,"duration":25},"spring":{"damping":15}}}}
{"op":"set","path":"/clips/1","value":{"id":"clip-2","trackId":"main","component":"TitleCard","props":{"title":"Features"},"from":90,"durationInFrames":90,"motion":{"enter":{"opacity":0,"x":-100,"duration":20},"exit":{"opacity":0,"x":100,"duration":15}}}}
{"op":"set","path":"/audio","value":{"tracks":[]}}`);
lines.push("");
@@ -71,6 +71,38 @@ function remotionPromptTemplate(context: PromptContext): string {
lines.push("");
}
// Motion system documentation
lines.push("MOTION SYSTEM:");
lines.push(
"Clips can have a 'motion' field for declarative animations (optional, use for dynamic/engaging videos):",
);
lines.push("");
lines.push(
"- enter: {opacity?, scale?, x?, y?, rotate?, duration?} - animate FROM these values TO normal when clip starts",
);
lines.push(
"- exit: {opacity?, scale?, x?, y?, rotate?, duration?} - animate FROM normal TO these values when clip ends",
);
lines.push(
"- spring: {damping?, stiffness?, mass?} - physics config (lower damping = more bounce)",
);
lines.push(
'- loop: {property, from, to, duration, easing?} - continuous animation (property: "scale"|"rotate"|"x"|"y"|"opacity")',
);
lines.push("");
lines.push("Example motion configs:");
lines.push(' Fade up: {"enter":{"opacity":0,"y":30,"duration":20}}');
lines.push(
' Scale pop: {"enter":{"scale":0.5,"opacity":0,"duration":15},"spring":{"damping":10}}',
);
lines.push(
' Slide in/out: {"enter":{"x":-100,"duration":20},"exit":{"x":100,"duration":15}}',
);
lines.push(
' Gentle pulse: {"loop":{"property":"scale","from":1,"to":1.05,"duration":60,"easing":"ease"}}',
);
lines.push("");
// Rules
lines.push("RULES:");
const baseRules = [
@@ -83,6 +115,8 @@ function remotionPromptTemplate(context: PromptContext): string {
"fps is always 30 (1 second = 30 frames, 10 seconds = 300 frames)",
'Clips on "main" track flow sequentially (from = previous clip\'s from + durationInFrames)',
'Overlay clips (LowerThird, TextOverlay) go on "overlay" track',
"Use motion.enter for engaging clip entrances, motion.exit for smooth departures",
"Spring damping: 20=smooth, 10=bouncy, 5=very bouncy",
];
const allRules = [...baseRules, ...customRules];
allRules.forEach((rule, i) => {
@@ -161,6 +195,61 @@ export const schema = defineSchema(
type: s.ref("catalog.transitions"),
durationInFrames: s.number(),
}),
/** Declarative motion configuration for custom animations */
motion: s.object({
/** Enter animation - animates FROM these values TO neutral */
enter: s.object({
/** Starting opacity (0-1), animates to 1 */
opacity: s.number(),
/** Starting scale (e.g., 0.8 = 80%), animates to 1 */
scale: s.number(),
/** Starting X offset in pixels, animates to 0 */
x: s.number(),
/** Starting Y offset in pixels, animates to 0 */
y: s.number(),
/** Starting rotation in degrees, animates to 0 */
rotate: s.number(),
/** Duration of enter animation in frames (default: 20) */
duration: s.number(),
}),
/** Exit animation - animates FROM neutral TO these values */
exit: s.object({
/** Ending opacity (0-1), animates from 1 */
opacity: s.number(),
/** Ending scale, animates from 1 */
scale: s.number(),
/** Ending X offset in pixels, animates from 0 */
x: s.number(),
/** Ending Y offset in pixels, animates from 0 */
y: s.number(),
/** Ending rotation in degrees, animates from 0 */
rotate: s.number(),
/** Duration of exit animation in frames (default: 20) */
duration: s.number(),
}),
/** Spring physics configuration */
spring: s.object({
/** Damping coefficient (default: 20) */
damping: s.number(),
/** Stiffness (default: 100) */
stiffness: s.number(),
/** Mass (default: 1) */
mass: s.number(),
}),
/** Continuous looping animation */
loop: s.object({
/** Property to animate: "scale" | "rotate" | "x" | "y" | "opacity" */
property: s.string(),
/** Starting value */
from: s.number(),
/** Ending value */
to: s.number(),
/** Duration of one cycle in frames */
duration: s.number(),
/** Easing type: "linear" | "ease" | "spring" (default: "ease") */
easing: s.string(),
}),
}),
}),
),
+4944 -7
View File
File diff suppressed because it is too large Load Diff
+27 -19
View File
@@ -10,34 +10,27 @@ React renderer that converts JSON specs into React component trees.
## Quick Start
```typescript
import { Renderer } from "@json-render/react";
import { defineRegistry, Renderer } from "@json-render/react";
import { catalog } from "./catalog";
const { registry } = defineRegistry(catalog, {
components: {
Card: ({ props, children }) => <div>{props.title}{children}</div>,
},
});
function App({ spec }) {
return <Renderer spec={spec} catalog={catalog} />;
return <Renderer spec={spec} registry={registry} />;
}
```
## Creating a Catalog
```typescript
import { defineCatalog, defineComponents } from "@json-render/react";
import { schema } from "@json-render/react"; // Uses element tree schema
import { defineCatalog } from "@json-render/core";
import { schema, defineRegistry } from "@json-render/react";
import { z } from "zod";
// Define component implementations
const components = defineComponents(catalog, {
Button: ({ props }) => (
<button className={props.variant}>{props.label}</button>
),
Card: ({ props, children }) => (
<div className="card">
<h2>{props.title}</h2>
{children}
</div>
),
});
// Create catalog with props schemas
export const catalog = defineCatalog(schema, {
components: {
@@ -54,6 +47,21 @@ export const catalog = defineCatalog(schema, {
},
},
});
// Define component implementations with type-safe props
const { registry } = defineRegistry(catalog, {
components: {
Button: ({ props }) => (
<button className={props.variant}>{props.label}</button>
),
Card: ({ props, children }) => (
<div className="card">
<h2>{props.title}</h2>
{children}
</div>
),
},
});
```
## Spec Structure (Element Tree)
@@ -85,8 +93,8 @@ The React schema uses an element tree format:
| Export | Purpose |
|--------|---------|
| `Renderer` | Render spec to React components |
| `defineRegistry` | Create a type-safe component registry from a catalog |
| `Renderer` | Render a spec using a registry |
| `schema` | Element tree schema |
| `defineComponents` | Type-safe component registry |
| `useData` | Access data context |
| `useActions` | Access actions context |