mirror of
https://github.com/vercel-labs/json-render.git
synced 2026-10-02 12:00:58 +08:00
Compare commits
5
Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
0a404302ef | ||
|
|
09376db2f6 | ||
|
|
4c294417f4 | ||
|
|
f77c1d6c98 | ||
|
|
a66aef17f9 |
@@ -6,6 +6,7 @@
|
||||
[
|
||||
"@json-render/core",
|
||||
"@json-render/react",
|
||||
"@json-render/react-pdf",
|
||||
"@json-render/shadcn",
|
||||
"@json-render/react-native",
|
||||
"@json-render/remotion",
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -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
|
||||
|
||||
Vendored
+1
-1
@@ -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.
|
||||
|
||||
Vendored
+1
-1
@@ -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.
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
@@ -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"] }],
|
||||
},
|
||||
},
|
||||
];
|
||||
@@ -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",
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
];
|
||||
@@ -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",
|
||||
},
|
||||
},
|
||||
});
|
||||
@@ -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?.(),
|
||||
};
|
||||
Vendored
+6
@@ -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.
|
||||
@@ -0,0 +1,5 @@
|
||||
import type { NextConfig } from "next";
|
||||
|
||||
const nextConfig: NextConfig = {};
|
||||
|
||||
export default nextConfig;
|
||||
@@ -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"
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,5 @@
|
||||
export default {
|
||||
plugins: {
|
||||
"@tailwindcss/postcss": {},
|
||||
},
|
||||
};
|
||||
@@ -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"]
|
||||
}
|
||||
@@ -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();
|
||||
}
|
||||
@@ -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",
|
||||
},
|
||||
});
|
||||
}
|
||||
@@ -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);
|
||||
}
|
||||
}
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
@@ -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 };
|
||||
@@ -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,
|
||||
};
|
||||
@@ -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 };
|
||||
@@ -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
@@ -0,0 +1,6 @@
|
||||
import { clsx, type ClassValue } from "clsx";
|
||||
import { twMerge } from "tailwind-merge";
|
||||
|
||||
export function cn(...inputs: ClassValue[]) {
|
||||
return twMerge(clsx(inputs));
|
||||
}
|
||||
Vendored
+6
@@ -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.
|
||||
@@ -0,0 +1,7 @@
|
||||
import type { NextConfig } from "next";
|
||||
|
||||
const nextConfig: NextConfig = {
|
||||
serverExternalPackages: ["@react-pdf/renderer", "@json-render/react-pdf"],
|
||||
};
|
||||
|
||||
export default nextConfig;
|
||||
@@ -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"
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,8 @@
|
||||
/** @type {import('postcss-load-config').Config} */
|
||||
const config = {
|
||||
plugins: {
|
||||
"@tailwindcss/postcss": {},
|
||||
},
|
||||
};
|
||||
|
||||
export default config;
|
||||
@@ -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"]
|
||||
}
|
||||
Vendored
+1
-1
@@ -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,7 +0,0 @@
|
||||
# AI Gateway API Key
|
||||
# Get your key from Vercel AI Gateway dashboard
|
||||
AI_GATEWAY_API_KEY=
|
||||
|
||||
# Optional: Stripe API keys if you want to use real data
|
||||
# STRIPE_SECRET_KEY=sk_test_...
|
||||
# STRIPE_PUBLISHABLE_KEY=pk_test_...
|
||||
@@ -1,99 +1,49 @@
|
||||
# Stripe App Example
|
||||
# Stripe App Examples
|
||||
|
||||
A [Stripe App](https://stripe.com/docs/stripe-apps) example demonstrating how to use json-render to build dynamic, AI-generated UI within the Stripe Dashboard.
|
||||
[Stripe Apps](https://stripe.com/docs/stripe-apps) examples demonstrating how to use json-render to build dynamic, AI-generated UI within the Stripe Dashboard.
|
||||
|
||||
## Overview
|
||||
## Structure
|
||||
|
||||
This example shows how to integrate json-render with Stripe's UI Extension SDK to create dashboard views that can be dynamically generated from prompts. The app includes:
|
||||
| Folder | Description |
|
||||
|--------|-------------|
|
||||
| [api/](./api) | Next.js server providing the `/api/generate` endpoint for AI-powered UI generation |
|
||||
| [drawer-app/](./drawer-app) | Standard Stripe App that renders in the Dashboard drawer (sidebar) |
|
||||
| [fullpage-app/](./fullpage-app) | Full-page Stripe App using `FullPageView` (requires alpha access) |
|
||||
|
||||
- **Home** - Revenue dashboard with AI-powered UI generation
|
||||
- **Customers** - Customer list and management
|
||||
- **Customer Details** - Individual customer view
|
||||
- **Payments** - Payment list and details
|
||||
- **Subscriptions** - Subscription management
|
||||
- **Invoices** - Invoice list
|
||||
- **Products** - Product catalog
|
||||
## Quick Start
|
||||
|
||||
## Features
|
||||
|
||||
- Full Stripe UIXT component catalog mapped to json-render
|
||||
- Dynamic spec generation from natural language prompts
|
||||
- Real-time data binding with Stripe API
|
||||
- Action handlers for Stripe operations (refunds, subscriptions, etc.)
|
||||
|
||||
## Getting Started
|
||||
|
||||
### Prerequisites
|
||||
|
||||
- [Stripe CLI](https://stripe.com/docs/stripe-cli) installed
|
||||
- A Stripe account with app development enabled
|
||||
|
||||
### Installation
|
||||
### 1. Start the API server
|
||||
|
||||
```bash
|
||||
# Install dependencies
|
||||
cd api
|
||||
pnpm install
|
||||
cp .env.example .env # Set AI_GATEWAY_API_KEY
|
||||
pnpm dev
|
||||
```
|
||||
|
||||
# Start the Stripe App in development mode
|
||||
### 2. Start a Stripe App
|
||||
|
||||
For the standard drawer app:
|
||||
|
||||
```bash
|
||||
cd drawer-app
|
||||
pnpm install
|
||||
pnpm setup
|
||||
stripe apps start
|
||||
```
|
||||
|
||||
### Development
|
||||
|
||||
The app uses the Stripe UI Extension SDK which handles authentication automatically - no API keys required in your environment.
|
||||
For the full-page app (requires alpha access):
|
||||
|
||||
```bash
|
||||
# Run linting
|
||||
pnpm lint
|
||||
|
||||
# Run tests
|
||||
pnpm test
|
||||
cd fullpage-app
|
||||
pnpm install
|
||||
cp .env.example .env # Set STRIPE_APP_ID
|
||||
pnpm setup
|
||||
stripe apps start
|
||||
# Navigate to https://dashboard.stripe.com/test/app/<your-app-id>
|
||||
```
|
||||
|
||||
## How It Works
|
||||
## Notes
|
||||
|
||||
1. **Component Catalog**: Maps json-render component types to Stripe UIXT components (`Box`, `Button`, `Badge`, etc.)
|
||||
|
||||
2. **Action Handlers**: Defines actions that can be triggered from the UI (e.g., `refundPayment`, `cancelSubscription`)
|
||||
|
||||
3. **StripeRenderer**: A custom renderer that uses the Stripe component catalog to render json-render specs
|
||||
|
||||
4. **Views**: Each view fetches data from the Stripe API and renders it using json-render specs
|
||||
|
||||
## Example Usage
|
||||
|
||||
```tsx
|
||||
import { StripeRenderer, stripeCatalog } from "./lib/render";
|
||||
import type { Spec } from "@json-render/react";
|
||||
|
||||
const spec: Spec = {
|
||||
root: "container",
|
||||
elements: {
|
||||
container: {
|
||||
type: "Stack",
|
||||
props: { direction: "vertical", gap: "medium" },
|
||||
children: ["heading", "metric"],
|
||||
},
|
||||
heading: {
|
||||
type: "Heading",
|
||||
props: { text: "Revenue", size: "large" },
|
||||
children: [],
|
||||
},
|
||||
metric: {
|
||||
type: "Metric",
|
||||
props: { label: "Total", value: "$12,345", format: "currency" },
|
||||
children: [],
|
||||
},
|
||||
},
|
||||
};
|
||||
|
||||
function MyView() {
|
||||
return <StripeRenderer spec={spec} catalog={stripeCatalog} />;
|
||||
}
|
||||
```
|
||||
|
||||
## Learn More
|
||||
|
||||
- [json-render Documentation](https://json-render.com/docs)
|
||||
- [Stripe Apps Documentation](https://stripe.com/docs/stripe-apps)
|
||||
- [Stripe UI Extension SDK](https://stripe.com/docs/stripe-apps/reference/ui-extension-sdk)
|
||||
- The AI generation feature requires the API server to be running. Without it, the apps fall back to locally generated specs using real Stripe data.
|
||||
- The `fullpage-app` uses Stripe's full-page apps **private developer preview**. You need Stripe to enable the feature flag for your app and account. See [fullpage-app/README.md](./fullpage-app/README.md) for details.
|
||||
|
||||
@@ -0,0 +1,3 @@
|
||||
# AI Gateway API Key (required for AI generation)
|
||||
# Get your key from Vercel AI Gateway dashboard
|
||||
AI_GATEWAY_API_KEY=
|
||||
@@ -0,0 +1,26 @@
|
||||
# Stripe App API
|
||||
|
||||
A lightweight Next.js server that provides the `/api/generate` endpoint for AI-powered UI generation in the Stripe App examples.
|
||||
|
||||
## Setup
|
||||
|
||||
```bash
|
||||
pnpm install
|
||||
|
||||
cp .env.example .env
|
||||
# Set AI_GATEWAY_API_KEY
|
||||
```
|
||||
|
||||
## Running
|
||||
|
||||
```bash
|
||||
pnpm dev
|
||||
```
|
||||
|
||||
Runs on port 3001. The Stripe apps (`drawer-app/` and `fullpage-app/`) expect this server to be running for AI generation features.
|
||||
|
||||
## API
|
||||
|
||||
### POST /api/generate
|
||||
|
||||
Accepts `{ prompt, systemPrompt }` and returns a streamed text response with a json-render spec.
|
||||
@@ -0,0 +1,50 @@
|
||||
import { streamText } from "ai";
|
||||
import { gateway } from "@ai-sdk/gateway";
|
||||
|
||||
export const maxDuration = 60;
|
||||
|
||||
const DEFAULT_MODEL = "anthropic/claude-haiku-4.5";
|
||||
|
||||
const CORS_HEADERS: Record<string, string> = {
|
||||
"Access-Control-Allow-Origin": "*",
|
||||
"Access-Control-Allow-Methods": "POST, OPTIONS",
|
||||
"Access-Control-Allow-Headers": "Content-Type",
|
||||
};
|
||||
|
||||
export async function POST(req: Request) {
|
||||
const { prompt, systemPrompt } = await req.json();
|
||||
|
||||
if (!prompt) {
|
||||
return new Response(JSON.stringify({ error: "prompt is required" }), {
|
||||
status: 400,
|
||||
headers: { "Content-Type": "application/json", ...CORS_HEADERS },
|
||||
});
|
||||
}
|
||||
|
||||
try {
|
||||
const result = streamText({
|
||||
model: gateway(DEFAULT_MODEL),
|
||||
system: systemPrompt ?? "You are a helpful UI builder.",
|
||||
prompt,
|
||||
temperature: 0.7,
|
||||
});
|
||||
|
||||
const response = result.toTextStreamResponse();
|
||||
|
||||
for (const [key, value] of Object.entries(CORS_HEADERS)) {
|
||||
response.headers.set(key, value);
|
||||
}
|
||||
|
||||
return response;
|
||||
} catch (err) {
|
||||
const message = err instanceof Error ? err.message : "Generation failed";
|
||||
return new Response(JSON.stringify({ error: message }), {
|
||||
status: 500,
|
||||
headers: { "Content-Type": "application/json", ...CORS_HEADERS },
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
export async function OPTIONS() {
|
||||
return new Response(null, { headers: CORS_HEADERS });
|
||||
}
|
||||
@@ -0,0 +1,15 @@
|
||||
export const metadata = {
|
||||
title: "Stripe App API",
|
||||
};
|
||||
|
||||
export default function RootLayout({
|
||||
children,
|
||||
}: {
|
||||
children: React.ReactNode;
|
||||
}) {
|
||||
return (
|
||||
<html lang="en">
|
||||
<body>{children}</body>
|
||||
</html>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,8 @@
|
||||
export default function Home() {
|
||||
return (
|
||||
<main style={{ padding: "2rem", fontFamily: "system-ui, sans-serif" }}>
|
||||
<h1>Stripe App API</h1>
|
||||
<p>POST /api/generate to generate UI specs.</p>
|
||||
</main>
|
||||
);
|
||||
}
|
||||
+6
@@ -0,0 +1,6 @@
|
||||
/// <reference types="next" />
|
||||
/// <reference types="next/image-types/global" />
|
||||
import "./.next/types/routes.d.ts";
|
||||
|
||||
// NOTE: This file should not be edited
|
||||
// see https://nextjs.org/docs/app/api-reference/config/typescript for more information.
|
||||
@@ -0,0 +1,5 @@
|
||||
import type { NextConfig } from "next";
|
||||
|
||||
const nextConfig: NextConfig = {};
|
||||
|
||||
export default nextConfig;
|
||||
@@ -0,0 +1,23 @@
|
||||
{
|
||||
"name": "stripe-app-api",
|
||||
"version": "0.1.0",
|
||||
"private": true,
|
||||
"type": "module",
|
||||
"scripts": {
|
||||
"dev": "next dev --port 3001",
|
||||
"build": "next build",
|
||||
"start": "next start --port 3001"
|
||||
},
|
||||
"dependencies": {
|
||||
"@ai-sdk/gateway": "^3.0.50",
|
||||
"ai": "^6.0.91",
|
||||
"next": "^16.1.6",
|
||||
"react": "^19.1.0",
|
||||
"react-dom": "^19.1.0"
|
||||
},
|
||||
"devDependencies": {
|
||||
"@types/node": "^22.0.0",
|
||||
"@types/react": "^19.1.0",
|
||||
"typescript": "^5.4.5"
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,41 @@
|
||||
{
|
||||
"compilerOptions": {
|
||||
"target": "ES2017",
|
||||
"lib": [
|
||||
"dom",
|
||||
"dom.iterable",
|
||||
"esnext"
|
||||
],
|
||||
"allowJs": true,
|
||||
"skipLibCheck": true,
|
||||
"strict": true,
|
||||
"noEmit": true,
|
||||
"esModuleInterop": true,
|
||||
"module": "esnext",
|
||||
"moduleResolution": "bundler",
|
||||
"resolveJsonModule": true,
|
||||
"isolatedModules": true,
|
||||
"jsx": "react-jsx",
|
||||
"incremental": true,
|
||||
"plugins": [
|
||||
{
|
||||
"name": "next"
|
||||
}
|
||||
],
|
||||
"paths": {
|
||||
"@/*": [
|
||||
"./*"
|
||||
]
|
||||
}
|
||||
},
|
||||
"include": [
|
||||
"next-env.d.ts",
|
||||
"**/*.ts",
|
||||
"**/*.tsx",
|
||||
".next/types/**/*.ts",
|
||||
".next/dev/types/**/*.ts"
|
||||
],
|
||||
"exclude": [
|
||||
"node_modules"
|
||||
]
|
||||
}
|
||||
@@ -0,0 +1,14 @@
|
||||
# Stripe App ID (optional - overrides the template default)
|
||||
# Get this from your Stripe Apps dashboard after uploading
|
||||
# STRIPE_APP_ID=com.example.your-app-id
|
||||
|
||||
# Stripe App Name (optional - overrides the template default)
|
||||
# STRIPE_APP_NAME=My App Name
|
||||
|
||||
# AI Gateway API Key
|
||||
# Get your key from Vercel AI Gateway dashboard
|
||||
AI_GATEWAY_API_KEY=
|
||||
|
||||
# Optional: Stripe API keys if you want to use real data
|
||||
# STRIPE_SECRET_KEY=sk_test_...
|
||||
# STRIPE_PUBLISHABLE_KEY=pk_test_...
|
||||
@@ -0,0 +1,45 @@
|
||||
# Stripe App (Drawer)
|
||||
|
||||
A [Stripe App](https://stripe.com/docs/stripe-apps) example demonstrating how to use json-render to build dynamic, AI-generated UI in the Stripe Dashboard drawer.
|
||||
|
||||
## Views
|
||||
|
||||
- **Home** - Revenue dashboard with AI-powered UI generation
|
||||
- **Customers** - Customer list and management
|
||||
- **Customer Details** - Individual customer view
|
||||
- **Payments** - Payment list and details
|
||||
- **Subscriptions** - Subscription management
|
||||
- **Invoices** - Invoice list
|
||||
- **Products** - Product catalog
|
||||
|
||||
## Setup
|
||||
|
||||
```bash
|
||||
pnpm install
|
||||
|
||||
cp .env.example .env
|
||||
# Optionally set STRIPE_APP_ID to your own app ID
|
||||
|
||||
pnpm setup
|
||||
```
|
||||
|
||||
## Running
|
||||
|
||||
Start the API server first (from `../api`):
|
||||
|
||||
```bash
|
||||
cd ../api && pnpm dev
|
||||
```
|
||||
|
||||
Then start the Stripe app:
|
||||
|
||||
```bash
|
||||
stripe apps start
|
||||
```
|
||||
|
||||
## How It Works
|
||||
|
||||
1. **Component Catalog** - Maps json-render component types to Stripe UIXT components
|
||||
2. **Action Handlers** - Stripe operations (refunds, subscriptions, etc.)
|
||||
3. **StripeRenderer** - Custom renderer connecting json-render specs to Stripe components
|
||||
4. **Views** - Each view fetches data from the Stripe API and renders it using json-render specs
|
||||
@@ -9,12 +9,14 @@
|
||||
"@json-render/core": "workspace:*",
|
||||
"@json-render/react": "workspace:*",
|
||||
"@stripe/ui-extension-sdk": "^9.1.0",
|
||||
"stripe": "^13.11.0"
|
||||
"stripe": "^13.11.0",
|
||||
"zod": "^4.0.0"
|
||||
},
|
||||
"engines": {
|
||||
"node": ">=14"
|
||||
},
|
||||
"scripts": {
|
||||
"setup": "node scripts/setup.mjs",
|
||||
"lint": "eslint src --max-warnings 0",
|
||||
"test": "jest"
|
||||
},
|
||||
@@ -0,0 +1,42 @@
|
||||
import { readFileSync, writeFileSync, existsSync } from "fs";
|
||||
import { resolve, dirname } from "path";
|
||||
import { fileURLToPath } from "url";
|
||||
|
||||
const __dirname = dirname(fileURLToPath(import.meta.url));
|
||||
const root = resolve(__dirname, "..");
|
||||
|
||||
function loadEnv() {
|
||||
const envPath = resolve(root, ".env");
|
||||
if (!existsSync(envPath)) return {};
|
||||
const env = {};
|
||||
for (const line of readFileSync(envPath, "utf-8").split("\n")) {
|
||||
const trimmed = line.trim();
|
||||
if (!trimmed || trimmed.startsWith("#")) continue;
|
||||
const idx = trimmed.indexOf("=");
|
||||
if (idx === -1) continue;
|
||||
const key = trimmed.slice(0, idx).trim();
|
||||
const val = trimmed.slice(idx + 1).trim();
|
||||
env[key] = val;
|
||||
}
|
||||
return env;
|
||||
}
|
||||
|
||||
const env = loadEnv();
|
||||
const templatePath = resolve(root, "stripe-app.template.json");
|
||||
const outputPath = resolve(root, "stripe-app.json");
|
||||
|
||||
const template = JSON.parse(readFileSync(templatePath, "utf-8"));
|
||||
|
||||
if (env.STRIPE_APP_ID) {
|
||||
template.id = env.STRIPE_APP_ID;
|
||||
console.log(`Using app ID from .env: ${env.STRIPE_APP_ID}`);
|
||||
} else {
|
||||
console.log(`No STRIPE_APP_ID in .env, using template default: ${template.id}`);
|
||||
}
|
||||
|
||||
if (env.STRIPE_APP_NAME) {
|
||||
template.name = env.STRIPE_APP_NAME;
|
||||
}
|
||||
|
||||
writeFileSync(outputPath, JSON.stringify(template, null, 4) + "\n");
|
||||
console.log("Generated stripe-app.json");
|
||||
@@ -0,0 +1 @@
|
||||
export const API_GENERATE_URL = "http://localhost:3001/api/generate";
|
||||
File diff suppressed because it is too large
Load Diff
+87
-22
@@ -62,6 +62,7 @@ type ExtendedRenderProps<P = Record<string, unknown>> =
|
||||
ComponentRenderProps<P> & {
|
||||
state?: Record<string, unknown>;
|
||||
getValue?: (path: string) => unknown;
|
||||
onAction?: (actionName: string, params?: Record<string, unknown>) => void;
|
||||
};
|
||||
|
||||
// NOTE: All interactive components use `emit` to fire named events.
|
||||
@@ -254,23 +255,47 @@ export const Metric: FunctionComponent<ExtendedRenderProps> = ({ element }) => {
|
||||
);
|
||||
};
|
||||
|
||||
const BADGE_TYPES = new Set([
|
||||
"neutral",
|
||||
"urgent",
|
||||
"warning",
|
||||
"negative",
|
||||
"positive",
|
||||
"info",
|
||||
]);
|
||||
const BADGE_ALIAS: Record<string, string> = {
|
||||
success: "positive",
|
||||
error: "negative",
|
||||
danger: "negative",
|
||||
critical: "urgent",
|
||||
default: "neutral",
|
||||
primary: "info",
|
||||
};
|
||||
|
||||
function coerceBadgeType(
|
||||
raw: unknown,
|
||||
): "neutral" | "urgent" | "warning" | "negative" | "positive" | "info" {
|
||||
const s = String(raw ?? "neutral");
|
||||
if (BADGE_TYPES.has(s))
|
||||
return s as
|
||||
| "neutral"
|
||||
| "urgent"
|
||||
| "warning"
|
||||
| "negative"
|
||||
| "positive"
|
||||
| "info";
|
||||
return (BADGE_ALIAS[s] ?? "neutral") as
|
||||
| "neutral"
|
||||
| "urgent"
|
||||
| "warning"
|
||||
| "negative"
|
||||
| "positive"
|
||||
| "info";
|
||||
}
|
||||
|
||||
export const Badge: FunctionComponent<ExtendedRenderProps> = ({ element }) => {
|
||||
const { label, type = "neutral" } = element.props as Record<string, unknown>;
|
||||
return (
|
||||
<UIBadge
|
||||
type={
|
||||
type as
|
||||
| "neutral"
|
||||
| "urgent"
|
||||
| "warning"
|
||||
| "negative"
|
||||
| "positive"
|
||||
| "info"
|
||||
}
|
||||
>
|
||||
{String(label || "")}
|
||||
</UIBadge>
|
||||
);
|
||||
return <UIBadge type={coerceBadgeType(type)}>{String(label || "")}</UIBadge>;
|
||||
};
|
||||
|
||||
export const Icon: FunctionComponent<ExtendedRenderProps> = ({ element }) => {
|
||||
@@ -309,6 +334,21 @@ export const Spinner: FunctionComponent<ExtendedRenderProps> = ({
|
||||
// =========================================================================
|
||||
// Feedback Components
|
||||
// =========================================================================
|
||||
const BANNER_TYPES = new Set(["default", "caution", "critical"]);
|
||||
const BANNER_ALIAS: Record<string, string> = {
|
||||
info: "default",
|
||||
warning: "caution",
|
||||
error: "critical",
|
||||
danger: "critical",
|
||||
success: "default",
|
||||
};
|
||||
|
||||
function coerceBannerType(raw: unknown): "default" | "caution" | "critical" {
|
||||
const s = String(raw ?? "default");
|
||||
if (BANNER_TYPES.has(s)) return s as "default" | "caution" | "critical";
|
||||
return (BANNER_ALIAS[s] ?? "default") as "default" | "caution" | "critical";
|
||||
}
|
||||
|
||||
export const Banner: FunctionComponent<ExtendedRenderProps> = ({ element }) => {
|
||||
const {
|
||||
title,
|
||||
@@ -319,7 +359,7 @@ export const Banner: FunctionComponent<ExtendedRenderProps> = ({ element }) => {
|
||||
<UIBanner
|
||||
title={title ? String(title) : undefined}
|
||||
description={description ? String(description) : undefined}
|
||||
type={type as "default" | "caution" | "critical"}
|
||||
type={coerceBannerType(type)}
|
||||
/>
|
||||
);
|
||||
};
|
||||
@@ -634,11 +674,12 @@ export const DateField: FunctionComponent<ExtendedRenderProps> = ({
|
||||
export const Button: FunctionComponent<ExtendedRenderProps> = ({
|
||||
element,
|
||||
emit,
|
||||
onAction,
|
||||
}) => {
|
||||
const {
|
||||
label,
|
||||
action,
|
||||
actionParams: _actionParams,
|
||||
actionParams,
|
||||
type = "primary",
|
||||
size = "medium",
|
||||
disabled,
|
||||
@@ -646,6 +687,18 @@ export const Button: FunctionComponent<ExtendedRenderProps> = ({
|
||||
href,
|
||||
} = element.props as Record<string, unknown>;
|
||||
|
||||
const handlePress = action
|
||||
? () => {
|
||||
emit("press");
|
||||
if (onAction) {
|
||||
onAction(
|
||||
String(action),
|
||||
actionParams as Record<string, unknown> | undefined,
|
||||
);
|
||||
}
|
||||
}
|
||||
: undefined;
|
||||
|
||||
return (
|
||||
<UIButton
|
||||
type={type as "primary" | "secondary" | "destructive"}
|
||||
@@ -653,7 +706,7 @@ export const Button: FunctionComponent<ExtendedRenderProps> = ({
|
||||
disabled={Boolean(disabled) || undefined}
|
||||
pending={Boolean(pending) || undefined}
|
||||
href={href ? String(href) : undefined}
|
||||
onPress={action ? () => emit("press") : undefined}
|
||||
onPress={handlePress}
|
||||
>
|
||||
{String(label || "")}
|
||||
</UIButton>
|
||||
@@ -715,14 +768,26 @@ export const BarChart: FunctionComponent<ExtendedRenderProps> = ({
|
||||
x={String(xKey || "x")}
|
||||
y={String(yKey || "y")}
|
||||
color={colorKey ? String(colorKey) : undefined}
|
||||
axis={showAxis as "x" | "y" | "both" | "none"}
|
||||
grid={showGrid as "x" | "y" | "both" | "none"}
|
||||
axis={coerceAxisGrid(showAxis, "both")}
|
||||
grid={coerceAxisGrid(showGrid, "none")}
|
||||
legend={Boolean(showLegend) || undefined}
|
||||
tooltip={Boolean(showTooltip)}
|
||||
/>
|
||||
);
|
||||
};
|
||||
|
||||
function coerceAxisGrid(
|
||||
val: unknown,
|
||||
fallback: string,
|
||||
): "x" | "y" | "both" | "none" {
|
||||
if (val === true) return "both";
|
||||
if (val === false) return "none";
|
||||
const s = String(val ?? fallback);
|
||||
if (["x", "y", "both", "none"].includes(s))
|
||||
return s as "x" | "y" | "both" | "none";
|
||||
return fallback as "x" | "y" | "both" | "none";
|
||||
}
|
||||
|
||||
export const LineChart: FunctionComponent<ExtendedRenderProps> = ({
|
||||
element,
|
||||
}) => {
|
||||
@@ -751,8 +816,8 @@ export const LineChart: FunctionComponent<ExtendedRenderProps> = ({
|
||||
x={String(xKey || "x")}
|
||||
y={String(yKey || "y")}
|
||||
color={colorKey ? String(colorKey) : undefined}
|
||||
axis={showAxis as "x" | "y" | "both" | "none"}
|
||||
grid={showGrid as "x" | "y" | "both" | "none"}
|
||||
axis={coerceAxisGrid(showAxis, "both")}
|
||||
grid={coerceAxisGrid(showGrid, "none")}
|
||||
legend={Boolean(showLegend) || undefined}
|
||||
tooltip={Boolean(showTooltip)}
|
||||
/>
|
||||
+13
-1
@@ -9,6 +9,7 @@ import {
|
||||
} from "@json-render/react";
|
||||
|
||||
import { components, Fallback } from "./catalog/components";
|
||||
import { executeAction } from "./catalog/actions";
|
||||
|
||||
// =============================================================================
|
||||
// Types
|
||||
@@ -72,6 +73,12 @@ function buildRegistry(
|
||||
}
|
||||
return current;
|
||||
},
|
||||
onAction: (actionName: string, params?: Record<string, unknown>) => {
|
||||
const setState = setDataRef.current;
|
||||
if (setState) {
|
||||
executeAction(actionName, params, setState, dataRef.current);
|
||||
}
|
||||
},
|
||||
});
|
||||
}
|
||||
|
||||
@@ -114,10 +121,15 @@ export function StripeRenderer({
|
||||
[loading],
|
||||
);
|
||||
|
||||
const mergedState = useMemo(
|
||||
() => (spec?.state ? { ...data, ...spec.state } : data),
|
||||
[data, spec?.state],
|
||||
);
|
||||
|
||||
if (!spec) return null;
|
||||
|
||||
return (
|
||||
<StateProvider initialState={data} onStateChange={onStateChange}>
|
||||
<StateProvider initialState={mergedState} onStateChange={onStateChange}>
|
||||
<VisibilityProvider>
|
||||
<ActionProvider>
|
||||
<Renderer
|
||||
@@ -0,0 +1,146 @@
|
||||
import type { Spec } from "@json-render/react";
|
||||
|
||||
interface JsonPatch {
|
||||
op: string;
|
||||
path: string;
|
||||
value?: unknown;
|
||||
from?: string;
|
||||
}
|
||||
|
||||
function setDeep(
|
||||
obj: Record<string, unknown>,
|
||||
segments: string[],
|
||||
value: unknown,
|
||||
): void {
|
||||
let current: unknown = obj;
|
||||
for (let i = 0; i < segments.length - 1; i++) {
|
||||
const seg = segments[i];
|
||||
const next = (current as Record<string, unknown>)[seg];
|
||||
if (next && typeof next === "object") {
|
||||
current = next;
|
||||
} else {
|
||||
const container = /^\d+$/.test(segments[i + 1]) ? [] : {};
|
||||
(current as Record<string, unknown>)[seg] = container;
|
||||
current = container;
|
||||
}
|
||||
}
|
||||
const last = segments[segments.length - 1];
|
||||
if (Array.isArray(current)) {
|
||||
(current as unknown[])[Number(last)] = value;
|
||||
} else {
|
||||
(current as Record<string, unknown>)[last] = value;
|
||||
}
|
||||
}
|
||||
|
||||
function setSpecValue(spec: Spec, path: string, value: unknown): void {
|
||||
if (path === "/root") {
|
||||
(spec as Record<string, unknown>).root = value as string;
|
||||
return;
|
||||
}
|
||||
if (path === "/state") {
|
||||
(spec as Record<string, unknown>).state = value;
|
||||
return;
|
||||
}
|
||||
if (path.startsWith("/state/")) {
|
||||
if (!spec.state) (spec as Record<string, unknown>).state = {};
|
||||
const segments = path.slice("/state/".length).split("/");
|
||||
setDeep(spec.state as Record<string, unknown>, segments, value);
|
||||
return;
|
||||
}
|
||||
const elemMatch = path.match(/^\/elements\/(.+)/);
|
||||
if (elemMatch) {
|
||||
spec.elements[elemMatch[1]] = value as Spec["elements"][string];
|
||||
}
|
||||
}
|
||||
|
||||
function applyPatch(spec: Spec, patch: JsonPatch): Spec {
|
||||
const next: Spec = {
|
||||
...spec,
|
||||
elements: { ...spec.elements },
|
||||
...(spec.state ? { state: { ...spec.state } } : {}),
|
||||
};
|
||||
switch (patch.op) {
|
||||
case "add":
|
||||
case "replace":
|
||||
setSpecValue(next, patch.path, patch.value);
|
||||
break;
|
||||
case "remove":
|
||||
if (patch.path.startsWith("/elements/")) {
|
||||
const key = patch.path.slice("/elements/".length);
|
||||
delete next.elements[key];
|
||||
}
|
||||
break;
|
||||
}
|
||||
return next;
|
||||
}
|
||||
|
||||
/**
|
||||
* Fetch an AI generation endpoint that returns a JSONL patch stream,
|
||||
* applying patches progressively via onPatch.
|
||||
*/
|
||||
export async function streamSpec(
|
||||
url: string,
|
||||
body: Record<string, unknown>,
|
||||
onPatch: (spec: Spec) => void,
|
||||
): Promise<Spec> {
|
||||
const response = await fetch(url, {
|
||||
method: "POST",
|
||||
headers: { "Content-Type": "application/json" },
|
||||
body: JSON.stringify(body),
|
||||
});
|
||||
|
||||
if (!response.ok) {
|
||||
let msg = `API error: ${response.status}`;
|
||||
try {
|
||||
const errData = await response.json();
|
||||
if (errData.error) msg = errData.error;
|
||||
} catch {
|
||||
// use default
|
||||
}
|
||||
throw new Error(msg);
|
||||
}
|
||||
|
||||
const reader = response.body?.getReader();
|
||||
if (!reader) throw new Error("No response body");
|
||||
|
||||
const decoder = new TextDecoder();
|
||||
let buffer = "";
|
||||
let spec: Spec = { root: "", elements: {} };
|
||||
|
||||
while (true) {
|
||||
const { done, value } = await reader.read();
|
||||
if (done) break;
|
||||
|
||||
buffer += decoder.decode(value, { stream: true });
|
||||
const lines = buffer.split("\n");
|
||||
buffer = lines.pop() ?? "";
|
||||
|
||||
for (const line of lines) {
|
||||
const trimmed = line.trim();
|
||||
if (!trimmed || trimmed.startsWith("//")) continue;
|
||||
try {
|
||||
const parsed = JSON.parse(trimmed) as JsonPatch;
|
||||
if (parsed.op) {
|
||||
spec = applyPatch(spec, parsed);
|
||||
onPatch(spec);
|
||||
}
|
||||
} catch {
|
||||
// skip non-JSON lines
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
if (buffer.trim()) {
|
||||
try {
|
||||
const parsed = JSON.parse(buffer.trim()) as JsonPatch;
|
||||
if (parsed.op) {
|
||||
spec = applyPatch(spec, parsed);
|
||||
onPatch(spec);
|
||||
}
|
||||
} catch {
|
||||
// skip
|
||||
}
|
||||
}
|
||||
|
||||
return spec;
|
||||
}
|
||||
+2
-1
@@ -10,6 +10,7 @@ import {
|
||||
import type { ExtensionContextValue } from "@stripe/ui-extension-sdk/context";
|
||||
|
||||
import BrandIcon from "./brand_icon.svg";
|
||||
import { API_GENERATE_URL } from "../lib/config";
|
||||
import { stripeCatalog, StripeRenderer } from "../lib/render";
|
||||
import { executeAction } from "../lib/render/catalog/actions";
|
||||
|
||||
@@ -373,7 +374,7 @@ const CustomerDetails = ({ environment }: ExtensionContextValue) => {
|
||||
const systemPrompt = stripeCatalog.prompt();
|
||||
|
||||
try {
|
||||
const response = await fetch("/api/generate", {
|
||||
const response = await fetch(API_GENERATE_URL, {
|
||||
method: "POST",
|
||||
headers: { "Content-Type": "application/json" },
|
||||
body: JSON.stringify({
|
||||
+2
-1
@@ -10,6 +10,7 @@ import {
|
||||
import type { ExtensionContextValue } from "@stripe/ui-extension-sdk/context";
|
||||
|
||||
import BrandIcon from "./brand_icon.svg";
|
||||
import { API_GENERATE_URL } from "../lib/config";
|
||||
import { stripeCatalog, StripeRenderer } from "../lib/render";
|
||||
import { executeAction } from "../lib/render/catalog/actions";
|
||||
|
||||
@@ -172,7 +173,7 @@ const Customers = (_props: ExtensionContextValue) => {
|
||||
const systemPrompt = stripeCatalog.prompt();
|
||||
|
||||
try {
|
||||
const response = await fetch("/api/generate", {
|
||||
const response = await fetch(API_GENERATE_URL, {
|
||||
method: "POST",
|
||||
headers: { "Content-Type": "application/json" },
|
||||
body: JSON.stringify({ prompt, systemPrompt }),
|
||||
+55
-41
@@ -1,10 +1,12 @@
|
||||
import { useState, useCallback, useEffect } from "react";
|
||||
import type { Spec } from "@json-render/react";
|
||||
import {
|
||||
Banner,
|
||||
Box,
|
||||
ContextView,
|
||||
Divider,
|
||||
Button,
|
||||
Link,
|
||||
TextField,
|
||||
Tabs,
|
||||
TabList,
|
||||
@@ -20,6 +22,8 @@ import type { ExtensionContextValue } from "@stripe/ui-extension-sdk/context";
|
||||
import BrandIcon from "./brand_icon.svg";
|
||||
import { stripeCatalog, StripeRenderer } from "../lib/render";
|
||||
import { executeAction } from "../lib/render/catalog/actions";
|
||||
import { API_GENERATE_URL } from "../lib/config";
|
||||
import { streamSpec } from "../lib/stream-spec";
|
||||
|
||||
// =============================================================================
|
||||
// Dynamic Specs (use real data from context)
|
||||
@@ -503,34 +507,23 @@ const Home = (_props: ExtensionContextValue) => {
|
||||
setError(null);
|
||||
|
||||
try {
|
||||
const response = await fetch("http://localhost:3000/api/generate", {
|
||||
method: "POST",
|
||||
headers: { "Content-Type": "application/json" },
|
||||
body: JSON.stringify({
|
||||
prompt,
|
||||
const dataSnapshot = JSON.stringify(data, null, 2);
|
||||
|
||||
await streamSpec(
|
||||
API_GENERATE_URL,
|
||||
{
|
||||
prompt: `${prompt}\n\nAVAILABLE STRIPE DATA (use this real data, do NOT invent fake numbers):\n${dataSnapshot}`,
|
||||
systemPrompt: stripeCatalog.prompt({
|
||||
system:
|
||||
"You are a Stripe dashboard widget builder. Generate UI specs for displaying Stripe data.",
|
||||
customRules: [
|
||||
'LAYOUT CONSTRAINT: This UI renders inside a narrow Stripe dashboard drawer (~320px wide). NEVER place more than 2 small items side-by-side horizontally. Prefer vertical (stacked) layouts. Use direction:"horizontal" sparingly and only for very compact items like a label+value pair.',
|
||||
'DATA: The user prompt includes real Stripe data under "AVAILABLE STRIPE DATA". You MUST use these real values when setting /state. NEVER invent or hallucinate numbers. If the data you need is not available, say so in a Text element rather than making up data. The data keys are: customers (data[], total), payments (data[], total, totalVolume, successRate), subscriptions (data[], total, active, trialing, pastDue, canceled), invoices (data[], total, totalAmount, paid, open, overdue).',
|
||||
],
|
||||
}),
|
||||
}),
|
||||
});
|
||||
|
||||
if (!response.ok) {
|
||||
throw new Error(`API error: ${response.status}`);
|
||||
}
|
||||
|
||||
const contentType = response.headers.get("content-type") || "";
|
||||
|
||||
if (contentType.includes("application/json")) {
|
||||
const responseData = await response.json();
|
||||
if (responseData.spec) {
|
||||
setCurrentSpec(responseData.spec);
|
||||
return;
|
||||
} else if (responseData.error) {
|
||||
throw new Error(responseData.error);
|
||||
}
|
||||
}
|
||||
throw new Error("Invalid response");
|
||||
},
|
||||
(spec) => setCurrentSpec(spec),
|
||||
);
|
||||
} catch (err) {
|
||||
// Use dynamic specs with real data as fallback
|
||||
const lowerPrompt = prompt.toLowerCase();
|
||||
@@ -625,26 +618,47 @@ const Home = (_props: ExtensionContextValue) => {
|
||||
{currentSpec && (
|
||||
<>
|
||||
<Divider />
|
||||
<Box css={{ font: "subheading" }}>
|
||||
Preview (with real Stripe data)
|
||||
</Box>
|
||||
<Box
|
||||
css={{
|
||||
padding: "medium",
|
||||
borderRadius: "medium",
|
||||
keyline: "neutral",
|
||||
}}
|
||||
>
|
||||
<StripeRenderer
|
||||
spec={currentSpec}
|
||||
data={data}
|
||||
setData={handleSetState}
|
||||
loading={isGenerating}
|
||||
/>
|
||||
</Box>
|
||||
<StripeRenderer
|
||||
spec={currentSpec}
|
||||
data={data}
|
||||
setData={handleSetState}
|
||||
loading={isGenerating}
|
||||
/>
|
||||
</>
|
||||
)}
|
||||
|
||||
{(
|
||||
data._actionResult as
|
||||
| { message?: string; url?: string }
|
||||
| undefined
|
||||
)?.message && (
|
||||
<Banner
|
||||
title={(data._actionResult as { message: string }).message}
|
||||
description={
|
||||
(data._actionResult as { url?: string }).url
|
||||
? "Open in Stripe Dashboard"
|
||||
: undefined
|
||||
}
|
||||
actions={
|
||||
(data._actionResult as { url?: string }).url ? (
|
||||
<Link
|
||||
href={(data._actionResult as { url: string }).url}
|
||||
external
|
||||
>
|
||||
Open
|
||||
</Link>
|
||||
) : undefined
|
||||
}
|
||||
onDismiss={() =>
|
||||
setState((prev) => {
|
||||
const next = { ...prev };
|
||||
delete next._actionResult;
|
||||
return next;
|
||||
})
|
||||
}
|
||||
/>
|
||||
)}
|
||||
|
||||
<Divider />
|
||||
<Box css={{ font: "caption", color: "secondary" }}>
|
||||
Try: "Show revenue metrics" • "Show recent
|
||||
+2
-1
@@ -10,6 +10,7 @@ import {
|
||||
import type { ExtensionContextValue } from "@stripe/ui-extension-sdk/context";
|
||||
|
||||
import BrandIcon from "./brand_icon.svg";
|
||||
import { API_GENERATE_URL } from "../lib/config";
|
||||
import { stripeCatalog, StripeRenderer } from "../lib/render";
|
||||
import { executeAction } from "../lib/render/catalog/actions";
|
||||
|
||||
@@ -215,7 +216,7 @@ const Invoices = (_props: ExtensionContextValue) => {
|
||||
const systemPrompt = stripeCatalog.prompt();
|
||||
|
||||
try {
|
||||
const response = await fetch("/api/generate", {
|
||||
const response = await fetch(API_GENERATE_URL, {
|
||||
method: "POST",
|
||||
headers: { "Content-Type": "application/json" },
|
||||
body: JSON.stringify({ prompt, systemPrompt }),
|
||||
+2
-1
@@ -10,6 +10,7 @@ import {
|
||||
import type { ExtensionContextValue } from "@stripe/ui-extension-sdk/context";
|
||||
|
||||
import BrandIcon from "./brand_icon.svg";
|
||||
import { API_GENERATE_URL } from "../lib/config";
|
||||
import { stripeCatalog, StripeRenderer } from "../lib/render";
|
||||
import { executeAction } from "../lib/render/catalog/actions";
|
||||
|
||||
@@ -264,7 +265,7 @@ const PaymentDetails = ({ environment }: ExtensionContextValue) => {
|
||||
const systemPrompt = stripeCatalog.prompt();
|
||||
|
||||
try {
|
||||
const response = await fetch("/api/generate", {
|
||||
const response = await fetch(API_GENERATE_URL, {
|
||||
method: "POST",
|
||||
headers: { "Content-Type": "application/json" },
|
||||
body: JSON.stringify({
|
||||
+2
-1
@@ -10,6 +10,7 @@ import {
|
||||
import type { ExtensionContextValue } from "@stripe/ui-extension-sdk/context";
|
||||
|
||||
import BrandIcon from "./brand_icon.svg";
|
||||
import { API_GENERATE_URL } from "../lib/config";
|
||||
import { stripeCatalog, StripeRenderer } from "../lib/render";
|
||||
import { executeAction } from "../lib/render/catalog/actions";
|
||||
|
||||
@@ -181,7 +182,7 @@ const Payments = (_props: ExtensionContextValue) => {
|
||||
const systemPrompt = stripeCatalog.prompt();
|
||||
|
||||
try {
|
||||
const response = await fetch("/api/generate", {
|
||||
const response = await fetch(API_GENERATE_URL, {
|
||||
method: "POST",
|
||||
headers: { "Content-Type": "application/json" },
|
||||
body: JSON.stringify({ prompt, systemPrompt }),
|
||||
+2
-1
@@ -10,6 +10,7 @@ import {
|
||||
import type { ExtensionContextValue } from "@stripe/ui-extension-sdk/context";
|
||||
|
||||
import BrandIcon from "./brand_icon.svg";
|
||||
import { API_GENERATE_URL } from "../lib/config";
|
||||
import { stripeCatalog, StripeRenderer } from "../lib/render";
|
||||
import { executeAction } from "../lib/render/catalog/actions";
|
||||
|
||||
@@ -232,7 +233,7 @@ const Products = (_props: ExtensionContextValue) => {
|
||||
const systemPrompt = stripeCatalog.prompt();
|
||||
|
||||
try {
|
||||
const response = await fetch("/api/generate", {
|
||||
const response = await fetch(API_GENERATE_URL, {
|
||||
method: "POST",
|
||||
headers: { "Content-Type": "application/json" },
|
||||
body: JSON.stringify({ prompt, systemPrompt }),
|
||||
+2
-1
@@ -10,6 +10,7 @@ import {
|
||||
import type { ExtensionContextValue } from "@stripe/ui-extension-sdk/context";
|
||||
|
||||
import BrandIcon from "./brand_icon.svg";
|
||||
import { API_GENERATE_URL } from "../lib/config";
|
||||
import { stripeCatalog, StripeRenderer } from "../lib/render";
|
||||
import { executeAction } from "../lib/render/catalog/actions";
|
||||
|
||||
@@ -230,7 +231,7 @@ const Subscriptions = (_props: ExtensionContextValue) => {
|
||||
const systemPrompt = stripeCatalog.prompt();
|
||||
|
||||
try {
|
||||
const response = await fetch("/api/generate", {
|
||||
const response = await fetch(API_GENERATE_URL, {
|
||||
method: "POST",
|
||||
headers: { "Content-Type": "application/json" },
|
||||
body: JSON.stringify({ prompt, systemPrompt }),
|
||||
|
Before Width: | Height: | Size: 881 B After Width: | Height: | Size: 881 B |
+4
-4
@@ -37,8 +37,8 @@
|
||||
"purpose": "Send and manage invoices"
|
||||
},
|
||||
{
|
||||
"permission": "refund_write",
|
||||
"purpose": "Process refunds"
|
||||
"permission": "charge_write",
|
||||
"purpose": "Process charges and refunds"
|
||||
},
|
||||
{
|
||||
"permission": "product_read",
|
||||
@@ -49,11 +49,11 @@
|
||||
"purpose": "Create and manage products"
|
||||
},
|
||||
{
|
||||
"permission": "price_read",
|
||||
"permission": "plan_read",
|
||||
"purpose": "Display pricing information"
|
||||
},
|
||||
{
|
||||
"permission": "price_write",
|
||||
"permission": "plan_write",
|
||||
"purpose": "Create and manage prices"
|
||||
},
|
||||
{
|
||||
@@ -0,0 +1,14 @@
|
||||
# Stripe App ID (optional - overrides the template default)
|
||||
# Get this from your Stripe Apps dashboard after uploading
|
||||
# STRIPE_APP_ID=com.example.your-app-id
|
||||
|
||||
# Stripe App Name (optional - overrides the template default)
|
||||
# STRIPE_APP_NAME=My App Name
|
||||
|
||||
# AI Gateway API Key
|
||||
# Get your key from Vercel AI Gateway dashboard
|
||||
AI_GATEWAY_API_KEY=
|
||||
|
||||
# Optional: Stripe API keys if you want to use real data
|
||||
# STRIPE_SECRET_KEY=sk_test_...
|
||||
# STRIPE_PUBLISHABLE_KEY=pk_test_...
|
||||
@@ -0,0 +1,49 @@
|
||||
# Full Page Stripe App (Alpha)
|
||||
|
||||
A full-page [Stripe App](https://stripe.com/docs/stripe-apps) example using json-render. This uses the `FullPageView` component and the `stripe.dashboard.fullpage` viewport, which are part of Stripe's **private developer preview**.
|
||||
|
||||
## Prerequisites
|
||||
|
||||
- [Stripe CLI](https://stripe.com/docs/stripe-cli) with the apps plugin
|
||||
- A Stripe account with **full-page apps alpha access** enabled by Stripe
|
||||
- Both your `app_id` and `account_id` must be flagged in by Stripe
|
||||
|
||||
## Setup
|
||||
|
||||
```bash
|
||||
pnpm install
|
||||
|
||||
cp .env.example .env
|
||||
# Set STRIPE_APP_ID to your app ID
|
||||
|
||||
pnpm setup
|
||||
```
|
||||
|
||||
## Running
|
||||
|
||||
Start the API server first (from `../api`):
|
||||
|
||||
```bash
|
||||
cd ../api && pnpm dev
|
||||
```
|
||||
|
||||
Then start the Stripe app:
|
||||
|
||||
```bash
|
||||
stripe apps start
|
||||
```
|
||||
|
||||
Navigate directly to your full-page app URL (it will not appear in the left navigation):
|
||||
|
||||
```
|
||||
https://dashboard.stripe.com/test/app/<your-app-id>
|
||||
```
|
||||
|
||||
## Troubleshooting
|
||||
|
||||
If you are redirected to the Dashboard home page, the feature flag is not enabled for your account. Contact your Stripe partner to have both your `app_id` and `account_id` flagged in.
|
||||
|
||||
## Resources
|
||||
|
||||
- [FullPageView Component](https://docs.stripe.com/stripe-apps/components/fullpageview?app-sdk-version=9)
|
||||
- [FullPageTabs Component](https://docs.stripe.com/stripe-apps/components/fullpagetabs?app-sdk-version=9)
|
||||
@@ -0,0 +1,17 @@
|
||||
import { config as reactConfig } from "@repo/eslint-config/react-internal";
|
||||
|
||||
/** @type {import("eslint").Linter.Config[]} */
|
||||
export default [
|
||||
...reactConfig,
|
||||
{
|
||||
rules: {
|
||||
// Disable prop-types - we use TypeScript for type checking
|
||||
"react/prop-types": "off",
|
||||
// Allow underscore-prefixed unused variables
|
||||
"@typescript-eslint/no-unused-vars": [
|
||||
"warn",
|
||||
{ argsIgnorePattern: "^_", varsIgnorePattern: "^_" },
|
||||
],
|
||||
},
|
||||
},
|
||||
];
|
||||
@@ -0,0 +1,7 @@
|
||||
/* eslint-env node */
|
||||
/* eslint-disable @typescript-eslint/no-var-requires */
|
||||
const UIExtensionsConfig = require("@stripe/ui-extension-tools/jest.config.ui-extension");
|
||||
|
||||
module.exports = {
|
||||
...UIExtensionsConfig,
|
||||
};
|
||||
@@ -0,0 +1,28 @@
|
||||
{
|
||||
"name": "com.example.json-render-fullpage-demo",
|
||||
"version": "0.0.1",
|
||||
"description": "Full-page Stripe App example (alpha)",
|
||||
"private": true,
|
||||
"license": "~~proprietary~~",
|
||||
"type": "module",
|
||||
"dependencies": {
|
||||
"@json-render/core": "workspace:*",
|
||||
"@json-render/react": "workspace:*",
|
||||
"@stripe/ui-extension-sdk": "9.2.0-alpha.0",
|
||||
"stripe": "^13.11.0",
|
||||
"zod": "^4.0.0"
|
||||
},
|
||||
"engines": {
|
||||
"node": ">=14"
|
||||
},
|
||||
"scripts": {
|
||||
"setup": "node scripts/setup.mjs",
|
||||
"lint": "eslint src --max-warnings 0",
|
||||
"test": "jest"
|
||||
},
|
||||
"devDependencies": {
|
||||
"@repo/eslint-config": "workspace:*",
|
||||
"@stripe/ui-extension-tools": "^0.0.1",
|
||||
"eslint": "^9.39.0"
|
||||
}
|
||||
}
|
||||
+5270
File diff suppressed because it is too large
Load Diff
@@ -0,0 +1,42 @@
|
||||
import { readFileSync, writeFileSync, existsSync } from "fs";
|
||||
import { resolve, dirname } from "path";
|
||||
import { fileURLToPath } from "url";
|
||||
|
||||
const __dirname = dirname(fileURLToPath(import.meta.url));
|
||||
const root = resolve(__dirname, "..");
|
||||
|
||||
function loadEnv() {
|
||||
const envPath = resolve(root, ".env");
|
||||
if (!existsSync(envPath)) return {};
|
||||
const env = {};
|
||||
for (const line of readFileSync(envPath, "utf-8").split("\n")) {
|
||||
const trimmed = line.trim();
|
||||
if (!trimmed || trimmed.startsWith("#")) continue;
|
||||
const idx = trimmed.indexOf("=");
|
||||
if (idx === -1) continue;
|
||||
const key = trimmed.slice(0, idx).trim();
|
||||
const val = trimmed.slice(idx + 1).trim();
|
||||
env[key] = val;
|
||||
}
|
||||
return env;
|
||||
}
|
||||
|
||||
const env = loadEnv();
|
||||
const templatePath = resolve(root, "stripe-app.template.json");
|
||||
const outputPath = resolve(root, "stripe-app.json");
|
||||
|
||||
const template = JSON.parse(readFileSync(templatePath, "utf-8"));
|
||||
|
||||
if (env.STRIPE_APP_ID) {
|
||||
template.id = env.STRIPE_APP_ID;
|
||||
console.log(`Using app ID from .env: ${env.STRIPE_APP_ID}`);
|
||||
} else {
|
||||
console.log(`No STRIPE_APP_ID in .env, using template default: ${template.id}`);
|
||||
}
|
||||
|
||||
if (env.STRIPE_APP_NAME) {
|
||||
template.name = env.STRIPE_APP_NAME;
|
||||
}
|
||||
|
||||
writeFileSync(outputPath, JSON.stringify(template, null, 4) + "\n");
|
||||
console.log("Generated stripe-app.json");
|
||||
@@ -0,0 +1 @@
|
||||
export const API_GENERATE_URL = "http://localhost:3001/api/generate";
|
||||
File diff suppressed because it is too large
Load Diff
+14
-20
@@ -1374,16 +1374,16 @@ export const actionHandlers: Record<
|
||||
await actionHandlers.fetchCustomers({}, setState, {});
|
||||
},
|
||||
|
||||
refreshPayments: async (_) => {
|
||||
await actionHandlers.fetchPayments({}, setData, {});
|
||||
refreshPayments: async (_, setState) => {
|
||||
await actionHandlers.fetchPayments({}, setState, {});
|
||||
},
|
||||
|
||||
refreshSubscriptions: async (_) => {
|
||||
await actionHandlers.fetchSubscriptions({}, setData, {});
|
||||
refreshSubscriptions: async (_, setState) => {
|
||||
await actionHandlers.fetchSubscriptions({}, setState, {});
|
||||
},
|
||||
|
||||
refreshInvoices: async (_) => {
|
||||
await actionHandlers.fetchInvoices({}, setData, {});
|
||||
refreshInvoices: async (_, setState) => {
|
||||
await actionHandlers.fetchInvoices({}, setState, {});
|
||||
},
|
||||
|
||||
exportData: async (params, _, data) => {
|
||||
@@ -1431,11 +1431,8 @@ export const actionHandlers: Record<
|
||||
// ===========================================================================
|
||||
// Navigation Actions
|
||||
// ===========================================================================
|
||||
navigate: async (params) => {
|
||||
if (params?.statePath) {
|
||||
console.log("Navigate to:", params.statePath);
|
||||
// In a real app, this would use the router
|
||||
}
|
||||
navigate: async (_params) => {
|
||||
// In a real app, this would use the router
|
||||
},
|
||||
|
||||
openDashboard: async (params) => {
|
||||
@@ -1463,8 +1460,7 @@ export const actionHandlers: Record<
|
||||
// ===========================================================================
|
||||
// Form Actions
|
||||
// ===========================================================================
|
||||
submitForm: async (params, _, data) => {
|
||||
console.log("Submit form:", params?.formId, data);
|
||||
submitForm: async (_params, _, _data) => {
|
||||
// Implementation depends on form handling logic
|
||||
},
|
||||
|
||||
@@ -1475,9 +1471,8 @@ export const actionHandlers: Record<
|
||||
}
|
||||
},
|
||||
|
||||
validateForm: async (params, _, data) => {
|
||||
console.log("Validate form:", params?.formId, data);
|
||||
return;
|
||||
validateForm: async (_params, _, _data) => {
|
||||
// Implementation depends on form validation logic
|
||||
},
|
||||
|
||||
setFormValue: async (params, setState) => {
|
||||
@@ -1492,9 +1487,8 @@ export const actionHandlers: Record<
|
||||
// ===========================================================================
|
||||
// UI Actions
|
||||
// ===========================================================================
|
||||
showToast: async (params) => {
|
||||
// In UIXT, you would use the showToast API from the SDK
|
||||
console.log("Toast:", params?.type, params?.message);
|
||||
showToast: async (_params) => {
|
||||
// In a real app, use showToast from @stripe/ui-extension-sdk/utils
|
||||
},
|
||||
|
||||
copyToClipboard: async (params) => {
|
||||
@@ -1567,6 +1561,6 @@ export async function executeAction(
|
||||
if (handler) {
|
||||
await handler(params, setState, data);
|
||||
} else {
|
||||
console.log("Unknown action:", actionName, params);
|
||||
console.warn("Unknown action:", actionName);
|
||||
}
|
||||
}
|
||||
File diff suppressed because it is too large
Load Diff
@@ -0,0 +1,11 @@
|
||||
// Catalog
|
||||
export { stripeCatalog } from "./catalog";
|
||||
|
||||
// Components
|
||||
export { components, Fallback } from "./catalog/components";
|
||||
|
||||
// Actions
|
||||
export { actionHandlers, executeAction } from "./catalog/actions";
|
||||
|
||||
// Renderer
|
||||
export { StripeRenderer, type StripeRendererProps } from "./renderer";
|
||||
@@ -0,0 +1,145 @@
|
||||
import { useMemo, useRef, type ReactNode } from "react";
|
||||
import {
|
||||
Renderer,
|
||||
type ComponentRegistry,
|
||||
type Spec,
|
||||
StateProvider,
|
||||
VisibilityProvider,
|
||||
ActionProvider,
|
||||
} from "@json-render/react";
|
||||
|
||||
import { components, Fallback } from "./catalog/components";
|
||||
import { executeAction } from "./catalog/actions";
|
||||
|
||||
// =============================================================================
|
||||
// Types
|
||||
// =============================================================================
|
||||
|
||||
type SetState = (
|
||||
updater: (prev: Record<string, unknown>) => Record<string, unknown>,
|
||||
) => void;
|
||||
|
||||
export interface StripeRendererProps {
|
||||
/** The UI spec to render (from json-render) */
|
||||
spec: Spec | null;
|
||||
/** Data context for components */
|
||||
data?: Record<string, unknown>;
|
||||
/** Function to update data */
|
||||
setData?: SetState;
|
||||
/** Callback when data changes */
|
||||
onStateChange?: (path: string, value: unknown) => void;
|
||||
/** Whether the spec is currently loading/streaming */
|
||||
loading?: boolean;
|
||||
}
|
||||
|
||||
// =============================================================================
|
||||
// Build Registry
|
||||
// =============================================================================
|
||||
|
||||
/**
|
||||
* Build a component registry from our components map.
|
||||
* Uses refs to avoid recreating on data changes.
|
||||
*/
|
||||
function buildRegistry(
|
||||
dataRef: React.RefObject<Record<string, unknown>>,
|
||||
setDataRef: React.RefObject<SetState | undefined>,
|
||||
loading?: boolean,
|
||||
): ComponentRegistry {
|
||||
const registry: ComponentRegistry = {};
|
||||
|
||||
for (const [name, Component] of Object.entries(components)) {
|
||||
const noop = () => {};
|
||||
registry[name] = (renderProps: {
|
||||
element: { type: string; props: Record<string, unknown> };
|
||||
children?: ReactNode;
|
||||
emit?: (event: string) => void;
|
||||
}) =>
|
||||
Component({
|
||||
element: renderProps.element,
|
||||
children: renderProps.children,
|
||||
emit: renderProps.emit ?? noop,
|
||||
loading,
|
||||
state: dataRef.current,
|
||||
getValue: (path: string) => {
|
||||
const data = dataRef.current;
|
||||
const parts = path.replace(/^\//, "").split("/");
|
||||
let current: unknown = data;
|
||||
for (const part of parts) {
|
||||
if (current && typeof current === "object" && part in current) {
|
||||
current = (current as Record<string, unknown>)[part];
|
||||
} else {
|
||||
return undefined;
|
||||
}
|
||||
}
|
||||
return current;
|
||||
},
|
||||
onAction: (actionName: string, params?: Record<string, unknown>) => {
|
||||
const setState = setDataRef.current;
|
||||
if (setState) {
|
||||
executeAction(actionName, params, setState, dataRef.current);
|
||||
}
|
||||
},
|
||||
});
|
||||
}
|
||||
|
||||
return registry;
|
||||
}
|
||||
|
||||
/**
|
||||
* Fallback component for unknown types
|
||||
*/
|
||||
const fallbackRegistry = (renderProps: {
|
||||
element: { type: string; props: Record<string, unknown> };
|
||||
}) => <Fallback element={renderProps.element} />;
|
||||
|
||||
// =============================================================================
|
||||
// StripeRenderer Component
|
||||
// =============================================================================
|
||||
|
||||
/**
|
||||
* Main renderer component for Stripe UIXT.
|
||||
*
|
||||
* Wraps the json-render Renderer with all necessary providers
|
||||
* and connects it to the Stripe component implementations.
|
||||
*/
|
||||
export function StripeRenderer({
|
||||
spec,
|
||||
data = {},
|
||||
setData,
|
||||
onStateChange,
|
||||
loading,
|
||||
}: StripeRendererProps): ReactNode {
|
||||
// Use refs to keep registry stable while still accessing latest data/setData
|
||||
const dataRef = useRef(data);
|
||||
const setDataRef = useRef(setData);
|
||||
dataRef.current = data;
|
||||
setDataRef.current = setData;
|
||||
|
||||
// Memoize registry - only changes when loading changes
|
||||
const registry = useMemo(
|
||||
() => buildRegistry(dataRef, setDataRef, loading),
|
||||
[loading],
|
||||
);
|
||||
|
||||
const mergedState = useMemo(
|
||||
() => (spec?.state ? { ...data, ...spec.state } : data),
|
||||
[data, spec?.state],
|
||||
);
|
||||
|
||||
if (!spec) return null;
|
||||
|
||||
return (
|
||||
<StateProvider initialState={mergedState} onStateChange={onStateChange}>
|
||||
<VisibilityProvider>
|
||||
<ActionProvider>
|
||||
<Renderer
|
||||
spec={spec}
|
||||
registry={registry}
|
||||
fallback={fallbackRegistry}
|
||||
loading={loading}
|
||||
/>
|
||||
</ActionProvider>
|
||||
</VisibilityProvider>
|
||||
</StateProvider>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,146 @@
|
||||
import type { Spec } from "@json-render/react";
|
||||
|
||||
interface JsonPatch {
|
||||
op: string;
|
||||
path: string;
|
||||
value?: unknown;
|
||||
from?: string;
|
||||
}
|
||||
|
||||
function setDeep(
|
||||
obj: Record<string, unknown>,
|
||||
segments: string[],
|
||||
value: unknown,
|
||||
): void {
|
||||
let current: unknown = obj;
|
||||
for (let i = 0; i < segments.length - 1; i++) {
|
||||
const seg = segments[i];
|
||||
const next = (current as Record<string, unknown>)[seg];
|
||||
if (next && typeof next === "object") {
|
||||
current = next;
|
||||
} else {
|
||||
const container = /^\d+$/.test(segments[i + 1]) ? [] : {};
|
||||
(current as Record<string, unknown>)[seg] = container;
|
||||
current = container;
|
||||
}
|
||||
}
|
||||
const last = segments[segments.length - 1];
|
||||
if (Array.isArray(current)) {
|
||||
(current as unknown[])[Number(last)] = value;
|
||||
} else {
|
||||
(current as Record<string, unknown>)[last] = value;
|
||||
}
|
||||
}
|
||||
|
||||
function setSpecValue(spec: Spec, path: string, value: unknown): void {
|
||||
if (path === "/root") {
|
||||
(spec as Record<string, unknown>).root = value as string;
|
||||
return;
|
||||
}
|
||||
if (path === "/state") {
|
||||
(spec as Record<string, unknown>).state = value;
|
||||
return;
|
||||
}
|
||||
if (path.startsWith("/state/")) {
|
||||
if (!spec.state) (spec as Record<string, unknown>).state = {};
|
||||
const segments = path.slice("/state/".length).split("/");
|
||||
setDeep(spec.state as Record<string, unknown>, segments, value);
|
||||
return;
|
||||
}
|
||||
const elemMatch = path.match(/^\/elements\/(.+)/);
|
||||
if (elemMatch) {
|
||||
spec.elements[elemMatch[1]] = value as Spec["elements"][string];
|
||||
}
|
||||
}
|
||||
|
||||
function applyPatch(spec: Spec, patch: JsonPatch): Spec {
|
||||
const next: Spec = {
|
||||
...spec,
|
||||
elements: { ...spec.elements },
|
||||
...(spec.state ? { state: { ...spec.state } } : {}),
|
||||
};
|
||||
switch (patch.op) {
|
||||
case "add":
|
||||
case "replace":
|
||||
setSpecValue(next, patch.path, patch.value);
|
||||
break;
|
||||
case "remove":
|
||||
if (patch.path.startsWith("/elements/")) {
|
||||
const key = patch.path.slice("/elements/".length);
|
||||
delete next.elements[key];
|
||||
}
|
||||
break;
|
||||
}
|
||||
return next;
|
||||
}
|
||||
|
||||
/**
|
||||
* Fetch an AI generation endpoint that returns a JSONL patch stream,
|
||||
* applying patches progressively via onPatch.
|
||||
*/
|
||||
export async function streamSpec(
|
||||
url: string,
|
||||
body: Record<string, unknown>,
|
||||
onPatch: (spec: Spec) => void,
|
||||
): Promise<Spec> {
|
||||
const response = await fetch(url, {
|
||||
method: "POST",
|
||||
headers: { "Content-Type": "application/json" },
|
||||
body: JSON.stringify(body),
|
||||
});
|
||||
|
||||
if (!response.ok) {
|
||||
let msg = `API error: ${response.status}`;
|
||||
try {
|
||||
const errData = await response.json();
|
||||
if (errData.error) msg = errData.error;
|
||||
} catch {
|
||||
// use default
|
||||
}
|
||||
throw new Error(msg);
|
||||
}
|
||||
|
||||
const reader = response.body?.getReader();
|
||||
if (!reader) throw new Error("No response body");
|
||||
|
||||
const decoder = new TextDecoder();
|
||||
let buffer = "";
|
||||
let spec: Spec = { root: "", elements: {} };
|
||||
|
||||
while (true) {
|
||||
const { done, value } = await reader.read();
|
||||
if (done) break;
|
||||
|
||||
buffer += decoder.decode(value, { stream: true });
|
||||
const lines = buffer.split("\n");
|
||||
buffer = lines.pop() ?? "";
|
||||
|
||||
for (const line of lines) {
|
||||
const trimmed = line.trim();
|
||||
if (!trimmed || trimmed.startsWith("//")) continue;
|
||||
try {
|
||||
const parsed = JSON.parse(trimmed) as JsonPatch;
|
||||
if (parsed.op) {
|
||||
spec = applyPatch(spec, parsed);
|
||||
onPatch(spec);
|
||||
}
|
||||
} catch {
|
||||
// skip non-JSON lines
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
if (buffer.trim()) {
|
||||
try {
|
||||
const parsed = JSON.parse(buffer.trim()) as JsonPatch;
|
||||
if (parsed.op) {
|
||||
spec = applyPatch(spec, parsed);
|
||||
onPatch(spec);
|
||||
}
|
||||
} catch {
|
||||
// skip
|
||||
}
|
||||
}
|
||||
|
||||
return spec;
|
||||
}
|
||||
@@ -0,0 +1,37 @@
|
||||
import Stripe from "stripe";
|
||||
import {
|
||||
createHttpClient,
|
||||
STRIPE_API_KEY,
|
||||
} from "@stripe/ui-extension-sdk/http_client";
|
||||
|
||||
/**
|
||||
* Create a Stripe client for use in Stripe Apps.
|
||||
*
|
||||
* The Stripe UI Extension SDK handles authentication automatically
|
||||
* through the httpClient - no real API key is needed.
|
||||
*/
|
||||
export const stripe = new Stripe(STRIPE_API_KEY, {
|
||||
httpClient: createHttpClient(),
|
||||
apiVersion: "2024-12-18.acacia",
|
||||
});
|
||||
|
||||
/**
|
||||
* Format amount for display (converts cents to dollars)
|
||||
*/
|
||||
export function formatAmount(amount: number, currency = "usd"): string {
|
||||
return new Intl.NumberFormat("en-US", {
|
||||
style: "currency",
|
||||
currency: currency.toUpperCase(),
|
||||
}).format(amount / 100);
|
||||
}
|
||||
|
||||
/**
|
||||
* Format date for display
|
||||
*/
|
||||
export function formatDate(timestamp: number): string {
|
||||
return new Date(timestamp * 1000).toLocaleDateString("en-US", {
|
||||
year: "numeric",
|
||||
month: "short",
|
||||
day: "numeric",
|
||||
});
|
||||
}
|
||||
@@ -0,0 +1,308 @@
|
||||
import { useState, useCallback, useEffect } from "react";
|
||||
import type { Spec } from "@json-render/react";
|
||||
import {
|
||||
Box,
|
||||
FullPageView,
|
||||
FullPageTabs,
|
||||
FullPageTab,
|
||||
Button,
|
||||
TextField,
|
||||
Divider,
|
||||
List,
|
||||
ListItem,
|
||||
Spinner,
|
||||
} from "@stripe/ui-extension-sdk/ui";
|
||||
import type { ExtensionContextValue } from "@stripe/ui-extension-sdk/context";
|
||||
|
||||
import { stripeCatalog, StripeRenderer } from "../lib/render";
|
||||
import { executeAction } from "../lib/render/catalog/actions";
|
||||
import { API_GENERATE_URL } from "../lib/config";
|
||||
import { streamSpec } from "../lib/stream-spec";
|
||||
|
||||
function createOverviewSpec(data: Record<string, unknown>): Spec {
|
||||
const customers = data.customers as { total?: number } | undefined;
|
||||
const payments = data.payments as
|
||||
| { totalVolume?: string; successRate?: string }
|
||||
| undefined;
|
||||
const subscriptions = data.subscriptions as
|
||||
| { active?: number; trialing?: number; pastDue?: number }
|
||||
| undefined;
|
||||
|
||||
return {
|
||||
root: "root",
|
||||
elements: {
|
||||
root: {
|
||||
type: "Stack",
|
||||
props: { direction: "vertical", gap: "large" },
|
||||
children: ["metrics", "divider", "refresh"],
|
||||
},
|
||||
metrics: {
|
||||
type: "Stack",
|
||||
props: { direction: "horizontal", gap: "medium" },
|
||||
children: ["m1", "m2", "m3"],
|
||||
},
|
||||
m1: {
|
||||
type: "Metric",
|
||||
props: {
|
||||
label: "Payment Volume",
|
||||
value: payments?.totalVolume ?? "$0",
|
||||
change: payments?.successRate
|
||||
? `${payments.successRate} success`
|
||||
: null,
|
||||
changeType: "positive",
|
||||
},
|
||||
children: [],
|
||||
},
|
||||
m2: {
|
||||
type: "Metric",
|
||||
props: {
|
||||
label: "Active Subscriptions",
|
||||
value: String(subscriptions?.active ?? 0),
|
||||
change: subscriptions?.trialing
|
||||
? `+${subscriptions.trialing} trialing`
|
||||
: null,
|
||||
changeType: "positive",
|
||||
},
|
||||
children: [],
|
||||
},
|
||||
m3: {
|
||||
type: "Metric",
|
||||
props: {
|
||||
label: "Total Customers",
|
||||
value: String(customers?.total ?? 0),
|
||||
changeType: "neutral",
|
||||
},
|
||||
children: [],
|
||||
},
|
||||
divider: { type: "Divider", props: {}, children: [] },
|
||||
refresh: {
|
||||
type: "Button",
|
||||
props: {
|
||||
label: "Refresh Data",
|
||||
action: "refreshData",
|
||||
type: "secondary",
|
||||
},
|
||||
children: [],
|
||||
},
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
const componentCatalogInfo = Object.entries(stripeCatalog.data.components).map(
|
||||
([name, def]) => ({
|
||||
name,
|
||||
description: def.description,
|
||||
}),
|
||||
);
|
||||
|
||||
const actionsCatalogInfo = Object.entries(stripeCatalog.data.actions).map(
|
||||
([name, def]) => ({
|
||||
name,
|
||||
description: def.description,
|
||||
}),
|
||||
);
|
||||
|
||||
const FullPage = (_props: ExtensionContextValue) => {
|
||||
const [prompt, setPrompt] = useState("");
|
||||
const [isGenerating, setIsGenerating] = useState(false);
|
||||
const [isLoading, setIsLoading] = useState(true);
|
||||
const [currentSpec, setCurrentSpec] = useState<Spec | null>(null);
|
||||
const [data, setState] = useState<Record<string, unknown>>({});
|
||||
const [error, setError] = useState<string | null>(null);
|
||||
|
||||
const handleSetState = useCallback(
|
||||
(updater: (prev: Record<string, unknown>) => Record<string, unknown>) => {
|
||||
setState((prev) => updater(prev));
|
||||
},
|
||||
[],
|
||||
);
|
||||
|
||||
useEffect(() => {
|
||||
async function loadData() {
|
||||
setIsLoading(true);
|
||||
try {
|
||||
await executeAction("refreshData", {}, handleSetState, {});
|
||||
} catch (err) {
|
||||
console.error("Failed to load data:", err);
|
||||
setError("Failed to load Stripe data");
|
||||
} finally {
|
||||
setIsLoading(false);
|
||||
}
|
||||
}
|
||||
loadData();
|
||||
}, [handleSetState]);
|
||||
|
||||
const handleGenerate = async () => {
|
||||
if (!prompt.trim()) return;
|
||||
setIsGenerating(true);
|
||||
setError(null);
|
||||
|
||||
try {
|
||||
await streamSpec(
|
||||
API_GENERATE_URL,
|
||||
{
|
||||
prompt,
|
||||
systemPrompt: stripeCatalog.prompt({
|
||||
system:
|
||||
"You are a Stripe dashboard builder. Generate UI specs for displaying Stripe data in a full-page layout.",
|
||||
}),
|
||||
},
|
||||
(spec) => setCurrentSpec(spec),
|
||||
);
|
||||
} catch (err) {
|
||||
setCurrentSpec(createOverviewSpec(data));
|
||||
setError(
|
||||
`Using local generation (${err instanceof Error ? err.message : "API unavailable"})`,
|
||||
);
|
||||
} finally {
|
||||
setIsGenerating(false);
|
||||
}
|
||||
};
|
||||
|
||||
if (isLoading) {
|
||||
return (
|
||||
<FullPageView>
|
||||
<FullPageTabs>
|
||||
<FullPageTab
|
||||
id="overview"
|
||||
label="Overview"
|
||||
content={
|
||||
<Box
|
||||
css={{
|
||||
stack: "y",
|
||||
gap: "medium",
|
||||
alignX: "center",
|
||||
paddingY: "xlarge",
|
||||
}}
|
||||
>
|
||||
<Spinner size="large" />
|
||||
<Box css={{ font: "body", color: "secondary" }}>
|
||||
Loading Stripe data...
|
||||
</Box>
|
||||
</Box>
|
||||
}
|
||||
/>
|
||||
</FullPageTabs>
|
||||
</FullPageView>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<FullPageView
|
||||
pageAction={{
|
||||
label: "Refresh Data",
|
||||
onPress: () => executeAction("refreshData", {}, handleSetState, {}),
|
||||
}}
|
||||
>
|
||||
<FullPageTabs>
|
||||
<FullPageTab
|
||||
id="overview"
|
||||
label="Overview"
|
||||
content={
|
||||
<Box css={{ stack: "y", gap: "large", padding: "large" }}>
|
||||
<StripeRenderer
|
||||
spec={createOverviewSpec(data)}
|
||||
data={data}
|
||||
setData={handleSetState}
|
||||
loading={false}
|
||||
/>
|
||||
</Box>
|
||||
}
|
||||
/>
|
||||
|
||||
<FullPageTab
|
||||
id="generate"
|
||||
label="Generate"
|
||||
content={
|
||||
<Box css={{ stack: "y", gap: "medium", padding: "large" }}>
|
||||
<TextField
|
||||
label="Describe the dashboard you want"
|
||||
placeholder="e.g., Show MRR, payments, or subscriptions"
|
||||
value={prompt}
|
||||
onChange={(e) => setPrompt(e.target.value)}
|
||||
/>
|
||||
<Button
|
||||
type="primary"
|
||||
onPress={handleGenerate}
|
||||
disabled={isGenerating || !prompt.trim()}
|
||||
>
|
||||
{isGenerating ? "Generating..." : "Generate"}
|
||||
</Button>
|
||||
|
||||
{error && (
|
||||
<Box css={{ color: "secondary", font: "caption" }}>{error}</Box>
|
||||
)}
|
||||
|
||||
{currentSpec && (
|
||||
<>
|
||||
<Divider />
|
||||
<Box css={{ font: "subheading" }}>
|
||||
Preview (with real Stripe data)
|
||||
</Box>
|
||||
<Box
|
||||
css={{
|
||||
padding: "medium",
|
||||
borderRadius: "medium",
|
||||
keyline: "neutral",
|
||||
}}
|
||||
>
|
||||
<StripeRenderer
|
||||
spec={currentSpec}
|
||||
data={data}
|
||||
setData={handleSetState}
|
||||
loading={isGenerating}
|
||||
/>
|
||||
</Box>
|
||||
</>
|
||||
)}
|
||||
</Box>
|
||||
}
|
||||
/>
|
||||
|
||||
<FullPageTab
|
||||
id="components"
|
||||
label="Components"
|
||||
content={
|
||||
<Box css={{ padding: "large" }}>
|
||||
<Box css={{ font: "subheading", marginBottom: "medium" }}>
|
||||
Available Components ({componentCatalogInfo.length})
|
||||
</Box>
|
||||
<List>
|
||||
{componentCatalogInfo.map((comp) => (
|
||||
<ListItem
|
||||
key={comp.name}
|
||||
title={comp.name}
|
||||
secondaryTitle={comp.description}
|
||||
/>
|
||||
))}
|
||||
</List>
|
||||
</Box>
|
||||
}
|
||||
/>
|
||||
|
||||
<FullPageTab
|
||||
id="actions"
|
||||
label="Actions"
|
||||
content={
|
||||
<Box css={{ padding: "large" }}>
|
||||
<Box css={{ font: "subheading", marginBottom: "medium" }}>
|
||||
Available Actions ({actionsCatalogInfo.length})
|
||||
</Box>
|
||||
<List>
|
||||
{actionsCatalogInfo.map((action) => (
|
||||
<ListItem
|
||||
key={action.name}
|
||||
title={action.name}
|
||||
secondaryTitle={action.description}
|
||||
/>
|
||||
))}
|
||||
</List>
|
||||
</Box>
|
||||
}
|
||||
/>
|
||||
</FullPageTabs>
|
||||
</FullPageView>
|
||||
);
|
||||
};
|
||||
|
||||
export default FullPage;
|
||||
@@ -0,0 +1,4 @@
|
||||
<svg width="32" height="32" viewBox="0 0 32 32" fill="none" xmlns="http://www.w3.org/2000/svg">
|
||||
<path d="M24.218 9.10285C24.4008 8.99422 24.5577 8.84887 24.682 8.67797L17.5155 4.41809C16.5777 3.86064 15.4223 3.86064 14.4845 4.41809L7.31798 8.67797C7.44229 8.84887 7.59924 8.99422 7.78199 9.10285L15.2509 13.5425C15.7163 13.8192 16.2837 13.8192 16.7491 13.5425L24.218 9.10285Z" fill="#FFFFFF"/>
|
||||
<path d="M26.5 11.5603C26.5 11.4215 26.491 11.2841 26.4736 11.1486C26.2529 11.3479 26.0113 11.5268 25.7509 11.6817L18.282 16.1213C16.8718 16.9596 15.1282 16.9596 13.718 16.1213L6.2491 11.6817C5.98866 11.5268 5.7471 11.3479 5.52644 11.1486C5.50895 11.2841 5.5 11.4215 5.5 11.5603V20.4397C5.5 21.5546 6.07772 22.5848 7.01554 23.1423L14.4845 27.5819C15.4223 28.1394 16.5777 28.1394 17.5155 27.5819L24.9845 23.1423C25.9223 22.5848 26.5 21.5546 26.5 20.4397V11.5603Z" fill="#FFFFFF"/>
|
||||
</svg>
|
||||
|
After Width: | Height: | Size: 881 B |
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user