Compare commits

...
Author SHA1 Message Date
github-actions[bot] 0a404302ef chore: version packages (#126)
Co-authored-by: github-actions[bot] <41898282+github-actions[bot]@users.noreply.github.com>
2026-02-20 02:17:13 -06:00
Chris Tate 09376db2f6 v0.8.0 changeset (#125) 2026-02-20 02:15:51 -06:00
Chris Tate 4c294417f4 pdf (#124)
* no-ai

* pdf

* pdf example

* fixes

* ai gateway

* fixes

* fixes

* fixes

* shadcn

* 3 panes

* fixes

* fixes

* fixes

* fixes

* fix CI

* fix
2026-02-20 02:08:25 -06:00
Brian Muenzenmeyer f77c1d6c98 Align rendering of ai-sdk table with other patterns (#122)
* Add table for AI SDK generation and chat modes

* Duplicate table for AI SDK generation modes
2026-02-18 21:02:38 -06:00
Chris Tate a66aef17f9 stripe updates (#121)
* stripe cleanup

* full screen flag

* fixes

* stripe cleanup

* refactor

* fixes

* progressive

* fix data

* fixes

* fixes actions

* fixes

* fixes
2026-02-18 13:12:21 -06:00
Chris Tate ba9ffa3c91 update star count (#120) 2026-02-18 09:21:04 -06:00
137 changed files with 19725 additions and 254 deletions
+1
View File
@@ -6,6 +6,7 @@
[
"@json-render/core",
"@json-render/react",
"@json-render/react-pdf",
"@json-render/shadcn",
"@json-render/react-native",
"@json-render/remotion",
+8
View File
@@ -43,4 +43,12 @@ yarn-error.log*
# opensrc - source code for packages
opensrc/
# json-studio (separate repo)
json-studio/
.env*.local
# Stripe apps (generated from template + build artifacts)
examples/stripe-app/*/stripe-app.json
examples/stripe-app/*/.build
examples/stripe-app/*/yarn.lock
+37
View File
@@ -12,6 +12,8 @@ npm install @json-render/shadcn
npm install @json-render/core @json-render/react-native
# or for video
npm install @json-render/core @json-render/remotion
# or for PDF documents
npm install @json-render/core @json-render/react-pdf
```
## Why json-render?
@@ -111,6 +113,7 @@ function Dashboard({ spec }) {
| `@json-render/shadcn` | 36 pre-built shadcn/ui components (Radix UI + Tailwind CSS) |
| `@json-render/react-native` | React Native renderer with standard mobile components |
| `@json-render/remotion` | Remotion video renderer, timeline schema |
| `@json-render/react-pdf` | React PDF renderer for generating PDF documents from specs |
## Renderers
@@ -221,6 +224,40 @@ const spec = {
/>
```
### React PDF (Documents)
```typescript
import { renderToBuffer } from "@json-render/react-pdf";
const spec = {
root: "doc",
elements: {
doc: { type: "Document", props: { title: "Invoice" }, children: ["page-1"] },
"page-1": {
type: "Page",
props: { size: "A4" },
children: ["heading-1", "table-1"],
},
"heading-1": {
type: "Heading",
props: { text: "Invoice #1234", level: "h1" },
children: [],
},
"table-1": {
type: "Table",
props: {
columns: [{ header: "Item", width: "60%" }, { header: "Price", width: "40%", align: "right" }],
rows: [["Widget A", "$10.00"], ["Widget B", "$25.00"]],
},
children: [],
},
},
};
// Render to buffer, stream, or file
const buffer = await renderToBuffer(spec);
```
## Features
### Streaming (SpecStream)
+38 -7
View File
@@ -191,13 +191,44 @@ In chat mode, the prompt instructs the AI to respond conversationally first, the
## Which Mode?
| | Generate | Chat |
|---|---|---|
| Output | JSONL only | Text + JSONL |
| Text-only replies | No | Yes |
| System prompt | `catalog.prompt()` | `catalog.prompt({ mode: "chat" })` |
| Stream utility | `useUIStream` | `pipeJsonRender` + `useJsonRenderMessage` |
| Use case | Playgrounds, builders | Chatbots, copilots |
<div className="my-6 overflow-x-auto">
<table className="mdx-table w-full text-sm border-collapse">
<thead>
<tr>
<th></th>
<th>Generate</th>
<th>Chat</th>
</tr>
</thead>
<tbody>
<tr>
<td>Output</td>
<td>JSONL only</td>
<td>Text + JSONL</td>
</tr>
<tr>
<td>Text-only replies</td>
<td>No</td>
<td>Yes</td>
</tr>
<tr>
<td>System prompt</td>
<td><code>catalog.prompt()</code></td>
<td><code>{"catalog.prompt({ mode: \"chat\" })"}</code></td>
</tr>
<tr>
<td>Stream utility</td>
<td><code>useUIStream</code></td>
<td><code>pipeJsonRender</code> + <code>useJsonRenderMessage</code></td>
</tr>
<tr>
<td>Use case</td>
<td>Playgrounds, builders</td>
<td>Chatbots, copilots</td>
</tr>
</tbody>
</table>
</div>
Learn more in the [Generation Modes](/docs/generation-modes) guide.
@@ -4,6 +4,68 @@ export const metadata = { title: "Changelog" }
Notable changes and updates to json-render.
## v0.8.0
February 2026
### New: `@json-render/react-pdf`
PDF renderer for json-render, powered by [`@react-pdf/renderer`](https://react-pdf.org/). Define catalogs and registries the same way as `@json-render/react`, but output PDF documents instead of web UI.
```bash
npm install @json-render/core @json-render/react-pdf
```
```typescript
import { renderToBuffer } from "@json-render/react-pdf";
import type { Spec } from "@json-render/core";
const spec: Spec = {
root: "doc",
elements: {
doc: { type: "Document", props: { title: "Invoice" }, children: ["page"] },
page: {
type: "Page",
props: { size: "A4" },
children: ["heading", "table"],
},
heading: {
type: "Heading",
props: { text: "Invoice #1234", level: "h1" },
children: [],
},
table: {
type: "Table",
props: {
columns: [
{ header: "Item", width: "60%" },
{ header: "Price", width: "40%", align: "right" },
],
rows: [
["Widget A", "$10.00"],
["Widget B", "$25.00"],
],
},
children: [],
},
},
};
const buffer = await renderToBuffer(spec);
```
Server-side rendering APIs:
- `renderToBuffer(spec)` -- render to an in-memory PDF buffer
- `renderToStream(spec)` -- render to a readable stream (pipe to HTTP response)
- `renderToFile(spec, path)` -- render directly to a file
15 standard components covering document structure (Document, Page), layout (View, Row, Column), content (Heading, Text, Image, Link), data (Table, List), decorative (Divider, Spacer), and page-level (PageNumber).
Supports custom catalogs with `defineRegistry`, server-safe imports via `@json-render/react-pdf/server`, and full context support (state, visibility, actions, validation, repeat scopes).
---
## v0.7.0
February 2026
+1 -1
View File
@@ -100,7 +100,7 @@ export function Header() {
>
<path d="M8 0C3.58 0 0 3.58 0 8c0 3.54 2.29 6.53 5.47 7.59.4.07.55-.17.55-.38 0-.19-.01-.82-.01-1.49-2.01.37-2.53-.49-2.69-.94-.09-.23-.48-.94-.82-1.13-.28-.15-.68-.52-.01-.53.63-.01 1.08.58 1.23.82.72 1.21 1.87.87 2.33.66.07-.52.28-.87.51-1.07-1.78-.2-3.64-.89-3.64-3.95 0-.87.31-1.59.82-2.15-.08-.2-.36-1.02.08-2.12 0 0 .67-.21 2.2.82.64-.18 1.32-.27 2-.27.68 0 1.36.09 2 .27 1.53-1.04 2.2-.82 2.2-.82.44 1.1.16 1.92.08 2.12.51.56.82 1.27.82 2.15 0 3.07-1.87 3.75-3.65 3.95.29.25.54.73.54 1.48 0 1.07-.01 1.93-.01 2.2 0 .21.15.46.55.38A8.013 8.013 0 0016 8c0-4.42-3.58-8-8-8z" />
</svg>
<span>10.5k</span>
<span>11k</span>
</a>
<ThemeToggle />
</nav>
+1 -1
View File
@@ -1,6 +1,6 @@
/// <reference types="next" />
/// <reference types="next/image-types/global" />
import "./.next/dev/types/routes.d.ts";
import "./.next/types/routes.d.ts";
// NOTE: This file should not be edited
// see https://nextjs.org/docs/app/api-reference/config/typescript for more information.
+1 -1
View File
@@ -1,6 +1,6 @@
/// <reference types="next" />
/// <reference types="next/image-types/global" />
import "./.next/dev/types/routes.d.ts";
import "./.next/types/routes.d.ts";
// NOTE: This file should not be edited
// see https://nextjs.org/docs/app/api-reference/config/typescript for more information.
+105
View File
@@ -0,0 +1,105 @@
@import "tailwindcss";
@import "tw-animate-css";
@source "../../../packages/shadcn/src/**/*.tsx";
@custom-variant dark (&:is(.dark *));
@theme inline {
--radius-sm: calc(var(--radius) - 4px);
--radius-md: calc(var(--radius) - 2px);
--radius-lg: var(--radius);
--radius-xl: calc(var(--radius) + 4px);
--radius-2xl: calc(var(--radius) + 8px);
--radius-3xl: calc(var(--radius) + 12px);
--radius-4xl: calc(var(--radius) + 16px);
--color-background: var(--background);
--color-foreground: var(--foreground);
--color-card: var(--card);
--color-card-foreground: var(--card-foreground);
--color-popover: var(--popover);
--color-popover-foreground: var(--popover-foreground);
--color-primary: var(--primary);
--color-primary-foreground: var(--primary-foreground);
--color-secondary: var(--secondary);
--color-secondary-foreground: var(--secondary-foreground);
--color-muted: var(--muted);
--color-muted-foreground: var(--muted-foreground);
--color-accent: var(--accent);
--color-accent-foreground: var(--accent-foreground);
--color-destructive: var(--destructive);
--color-border: var(--border);
--color-input: var(--input);
--color-ring: var(--ring);
--color-chart-1: var(--chart-1);
--color-chart-2: var(--chart-2);
--color-chart-3: var(--chart-3);
--color-chart-4: var(--chart-4);
--color-chart-5: var(--chart-5);
}
:root {
--radius: 0.625rem;
--background: oklch(1 0 0);
--foreground: oklch(0.145 0 0);
--card: oklch(1 0 0);
--card-foreground: oklch(0.145 0 0);
--popover: oklch(1 0 0);
--popover-foreground: oklch(0.145 0 0);
--primary: oklch(0.205 0 0);
--primary-foreground: oklch(0.985 0 0);
--secondary: oklch(0.97 0 0);
--secondary-foreground: oklch(0.205 0 0);
--muted: oklch(0.97 0 0);
--muted-foreground: oklch(0.556 0 0);
--accent: oklch(0.97 0 0);
--accent-foreground: oklch(0.205 0 0);
--destructive: oklch(0.577 0.245 27.325);
--border: oklch(0.922 0 0);
--input: oklch(0.922 0 0);
--ring: oklch(0.708 0 0);
--chart-1: oklch(0.646 0.222 41.116);
--chart-2: oklch(0.6 0.118 184.704);
--chart-3: oklch(0.398 0.07 227.392);
--chart-4: oklch(0.828 0.189 84.429);
--chart-5: oklch(0.769 0.188 70.08);
}
.dark {
--background: oklch(0.145 0 0);
--foreground: oklch(0.985 0 0);
--card: oklch(0.205 0 0);
--card-foreground: oklch(0.985 0 0);
--popover: oklch(0.205 0 0);
--popover-foreground: oklch(0.985 0 0);
--primary: oklch(0.922 0 0);
--primary-foreground: oklch(0.205 0 0);
--secondary: oklch(0.269 0 0);
--secondary-foreground: oklch(0.985 0 0);
--muted: oklch(0.269 0 0);
--muted-foreground: oklch(0.708 0 0);
--accent: oklch(0.269 0 0);
--accent-foreground: oklch(0.985 0 0);
--destructive: oklch(0.704 0.191 22.216);
--border: oklch(1 0 0 / 10%);
--input: oklch(1 0 0 / 15%);
--ring: oklch(0.556 0 0);
--chart-1: oklch(0.488 0.243 264.376);
--chart-2: oklch(0.696 0.17 162.48);
--chart-3: oklch(0.769 0.188 70.08);
--chart-4: oklch(0.627 0.265 303.9);
--chart-5: oklch(0.645 0.246 16.439);
}
@layer base {
* {
@apply border-border outline-ring/50;
}
body {
@apply bg-background text-foreground;
}
}
button {
cursor: pointer;
}
+34
View File
@@ -0,0 +1,34 @@
import type { Metadata } from "next";
import { Geist, Geist_Mono } from "next/font/google";
import "./globals.css";
const geistSans = Geist({
variable: "--font-geist-sans",
subsets: ["latin"],
});
const geistMono = Geist_Mono({
variable: "--font-geist-mono",
subsets: ["latin"],
});
export const metadata: Metadata = {
title: "json-render No-AI Example",
description: "Static JSON specs rendered with json-render -- no AI required",
};
export default function RootLayout({
children,
}: {
children: React.ReactNode;
}) {
return (
<html lang="en">
<body
className={`${geistSans.variable} ${geistMono.variable} font-sans antialiased`}
>
{children}
</body>
</html>
);
}
+72
View File
@@ -0,0 +1,72 @@
"use client";
import { useState, useEffect, useCallback, type ReactNode } from "react";
import ConfettiExplosion from "react-confetti-explosion";
import {
Renderer,
StateProvider,
VisibilityProvider,
ActionProvider,
ValidationProvider,
} from "@json-render/react";
import type { Spec } from "@json-render/core";
import { registry, actionHandlers, onConfetti } from "@/lib/render/registry";
import { examples } from "@/lib/examples";
function SpecRenderer({ spec }: { spec: Spec }): ReactNode {
return (
<StateProvider initialState={spec.state ?? {}}>
<VisibilityProvider>
<ActionProvider handlers={actionHandlers}>
<ValidationProvider>
<Renderer spec={spec} registry={registry} />
</ValidationProvider>
</ActionProvider>
</VisibilityProvider>
</StateProvider>
);
}
export default function Page() {
const [selectedIndex] = useState(0);
const selected = examples[selectedIndex]!;
const [confettiKey, setConfettiKey] = useState(0);
const [confettiActive, setConfettiActive] = useState(false);
const fireConfetti = useCallback(() => {
setConfettiKey((k) => k + 1);
setConfettiActive(true);
}, []);
useEffect(() => onConfetti(fireConfetti), [fireConfetti]);
return (
<div className="h-screen flex items-center justify-center bg-muted/30">
<div
className="relative bg-background border rounded-lg shadow-sm"
style={{ width: 960, height: 1080 }}
>
{confettiActive && (
<div className="absolute inset-0 flex items-center justify-center pointer-events-none">
<ConfettiExplosion
key={confettiKey}
portal={false}
force={0.8}
duration={3500}
particleCount={400}
particleSize={8}
colors={["#00F0FF", "#7B61FF", "#FF3DFF", "#00FF94", "#FFE14D"]}
width={1600}
height="200vh"
zIndex={1}
onComplete={() => setConfettiActive(false)}
/>
</div>
)}
<div className="h-full overflow-auto p-6 flex items-center justify-center relative z-10">
<SpecRenderer key={selectedIndex} spec={selected.spec} />
</div>
</div>
</div>
);
}
+12
View File
@@ -0,0 +1,12 @@
import { nextJsConfig } from "@repo/eslint-config/next-js";
/** @type {import("eslint").Linter.Config[]} */
export default [
...nextJsConfig,
{
rules: {
"react/prop-types": "off",
"react/no-unknown-property": ["error", { ignore: ["jsx"] }],
},
},
];
+511
View File
@@ -0,0 +1,511 @@
import type { Spec } from "@json-render/core";
export interface Example {
name: string;
description: string;
spec: Spec;
}
export const examples: Example[] = [
{
name: "Confetti",
description: "A button that fires confetti when clicked",
spec: {
root: "btn",
elements: {
btn: {
type: "Button",
props: { label: "Click me", variant: "primary" },
on: { press: { action: "confetti" } },
},
},
},
},
{
name: "User Profile Card",
description: "A card with user info, badges, and a progress bar",
spec: {
root: "card",
elements: {
card: {
type: "Card",
props: {
title: "User Profile",
description: null,
maxWidth: "md",
centered: null,
},
children: ["stack"],
},
stack: {
type: "Stack",
props: {
direction: "vertical",
gap: "md",
align: null,
justify: null,
},
children: ["heading", "text", "badges", "sep", "progress"],
},
heading: {
type: "Heading",
props: { text: "Jane Cooper", level: "h2" },
},
text: {
type: "Text",
props: {
text: "Senior software engineer based in San Francisco. Passionate about building accessible, high-performance web applications.",
variant: "muted",
},
},
badges: {
type: "Stack",
props: {
direction: "horizontal",
gap: "sm",
align: null,
justify: null,
},
children: ["b1", "b2", "b3"],
},
b1: {
type: "Badge",
props: { text: "TypeScript", variant: "default" },
},
b2: { type: "Badge", props: { text: "React", variant: "secondary" } },
b3: { type: "Badge", props: { text: "Node.js", variant: "outline" } },
sep: { type: "Separator", props: { orientation: null } },
progress: {
type: "Progress",
props: { value: 72, max: 100, label: "Profile completion" },
},
},
},
},
{
name: "Settings Form",
description: "A form with inputs, selects, switches, and checkboxes",
spec: {
root: "card",
state: {
name: "Ada Lovelace",
email: "ada@example.com",
role: "Engineer",
notifications: true,
darkMode: false,
},
elements: {
card: {
type: "Card",
props: {
title: "Account Settings",
description: "Manage your preferences",
maxWidth: "md",
centered: null,
},
children: ["form"],
},
form: {
type: "Stack",
props: {
direction: "vertical",
gap: "md",
align: null,
justify: null,
},
children: [
"nameInput",
"emailInput",
"roleSelect",
"sep",
"notifSwitch",
"darkSwitch",
"sep2",
"actions",
],
},
nameInput: {
type: "Input",
props: {
label: "Full Name",
name: "name",
type: "text",
placeholder: "Your name",
value: { $bindState: "/name" },
checks: null,
},
},
emailInput: {
type: "Input",
props: {
label: "Email",
name: "email",
type: "email",
placeholder: "you@example.com",
value: { $bindState: "/email" },
checks: null,
},
},
roleSelect: {
type: "Select",
props: {
label: "Role",
name: "role",
options: [
"Engineer",
"Designer",
"Product Manager",
"Data Scientist",
],
placeholder: "Choose a role",
value: { $bindState: "/role" },
checks: null,
},
},
sep: { type: "Separator", props: { orientation: null } },
notifSwitch: {
type: "Switch",
props: {
label: "Email notifications",
name: "notifications",
checked: { $bindState: "/notifications" },
},
},
darkSwitch: {
type: "Switch",
props: {
label: "Dark mode",
name: "darkMode",
checked: { $bindState: "/darkMode" },
},
},
sep2: { type: "Separator", props: { orientation: null } },
actions: {
type: "Stack",
props: {
direction: "horizontal",
gap: "sm",
align: null,
justify: "end",
},
children: ["cancelBtn", "saveBtn"],
},
cancelBtn: {
type: "Button",
props: { label: "Cancel", variant: "secondary", disabled: null },
},
saveBtn: {
type: "Button",
props: { label: "Save Changes", variant: "primary", disabled: null },
},
},
},
},
{
name: "Pricing Table",
description: "A grid of pricing cards with feature lists",
spec: {
root: "outer",
elements: {
outer: {
type: "Stack",
props: {
direction: "vertical",
gap: "lg",
align: null,
justify: null,
},
children: ["header", "grid"],
},
header: {
type: "Stack",
props: {
direction: "vertical",
gap: "sm",
align: "center",
justify: null,
},
children: ["title", "subtitle"],
},
title: {
type: "Heading",
props: { text: "Simple, transparent pricing", level: "h1" },
},
subtitle: {
type: "Text",
props: {
text: "Choose the plan that fits your needs. Upgrade or downgrade at any time.",
variant: "muted",
},
},
grid: {
type: "Grid",
props: { columns: 3, gap: "md" },
children: ["free", "pro", "enterprise"],
},
free: {
type: "Card",
props: {
title: "Free",
description: "$0/month",
maxWidth: null,
centered: null,
},
children: ["freeContent"],
},
freeContent: {
type: "Stack",
props: {
direction: "vertical",
gap: "sm",
align: null,
justify: null,
},
children: ["f1", "f2", "f3", "freeBtn"],
},
f1: {
type: "Text",
props: { text: "Up to 3 projects", variant: "body" },
},
f2: { type: "Text", props: { text: "1 GB storage", variant: "body" } },
f3: {
type: "Text",
props: { text: "Community support", variant: "body" },
},
freeBtn: {
type: "Button",
props: { label: "Get Started", variant: "secondary", disabled: null },
},
pro: {
type: "Card",
props: {
title: "Pro",
description: "$19/month",
maxWidth: null,
centered: null,
},
children: ["proContent"],
},
proContent: {
type: "Stack",
props: {
direction: "vertical",
gap: "sm",
align: null,
justify: null,
},
children: ["p1", "p2", "p3", "p4", "proBtn"],
},
p1: {
type: "Text",
props: { text: "Unlimited projects", variant: "body" },
},
p2: { type: "Text", props: { text: "50 GB storage", variant: "body" } },
p3: {
type: "Text",
props: { text: "Priority support", variant: "body" },
},
p4: {
type: "Text",
props: { text: "Custom domains", variant: "body" },
},
proBtn: {
type: "Button",
props: {
label: "Upgrade to Pro",
variant: "primary",
disabled: null,
},
},
enterprise: {
type: "Card",
props: {
title: "Enterprise",
description: "Custom pricing",
maxWidth: null,
centered: null,
},
children: ["entContent"],
},
entContent: {
type: "Stack",
props: {
direction: "vertical",
gap: "sm",
align: null,
justify: null,
},
children: ["e1", "e2", "e3", "e4", "entBtn"],
},
e1: {
type: "Text",
props: { text: "Everything in Pro", variant: "body" },
},
e2: {
type: "Text",
props: { text: "Unlimited storage", variant: "body" },
},
e3: {
type: "Text",
props: { text: "Dedicated support", variant: "body" },
},
e4: {
type: "Text",
props: { text: "SLA guarantees", variant: "body" },
},
entBtn: {
type: "Button",
props: {
label: "Contact Sales",
variant: "secondary",
disabled: null,
},
},
},
},
},
{
name: "Status Dashboard",
description: "System status with alerts, a table, and an accordion",
spec: {
root: "root",
elements: {
root: {
type: "Stack",
props: {
direction: "vertical",
gap: "lg",
align: null,
justify: null,
},
children: [
"heading",
"alertOk",
"alertWarn",
"sep",
"table",
"sep2",
"accordion",
],
},
heading: {
type: "Heading",
props: { text: "System Status", level: "h1" },
},
alertOk: {
type: "Alert",
props: {
title: "API",
message: "All systems operational -- 99.98% uptime this month",
type: "success",
},
},
alertWarn: {
type: "Alert",
props: {
title: "Database",
message: "Elevated latency detected in us-east-1 region",
type: "warning",
},
},
sep: { type: "Separator", props: { orientation: null } },
table: {
type: "Table",
props: {
columns: ["Service", "Status", "Latency", "Uptime"],
rows: [
["API Gateway", "Operational", "12ms", "99.99%"],
["Auth Service", "Operational", "8ms", "99.98%"],
["Database", "Degraded", "145ms", "99.85%"],
["CDN", "Operational", "3ms", "100%"],
["Workers", "Operational", "22ms", "99.97%"],
],
caption: "Last updated 2 minutes ago",
},
},
sep2: { type: "Separator", props: { orientation: null } },
accordion: {
type: "Accordion",
props: {
items: [
{
title: "What does 'Degraded' mean?",
content:
"The service is still operational but experiencing slower response times than normal. We are actively investigating.",
},
{
title: "How is uptime calculated?",
content:
"Uptime is measured over the current calendar month based on successful health-check pings every 30 seconds.",
},
{
title: "How do I subscribe to updates?",
content:
"You can subscribe to incident notifications via email or webhook on the Settings page.",
},
],
type: "single",
},
},
},
},
},
{
name: "Feature Comparison",
description: "Tables inside an accordion for comparing plan tiers",
spec: {
root: "root",
elements: {
root: {
type: "Stack",
props: {
direction: "vertical",
gap: "lg",
align: null,
justify: null,
},
children: ["heading", "subtitle", "accordion"],
},
heading: {
type: "Heading",
props: { text: "Feature Comparison", level: "h1" },
},
subtitle: {
type: "Text",
props: {
text: "Expand each section to see how plans compare.",
variant: "muted",
},
},
accordion: {
type: "Accordion",
props: {
items: [
{
title: "Projects & Team",
content:
"Free: 3 projects, 1 member | Pro: Unlimited projects, 10 members | Enterprise: Unlimited everything",
},
{
title: "Storage & Bandwidth",
content:
"Free: 1 GB storage, 10 GB/mo bandwidth | Pro: 50 GB storage, 500 GB/mo | Enterprise: Unlimited",
},
{
title: "Support & SLA",
content:
"Free: Forum, no SLA | Pro: Email + Chat, 99.9% SLA | Enterprise: Dedicated Slack, 99.99% SLA",
},
],
type: "single",
},
},
},
},
},
];
+16
View File
@@ -0,0 +1,16 @@
import { defineCatalog } from "@json-render/core";
import { schema } from "@json-render/react/schema";
import { shadcnComponentDefinitions } from "@json-render/shadcn/catalog";
import { z } from "zod";
export const catalog = defineCatalog(schema, {
components: {
...shadcnComponentDefinitions,
},
actions: {
confetti: {
params: z.object({}),
description: "Fire confetti",
},
},
});
+29
View File
@@ -0,0 +1,29 @@
"use client";
import { defineRegistry } from "@json-render/react";
import { shadcnComponents } from "@json-render/shadcn";
import { catalog } from "./catalog";
let confettiListener: (() => void) | null = null;
export function onConfetti(cb: () => void) {
confettiListener = cb;
return () => {
confettiListener = null;
};
}
export const { registry } = defineRegistry(catalog, {
components: {
...shadcnComponents,
},
actions: {
confetti: async () => {
confettiListener?.();
},
},
});
export const actionHandlers: Record<string, () => void> = {
confetti: () => confettiListener?.(),
};
+6
View File
@@ -0,0 +1,6 @@
/// <reference types="next" />
/// <reference types="next/image-types/global" />
import "./.next/types/routes.d.ts";
// NOTE: This file should not be edited
// see https://nextjs.org/docs/app/api-reference/config/typescript for more information.
+5
View File
@@ -0,0 +1,5 @@
import type { NextConfig } from "next";
const nextConfig: NextConfig = {};
export default nextConfig;
+40
View File
@@ -0,0 +1,40 @@
{
"name": "example-no-ai",
"version": "0.1.0",
"type": "module",
"private": true,
"scripts": {
"dev": "next dev --turbopack --port 3003",
"build": "next build",
"start": "next start",
"lint": "eslint --max-warnings 0",
"check-types": "tsc --noEmit"
},
"dependencies": {
"@json-render/core": "workspace:*",
"@json-render/react": "workspace:*",
"@json-render/shadcn": "workspace:*",
"class-variance-authority": "^0.7.1",
"clsx": "^2.1.1",
"lucide-react": "^0.563.0",
"next": "16.1.6",
"radix-ui": "^1.4.3",
"react": "19.2.4",
"react-dom": "19.2.4",
"react-confetti-explosion": "^3.0.3",
"tailwind-merge": "^3.4.0",
"zod": "4.3.5"
},
"devDependencies": {
"@repo/eslint-config": "workspace:*",
"@tailwindcss/postcss": "^4.1.18",
"@types/node": "^22.10.0",
"@types/react": "19.2.3",
"@types/react-dom": "19.2.3",
"eslint": "^9.39.1",
"postcss": "^8.5.6",
"tailwindcss": "^4.1.18",
"tw-animate-css": "^1.4.0",
"typescript": "^5.7.2"
}
}
+5
View File
@@ -0,0 +1,5 @@
export default {
plugins: {
"@tailwindcss/postcss": {},
},
};
+13
View File
@@ -0,0 +1,13 @@
{
"extends": "../../packages/typescript-config/nextjs.json",
"compilerOptions": {
"plugins": [{ "name": "next" }],
"declaration": false,
"declarationMap": false,
"paths": {
"@/*": ["./*"]
}
},
"include": ["next-env.d.ts", "**/*.ts", "**/*.tsx", ".next/types/**/*.ts"],
"exclude": ["node_modules"]
}
+5
View File
@@ -0,0 +1,5 @@
# Required: AI Gateway API key
AI_GATEWAY_API_KEY=
# Optional: override the default model (default: anthropic/claude-haiku-4.5)
# AI_GATEWAY_MODEL=openai/gpt-4o-mini
@@ -0,0 +1,35 @@
import { streamText } from "ai";
import { gateway } from "@ai-sdk/gateway";
import { buildUserPrompt, type Spec } from "@json-render/core";
import { pdfCatalog } from "@/lib/catalog";
export const maxDuration = 60;
const SYSTEM_PROMPT = pdfCatalog.prompt();
const DEFAULT_MODEL = "anthropic/claude-haiku-4.5";
export async function POST(req: Request) {
const { prompt, startingSpec } = (await req.json()) as {
prompt: string;
startingSpec?: Spec | null;
};
if (!prompt || typeof prompt !== "string") {
return Response.json({ error: "prompt is required" }, { status: 400 });
}
const userPrompt = buildUserPrompt({
prompt,
currentSpec: startingSpec,
});
const result = streamText({
model: gateway(process.env.AI_GATEWAY_MODEL ?? DEFAULT_MODEL),
system: SYSTEM_PROMPT,
prompt: userPrompt,
temperature: 0.7,
});
return result.toTextStreamResponse();
}
+46
View File
@@ -0,0 +1,46 @@
import { renderToBuffer } from "@json-render/react-pdf/render";
import { examples } from "@/lib/examples";
import type { Spec } from "@json-render/core";
export async function GET(req: Request) {
const { searchParams } = new URL(req.url);
const name = searchParams.get("name") ?? "invoice";
const download = searchParams.get("download") === "1";
const example = examples.find((e) => e.name === name);
if (!example) {
return new Response("Example not found", { status: 404 });
}
return pdfResponse(example.spec, name, download);
}
export async function POST(req: Request) {
const { spec, download, filename } = (await req.json()) as {
spec: Spec;
download?: boolean;
filename?: string;
};
if (!spec || !spec.root || !spec.elements) {
return new Response("Invalid spec", { status: 400 });
}
return pdfResponse(spec, filename ?? "document", download ?? false);
}
async function pdfResponse(spec: Spec, name: string, download: boolean) {
const buffer = await renderToBuffer(spec);
const disposition = download
? `attachment; filename="${name}.pdf"`
: `inline; filename="${name}.pdf"`;
return new Response(buffer as unknown as ArrayBuffer, {
headers: {
"Content-Type": "application/pdf",
"Content-Disposition": disposition,
"Cache-Control": "no-store",
},
});
}
+130
View File
@@ -0,0 +1,130 @@
@import "tailwindcss";
@import "tw-animate-css";
@import "shadcn/tailwind.css";
@custom-variant dark (&:is(.dark *));
@theme inline {
--radius-sm: calc(var(--radius) - 4px);
--radius-md: calc(var(--radius) - 2px);
--radius-lg: var(--radius);
--radius-xl: calc(var(--radius) + 4px);
--radius-2xl: calc(var(--radius) + 8px);
--radius-3xl: calc(var(--radius) + 12px);
--radius-4xl: calc(var(--radius) + 16px);
--color-background: var(--background);
--color-foreground: var(--foreground);
--color-card: var(--card);
--color-card-foreground: var(--card-foreground);
--color-popover: var(--popover);
--color-popover-foreground: var(--popover-foreground);
--color-primary: var(--primary);
--color-primary-foreground: var(--primary-foreground);
--color-secondary: var(--secondary);
--color-secondary-foreground: var(--secondary-foreground);
--color-muted: var(--muted);
--color-muted-foreground: var(--muted-foreground);
--color-accent: var(--accent);
--color-accent-foreground: var(--accent-foreground);
--color-destructive: var(--destructive);
--color-border: var(--border);
--color-input: var(--input);
--color-ring: var(--ring);
--color-chart-1: var(--chart-1);
--color-chart-2: var(--chart-2);
--color-chart-3: var(--chart-3);
--color-chart-4: var(--chart-4);
--color-chart-5: var(--chart-5);
--color-sidebar: var(--sidebar);
--color-sidebar-foreground: var(--sidebar-foreground);
--color-sidebar-primary: var(--sidebar-primary);
--color-sidebar-primary-foreground: var(--sidebar-primary-foreground);
--color-sidebar-accent: var(--sidebar-accent);
--color-sidebar-accent-foreground: var(--sidebar-accent-foreground);
--color-sidebar-border: var(--sidebar-border);
--color-sidebar-ring: var(--sidebar-ring);
}
:root {
--radius: 0.625rem;
--background: oklch(1 0 0);
--foreground: oklch(0.145 0 0);
--card: oklch(1 0 0);
--card-foreground: oklch(0.145 0 0);
--popover: oklch(1 0 0);
--popover-foreground: oklch(0.145 0 0);
--primary: oklch(0.205 0 0);
--primary-foreground: oklch(0.985 0 0);
--secondary: oklch(0.97 0 0);
--secondary-foreground: oklch(0.205 0 0);
--muted: oklch(0.97 0 0);
--muted-foreground: oklch(0.556 0 0);
--accent: oklch(0.97 0 0);
--accent-foreground: oklch(0.205 0 0);
--destructive: oklch(0.577 0.245 27.325);
--border: oklch(0.922 0 0);
--input: oklch(0.922 0 0);
--ring: oklch(0.708 0 0);
--chart-1: oklch(0.646 0.222 41.116);
--chart-2: oklch(0.6 0.118 184.704);
--chart-3: oklch(0.398 0.07 227.392);
--chart-4: oklch(0.828 0.189 84.429);
--chart-5: oklch(0.769 0.188 70.08);
--sidebar: oklch(0.985 0 0);
--sidebar-foreground: oklch(0.145 0 0);
--sidebar-primary: oklch(0.205 0 0);
--sidebar-primary-foreground: oklch(0.985 0 0);
--sidebar-accent: oklch(0.97 0 0);
--sidebar-accent-foreground: oklch(0.205 0 0);
--sidebar-border: oklch(0.922 0 0);
--sidebar-ring: oklch(0.708 0 0);
}
.dark {
--background: oklch(0.145 0 0);
--foreground: oklch(0.985 0 0);
--card: oklch(0.205 0 0);
--card-foreground: oklch(0.985 0 0);
--popover: oklch(0.205 0 0);
--popover-foreground: oklch(0.985 0 0);
--primary: oklch(0.922 0 0);
--primary-foreground: oklch(0.205 0 0);
--secondary: oklch(0.269 0 0);
--secondary-foreground: oklch(0.985 0 0);
--muted: oklch(0.269 0 0);
--muted-foreground: oklch(0.708 0 0);
--accent: oklch(0.269 0 0);
--accent-foreground: oklch(0.985 0 0);
--destructive: oklch(0.704 0.191 22.216);
--border: oklch(1 0 0 / 10%);
--input: oklch(1 0 0 / 15%);
--ring: oklch(0.556 0 0);
--chart-1: oklch(0.488 0.243 264.376);
--chart-2: oklch(0.696 0.17 162.48);
--chart-3: oklch(0.769 0.188 70.08);
--chart-4: oklch(0.627 0.265 303.9);
--chart-5: oklch(0.645 0.246 16.439);
--sidebar: oklch(0.205 0 0);
--sidebar-foreground: oklch(0.985 0 0);
--sidebar-primary: oklch(0.488 0.243 264.376);
--sidebar-primary-foreground: oklch(0.985 0 0);
--sidebar-accent: oklch(0.269 0 0);
--sidebar-accent-foreground: oklch(0.985 0 0);
--sidebar-border: oklch(1 0 0 / 10%);
--sidebar-ring: oklch(0.556 0 0);
}
@layer base {
* {
@apply border-border outline-ring/50;
}
body {
@apply bg-background text-foreground;
}
}
@keyframes spin {
to {
transform: rotate(360deg);
}
}
+23
View File
@@ -0,0 +1,23 @@
import type { Metadata } from "next";
import { Inter } from "next/font/google";
import "./globals.css";
const inter = Inter({ subsets: ["latin"] });
export const metadata: Metadata = {
title: "json-render React PDF Example",
description:
"Generate PDF documents from JSON specs with @json-render/react-pdf",
};
export default function RootLayout({
children,
}: {
children: React.ReactNode;
}) {
return (
<html lang="en">
<body className={inter.className}>{children}</body>
</html>
);
}
+615
View File
@@ -0,0 +1,615 @@
"use client";
import { useState, useCallback, useRef, useEffect } from "react";
import { examples } from "@/lib/examples";
import { createSpecStreamCompiler } from "@json-render/core";
import type { Spec } from "@json-render/core";
import { cn } from "@/lib/utils";
import { ScrollArea } from "@/components/ui/scroll-area";
import { Sheet, SheetContent, SheetTitle } from "@/components/ui/sheet";
import {
ResizablePanelGroup,
ResizablePanel,
ResizableHandle,
} from "@/components/ui/resizable";
import { FileText, Download, Loader2, ArrowRight, Square } from "lucide-react";
type Mode = "scratch" | "example";
type MobileView = "json" | "preview";
interface Selection {
mode: Mode;
exampleName?: string;
}
const PDF_REFRESH_INTERVAL_MS = 2000;
function CopyButton({ text }: { text: string }) {
const [copied, setCopied] = useState(false);
return (
<button
onClick={() => {
navigator.clipboard.writeText(text);
setCopied(true);
setTimeout(() => setCopied(false), 1500);
}}
className="text-xs text-muted-foreground hover:text-foreground transition-colors font-mono"
>
{copied ? "copied" : "copy"}
</button>
);
}
function isRenderableSpec(spec: Spec | null): spec is Spec {
if (!spec?.root || !spec.elements) return false;
const root = spec.elements[spec.root];
if (!root) return false;
if (root.type !== "Document" || !root.children?.length) return false;
const firstChild = spec.elements[root.children[0]!];
return firstChild?.type === "Page";
}
export default function Page() {
const [selection, setSelection] = useState<Selection>({
mode: "example",
exampleName: examples[0]!.name,
});
const [prompt, setPrompt] = useState("");
const [generating, setGenerating] = useState(false);
const [generatedSpec, setGeneratedSpec] = useState<Spec | null>(null);
const [pdfUrl, setPdfUrl] = useState<string | null>(null);
const [error, setError] = useState<string | null>(null);
const [mobileView, setMobileView] = useState<MobileView>("preview");
const [examplesSheetOpen, setExamplesSheetOpen] = useState(false);
const [refreshing, setRefreshing] = useState(false);
const pdfUrlRef = useRef<string | null>(null);
const inputRef = useRef<HTMLTextAreaElement>(null);
const mobileInputRef = useRef<HTMLTextAreaElement>(null);
const abortRef = useRef<AbortController | null>(null);
const codeScrollRef = useRef<HTMLDivElement>(null);
const mobileCodeScrollRef = useRef<HTMLDivElement>(null);
useEffect(() => {
if (!generating) return;
codeScrollRef.current?.scrollTo({
top: codeScrollRef.current.scrollHeight,
});
mobileCodeScrollRef.current?.scrollTo({
top: mobileCodeScrollRef.current.scrollHeight,
});
}, [generating, generatedSpec]);
const currentExample =
selection.mode === "example"
? examples.find((e) => e.name === selection.exampleName)
: null;
const activeSpec = generatedSpec ?? currentExample?.spec ?? null;
const examplePdfUrl =
selection.mode === "example" && !generatedSpec
? `/api/pdf?name=${selection.exampleName}`
: null;
const displayPdfUrl = pdfUrl ?? examplePdfUrl;
useEffect(() => {
inputRef.current?.focus();
}, [selection.mode, selection.exampleName]);
const fetchPdfBlob = useCallback(async (spec: Spec, signal?: AbortSignal) => {
const res = await fetch("/api/pdf", {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({ spec }),
signal,
});
if (!res.ok) throw new Error("Failed to generate PDF");
const blob = await res.blob();
const url = URL.createObjectURL(blob);
const prev = pdfUrlRef.current;
pdfUrlRef.current = url;
setPdfUrl(url);
if (prev) URL.revokeObjectURL(prev);
}, []);
// Progressive PDF refresh during generation
const lastRefreshSpec = useRef<string>("");
const generatedSpecRef = useRef<Spec | null>(null);
generatedSpecRef.current = generatedSpec;
useEffect(() => {
if (!generating) return;
const interval = setInterval(() => {
const spec = generatedSpecRef.current;
if (!spec) return;
const specKey = JSON.stringify(spec);
if (specKey === lastRefreshSpec.current) return;
if (!isRenderableSpec(spec)) return;
lastRefreshSpec.current = specKey;
setRefreshing(true);
fetchPdfBlob(spec)
.catch(() => {})
.finally(() => setRefreshing(false));
}, PDF_REFRESH_INTERVAL_MS);
return () => clearInterval(interval);
}, [generating, fetchPdfBlob]);
const handleGenerate = useCallback(async () => {
if (!prompt.trim()) return;
abortRef.current?.abort();
const controller = new AbortController();
abortRef.current = controller;
setGenerating(true);
setError(null);
lastRefreshSpec.current = "";
try {
const startingSpec =
selection.mode === "example" && currentExample
? currentExample.spec
: null;
const res = await fetch("/api/generate", {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({ prompt: prompt.trim(), startingSpec }),
signal: controller.signal,
});
if (!res.ok) throw new Error("Generation failed");
const reader = res.body?.getReader();
if (!reader) throw new Error("No response body");
const decoder = new TextDecoder();
const compiler = createSpecStreamCompiler<Spec>(
startingSpec ? { ...startingSpec } : {},
);
while (true) {
const { done, value } = await reader.read();
if (done) break;
const chunk = decoder.decode(value, { stream: true });
const { result, newPatches } = compiler.push(chunk);
if (newPatches.length > 0) setGeneratedSpec(result);
}
const finalSpec = compiler.getResult();
setGeneratedSpec(finalSpec);
setGenerating(false);
await fetchPdfBlob(finalSpec);
} catch (e) {
if (controller.signal.aborted) return;
setError(e instanceof Error ? e.message : "Something went wrong");
setGenerating(false);
}
}, [prompt, selection, currentExample, fetchPdfBlob]);
const handleStop = useCallback(() => {
abortRef.current?.abort();
setGenerating(false);
if (isRenderableSpec(generatedSpec)) {
fetchPdfBlob(generatedSpec).catch(() => {});
}
}, [generatedSpec, fetchPdfBlob]);
const select = (next: Selection) => {
abortRef.current?.abort();
setSelection(next);
setGeneratedSpec(null);
setPdfUrl(null);
setError(null);
setPrompt("");
setGenerating(false);
setExamplesSheetOpen(false);
};
const handleDownload = async () => {
if (!activeSpec) return;
if (generatedSpec) {
const res = await fetch("/api/pdf", {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({ spec: generatedSpec, download: true }),
});
const blob = await res.blob();
const url = URL.createObjectURL(blob);
const a = document.createElement("a");
a.href = url;
a.download = "document.pdf";
a.click();
URL.revokeObjectURL(url);
} else if (selection.mode === "example") {
window.open(
`/api/pdf?name=${selection.exampleName}&download=1`,
"_blank",
);
}
};
const handleKeyDown = useCallback(
(e: React.KeyboardEvent) => {
if (e.key === "Enter" && !e.shiftKey) {
e.preventDefault();
handleGenerate();
}
},
[handleGenerate],
);
const jsonCode = activeSpec
? JSON.stringify(activeSpec, null, 2)
: "// select an example or generate a PDF";
// ---------------------------------------------------------------------------
// Pane: Chat / Examples
// ---------------------------------------------------------------------------
const chatPane = (
<div className="h-full flex flex-col">
<div className="border-b border-border px-3 h-9 flex items-center gap-2">
<FileText className="h-3.5 w-3.5 text-muted-foreground" />
<span className="text-xs font-mono text-muted-foreground">
json-render / react-pdf
</span>
</div>
<ScrollArea className="flex-1">
<div className="p-2 space-y-1">
<p className="px-2 pt-2 pb-1 text-[11px] font-mono text-muted-foreground">
start
</p>
<button
onClick={() => select({ mode: "scratch" })}
className={cn(
"w-full text-left px-3 py-2 rounded text-sm transition-colors",
selection.mode === "scratch"
? "bg-muted text-foreground"
: "text-muted-foreground hover:bg-muted/50 hover:text-foreground",
)}
>
<span className="font-medium">From scratch</span>
</button>
<p className="px-2 pt-3 pb-1 text-[11px] font-mono text-muted-foreground">
examples
</p>
{examples.map((ex) => (
<button
key={ex.name}
onClick={() => select({ mode: "example", exampleName: ex.name })}
className={cn(
"w-full text-left px-3 py-2 rounded text-sm transition-colors",
selection.mode === "example" &&
selection.exampleName === ex.name
? "bg-muted text-foreground"
: "text-muted-foreground hover:bg-muted/50 hover:text-foreground",
)}
>
<span className="font-medium">{ex.label}</span>
<p className="text-xs text-muted-foreground/70 mt-0.5 leading-snug">
{ex.description}
</p>
</button>
))}
</div>
</ScrollArea>
<div
className="border-t border-border p-3 cursor-text"
onMouseDown={(e) => {
const target = e.target as HTMLElement;
if (!target.closest("button") && target.tagName !== "TEXTAREA") {
e.preventDefault();
inputRef.current?.focus();
}
}}
>
{error && (
<div className="mb-2 rounded bg-destructive/10 px-3 py-1.5 text-xs text-destructive">
{error}
</div>
)}
<textarea
ref={inputRef}
value={prompt}
onChange={(e) => setPrompt(e.target.value)}
onKeyDown={handleKeyDown}
placeholder={
selection.mode === "scratch"
? "Describe the PDF you want..."
: `Modify the ${currentExample?.label ?? "example"}...`
}
className="w-full bg-background text-sm resize-none outline-none placeholder:text-muted-foreground/50"
rows={2}
autoFocus
/>
<div className="flex justify-between items-center mt-2">
<span className="text-[11px] text-muted-foreground">
{selection.mode === "example" && currentExample
? currentExample.label
: "scratch"}
</span>
{generating ? (
<button
onClick={handleStop}
className="w-7 h-7 rounded-full bg-primary text-primary-foreground flex items-center justify-center hover:bg-primary/90 transition-colors"
aria-label="Stop"
>
<Square className="h-3 w-3" fill="currentColor" />
</button>
) : (
<button
onClick={handleGenerate}
disabled={!prompt.trim()}
className="w-7 h-7 rounded-full bg-primary text-primary-foreground flex items-center justify-center hover:bg-primary/90 transition-colors disabled:opacity-30"
aria-label="Generate"
>
<ArrowRight className="h-3.5 w-3.5" />
</button>
)}
</div>
</div>
</div>
);
// ---------------------------------------------------------------------------
// Pane: JSON Spec
// ---------------------------------------------------------------------------
const codePane = (
<div className="h-full flex flex-col">
<div className="border-b border-border px-3 h-9 flex items-center gap-3">
<span className="text-xs font-mono text-foreground">json</span>
{generating && (
<Loader2 className="h-3 w-3 text-muted-foreground animate-spin" />
)}
<div className="flex-1" />
{activeSpec && <CopyButton text={jsonCode} />}
</div>
<div ref={codeScrollRef} className="flex-1 overflow-auto">
<pre className="p-3 text-xs leading-relaxed font-mono text-muted-foreground whitespace-pre">
{jsonCode}
</pre>
</div>
</div>
);
// ---------------------------------------------------------------------------
// Pane: PDF Preview
// ---------------------------------------------------------------------------
const previewPane = (
<div className="h-full flex flex-col">
<div className="border-b border-border px-3 h-9 flex items-center gap-3">
<span className="text-xs font-mono text-foreground">preview</span>
{(generating || refreshing) && (
<Loader2 className="h-3 w-3 text-muted-foreground animate-spin" />
)}
<div className="flex-1" />
{activeSpec && (
<button
onClick={handleDownload}
className="text-xs text-muted-foreground hover:text-foreground transition-colors font-mono flex items-center gap-1"
>
<Download className="h-3 w-3" />
download
</button>
)}
</div>
<div className="flex-1 relative bg-neutral-600">
{displayPdfUrl ? (
<iframe
key={displayPdfUrl}
src={displayPdfUrl}
className="h-full w-full border-none"
title="PDF preview"
/>
) : (
<div className="h-full flex flex-col items-center justify-center gap-2 text-neutral-400">
<FileText className="h-10 w-10" />
<p className="text-sm">
{selection.mode === "scratch"
? "Enter a prompt to generate a PDF"
: "Select an example to preview"}
</p>
</div>
)}
</div>
</div>
);
// ---------------------------------------------------------------------------
// Render
// ---------------------------------------------------------------------------
return (
<div className="h-dvh flex flex-col">
{/* Desktop: 3-pane resizable layout */}
<div className="hidden lg:flex flex-1 min-h-0">
<ResizablePanelGroup className="flex-1">
<ResizablePanel defaultSize={25} minSize={15}>
{chatPane}
</ResizablePanel>
<ResizableHandle />
<ResizablePanel defaultSize={35} minSize={20}>
{codePane}
</ResizablePanel>
<ResizableHandle />
<ResizablePanel defaultSize={40} minSize={20}>
{previewPane}
</ResizablePanel>
</ResizablePanelGroup>
</div>
{/* Mobile: toolbar + content + prompt */}
<div className="flex lg:hidden flex-col flex-1 min-h-0">
<div className="border-b border-border px-3 h-9 flex items-center gap-3 shrink-0">
<button
onClick={() => setExamplesSheetOpen(true)}
className="text-xs font-mono font-medium px-1.5 py-0.5 rounded bg-muted text-foreground shrink-0"
>
{selection.mode === "example" && currentExample
? currentExample.label
: "scratch"}
</button>
{(["json", "preview"] as const).map((tab) => (
<button
key={tab}
onClick={() => setMobileView(tab)}
className={cn(
"text-xs font-mono transition-colors shrink-0",
mobileView === tab
? "text-foreground"
: "text-muted-foreground hover:text-foreground",
)}
>
{tab}
</button>
))}
{(generating || refreshing) && (
<Loader2 className="h-3 w-3 text-muted-foreground animate-spin shrink-0" />
)}
<div className="flex-1" />
{activeSpec && (
<button
onClick={handleDownload}
className="text-xs text-muted-foreground hover:text-foreground transition-colors font-mono flex items-center gap-1"
>
<Download className="h-3 w-3" />
</button>
)}
</div>
<div ref={mobileCodeScrollRef} className="flex-1 min-h-0 overflow-auto">
{mobileView === "json" ? (
<pre className="p-3 text-xs leading-relaxed font-mono text-muted-foreground whitespace-pre">
{jsonCode}
</pre>
) : (
<div className="h-full relative bg-neutral-600">
{displayPdfUrl ? (
<iframe
key={displayPdfUrl}
src={displayPdfUrl}
className="h-full w-full border-none"
title="PDF preview"
/>
) : (
<div className="h-full flex flex-col items-center justify-center gap-2 text-neutral-400">
<FileText className="h-10 w-10" />
<p className="text-sm">Enter a prompt to generate a PDF</p>
</div>
)}
</div>
)}
</div>
<div
className="border-t border-border p-3 shrink-0 cursor-text"
onMouseDown={(e) => {
const target = e.target as HTMLElement;
if (!target.closest("button") && target.tagName !== "TEXTAREA") {
e.preventDefault();
mobileInputRef.current?.focus();
}
}}
>
<textarea
ref={mobileInputRef}
value={prompt}
onChange={(e) => setPrompt(e.target.value)}
onKeyDown={handleKeyDown}
placeholder={
selection.mode === "scratch"
? "Describe the PDF you want..."
: `Modify the ${currentExample?.label ?? "example"}...`
}
className="w-full bg-background text-base resize-none outline-none placeholder:text-muted-foreground/50"
rows={2}
/>
<div className="flex justify-between items-center mt-2">
<span className="text-[11px] text-muted-foreground">
{selection.mode === "example" && currentExample
? currentExample.label
: "scratch"}
</span>
{generating ? (
<button
onClick={handleStop}
className="w-7 h-7 rounded-full bg-primary text-primary-foreground flex items-center justify-center hover:bg-primary/90 transition-colors"
aria-label="Stop"
>
<Square className="h-3 w-3" fill="currentColor" />
</button>
) : (
<button
onClick={handleGenerate}
disabled={!prompt.trim()}
className="w-7 h-7 rounded-full bg-primary text-primary-foreground flex items-center justify-center hover:bg-primary/90 transition-colors disabled:opacity-30"
aria-label="Generate"
>
<ArrowRight className="h-3.5 w-3.5" />
</button>
)}
</div>
</div>
<Sheet open={examplesSheetOpen} onOpenChange={setExamplesSheetOpen}>
<SheetContent side="left" className="w-80 p-0">
<SheetTitle className="sr-only">Examples</SheetTitle>
<ScrollArea className="h-full">
<div className="p-2 space-y-1">
<p className="px-2 pt-2 pb-1 text-[11px] font-mono text-muted-foreground">
start
</p>
<button
onClick={() => select({ mode: "scratch" })}
className={cn(
"w-full text-left px-3 py-2 rounded text-sm transition-colors",
selection.mode === "scratch"
? "bg-muted text-foreground"
: "text-muted-foreground hover:bg-muted/50 hover:text-foreground",
)}
>
From scratch
</button>
<p className="px-2 pt-3 pb-1 text-[11px] font-mono text-muted-foreground">
examples
</p>
{examples.map((ex) => (
<button
key={ex.name}
onClick={() =>
select({ mode: "example", exampleName: ex.name })
}
className={cn(
"w-full text-left px-3 py-2 rounded text-sm transition-colors",
selection.mode === "example" &&
selection.exampleName === ex.name
? "bg-muted text-foreground"
: "text-muted-foreground hover:bg-muted/50 hover:text-foreground",
)}
>
<span className="font-medium">{ex.label}</span>
<p className="text-xs text-muted-foreground/70 mt-0.5 leading-snug">
{ex.description}
</p>
</button>
))}
</div>
</ScrollArea>
</SheetContent>
</Sheet>
</div>
</div>
);
}
+23
View File
@@ -0,0 +1,23 @@
{
"$schema": "https://ui.shadcn.com/schema.json",
"style": "new-york",
"rsc": true,
"tsx": true,
"tailwind": {
"config": "",
"css": "app/globals.css",
"baseColor": "neutral",
"cssVariables": true,
"prefix": ""
},
"iconLibrary": "lucide",
"rtl": false,
"aliases": {
"components": "@/components",
"utils": "@/lib/utils",
"ui": "@/components/ui",
"lib": "@/lib",
"hooks": "@/hooks"
},
"registries": {}
}
@@ -0,0 +1,64 @@
import * as React from "react";
import { cva, type VariantProps } from "class-variance-authority";
import { Slot } from "radix-ui";
import { cn } from "@/lib/utils";
const buttonVariants = cva(
"inline-flex items-center justify-center gap-2 whitespace-nowrap rounded-md text-sm font-medium transition-all disabled:pointer-events-none disabled:opacity-50 [&_svg]:pointer-events-none [&_svg:not([class*='size-'])]:size-4 shrink-0 [&_svg]:shrink-0 outline-none focus-visible:border-ring focus-visible:ring-ring/50 focus-visible:ring-[3px] aria-invalid:ring-destructive/20 dark:aria-invalid:ring-destructive/40 aria-invalid:border-destructive",
{
variants: {
variant: {
default: "bg-primary text-primary-foreground hover:bg-primary/90",
destructive:
"bg-destructive text-white hover:bg-destructive/90 focus-visible:ring-destructive/20 dark:focus-visible:ring-destructive/40 dark:bg-destructive/60",
outline:
"border bg-background shadow-xs hover:bg-accent hover:text-accent-foreground dark:bg-input/30 dark:border-input dark:hover:bg-input/50",
secondary:
"bg-secondary text-secondary-foreground hover:bg-secondary/80",
ghost:
"hover:bg-accent hover:text-accent-foreground dark:hover:bg-accent/50",
link: "text-primary underline-offset-4 hover:underline",
},
size: {
default: "h-9 px-4 py-2 has-[>svg]:px-3",
xs: "h-6 gap-1 rounded-md px-2 text-xs has-[>svg]:px-1.5 [&_svg:not([class*='size-'])]:size-3",
sm: "h-8 rounded-md gap-1.5 px-3 has-[>svg]:px-2.5",
lg: "h-10 rounded-md px-6 has-[>svg]:px-4",
icon: "size-9",
"icon-xs": "size-6 rounded-md [&_svg:not([class*='size-'])]:size-3",
"icon-sm": "size-8",
"icon-lg": "size-10",
},
},
defaultVariants: {
variant: "default",
size: "default",
},
},
);
function Button({
className,
variant = "default",
size = "default",
asChild = false,
...props
}: React.ComponentProps<"button"> &
VariantProps<typeof buttonVariants> & {
asChild?: boolean;
}) {
const Comp = asChild ? Slot.Root : "button";
return (
<Comp
data-slot="button"
data-variant={variant}
data-size={size}
className={cn(buttonVariants({ variant, size, className }))}
{...props}
/>
);
}
export { Button, buttonVariants };
@@ -0,0 +1,53 @@
"use client";
import { GripVerticalIcon } from "lucide-react";
import * as ResizablePrimitive from "react-resizable-panels";
import { cn } from "@/lib/utils";
function ResizablePanelGroup({
className,
...props
}: ResizablePrimitive.GroupProps) {
return (
<ResizablePrimitive.Group
data-slot="resizable-panel-group"
className={cn(
"flex h-full w-full aria-[orientation=vertical]:flex-col",
className,
)}
{...props}
/>
);
}
function ResizablePanel({ ...props }: ResizablePrimitive.PanelProps) {
return <ResizablePrimitive.Panel data-slot="resizable-panel" {...props} />;
}
function ResizableHandle({
withHandle,
className,
...props
}: ResizablePrimitive.SeparatorProps & {
withHandle?: boolean;
}) {
return (
<ResizablePrimitive.Separator
data-slot="resizable-handle"
className={cn(
"bg-border focus-visible:ring-ring relative flex w-px items-center justify-center after:absolute after:inset-y-0 after:left-1/2 after:w-1 after:-translate-x-1/2 focus-visible:ring-1 focus-visible:ring-offset-1 focus-visible:outline-hidden aria-[orientation=horizontal]:h-px aria-[orientation=horizontal]:w-full aria-[orientation=horizontal]:after:left-0 aria-[orientation=horizontal]:after:h-1 aria-[orientation=horizontal]:after:w-full aria-[orientation=horizontal]:after:translate-x-0 aria-[orientation=horizontal]:after:-translate-y-1/2 [&[aria-orientation=horizontal]>div]:rotate-90",
className,
)}
{...props}
>
{withHandle && (
<div className="bg-border z-10 flex h-4 w-3 items-center justify-center rounded-xs border">
<GripVerticalIcon className="size-2.5" />
</div>
)}
</ResizablePrimitive.Separator>
);
}
export { ResizableHandle, ResizablePanel, ResizablePanelGroup };
@@ -0,0 +1,58 @@
"use client";
import * as React from "react";
import { ScrollArea as ScrollAreaPrimitive } from "radix-ui";
import { cn } from "@/lib/utils";
function ScrollArea({
className,
children,
...props
}: React.ComponentProps<typeof ScrollAreaPrimitive.Root>) {
return (
<ScrollAreaPrimitive.Root
data-slot="scroll-area"
className={cn("relative", className)}
{...props}
>
<ScrollAreaPrimitive.Viewport
data-slot="scroll-area-viewport"
className="focus-visible:ring-ring/50 size-full rounded-[inherit] transition-[color,box-shadow] outline-none focus-visible:ring-[3px] focus-visible:outline-1"
>
{children}
</ScrollAreaPrimitive.Viewport>
<ScrollBar />
<ScrollAreaPrimitive.Corner />
</ScrollAreaPrimitive.Root>
);
}
function ScrollBar({
className,
orientation = "vertical",
...props
}: React.ComponentProps<typeof ScrollAreaPrimitive.ScrollAreaScrollbar>) {
return (
<ScrollAreaPrimitive.ScrollAreaScrollbar
data-slot="scroll-area-scrollbar"
orientation={orientation}
className={cn(
"flex touch-none p-px transition-colors select-none",
orientation === "vertical" &&
"h-full w-2.5 border-l border-l-transparent",
orientation === "horizontal" &&
"h-2.5 flex-col border-t border-t-transparent",
className,
)}
{...props}
>
<ScrollAreaPrimitive.ScrollAreaThumb
data-slot="scroll-area-thumb"
className="bg-border relative flex-1 rounded-full"
/>
</ScrollAreaPrimitive.ScrollAreaScrollbar>
);
}
export { ScrollArea, ScrollBar };
@@ -0,0 +1,28 @@
"use client";
import * as React from "react";
import { Separator as SeparatorPrimitive } from "radix-ui";
import { cn } from "@/lib/utils";
function Separator({
className,
orientation = "horizontal",
decorative = true,
...props
}: React.ComponentProps<typeof SeparatorPrimitive.Root>) {
return (
<SeparatorPrimitive.Root
data-slot="separator"
decorative={decorative}
orientation={orientation}
className={cn(
"bg-border shrink-0 data-[orientation=horizontal]:h-px data-[orientation=horizontal]:w-full data-[orientation=vertical]:h-full data-[orientation=vertical]:w-px",
className,
)}
{...props}
/>
);
}
export { Separator };
+143
View File
@@ -0,0 +1,143 @@
"use client";
import * as React from "react";
import { XIcon } from "lucide-react";
import { Dialog as SheetPrimitive } from "radix-ui";
import { cn } from "@/lib/utils";
function Sheet({ ...props }: React.ComponentProps<typeof SheetPrimitive.Root>) {
return <SheetPrimitive.Root data-slot="sheet" {...props} />;
}
function SheetTrigger({
...props
}: React.ComponentProps<typeof SheetPrimitive.Trigger>) {
return <SheetPrimitive.Trigger data-slot="sheet-trigger" {...props} />;
}
function SheetClose({
...props
}: React.ComponentProps<typeof SheetPrimitive.Close>) {
return <SheetPrimitive.Close data-slot="sheet-close" {...props} />;
}
function SheetPortal({
...props
}: React.ComponentProps<typeof SheetPrimitive.Portal>) {
return <SheetPrimitive.Portal data-slot="sheet-portal" {...props} />;
}
function SheetOverlay({
className,
...props
}: React.ComponentProps<typeof SheetPrimitive.Overlay>) {
return (
<SheetPrimitive.Overlay
data-slot="sheet-overlay"
className={cn(
"data-[state=open]:animate-in data-[state=closed]:animate-out data-[state=closed]:fade-out-0 data-[state=open]:fade-in-0 fixed inset-0 z-50 bg-black/50",
className,
)}
{...props}
/>
);
}
function SheetContent({
className,
children,
side = "right",
showCloseButton = true,
...props
}: React.ComponentProps<typeof SheetPrimitive.Content> & {
side?: "top" | "right" | "bottom" | "left";
showCloseButton?: boolean;
}) {
return (
<SheetPortal>
<SheetOverlay />
<SheetPrimitive.Content
data-slot="sheet-content"
className={cn(
"bg-background data-[state=open]:animate-in data-[state=closed]:animate-out fixed z-50 flex flex-col gap-4 shadow-lg transition ease-in-out data-[state=closed]:duration-300 data-[state=open]:duration-500",
side === "right" &&
"data-[state=closed]:slide-out-to-right data-[state=open]:slide-in-from-right inset-y-0 right-0 h-full w-3/4 border-l sm:max-w-sm",
side === "left" &&
"data-[state=closed]:slide-out-to-left data-[state=open]:slide-in-from-left inset-y-0 left-0 h-full w-3/4 border-r sm:max-w-sm",
side === "top" &&
"data-[state=closed]:slide-out-to-top data-[state=open]:slide-in-from-top inset-x-0 top-0 h-auto border-b",
side === "bottom" &&
"data-[state=closed]:slide-out-to-bottom data-[state=open]:slide-in-from-bottom inset-x-0 bottom-0 h-auto border-t",
className,
)}
{...props}
>
{children}
{showCloseButton && (
<SheetPrimitive.Close className="ring-offset-background focus:ring-ring data-[state=open]:bg-secondary absolute top-4 right-4 rounded-xs opacity-70 transition-opacity hover:opacity-100 focus:ring-2 focus:ring-offset-2 focus:outline-hidden disabled:pointer-events-none">
<XIcon className="size-4" />
<span className="sr-only">Close</span>
</SheetPrimitive.Close>
)}
</SheetPrimitive.Content>
</SheetPortal>
);
}
function SheetHeader({ className, ...props }: React.ComponentProps<"div">) {
return (
<div
data-slot="sheet-header"
className={cn("flex flex-col gap-1.5 p-4", className)}
{...props}
/>
);
}
function SheetFooter({ className, ...props }: React.ComponentProps<"div">) {
return (
<div
data-slot="sheet-footer"
className={cn("mt-auto flex flex-col gap-2 p-4", className)}
{...props}
/>
);
}
function SheetTitle({
className,
...props
}: React.ComponentProps<typeof SheetPrimitive.Title>) {
return (
<SheetPrimitive.Title
data-slot="sheet-title"
className={cn("text-foreground font-semibold", className)}
{...props}
/>
);
}
function SheetDescription({
className,
...props
}: React.ComponentProps<typeof SheetPrimitive.Description>) {
return (
<SheetPrimitive.Description
data-slot="sheet-description"
className={cn("text-muted-foreground text-sm", className)}
{...props}
/>
);
}
export {
Sheet,
SheetTrigger,
SheetClose,
SheetContent,
SheetHeader,
SheetFooter,
SheetTitle,
SheetDescription,
};
+91
View File
@@ -0,0 +1,91 @@
"use client";
import * as React from "react";
import { cva, type VariantProps } from "class-variance-authority";
import { Tabs as TabsPrimitive } from "radix-ui";
import { cn } from "@/lib/utils";
function Tabs({
className,
orientation = "horizontal",
...props
}: React.ComponentProps<typeof TabsPrimitive.Root>) {
return (
<TabsPrimitive.Root
data-slot="tabs"
data-orientation={orientation}
orientation={orientation}
className={cn(
"group/tabs flex gap-2 data-[orientation=horizontal]:flex-col",
className,
)}
{...props}
/>
);
}
const tabsListVariants = cva(
"rounded-lg p-[3px] group-data-[orientation=horizontal]/tabs:h-9 data-[variant=line]:rounded-none group/tabs-list text-muted-foreground inline-flex w-fit items-center justify-center group-data-[orientation=vertical]/tabs:h-fit group-data-[orientation=vertical]/tabs:flex-col",
{
variants: {
variant: {
default: "bg-muted",
line: "gap-1 bg-transparent",
},
},
defaultVariants: {
variant: "default",
},
},
);
function TabsList({
className,
variant = "default",
...props
}: React.ComponentProps<typeof TabsPrimitive.List> &
VariantProps<typeof tabsListVariants>) {
return (
<TabsPrimitive.List
data-slot="tabs-list"
data-variant={variant}
className={cn(tabsListVariants({ variant }), className)}
{...props}
/>
);
}
function TabsTrigger({
className,
...props
}: React.ComponentProps<typeof TabsPrimitive.Trigger>) {
return (
<TabsPrimitive.Trigger
data-slot="tabs-trigger"
className={cn(
"focus-visible:border-ring focus-visible:ring-ring/50 focus-visible:outline-ring text-foreground/60 hover:text-foreground dark:text-muted-foreground dark:hover:text-foreground relative inline-flex h-[calc(100%-1px)] flex-1 items-center justify-center gap-1.5 rounded-md border border-transparent px-2 py-1 text-sm font-medium whitespace-nowrap transition-all group-data-[orientation=vertical]/tabs:w-full group-data-[orientation=vertical]/tabs:justify-start focus-visible:ring-[3px] focus-visible:outline-1 disabled:pointer-events-none disabled:opacity-50 group-data-[variant=default]/tabs-list:data-[state=active]:shadow-sm group-data-[variant=line]/tabs-list:data-[state=active]:shadow-none [&_svg]:pointer-events-none [&_svg]:shrink-0 [&_svg:not([class*='size-'])]:size-4",
"group-data-[variant=line]/tabs-list:bg-transparent group-data-[variant=line]/tabs-list:data-[state=active]:bg-transparent dark:group-data-[variant=line]/tabs-list:data-[state=active]:border-transparent dark:group-data-[variant=line]/tabs-list:data-[state=active]:bg-transparent",
"data-[state=active]:bg-background dark:data-[state=active]:text-foreground dark:data-[state=active]:border-input dark:data-[state=active]:bg-input/30 data-[state=active]:text-foreground",
"after:bg-foreground after:absolute after:opacity-0 after:transition-opacity group-data-[orientation=horizontal]/tabs:after:inset-x-0 group-data-[orientation=horizontal]/tabs:after:bottom-[-5px] group-data-[orientation=horizontal]/tabs:after:h-0.5 group-data-[orientation=vertical]/tabs:after:inset-y-0 group-data-[orientation=vertical]/tabs:after:-right-1 group-data-[orientation=vertical]/tabs:after:w-0.5 group-data-[variant=line]/tabs-list:data-[state=active]:after:opacity-100",
className,
)}
{...props}
/>
);
}
function TabsContent({
className,
...props
}: React.ComponentProps<typeof TabsPrimitive.Content>) {
return (
<TabsPrimitive.Content
data-slot="tabs-content"
className={cn("flex-1 outline-none", className)}
{...props}
/>
);
}
export { Tabs, TabsList, TabsTrigger, TabsContent, tabsListVariants };
@@ -0,0 +1,18 @@
import * as React from "react";
import { cn } from "@/lib/utils";
function Textarea({ className, ...props }: React.ComponentProps<"textarea">) {
return (
<textarea
data-slot="textarea"
className={cn(
"border-input placeholder:text-muted-foreground focus-visible:border-ring focus-visible:ring-ring/50 aria-invalid:ring-destructive/20 dark:aria-invalid:ring-destructive/40 aria-invalid:border-destructive dark:bg-input/30 flex field-sizing-content min-h-16 w-full rounded-md border bg-transparent px-3 py-2 text-base shadow-xs transition-[color,box-shadow] outline-none focus-visible:ring-[3px] disabled:cursor-not-allowed disabled:opacity-50 md:text-sm",
className,
)}
{...props}
/>
);
}
export { Textarea };
+8
View File
@@ -0,0 +1,8 @@
import { defineCatalog } from "@json-render/core";
import { schema } from "@json-render/react-pdf/server";
import { standardComponentDefinitions } from "@json-render/react-pdf/catalog";
export const pdfCatalog = defineCatalog(schema, {
components: standardComponentDefinitions,
actions: {},
});
File diff suppressed because it is too large Load Diff
+6
View File
@@ -0,0 +1,6 @@
import { clsx, type ClassValue } from "clsx";
import { twMerge } from "tailwind-merge";
export function cn(...inputs: ClassValue[]) {
return twMerge(clsx(inputs));
}
+6
View File
@@ -0,0 +1,6 @@
/// <reference types="next" />
/// <reference types="next/image-types/global" />
import "./.next/types/routes.d.ts";
// NOTE: This file should not be edited
// see https://nextjs.org/docs/app/api-reference/config/typescript for more information.
+7
View File
@@ -0,0 +1,7 @@
import type { NextConfig } from "next";
const nextConfig: NextConfig = {
serverExternalPackages: ["@react-pdf/renderer", "@json-render/react-pdf"],
};
export default nextConfig;
+39
View File
@@ -0,0 +1,39 @@
{
"name": "example-react-pdf",
"version": "0.1.0",
"type": "module",
"private": true,
"scripts": {
"dev": "next dev --turbopack --port 3005",
"build": "next build",
"start": "next start",
"check-types": "tsc --noEmit"
},
"dependencies": {
"@ai-sdk/gateway": "^3.0.52",
"@json-render/core": "workspace:*",
"@json-render/react-pdf": "workspace:*",
"@tailwindcss/postcss": "^4.1.18",
"ai": "6.0.94",
"class-variance-authority": "^0.7.1",
"clsx": "^2.1.1",
"lucide-react": "^0.575.0",
"next": "16.1.6",
"radix-ui": "^1.4.3",
"react": "19.2.4",
"react-dom": "19.2.4",
"react-resizable-panels": "^4.4.1",
"tailwind-merge": "^3.5.0",
"tailwindcss": "^4.1.18",
"zod": "4.3.5"
},
"devDependencies": {
"@repo/typescript-config": "workspace:*",
"@types/node": "^22.10.0",
"@types/react": "19.2.3",
"@types/react-dom": "19.2.3",
"shadcn": "^3.8.5",
"tw-animate-css": "^1.4.0",
"typescript": "^5.7.2"
}
}
+8
View File
@@ -0,0 +1,8 @@
/** @type {import('postcss-load-config').Config} */
const config = {
plugins: {
"@tailwindcss/postcss": {},
},
};
export default config;
+13
View File
@@ -0,0 +1,13 @@
{
"extends": "../../packages/typescript-config/nextjs.json",
"compilerOptions": {
"plugins": [{ "name": "next" }],
"declaration": false,
"declarationMap": false,
"paths": {
"@/*": ["./*"]
}
},
"include": ["next-env.d.ts", "**/*.ts", "**/*.tsx", ".next/types/**/*.ts"],
"exclude": ["node_modules"]
}
+1 -1
View File
@@ -1,6 +1,6 @@
/// <reference types="next" />
/// <reference types="next/image-types/global" />
import "./.next/dev/types/routes.d.ts";
import "./.next/types/routes.d.ts";
// NOTE: This file should not be edited
// see https://nextjs.org/docs/app/api-reference/config/typescript for more information.
-7
View File
@@ -1,7 +0,0 @@
# AI Gateway API Key
# Get your key from Vercel AI Gateway dashboard
AI_GATEWAY_API_KEY=
# Optional: Stripe API keys if you want to use real data
# STRIPE_SECRET_KEY=sk_test_...
# STRIPE_PUBLISHABLE_KEY=pk_test_...
+32 -82
View File
@@ -1,99 +1,49 @@
# Stripe App Example
# Stripe App Examples
A [Stripe App](https://stripe.com/docs/stripe-apps) example demonstrating how to use json-render to build dynamic, AI-generated UI within the Stripe Dashboard.
[Stripe Apps](https://stripe.com/docs/stripe-apps) examples demonstrating how to use json-render to build dynamic, AI-generated UI within the Stripe Dashboard.
## Overview
## Structure
This example shows how to integrate json-render with Stripe's UI Extension SDK to create dashboard views that can be dynamically generated from prompts. The app includes:
| Folder | Description |
|--------|-------------|
| [api/](./api) | Next.js server providing the `/api/generate` endpoint for AI-powered UI generation |
| [drawer-app/](./drawer-app) | Standard Stripe App that renders in the Dashboard drawer (sidebar) |
| [fullpage-app/](./fullpage-app) | Full-page Stripe App using `FullPageView` (requires alpha access) |
- **Home** - Revenue dashboard with AI-powered UI generation
- **Customers** - Customer list and management
- **Customer Details** - Individual customer view
- **Payments** - Payment list and details
- **Subscriptions** - Subscription management
- **Invoices** - Invoice list
- **Products** - Product catalog
## Quick Start
## Features
- Full Stripe UIXT component catalog mapped to json-render
- Dynamic spec generation from natural language prompts
- Real-time data binding with Stripe API
- Action handlers for Stripe operations (refunds, subscriptions, etc.)
## Getting Started
### Prerequisites
- [Stripe CLI](https://stripe.com/docs/stripe-cli) installed
- A Stripe account with app development enabled
### Installation
### 1. Start the API server
```bash
# Install dependencies
cd api
pnpm install
cp .env.example .env # Set AI_GATEWAY_API_KEY
pnpm dev
```
# Start the Stripe App in development mode
### 2. Start a Stripe App
For the standard drawer app:
```bash
cd drawer-app
pnpm install
pnpm setup
stripe apps start
```
### Development
The app uses the Stripe UI Extension SDK which handles authentication automatically - no API keys required in your environment.
For the full-page app (requires alpha access):
```bash
# Run linting
pnpm lint
# Run tests
pnpm test
cd fullpage-app
pnpm install
cp .env.example .env # Set STRIPE_APP_ID
pnpm setup
stripe apps start
# Navigate to https://dashboard.stripe.com/test/app/<your-app-id>
```
## How It Works
## Notes
1. **Component Catalog**: Maps json-render component types to Stripe UIXT components (`Box`, `Button`, `Badge`, etc.)
2. **Action Handlers**: Defines actions that can be triggered from the UI (e.g., `refundPayment`, `cancelSubscription`)
3. **StripeRenderer**: A custom renderer that uses the Stripe component catalog to render json-render specs
4. **Views**: Each view fetches data from the Stripe API and renders it using json-render specs
## Example Usage
```tsx
import { StripeRenderer, stripeCatalog } from "./lib/render";
import type { Spec } from "@json-render/react";
const spec: Spec = {
root: "container",
elements: {
container: {
type: "Stack",
props: { direction: "vertical", gap: "medium" },
children: ["heading", "metric"],
},
heading: {
type: "Heading",
props: { text: "Revenue", size: "large" },
children: [],
},
metric: {
type: "Metric",
props: { label: "Total", value: "$12,345", format: "currency" },
children: [],
},
},
};
function MyView() {
return <StripeRenderer spec={spec} catalog={stripeCatalog} />;
}
```
## Learn More
- [json-render Documentation](https://json-render.com/docs)
- [Stripe Apps Documentation](https://stripe.com/docs/stripe-apps)
- [Stripe UI Extension SDK](https://stripe.com/docs/stripe-apps/reference/ui-extension-sdk)
- The AI generation feature requires the API server to be running. Without it, the apps fall back to locally generated specs using real Stripe data.
- The `fullpage-app` uses Stripe's full-page apps **private developer preview**. You need Stripe to enable the feature flag for your app and account. See [fullpage-app/README.md](./fullpage-app/README.md) for details.
+3
View File
@@ -0,0 +1,3 @@
# AI Gateway API Key (required for AI generation)
# Get your key from Vercel AI Gateway dashboard
AI_GATEWAY_API_KEY=
+26
View File
@@ -0,0 +1,26 @@
# Stripe App API
A lightweight Next.js server that provides the `/api/generate` endpoint for AI-powered UI generation in the Stripe App examples.
## Setup
```bash
pnpm install
cp .env.example .env
# Set AI_GATEWAY_API_KEY
```
## Running
```bash
pnpm dev
```
Runs on port 3001. The Stripe apps (`drawer-app/` and `fullpage-app/`) expect this server to be running for AI generation features.
## API
### POST /api/generate
Accepts `{ prompt, systemPrompt }` and returns a streamed text response with a json-render spec.
@@ -0,0 +1,50 @@
import { streamText } from "ai";
import { gateway } from "@ai-sdk/gateway";
export const maxDuration = 60;
const DEFAULT_MODEL = "anthropic/claude-haiku-4.5";
const CORS_HEADERS: Record<string, string> = {
"Access-Control-Allow-Origin": "*",
"Access-Control-Allow-Methods": "POST, OPTIONS",
"Access-Control-Allow-Headers": "Content-Type",
};
export async function POST(req: Request) {
const { prompt, systemPrompt } = await req.json();
if (!prompt) {
return new Response(JSON.stringify({ error: "prompt is required" }), {
status: 400,
headers: { "Content-Type": "application/json", ...CORS_HEADERS },
});
}
try {
const result = streamText({
model: gateway(DEFAULT_MODEL),
system: systemPrompt ?? "You are a helpful UI builder.",
prompt,
temperature: 0.7,
});
const response = result.toTextStreamResponse();
for (const [key, value] of Object.entries(CORS_HEADERS)) {
response.headers.set(key, value);
}
return response;
} catch (err) {
const message = err instanceof Error ? err.message : "Generation failed";
return new Response(JSON.stringify({ error: message }), {
status: 500,
headers: { "Content-Type": "application/json", ...CORS_HEADERS },
});
}
}
export async function OPTIONS() {
return new Response(null, { headers: CORS_HEADERS });
}
+15
View File
@@ -0,0 +1,15 @@
export const metadata = {
title: "Stripe App API",
};
export default function RootLayout({
children,
}: {
children: React.ReactNode;
}) {
return (
<html lang="en">
<body>{children}</body>
</html>
);
}
+8
View File
@@ -0,0 +1,8 @@
export default function Home() {
return (
<main style={{ padding: "2rem", fontFamily: "system-ui, sans-serif" }}>
<h1>Stripe App API</h1>
<p>POST /api/generate to generate UI specs.</p>
</main>
);
}
+6
View File
@@ -0,0 +1,6 @@
/// <reference types="next" />
/// <reference types="next/image-types/global" />
import "./.next/types/routes.d.ts";
// NOTE: This file should not be edited
// see https://nextjs.org/docs/app/api-reference/config/typescript for more information.
+5
View File
@@ -0,0 +1,5 @@
import type { NextConfig } from "next";
const nextConfig: NextConfig = {};
export default nextConfig;
+23
View File
@@ -0,0 +1,23 @@
{
"name": "stripe-app-api",
"version": "0.1.0",
"private": true,
"type": "module",
"scripts": {
"dev": "next dev --port 3001",
"build": "next build",
"start": "next start --port 3001"
},
"dependencies": {
"@ai-sdk/gateway": "^3.0.50",
"ai": "^6.0.91",
"next": "^16.1.6",
"react": "^19.1.0",
"react-dom": "^19.1.0"
},
"devDependencies": {
"@types/node": "^22.0.0",
"@types/react": "^19.1.0",
"typescript": "^5.4.5"
}
}
+41
View File
@@ -0,0 +1,41 @@
{
"compilerOptions": {
"target": "ES2017",
"lib": [
"dom",
"dom.iterable",
"esnext"
],
"allowJs": true,
"skipLibCheck": true,
"strict": true,
"noEmit": true,
"esModuleInterop": true,
"module": "esnext",
"moduleResolution": "bundler",
"resolveJsonModule": true,
"isolatedModules": true,
"jsx": "react-jsx",
"incremental": true,
"plugins": [
{
"name": "next"
}
],
"paths": {
"@/*": [
"./*"
]
}
},
"include": [
"next-env.d.ts",
"**/*.ts",
"**/*.tsx",
".next/types/**/*.ts",
".next/dev/types/**/*.ts"
],
"exclude": [
"node_modules"
]
}
@@ -0,0 +1,14 @@
# Stripe App ID (optional - overrides the template default)
# Get this from your Stripe Apps dashboard after uploading
# STRIPE_APP_ID=com.example.your-app-id
# Stripe App Name (optional - overrides the template default)
# STRIPE_APP_NAME=My App Name
# AI Gateway API Key
# Get your key from Vercel AI Gateway dashboard
AI_GATEWAY_API_KEY=
# Optional: Stripe API keys if you want to use real data
# STRIPE_SECRET_KEY=sk_test_...
# STRIPE_PUBLISHABLE_KEY=pk_test_...
+45
View File
@@ -0,0 +1,45 @@
# Stripe App (Drawer)
A [Stripe App](https://stripe.com/docs/stripe-apps) example demonstrating how to use json-render to build dynamic, AI-generated UI in the Stripe Dashboard drawer.
## Views
- **Home** - Revenue dashboard with AI-powered UI generation
- **Customers** - Customer list and management
- **Customer Details** - Individual customer view
- **Payments** - Payment list and details
- **Subscriptions** - Subscription management
- **Invoices** - Invoice list
- **Products** - Product catalog
## Setup
```bash
pnpm install
cp .env.example .env
# Optionally set STRIPE_APP_ID to your own app ID
pnpm setup
```
## Running
Start the API server first (from `../api`):
```bash
cd ../api && pnpm dev
```
Then start the Stripe app:
```bash
stripe apps start
```
## How It Works
1. **Component Catalog** - Maps json-render component types to Stripe UIXT components
2. **Action Handlers** - Stripe operations (refunds, subscriptions, etc.)
3. **StripeRenderer** - Custom renderer connecting json-render specs to Stripe components
4. **Views** - Each view fetches data from the Stripe API and renders it using json-render specs
@@ -9,12 +9,14 @@
"@json-render/core": "workspace:*",
"@json-render/react": "workspace:*",
"@stripe/ui-extension-sdk": "^9.1.0",
"stripe": "^13.11.0"
"stripe": "^13.11.0",
"zod": "^4.0.0"
},
"engines": {
"node": ">=14"
},
"scripts": {
"setup": "node scripts/setup.mjs",
"lint": "eslint src --max-warnings 0",
"test": "jest"
},
@@ -0,0 +1,42 @@
import { readFileSync, writeFileSync, existsSync } from "fs";
import { resolve, dirname } from "path";
import { fileURLToPath } from "url";
const __dirname = dirname(fileURLToPath(import.meta.url));
const root = resolve(__dirname, "..");
function loadEnv() {
const envPath = resolve(root, ".env");
if (!existsSync(envPath)) return {};
const env = {};
for (const line of readFileSync(envPath, "utf-8").split("\n")) {
const trimmed = line.trim();
if (!trimmed || trimmed.startsWith("#")) continue;
const idx = trimmed.indexOf("=");
if (idx === -1) continue;
const key = trimmed.slice(0, idx).trim();
const val = trimmed.slice(idx + 1).trim();
env[key] = val;
}
return env;
}
const env = loadEnv();
const templatePath = resolve(root, "stripe-app.template.json");
const outputPath = resolve(root, "stripe-app.json");
const template = JSON.parse(readFileSync(templatePath, "utf-8"));
if (env.STRIPE_APP_ID) {
template.id = env.STRIPE_APP_ID;
console.log(`Using app ID from .env: ${env.STRIPE_APP_ID}`);
} else {
console.log(`No STRIPE_APP_ID in .env, using template default: ${template.id}`);
}
if (env.STRIPE_APP_NAME) {
template.name = env.STRIPE_APP_NAME;
}
writeFileSync(outputPath, JSON.stringify(template, null, 4) + "\n");
console.log("Generated stripe-app.json");
@@ -0,0 +1 @@
export const API_GENERATE_URL = "http://localhost:3001/api/generate";
File diff suppressed because it is too large Load Diff
@@ -62,6 +62,7 @@ type ExtendedRenderProps<P = Record<string, unknown>> =
ComponentRenderProps<P> & {
state?: Record<string, unknown>;
getValue?: (path: string) => unknown;
onAction?: (actionName: string, params?: Record<string, unknown>) => void;
};
// NOTE: All interactive components use `emit` to fire named events.
@@ -254,23 +255,47 @@ export const Metric: FunctionComponent<ExtendedRenderProps> = ({ element }) => {
);
};
const BADGE_TYPES = new Set([
"neutral",
"urgent",
"warning",
"negative",
"positive",
"info",
]);
const BADGE_ALIAS: Record<string, string> = {
success: "positive",
error: "negative",
danger: "negative",
critical: "urgent",
default: "neutral",
primary: "info",
};
function coerceBadgeType(
raw: unknown,
): "neutral" | "urgent" | "warning" | "negative" | "positive" | "info" {
const s = String(raw ?? "neutral");
if (BADGE_TYPES.has(s))
return s as
| "neutral"
| "urgent"
| "warning"
| "negative"
| "positive"
| "info";
return (BADGE_ALIAS[s] ?? "neutral") as
| "neutral"
| "urgent"
| "warning"
| "negative"
| "positive"
| "info";
}
export const Badge: FunctionComponent<ExtendedRenderProps> = ({ element }) => {
const { label, type = "neutral" } = element.props as Record<string, unknown>;
return (
<UIBadge
type={
type as
| "neutral"
| "urgent"
| "warning"
| "negative"
| "positive"
| "info"
}
>
{String(label || "")}
</UIBadge>
);
return <UIBadge type={coerceBadgeType(type)}>{String(label || "")}</UIBadge>;
};
export const Icon: FunctionComponent<ExtendedRenderProps> = ({ element }) => {
@@ -309,6 +334,21 @@ export const Spinner: FunctionComponent<ExtendedRenderProps> = ({
// =========================================================================
// Feedback Components
// =========================================================================
const BANNER_TYPES = new Set(["default", "caution", "critical"]);
const BANNER_ALIAS: Record<string, string> = {
info: "default",
warning: "caution",
error: "critical",
danger: "critical",
success: "default",
};
function coerceBannerType(raw: unknown): "default" | "caution" | "critical" {
const s = String(raw ?? "default");
if (BANNER_TYPES.has(s)) return s as "default" | "caution" | "critical";
return (BANNER_ALIAS[s] ?? "default") as "default" | "caution" | "critical";
}
export const Banner: FunctionComponent<ExtendedRenderProps> = ({ element }) => {
const {
title,
@@ -319,7 +359,7 @@ export const Banner: FunctionComponent<ExtendedRenderProps> = ({ element }) => {
<UIBanner
title={title ? String(title) : undefined}
description={description ? String(description) : undefined}
type={type as "default" | "caution" | "critical"}
type={coerceBannerType(type)}
/>
);
};
@@ -634,11 +674,12 @@ export const DateField: FunctionComponent<ExtendedRenderProps> = ({
export const Button: FunctionComponent<ExtendedRenderProps> = ({
element,
emit,
onAction,
}) => {
const {
label,
action,
actionParams: _actionParams,
actionParams,
type = "primary",
size = "medium",
disabled,
@@ -646,6 +687,18 @@ export const Button: FunctionComponent<ExtendedRenderProps> = ({
href,
} = element.props as Record<string, unknown>;
const handlePress = action
? () => {
emit("press");
if (onAction) {
onAction(
String(action),
actionParams as Record<string, unknown> | undefined,
);
}
}
: undefined;
return (
<UIButton
type={type as "primary" | "secondary" | "destructive"}
@@ -653,7 +706,7 @@ export const Button: FunctionComponent<ExtendedRenderProps> = ({
disabled={Boolean(disabled) || undefined}
pending={Boolean(pending) || undefined}
href={href ? String(href) : undefined}
onPress={action ? () => emit("press") : undefined}
onPress={handlePress}
>
{String(label || "")}
</UIButton>
@@ -715,14 +768,26 @@ export const BarChart: FunctionComponent<ExtendedRenderProps> = ({
x={String(xKey || "x")}
y={String(yKey || "y")}
color={colorKey ? String(colorKey) : undefined}
axis={showAxis as "x" | "y" | "both" | "none"}
grid={showGrid as "x" | "y" | "both" | "none"}
axis={coerceAxisGrid(showAxis, "both")}
grid={coerceAxisGrid(showGrid, "none")}
legend={Boolean(showLegend) || undefined}
tooltip={Boolean(showTooltip)}
/>
);
};
function coerceAxisGrid(
val: unknown,
fallback: string,
): "x" | "y" | "both" | "none" {
if (val === true) return "both";
if (val === false) return "none";
const s = String(val ?? fallback);
if (["x", "y", "both", "none"].includes(s))
return s as "x" | "y" | "both" | "none";
return fallback as "x" | "y" | "both" | "none";
}
export const LineChart: FunctionComponent<ExtendedRenderProps> = ({
element,
}) => {
@@ -751,8 +816,8 @@ export const LineChart: FunctionComponent<ExtendedRenderProps> = ({
x={String(xKey || "x")}
y={String(yKey || "y")}
color={colorKey ? String(colorKey) : undefined}
axis={showAxis as "x" | "y" | "both" | "none"}
grid={showGrid as "x" | "y" | "both" | "none"}
axis={coerceAxisGrid(showAxis, "both")}
grid={coerceAxisGrid(showGrid, "none")}
legend={Boolean(showLegend) || undefined}
tooltip={Boolean(showTooltip)}
/>
@@ -9,6 +9,7 @@ import {
} from "@json-render/react";
import { components, Fallback } from "./catalog/components";
import { executeAction } from "./catalog/actions";
// =============================================================================
// Types
@@ -72,6 +73,12 @@ function buildRegistry(
}
return current;
},
onAction: (actionName: string, params?: Record<string, unknown>) => {
const setState = setDataRef.current;
if (setState) {
executeAction(actionName, params, setState, dataRef.current);
}
},
});
}
@@ -114,10 +121,15 @@ export function StripeRenderer({
[loading],
);
const mergedState = useMemo(
() => (spec?.state ? { ...data, ...spec.state } : data),
[data, spec?.state],
);
if (!spec) return null;
return (
<StateProvider initialState={data} onStateChange={onStateChange}>
<StateProvider initialState={mergedState} onStateChange={onStateChange}>
<VisibilityProvider>
<ActionProvider>
<Renderer
@@ -0,0 +1,146 @@
import type { Spec } from "@json-render/react";
interface JsonPatch {
op: string;
path: string;
value?: unknown;
from?: string;
}
function setDeep(
obj: Record<string, unknown>,
segments: string[],
value: unknown,
): void {
let current: unknown = obj;
for (let i = 0; i < segments.length - 1; i++) {
const seg = segments[i];
const next = (current as Record<string, unknown>)[seg];
if (next && typeof next === "object") {
current = next;
} else {
const container = /^\d+$/.test(segments[i + 1]) ? [] : {};
(current as Record<string, unknown>)[seg] = container;
current = container;
}
}
const last = segments[segments.length - 1];
if (Array.isArray(current)) {
(current as unknown[])[Number(last)] = value;
} else {
(current as Record<string, unknown>)[last] = value;
}
}
function setSpecValue(spec: Spec, path: string, value: unknown): void {
if (path === "/root") {
(spec as Record<string, unknown>).root = value as string;
return;
}
if (path === "/state") {
(spec as Record<string, unknown>).state = value;
return;
}
if (path.startsWith("/state/")) {
if (!spec.state) (spec as Record<string, unknown>).state = {};
const segments = path.slice("/state/".length).split("/");
setDeep(spec.state as Record<string, unknown>, segments, value);
return;
}
const elemMatch = path.match(/^\/elements\/(.+)/);
if (elemMatch) {
spec.elements[elemMatch[1]] = value as Spec["elements"][string];
}
}
function applyPatch(spec: Spec, patch: JsonPatch): Spec {
const next: Spec = {
...spec,
elements: { ...spec.elements },
...(spec.state ? { state: { ...spec.state } } : {}),
};
switch (patch.op) {
case "add":
case "replace":
setSpecValue(next, patch.path, patch.value);
break;
case "remove":
if (patch.path.startsWith("/elements/")) {
const key = patch.path.slice("/elements/".length);
delete next.elements[key];
}
break;
}
return next;
}
/**
* Fetch an AI generation endpoint that returns a JSONL patch stream,
* applying patches progressively via onPatch.
*/
export async function streamSpec(
url: string,
body: Record<string, unknown>,
onPatch: (spec: Spec) => void,
): Promise<Spec> {
const response = await fetch(url, {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify(body),
});
if (!response.ok) {
let msg = `API error: ${response.status}`;
try {
const errData = await response.json();
if (errData.error) msg = errData.error;
} catch {
// use default
}
throw new Error(msg);
}
const reader = response.body?.getReader();
if (!reader) throw new Error("No response body");
const decoder = new TextDecoder();
let buffer = "";
let spec: Spec = { root: "", elements: {} };
while (true) {
const { done, value } = await reader.read();
if (done) break;
buffer += decoder.decode(value, { stream: true });
const lines = buffer.split("\n");
buffer = lines.pop() ?? "";
for (const line of lines) {
const trimmed = line.trim();
if (!trimmed || trimmed.startsWith("//")) continue;
try {
const parsed = JSON.parse(trimmed) as JsonPatch;
if (parsed.op) {
spec = applyPatch(spec, parsed);
onPatch(spec);
}
} catch {
// skip non-JSON lines
}
}
}
if (buffer.trim()) {
try {
const parsed = JSON.parse(buffer.trim()) as JsonPatch;
if (parsed.op) {
spec = applyPatch(spec, parsed);
onPatch(spec);
}
} catch {
// skip
}
}
return spec;
}
@@ -10,6 +10,7 @@ import {
import type { ExtensionContextValue } from "@stripe/ui-extension-sdk/context";
import BrandIcon from "./brand_icon.svg";
import { API_GENERATE_URL } from "../lib/config";
import { stripeCatalog, StripeRenderer } from "../lib/render";
import { executeAction } from "../lib/render/catalog/actions";
@@ -373,7 +374,7 @@ const CustomerDetails = ({ environment }: ExtensionContextValue) => {
const systemPrompt = stripeCatalog.prompt();
try {
const response = await fetch("/api/generate", {
const response = await fetch(API_GENERATE_URL, {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({
@@ -10,6 +10,7 @@ import {
import type { ExtensionContextValue } from "@stripe/ui-extension-sdk/context";
import BrandIcon from "./brand_icon.svg";
import { API_GENERATE_URL } from "../lib/config";
import { stripeCatalog, StripeRenderer } from "../lib/render";
import { executeAction } from "../lib/render/catalog/actions";
@@ -172,7 +173,7 @@ const Customers = (_props: ExtensionContextValue) => {
const systemPrompt = stripeCatalog.prompt();
try {
const response = await fetch("/api/generate", {
const response = await fetch(API_GENERATE_URL, {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({ prompt, systemPrompt }),
@@ -1,10 +1,12 @@
import { useState, useCallback, useEffect } from "react";
import type { Spec } from "@json-render/react";
import {
Banner,
Box,
ContextView,
Divider,
Button,
Link,
TextField,
Tabs,
TabList,
@@ -20,6 +22,8 @@ import type { ExtensionContextValue } from "@stripe/ui-extension-sdk/context";
import BrandIcon from "./brand_icon.svg";
import { stripeCatalog, StripeRenderer } from "../lib/render";
import { executeAction } from "../lib/render/catalog/actions";
import { API_GENERATE_URL } from "../lib/config";
import { streamSpec } from "../lib/stream-spec";
// =============================================================================
// Dynamic Specs (use real data from context)
@@ -503,34 +507,23 @@ const Home = (_props: ExtensionContextValue) => {
setError(null);
try {
const response = await fetch("http://localhost:3000/api/generate", {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({
prompt,
const dataSnapshot = JSON.stringify(data, null, 2);
await streamSpec(
API_GENERATE_URL,
{
prompt: `${prompt}\n\nAVAILABLE STRIPE DATA (use this real data, do NOT invent fake numbers):\n${dataSnapshot}`,
systemPrompt: stripeCatalog.prompt({
system:
"You are a Stripe dashboard widget builder. Generate UI specs for displaying Stripe data.",
customRules: [
'LAYOUT CONSTRAINT: This UI renders inside a narrow Stripe dashboard drawer (~320px wide). NEVER place more than 2 small items side-by-side horizontally. Prefer vertical (stacked) layouts. Use direction:"horizontal" sparingly and only for very compact items like a label+value pair.',
'DATA: The user prompt includes real Stripe data under "AVAILABLE STRIPE DATA". You MUST use these real values when setting /state. NEVER invent or hallucinate numbers. If the data you need is not available, say so in a Text element rather than making up data. The data keys are: customers (data[], total), payments (data[], total, totalVolume, successRate), subscriptions (data[], total, active, trialing, pastDue, canceled), invoices (data[], total, totalAmount, paid, open, overdue).',
],
}),
}),
});
if (!response.ok) {
throw new Error(`API error: ${response.status}`);
}
const contentType = response.headers.get("content-type") || "";
if (contentType.includes("application/json")) {
const responseData = await response.json();
if (responseData.spec) {
setCurrentSpec(responseData.spec);
return;
} else if (responseData.error) {
throw new Error(responseData.error);
}
}
throw new Error("Invalid response");
},
(spec) => setCurrentSpec(spec),
);
} catch (err) {
// Use dynamic specs with real data as fallback
const lowerPrompt = prompt.toLowerCase();
@@ -625,26 +618,47 @@ const Home = (_props: ExtensionContextValue) => {
{currentSpec && (
<>
<Divider />
<Box css={{ font: "subheading" }}>
Preview (with real Stripe data)
</Box>
<Box
css={{
padding: "medium",
borderRadius: "medium",
keyline: "neutral",
}}
>
<StripeRenderer
spec={currentSpec}
data={data}
setData={handleSetState}
loading={isGenerating}
/>
</Box>
<StripeRenderer
spec={currentSpec}
data={data}
setData={handleSetState}
loading={isGenerating}
/>
</>
)}
{(
data._actionResult as
| { message?: string; url?: string }
| undefined
)?.message && (
<Banner
title={(data._actionResult as { message: string }).message}
description={
(data._actionResult as { url?: string }).url
? "Open in Stripe Dashboard"
: undefined
}
actions={
(data._actionResult as { url?: string }).url ? (
<Link
href={(data._actionResult as { url: string }).url}
external
>
Open
</Link>
) : undefined
}
onDismiss={() =>
setState((prev) => {
const next = { ...prev };
delete next._actionResult;
return next;
})
}
/>
)}
<Divider />
<Box css={{ font: "caption", color: "secondary" }}>
Try: &quot;Show revenue metrics&quot; &bull; &quot;Show recent
@@ -10,6 +10,7 @@ import {
import type { ExtensionContextValue } from "@stripe/ui-extension-sdk/context";
import BrandIcon from "./brand_icon.svg";
import { API_GENERATE_URL } from "../lib/config";
import { stripeCatalog, StripeRenderer } from "../lib/render";
import { executeAction } from "../lib/render/catalog/actions";
@@ -215,7 +216,7 @@ const Invoices = (_props: ExtensionContextValue) => {
const systemPrompt = stripeCatalog.prompt();
try {
const response = await fetch("/api/generate", {
const response = await fetch(API_GENERATE_URL, {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({ prompt, systemPrompt }),
@@ -10,6 +10,7 @@ import {
import type { ExtensionContextValue } from "@stripe/ui-extension-sdk/context";
import BrandIcon from "./brand_icon.svg";
import { API_GENERATE_URL } from "../lib/config";
import { stripeCatalog, StripeRenderer } from "../lib/render";
import { executeAction } from "../lib/render/catalog/actions";
@@ -264,7 +265,7 @@ const PaymentDetails = ({ environment }: ExtensionContextValue) => {
const systemPrompt = stripeCatalog.prompt();
try {
const response = await fetch("/api/generate", {
const response = await fetch(API_GENERATE_URL, {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({
@@ -10,6 +10,7 @@ import {
import type { ExtensionContextValue } from "@stripe/ui-extension-sdk/context";
import BrandIcon from "./brand_icon.svg";
import { API_GENERATE_URL } from "../lib/config";
import { stripeCatalog, StripeRenderer } from "../lib/render";
import { executeAction } from "../lib/render/catalog/actions";
@@ -181,7 +182,7 @@ const Payments = (_props: ExtensionContextValue) => {
const systemPrompt = stripeCatalog.prompt();
try {
const response = await fetch("/api/generate", {
const response = await fetch(API_GENERATE_URL, {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({ prompt, systemPrompt }),
@@ -10,6 +10,7 @@ import {
import type { ExtensionContextValue } from "@stripe/ui-extension-sdk/context";
import BrandIcon from "./brand_icon.svg";
import { API_GENERATE_URL } from "../lib/config";
import { stripeCatalog, StripeRenderer } from "../lib/render";
import { executeAction } from "../lib/render/catalog/actions";
@@ -232,7 +233,7 @@ const Products = (_props: ExtensionContextValue) => {
const systemPrompt = stripeCatalog.prompt();
try {
const response = await fetch("/api/generate", {
const response = await fetch(API_GENERATE_URL, {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({ prompt, systemPrompt }),
@@ -10,6 +10,7 @@ import {
import type { ExtensionContextValue } from "@stripe/ui-extension-sdk/context";
import BrandIcon from "./brand_icon.svg";
import { API_GENERATE_URL } from "../lib/config";
import { stripeCatalog, StripeRenderer } from "../lib/render";
import { executeAction } from "../lib/render/catalog/actions";
@@ -230,7 +231,7 @@ const Subscriptions = (_props: ExtensionContextValue) => {
const systemPrompt = stripeCatalog.prompt();
try {
const response = await fetch("/api/generate", {
const response = await fetch(API_GENERATE_URL, {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({ prompt, systemPrompt }),

Before

Width:  |  Height:  |  Size: 881 B

After

Width:  |  Height:  |  Size: 881 B

@@ -37,8 +37,8 @@
"purpose": "Send and manage invoices"
},
{
"permission": "refund_write",
"purpose": "Process refunds"
"permission": "charge_write",
"purpose": "Process charges and refunds"
},
{
"permission": "product_read",
@@ -49,11 +49,11 @@
"purpose": "Create and manage products"
},
{
"permission": "price_read",
"permission": "plan_read",
"purpose": "Display pricing information"
},
{
"permission": "price_write",
"permission": "plan_write",
"purpose": "Create and manage prices"
},
{
@@ -0,0 +1,14 @@
# Stripe App ID (optional - overrides the template default)
# Get this from your Stripe Apps dashboard after uploading
# STRIPE_APP_ID=com.example.your-app-id
# Stripe App Name (optional - overrides the template default)
# STRIPE_APP_NAME=My App Name
# AI Gateway API Key
# Get your key from Vercel AI Gateway dashboard
AI_GATEWAY_API_KEY=
# Optional: Stripe API keys if you want to use real data
# STRIPE_SECRET_KEY=sk_test_...
# STRIPE_PUBLISHABLE_KEY=pk_test_...
@@ -0,0 +1,49 @@
# Full Page Stripe App (Alpha)
A full-page [Stripe App](https://stripe.com/docs/stripe-apps) example using json-render. This uses the `FullPageView` component and the `stripe.dashboard.fullpage` viewport, which are part of Stripe's **private developer preview**.
## Prerequisites
- [Stripe CLI](https://stripe.com/docs/stripe-cli) with the apps plugin
- A Stripe account with **full-page apps alpha access** enabled by Stripe
- Both your `app_id` and `account_id` must be flagged in by Stripe
## Setup
```bash
pnpm install
cp .env.example .env
# Set STRIPE_APP_ID to your app ID
pnpm setup
```
## Running
Start the API server first (from `../api`):
```bash
cd ../api && pnpm dev
```
Then start the Stripe app:
```bash
stripe apps start
```
Navigate directly to your full-page app URL (it will not appear in the left navigation):
```
https://dashboard.stripe.com/test/app/<your-app-id>
```
## Troubleshooting
If you are redirected to the Dashboard home page, the feature flag is not enabled for your account. Contact your Stripe partner to have both your `app_id` and `account_id` flagged in.
## Resources
- [FullPageView Component](https://docs.stripe.com/stripe-apps/components/fullpageview?app-sdk-version=9)
- [FullPageTabs Component](https://docs.stripe.com/stripe-apps/components/fullpagetabs?app-sdk-version=9)
@@ -0,0 +1,17 @@
import { config as reactConfig } from "@repo/eslint-config/react-internal";
/** @type {import("eslint").Linter.Config[]} */
export default [
...reactConfig,
{
rules: {
// Disable prop-types - we use TypeScript for type checking
"react/prop-types": "off",
// Allow underscore-prefixed unused variables
"@typescript-eslint/no-unused-vars": [
"warn",
{ argsIgnorePattern: "^_", varsIgnorePattern: "^_" },
],
},
},
];
@@ -0,0 +1,7 @@
/* eslint-env node */
/* eslint-disable @typescript-eslint/no-var-requires */
const UIExtensionsConfig = require("@stripe/ui-extension-tools/jest.config.ui-extension");
module.exports = {
...UIExtensionsConfig,
};
@@ -0,0 +1,28 @@
{
"name": "com.example.json-render-fullpage-demo",
"version": "0.0.1",
"description": "Full-page Stripe App example (alpha)",
"private": true,
"license": "~~proprietary~~",
"type": "module",
"dependencies": {
"@json-render/core": "workspace:*",
"@json-render/react": "workspace:*",
"@stripe/ui-extension-sdk": "9.2.0-alpha.0",
"stripe": "^13.11.0",
"zod": "^4.0.0"
},
"engines": {
"node": ">=14"
},
"scripts": {
"setup": "node scripts/setup.mjs",
"lint": "eslint src --max-warnings 0",
"test": "jest"
},
"devDependencies": {
"@repo/eslint-config": "workspace:*",
"@stripe/ui-extension-tools": "^0.0.1",
"eslint": "^9.39.0"
}
}
File diff suppressed because it is too large Load Diff
@@ -0,0 +1,42 @@
import { readFileSync, writeFileSync, existsSync } from "fs";
import { resolve, dirname } from "path";
import { fileURLToPath } from "url";
const __dirname = dirname(fileURLToPath(import.meta.url));
const root = resolve(__dirname, "..");
function loadEnv() {
const envPath = resolve(root, ".env");
if (!existsSync(envPath)) return {};
const env = {};
for (const line of readFileSync(envPath, "utf-8").split("\n")) {
const trimmed = line.trim();
if (!trimmed || trimmed.startsWith("#")) continue;
const idx = trimmed.indexOf("=");
if (idx === -1) continue;
const key = trimmed.slice(0, idx).trim();
const val = trimmed.slice(idx + 1).trim();
env[key] = val;
}
return env;
}
const env = loadEnv();
const templatePath = resolve(root, "stripe-app.template.json");
const outputPath = resolve(root, "stripe-app.json");
const template = JSON.parse(readFileSync(templatePath, "utf-8"));
if (env.STRIPE_APP_ID) {
template.id = env.STRIPE_APP_ID;
console.log(`Using app ID from .env: ${env.STRIPE_APP_ID}`);
} else {
console.log(`No STRIPE_APP_ID in .env, using template default: ${template.id}`);
}
if (env.STRIPE_APP_NAME) {
template.name = env.STRIPE_APP_NAME;
}
writeFileSync(outputPath, JSON.stringify(template, null, 4) + "\n");
console.log("Generated stripe-app.json");
@@ -0,0 +1 @@
export const API_GENERATE_URL = "http://localhost:3001/api/generate";
File diff suppressed because it is too large Load Diff
@@ -1374,16 +1374,16 @@ export const actionHandlers: Record<
await actionHandlers.fetchCustomers({}, setState, {});
},
refreshPayments: async (_) => {
await actionHandlers.fetchPayments({}, setData, {});
refreshPayments: async (_, setState) => {
await actionHandlers.fetchPayments({}, setState, {});
},
refreshSubscriptions: async (_) => {
await actionHandlers.fetchSubscriptions({}, setData, {});
refreshSubscriptions: async (_, setState) => {
await actionHandlers.fetchSubscriptions({}, setState, {});
},
refreshInvoices: async (_) => {
await actionHandlers.fetchInvoices({}, setData, {});
refreshInvoices: async (_, setState) => {
await actionHandlers.fetchInvoices({}, setState, {});
},
exportData: async (params, _, data) => {
@@ -1431,11 +1431,8 @@ export const actionHandlers: Record<
// ===========================================================================
// Navigation Actions
// ===========================================================================
navigate: async (params) => {
if (params?.statePath) {
console.log("Navigate to:", params.statePath);
// In a real app, this would use the router
}
navigate: async (_params) => {
// In a real app, this would use the router
},
openDashboard: async (params) => {
@@ -1463,8 +1460,7 @@ export const actionHandlers: Record<
// ===========================================================================
// Form Actions
// ===========================================================================
submitForm: async (params, _, data) => {
console.log("Submit form:", params?.formId, data);
submitForm: async (_params, _, _data) => {
// Implementation depends on form handling logic
},
@@ -1475,9 +1471,8 @@ export const actionHandlers: Record<
}
},
validateForm: async (params, _, data) => {
console.log("Validate form:", params?.formId, data);
return;
validateForm: async (_params, _, _data) => {
// Implementation depends on form validation logic
},
setFormValue: async (params, setState) => {
@@ -1492,9 +1487,8 @@ export const actionHandlers: Record<
// ===========================================================================
// UI Actions
// ===========================================================================
showToast: async (params) => {
// In UIXT, you would use the showToast API from the SDK
console.log("Toast:", params?.type, params?.message);
showToast: async (_params) => {
// In a real app, use showToast from @stripe/ui-extension-sdk/utils
},
copyToClipboard: async (params) => {
@@ -1567,6 +1561,6 @@ export async function executeAction(
if (handler) {
await handler(params, setState, data);
} else {
console.log("Unknown action:", actionName, params);
console.warn("Unknown action:", actionName);
}
}
File diff suppressed because it is too large Load Diff
@@ -0,0 +1,11 @@
// Catalog
export { stripeCatalog } from "./catalog";
// Components
export { components, Fallback } from "./catalog/components";
// Actions
export { actionHandlers, executeAction } from "./catalog/actions";
// Renderer
export { StripeRenderer, type StripeRendererProps } from "./renderer";
@@ -0,0 +1,145 @@
import { useMemo, useRef, type ReactNode } from "react";
import {
Renderer,
type ComponentRegistry,
type Spec,
StateProvider,
VisibilityProvider,
ActionProvider,
} from "@json-render/react";
import { components, Fallback } from "./catalog/components";
import { executeAction } from "./catalog/actions";
// =============================================================================
// Types
// =============================================================================
type SetState = (
updater: (prev: Record<string, unknown>) => Record<string, unknown>,
) => void;
export interface StripeRendererProps {
/** The UI spec to render (from json-render) */
spec: Spec | null;
/** Data context for components */
data?: Record<string, unknown>;
/** Function to update data */
setData?: SetState;
/** Callback when data changes */
onStateChange?: (path: string, value: unknown) => void;
/** Whether the spec is currently loading/streaming */
loading?: boolean;
}
// =============================================================================
// Build Registry
// =============================================================================
/**
* Build a component registry from our components map.
* Uses refs to avoid recreating on data changes.
*/
function buildRegistry(
dataRef: React.RefObject<Record<string, unknown>>,
setDataRef: React.RefObject<SetState | undefined>,
loading?: boolean,
): ComponentRegistry {
const registry: ComponentRegistry = {};
for (const [name, Component] of Object.entries(components)) {
const noop = () => {};
registry[name] = (renderProps: {
element: { type: string; props: Record<string, unknown> };
children?: ReactNode;
emit?: (event: string) => void;
}) =>
Component({
element: renderProps.element,
children: renderProps.children,
emit: renderProps.emit ?? noop,
loading,
state: dataRef.current,
getValue: (path: string) => {
const data = dataRef.current;
const parts = path.replace(/^\//, "").split("/");
let current: unknown = data;
for (const part of parts) {
if (current && typeof current === "object" && part in current) {
current = (current as Record<string, unknown>)[part];
} else {
return undefined;
}
}
return current;
},
onAction: (actionName: string, params?: Record<string, unknown>) => {
const setState = setDataRef.current;
if (setState) {
executeAction(actionName, params, setState, dataRef.current);
}
},
});
}
return registry;
}
/**
* Fallback component for unknown types
*/
const fallbackRegistry = (renderProps: {
element: { type: string; props: Record<string, unknown> };
}) => <Fallback element={renderProps.element} />;
// =============================================================================
// StripeRenderer Component
// =============================================================================
/**
* Main renderer component for Stripe UIXT.
*
* Wraps the json-render Renderer with all necessary providers
* and connects it to the Stripe component implementations.
*/
export function StripeRenderer({
spec,
data = {},
setData,
onStateChange,
loading,
}: StripeRendererProps): ReactNode {
// Use refs to keep registry stable while still accessing latest data/setData
const dataRef = useRef(data);
const setDataRef = useRef(setData);
dataRef.current = data;
setDataRef.current = setData;
// Memoize registry - only changes when loading changes
const registry = useMemo(
() => buildRegistry(dataRef, setDataRef, loading),
[loading],
);
const mergedState = useMemo(
() => (spec?.state ? { ...data, ...spec.state } : data),
[data, spec?.state],
);
if (!spec) return null;
return (
<StateProvider initialState={mergedState} onStateChange={onStateChange}>
<VisibilityProvider>
<ActionProvider>
<Renderer
spec={spec}
registry={registry}
fallback={fallbackRegistry}
loading={loading}
/>
</ActionProvider>
</VisibilityProvider>
</StateProvider>
);
}
@@ -0,0 +1,146 @@
import type { Spec } from "@json-render/react";
interface JsonPatch {
op: string;
path: string;
value?: unknown;
from?: string;
}
function setDeep(
obj: Record<string, unknown>,
segments: string[],
value: unknown,
): void {
let current: unknown = obj;
for (let i = 0; i < segments.length - 1; i++) {
const seg = segments[i];
const next = (current as Record<string, unknown>)[seg];
if (next && typeof next === "object") {
current = next;
} else {
const container = /^\d+$/.test(segments[i + 1]) ? [] : {};
(current as Record<string, unknown>)[seg] = container;
current = container;
}
}
const last = segments[segments.length - 1];
if (Array.isArray(current)) {
(current as unknown[])[Number(last)] = value;
} else {
(current as Record<string, unknown>)[last] = value;
}
}
function setSpecValue(spec: Spec, path: string, value: unknown): void {
if (path === "/root") {
(spec as Record<string, unknown>).root = value as string;
return;
}
if (path === "/state") {
(spec as Record<string, unknown>).state = value;
return;
}
if (path.startsWith("/state/")) {
if (!spec.state) (spec as Record<string, unknown>).state = {};
const segments = path.slice("/state/".length).split("/");
setDeep(spec.state as Record<string, unknown>, segments, value);
return;
}
const elemMatch = path.match(/^\/elements\/(.+)/);
if (elemMatch) {
spec.elements[elemMatch[1]] = value as Spec["elements"][string];
}
}
function applyPatch(spec: Spec, patch: JsonPatch): Spec {
const next: Spec = {
...spec,
elements: { ...spec.elements },
...(spec.state ? { state: { ...spec.state } } : {}),
};
switch (patch.op) {
case "add":
case "replace":
setSpecValue(next, patch.path, patch.value);
break;
case "remove":
if (patch.path.startsWith("/elements/")) {
const key = patch.path.slice("/elements/".length);
delete next.elements[key];
}
break;
}
return next;
}
/**
* Fetch an AI generation endpoint that returns a JSONL patch stream,
* applying patches progressively via onPatch.
*/
export async function streamSpec(
url: string,
body: Record<string, unknown>,
onPatch: (spec: Spec) => void,
): Promise<Spec> {
const response = await fetch(url, {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify(body),
});
if (!response.ok) {
let msg = `API error: ${response.status}`;
try {
const errData = await response.json();
if (errData.error) msg = errData.error;
} catch {
// use default
}
throw new Error(msg);
}
const reader = response.body?.getReader();
if (!reader) throw new Error("No response body");
const decoder = new TextDecoder();
let buffer = "";
let spec: Spec = { root: "", elements: {} };
while (true) {
const { done, value } = await reader.read();
if (done) break;
buffer += decoder.decode(value, { stream: true });
const lines = buffer.split("\n");
buffer = lines.pop() ?? "";
for (const line of lines) {
const trimmed = line.trim();
if (!trimmed || trimmed.startsWith("//")) continue;
try {
const parsed = JSON.parse(trimmed) as JsonPatch;
if (parsed.op) {
spec = applyPatch(spec, parsed);
onPatch(spec);
}
} catch {
// skip non-JSON lines
}
}
}
if (buffer.trim()) {
try {
const parsed = JSON.parse(buffer.trim()) as JsonPatch;
if (parsed.op) {
spec = applyPatch(spec, parsed);
onPatch(spec);
}
} catch {
// skip
}
}
return spec;
}
@@ -0,0 +1,37 @@
import Stripe from "stripe";
import {
createHttpClient,
STRIPE_API_KEY,
} from "@stripe/ui-extension-sdk/http_client";
/**
* Create a Stripe client for use in Stripe Apps.
*
* The Stripe UI Extension SDK handles authentication automatically
* through the httpClient - no real API key is needed.
*/
export const stripe = new Stripe(STRIPE_API_KEY, {
httpClient: createHttpClient(),
apiVersion: "2024-12-18.acacia",
});
/**
* Format amount for display (converts cents to dollars)
*/
export function formatAmount(amount: number, currency = "usd"): string {
return new Intl.NumberFormat("en-US", {
style: "currency",
currency: currency.toUpperCase(),
}).format(amount / 100);
}
/**
* Format date for display
*/
export function formatDate(timestamp: number): string {
return new Date(timestamp * 1000).toLocaleDateString("en-US", {
year: "numeric",
month: "short",
day: "numeric",
});
}
@@ -0,0 +1,308 @@
import { useState, useCallback, useEffect } from "react";
import type { Spec } from "@json-render/react";
import {
Box,
FullPageView,
FullPageTabs,
FullPageTab,
Button,
TextField,
Divider,
List,
ListItem,
Spinner,
} from "@stripe/ui-extension-sdk/ui";
import type { ExtensionContextValue } from "@stripe/ui-extension-sdk/context";
import { stripeCatalog, StripeRenderer } from "../lib/render";
import { executeAction } from "../lib/render/catalog/actions";
import { API_GENERATE_URL } from "../lib/config";
import { streamSpec } from "../lib/stream-spec";
function createOverviewSpec(data: Record<string, unknown>): Spec {
const customers = data.customers as { total?: number } | undefined;
const payments = data.payments as
| { totalVolume?: string; successRate?: string }
| undefined;
const subscriptions = data.subscriptions as
| { active?: number; trialing?: number; pastDue?: number }
| undefined;
return {
root: "root",
elements: {
root: {
type: "Stack",
props: { direction: "vertical", gap: "large" },
children: ["metrics", "divider", "refresh"],
},
metrics: {
type: "Stack",
props: { direction: "horizontal", gap: "medium" },
children: ["m1", "m2", "m3"],
},
m1: {
type: "Metric",
props: {
label: "Payment Volume",
value: payments?.totalVolume ?? "$0",
change: payments?.successRate
? `${payments.successRate} success`
: null,
changeType: "positive",
},
children: [],
},
m2: {
type: "Metric",
props: {
label: "Active Subscriptions",
value: String(subscriptions?.active ?? 0),
change: subscriptions?.trialing
? `+${subscriptions.trialing} trialing`
: null,
changeType: "positive",
},
children: [],
},
m3: {
type: "Metric",
props: {
label: "Total Customers",
value: String(customers?.total ?? 0),
changeType: "neutral",
},
children: [],
},
divider: { type: "Divider", props: {}, children: [] },
refresh: {
type: "Button",
props: {
label: "Refresh Data",
action: "refreshData",
type: "secondary",
},
children: [],
},
},
};
}
const componentCatalogInfo = Object.entries(stripeCatalog.data.components).map(
([name, def]) => ({
name,
description: def.description,
}),
);
const actionsCatalogInfo = Object.entries(stripeCatalog.data.actions).map(
([name, def]) => ({
name,
description: def.description,
}),
);
const FullPage = (_props: ExtensionContextValue) => {
const [prompt, setPrompt] = useState("");
const [isGenerating, setIsGenerating] = useState(false);
const [isLoading, setIsLoading] = useState(true);
const [currentSpec, setCurrentSpec] = useState<Spec | null>(null);
const [data, setState] = useState<Record<string, unknown>>({});
const [error, setError] = useState<string | null>(null);
const handleSetState = useCallback(
(updater: (prev: Record<string, unknown>) => Record<string, unknown>) => {
setState((prev) => updater(prev));
},
[],
);
useEffect(() => {
async function loadData() {
setIsLoading(true);
try {
await executeAction("refreshData", {}, handleSetState, {});
} catch (err) {
console.error("Failed to load data:", err);
setError("Failed to load Stripe data");
} finally {
setIsLoading(false);
}
}
loadData();
}, [handleSetState]);
const handleGenerate = async () => {
if (!prompt.trim()) return;
setIsGenerating(true);
setError(null);
try {
await streamSpec(
API_GENERATE_URL,
{
prompt,
systemPrompt: stripeCatalog.prompt({
system:
"You are a Stripe dashboard builder. Generate UI specs for displaying Stripe data in a full-page layout.",
}),
},
(spec) => setCurrentSpec(spec),
);
} catch (err) {
setCurrentSpec(createOverviewSpec(data));
setError(
`Using local generation (${err instanceof Error ? err.message : "API unavailable"})`,
);
} finally {
setIsGenerating(false);
}
};
if (isLoading) {
return (
<FullPageView>
<FullPageTabs>
<FullPageTab
id="overview"
label="Overview"
content={
<Box
css={{
stack: "y",
gap: "medium",
alignX: "center",
paddingY: "xlarge",
}}
>
<Spinner size="large" />
<Box css={{ font: "body", color: "secondary" }}>
Loading Stripe data...
</Box>
</Box>
}
/>
</FullPageTabs>
</FullPageView>
);
}
return (
<FullPageView
pageAction={{
label: "Refresh Data",
onPress: () => executeAction("refreshData", {}, handleSetState, {}),
}}
>
<FullPageTabs>
<FullPageTab
id="overview"
label="Overview"
content={
<Box css={{ stack: "y", gap: "large", padding: "large" }}>
<StripeRenderer
spec={createOverviewSpec(data)}
data={data}
setData={handleSetState}
loading={false}
/>
</Box>
}
/>
<FullPageTab
id="generate"
label="Generate"
content={
<Box css={{ stack: "y", gap: "medium", padding: "large" }}>
<TextField
label="Describe the dashboard you want"
placeholder="e.g., Show MRR, payments, or subscriptions"
value={prompt}
onChange={(e) => setPrompt(e.target.value)}
/>
<Button
type="primary"
onPress={handleGenerate}
disabled={isGenerating || !prompt.trim()}
>
{isGenerating ? "Generating..." : "Generate"}
</Button>
{error && (
<Box css={{ color: "secondary", font: "caption" }}>{error}</Box>
)}
{currentSpec && (
<>
<Divider />
<Box css={{ font: "subheading" }}>
Preview (with real Stripe data)
</Box>
<Box
css={{
padding: "medium",
borderRadius: "medium",
keyline: "neutral",
}}
>
<StripeRenderer
spec={currentSpec}
data={data}
setData={handleSetState}
loading={isGenerating}
/>
</Box>
</>
)}
</Box>
}
/>
<FullPageTab
id="components"
label="Components"
content={
<Box css={{ padding: "large" }}>
<Box css={{ font: "subheading", marginBottom: "medium" }}>
Available Components ({componentCatalogInfo.length})
</Box>
<List>
{componentCatalogInfo.map((comp) => (
<ListItem
key={comp.name}
title={comp.name}
secondaryTitle={comp.description}
/>
))}
</List>
</Box>
}
/>
<FullPageTab
id="actions"
label="Actions"
content={
<Box css={{ padding: "large" }}>
<Box css={{ font: "subheading", marginBottom: "medium" }}>
Available Actions ({actionsCatalogInfo.length})
</Box>
<List>
{actionsCatalogInfo.map((action) => (
<ListItem
key={action.name}
title={action.name}
secondaryTitle={action.description}
/>
))}
</List>
</Box>
}
/>
</FullPageTabs>
</FullPageView>
);
};
export default FullPage;

Some files were not shown because too many files have changed in this diff Show More