create-dowel-app 0.7.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/LICENSE +21 -0
- package/dist/index.d.ts +18 -0
- package/dist/index.d.ts.map +1 -0
- package/dist/index.js +465 -0
- package/dist/index.js.map +1 -0
- package/package.json +66 -0
- package/templates/ai/src/app/app/agents/page.tsx +66 -0
- package/templates/ai/src/app/app/page.tsx +42 -0
- package/templates/ai/src/app/app/usage/page.tsx +58 -0
- package/templates/ai/src/app/page.tsx +25 -0
- package/templates/app-shell/src/app/app/layout.tsx +48 -0
- package/templates/app-shell/src/components/app-nav.tsx +41 -0
- package/templates/base/gitignore +16 -0
- package/templates/base/next.config.ts +5 -0
- package/templates/base/package.json +25 -0
- package/templates/base/postcss.config.mjs +5 -0
- package/templates/base/src/app/globals.css +1 -0
- package/templates/base/src/app/layout.tsx +22 -0
- package/templates/base/tsconfig.json +23 -0
- package/templates/saas/src/app/app/analytics/page.tsx +62 -0
- package/templates/saas/src/app/app/billing/page.tsx +36 -0
- package/templates/saas/src/app/app/page.tsx +69 -0
- package/templates/saas/src/app/app/settings/page.tsx +51 -0
- package/templates/saas/src/app/page.tsx +24 -0
- package/templates/starter/src/app/page.tsx +65 -0
|
@@ -0,0 +1,66 @@
|
|
|
1
|
+
"use client";
|
|
2
|
+
|
|
3
|
+
import { useState } from "react";
|
|
4
|
+
|
|
5
|
+
import type { LedgerAction } from "@/components/ui/ai-action-ledger";
|
|
6
|
+
import type { PlanStep } from "@/components/ui/ai-agent-plan";
|
|
7
|
+
import type { ApprovalDecision } from "@/components/ui/ai-approval-request";
|
|
8
|
+
import { AgentConsoleBlock } from "@/components/blocks/agent-console";
|
|
9
|
+
|
|
10
|
+
const PLAN: PlanStep[] = [
|
|
11
|
+
{ id: "1", title: "Scan the contact table", status: "done", detail: "18,402 records" },
|
|
12
|
+
{ id: "2", title: "Group likely duplicates", status: "done", detail: "41 groups" },
|
|
13
|
+
{ id: "3", title: "Merge each group", status: "running" },
|
|
14
|
+
{ id: "4", title: "Notify the record owners", status: "pending" },
|
|
15
|
+
];
|
|
16
|
+
|
|
17
|
+
const ACTIONS: LedgerAction[] = [
|
|
18
|
+
{
|
|
19
|
+
id: "a1",
|
|
20
|
+
summary: "Merged 3 contacts into Acme Inc.",
|
|
21
|
+
reversibility: "revertible",
|
|
22
|
+
status: "applied",
|
|
23
|
+
},
|
|
24
|
+
{
|
|
25
|
+
id: "a2",
|
|
26
|
+
summary: "Refunded $40.00 to Acme Inc.",
|
|
27
|
+
reversibility: "compensable",
|
|
28
|
+
status: "applied",
|
|
29
|
+
},
|
|
30
|
+
{
|
|
31
|
+
id: "a3",
|
|
32
|
+
summary: "Emailed 12 record owners",
|
|
33
|
+
reversibility: "irreversible",
|
|
34
|
+
status: "applied",
|
|
35
|
+
},
|
|
36
|
+
];
|
|
37
|
+
|
|
38
|
+
export default function AgentsPage() {
|
|
39
|
+
const [decision, setDecision] = useState<ApprovalDecision | undefined>(undefined);
|
|
40
|
+
|
|
41
|
+
return (
|
|
42
|
+
<AgentConsoleBlock
|
|
43
|
+
title="Deduplicate contacts"
|
|
44
|
+
description="Replace this with a real run from your own agent."
|
|
45
|
+
state={decision ? "working" : "waiting"}
|
|
46
|
+
plan={PLAN}
|
|
47
|
+
tokensUsed={42_180}
|
|
48
|
+
tokenLimit={128_000}
|
|
49
|
+
approval={{
|
|
50
|
+
tool: "delete_contacts",
|
|
51
|
+
summary: "Delete 3 contacts that look like duplicates of existing records.",
|
|
52
|
+
arguments: { ids: "c_8812, c_8813, c_8901", reason: "duplicate of c_1204" },
|
|
53
|
+
fields: [
|
|
54
|
+
{ name: "ids", label: "Contact ids", readOnly: true },
|
|
55
|
+
{ name: "reason", label: "Reason" },
|
|
56
|
+
],
|
|
57
|
+
irreversible: "Deleted contacts cannot be restored.",
|
|
58
|
+
decision,
|
|
59
|
+
}}
|
|
60
|
+
onApprovalDecision={setDecision}
|
|
61
|
+
actions={ACTIONS}
|
|
62
|
+
onRevert={() => undefined}
|
|
63
|
+
onStop={() => undefined}
|
|
64
|
+
/>
|
|
65
|
+
);
|
|
66
|
+
}
|
|
@@ -0,0 +1,42 @@
|
|
|
1
|
+
"use client";
|
|
2
|
+
|
|
3
|
+
import { useState } from "react";
|
|
4
|
+
|
|
5
|
+
import { AiChatBlock, type ChatMessage } from "@/components/blocks/ai-chat";
|
|
6
|
+
|
|
7
|
+
/**
|
|
8
|
+
* Wire `onSend` to your own endpoint.
|
|
9
|
+
*
|
|
10
|
+
* The reply here is canned so the page works the moment it is generated. Swap
|
|
11
|
+
* the timeout for a fetch and stream tokens into the last message — the block
|
|
12
|
+
* renders `streaming` as a caret rather than as a spinner that hides the text
|
|
13
|
+
* already written.
|
|
14
|
+
*/
|
|
15
|
+
export default function ChatPage() {
|
|
16
|
+
const [messages, setMessages] = useState<ChatMessage[]>([]);
|
|
17
|
+
const [busy, setBusy] = useState(false);
|
|
18
|
+
|
|
19
|
+
const send = (content: string) => {
|
|
20
|
+
const question: ChatMessage = { id: crypto.randomUUID(), from: "user", content };
|
|
21
|
+
setMessages((previous) => [...previous, question]);
|
|
22
|
+
setBusy(true);
|
|
23
|
+
|
|
24
|
+
window.setTimeout(() => {
|
|
25
|
+
setMessages((previous) => [
|
|
26
|
+
...previous,
|
|
27
|
+
{
|
|
28
|
+
id: crypto.randomUUID(),
|
|
29
|
+
from: "assistant",
|
|
30
|
+
content: "Replace this with a response from your model.",
|
|
31
|
+
},
|
|
32
|
+
]);
|
|
33
|
+
setBusy(false);
|
|
34
|
+
}, 600);
|
|
35
|
+
};
|
|
36
|
+
|
|
37
|
+
return (
|
|
38
|
+
<div className="h-[calc(100dvh-8rem)]">
|
|
39
|
+
<AiChatBlock messages={messages} onSend={send} busy={busy} waiting={busy} />
|
|
40
|
+
</div>
|
|
41
|
+
);
|
|
42
|
+
}
|
|
@@ -0,0 +1,58 @@
|
|
|
1
|
+
import { AiDashboardBlock } from "@/components/blocks/ai-dashboard";
|
|
2
|
+
|
|
3
|
+
export default function UsagePage() {
|
|
4
|
+
return (
|
|
5
|
+
<AiDashboardBlock
|
|
6
|
+
tokens={15_480_000}
|
|
7
|
+
previousTokens={11_200_000}
|
|
8
|
+
spend="$252.50"
|
|
9
|
+
spendValue={252.5}
|
|
10
|
+
previousSpendValue={198.4}
|
|
11
|
+
runs={3_470}
|
|
12
|
+
previousRuns={2_910}
|
|
13
|
+
failureRate={0.041}
|
|
14
|
+
previousFailureRate={0.062}
|
|
15
|
+
models={[
|
|
16
|
+
{ id: "opus", model: "claude-opus-5", runs: 118, tokens: 4_240_000, cost: "$182.40" },
|
|
17
|
+
{
|
|
18
|
+
id: "sonnet",
|
|
19
|
+
model: "claude-sonnet-5",
|
|
20
|
+
runs: 942,
|
|
21
|
+
tokens: 8_060_000,
|
|
22
|
+
cost: "$61.20",
|
|
23
|
+
},
|
|
24
|
+
{
|
|
25
|
+
id: "haiku",
|
|
26
|
+
model: "claude-haiku-4-5",
|
|
27
|
+
runs: 2_410,
|
|
28
|
+
tokens: 3_180_000,
|
|
29
|
+
cost: "$8.90",
|
|
30
|
+
},
|
|
31
|
+
]}
|
|
32
|
+
recentRuns={[
|
|
33
|
+
{
|
|
34
|
+
id: "r1",
|
|
35
|
+
title: "Deduplicate contacts",
|
|
36
|
+
state: "working",
|
|
37
|
+
model: "claude-opus-5",
|
|
38
|
+
tokens: 42_180,
|
|
39
|
+
href: "/app/agents",
|
|
40
|
+
},
|
|
41
|
+
{
|
|
42
|
+
id: "r2",
|
|
43
|
+
title: "Draft weekly digest",
|
|
44
|
+
state: "waiting",
|
|
45
|
+
model: "claude-sonnet-5",
|
|
46
|
+
tokens: 18_400,
|
|
47
|
+
},
|
|
48
|
+
{
|
|
49
|
+
id: "r3",
|
|
50
|
+
title: "Classify inbound tickets",
|
|
51
|
+
state: "done",
|
|
52
|
+
model: "claude-haiku-4-5",
|
|
53
|
+
tokens: 6_120,
|
|
54
|
+
},
|
|
55
|
+
]}
|
|
56
|
+
/>
|
|
57
|
+
);
|
|
58
|
+
}
|
|
@@ -0,0 +1,25 @@
|
|
|
1
|
+
import Link from "next/link";
|
|
2
|
+
|
|
3
|
+
import { Button } from "@/components/ui/button";
|
|
4
|
+
|
|
5
|
+
export default function Home() {
|
|
6
|
+
return (
|
|
7
|
+
<main className="mx-auto flex min-h-dvh max-w-3xl flex-col justify-center gap-6 px-6 py-16">
|
|
8
|
+
<h1 className="text-3xl font-semibold tracking-tight text-balance">__PROJECT_NAME__</h1>
|
|
9
|
+
<p className="text-pretty text-muted-foreground">
|
|
10
|
+
An AI product scaffolded with __LIBRARY_NAME__. The chat surface, agent console and
|
|
11
|
+
usage dashboard are source files in this repository — including the parts most component
|
|
12
|
+
sets do not ship: the approval before a tool runs, and the ledger of what it did
|
|
13
|
+
afterwards.
|
|
14
|
+
</p>
|
|
15
|
+
<div className="flex flex-wrap gap-3">
|
|
16
|
+
<Button asChild>
|
|
17
|
+
<Link href="/app">Open the app</Link>
|
|
18
|
+
</Button>
|
|
19
|
+
<Button variant="outline" asChild>
|
|
20
|
+
<a href="__DOCS_URL__">Documentation</a>
|
|
21
|
+
</Button>
|
|
22
|
+
</div>
|
|
23
|
+
</main>
|
|
24
|
+
);
|
|
25
|
+
}
|
|
@@ -0,0 +1,48 @@
|
|
|
1
|
+
import type { ReactNode } from "react";
|
|
2
|
+
|
|
3
|
+
import {
|
|
4
|
+
Sidebar,
|
|
5
|
+
SidebarContent,
|
|
6
|
+
SidebarGroup,
|
|
7
|
+
SidebarHeader,
|
|
8
|
+
SidebarInset,
|
|
9
|
+
SidebarProvider,
|
|
10
|
+
SidebarTrigger,
|
|
11
|
+
} from "@/components/ui/sidebar";
|
|
12
|
+
|
|
13
|
+
import { AppNav, type AppNavLink } from "@/components/app-nav";
|
|
14
|
+
|
|
15
|
+
const LINKS: AppNavLink[] = __APP_LINKS__;
|
|
16
|
+
|
|
17
|
+
export default function AppLayout({ children }: { children: ReactNode }) {
|
|
18
|
+
return (
|
|
19
|
+
<SidebarProvider>
|
|
20
|
+
<div className="flex min-h-dvh">
|
|
21
|
+
<Sidebar label="Application">
|
|
22
|
+
<SidebarHeader>
|
|
23
|
+
<SidebarTrigger />
|
|
24
|
+
<span className="truncate font-semibold tracking-tight">__PROJECT_NAME__</span>
|
|
25
|
+
</SidebarHeader>
|
|
26
|
+
|
|
27
|
+
<SidebarContent>
|
|
28
|
+
<SidebarGroup>
|
|
29
|
+
<AppNav links={LINKS} />
|
|
30
|
+
</SidebarGroup>
|
|
31
|
+
</SidebarContent>
|
|
32
|
+
</Sidebar>
|
|
33
|
+
|
|
34
|
+
<SidebarInset className="px-6 py-8">
|
|
35
|
+
{/* Skip link first in the tab order, so a keyboard user is not made
|
|
36
|
+
to walk the navigation on every page. */}
|
|
37
|
+
<a
|
|
38
|
+
href="#content"
|
|
39
|
+
className="sr-only rounded-md px-2 py-1 text-sm underline-offset-4 focus-visible:not-sr-only focus-visible:ring-2 focus-visible:ring-ring/55"
|
|
40
|
+
>
|
|
41
|
+
Skip to content
|
|
42
|
+
</a>
|
|
43
|
+
<div id="content">{children}</div>
|
|
44
|
+
</SidebarInset>
|
|
45
|
+
</div>
|
|
46
|
+
</SidebarProvider>
|
|
47
|
+
);
|
|
48
|
+
}
|
|
@@ -0,0 +1,41 @@
|
|
|
1
|
+
"use client";
|
|
2
|
+
|
|
3
|
+
import Link from "next/link";
|
|
4
|
+
import { usePathname } from "next/navigation";
|
|
5
|
+
|
|
6
|
+
import {
|
|
7
|
+
SidebarMenu,
|
|
8
|
+
SidebarMenuButton,
|
|
9
|
+
SidebarMenuItem,
|
|
10
|
+
SidebarMenuLabel,
|
|
11
|
+
} from "@/components/ui/sidebar";
|
|
12
|
+
|
|
13
|
+
export interface AppNavLink {
|
|
14
|
+
href: string;
|
|
15
|
+
label: string;
|
|
16
|
+
}
|
|
17
|
+
|
|
18
|
+
/**
|
|
19
|
+
* The application's navigation entries.
|
|
20
|
+
*
|
|
21
|
+
* `asChild` so Next's Link does the routing while the sidebar does the styling
|
|
22
|
+
* and the semantics — `aria-current` on the active entry comes from
|
|
23
|
+
* SidebarMenuButton rather than being hand-wired here.
|
|
24
|
+
*/
|
|
25
|
+
export function AppNav({ links }: { links: AppNavLink[] }) {
|
|
26
|
+
const pathname = usePathname();
|
|
27
|
+
|
|
28
|
+
return (
|
|
29
|
+
<SidebarMenu>
|
|
30
|
+
{links.map((link) => (
|
|
31
|
+
<SidebarMenuItem key={link.href}>
|
|
32
|
+
<SidebarMenuButton asChild isActive={pathname === link.href}>
|
|
33
|
+
<Link href={link.href}>
|
|
34
|
+
<SidebarMenuLabel>{link.label}</SidebarMenuLabel>
|
|
35
|
+
</Link>
|
|
36
|
+
</SidebarMenuButton>
|
|
37
|
+
</SidebarMenuItem>
|
|
38
|
+
))}
|
|
39
|
+
</SidebarMenu>
|
|
40
|
+
);
|
|
41
|
+
}
|
|
@@ -0,0 +1,25 @@
|
|
|
1
|
+
{
|
|
2
|
+
"name": "__PROJECT_NAME__",
|
|
3
|
+
"version": "0.1.0",
|
|
4
|
+
"private": true,
|
|
5
|
+
"type": "module",
|
|
6
|
+
"scripts": {
|
|
7
|
+
"dev": "next dev",
|
|
8
|
+
"build": "next build",
|
|
9
|
+
"start": "next start",
|
|
10
|
+
"typecheck": "tsc --noEmit"
|
|
11
|
+
},
|
|
12
|
+
"dependencies": {
|
|
13
|
+
"next": "16.3.3",
|
|
14
|
+
"react": "19.2.8",
|
|
15
|
+
"react-dom": "19.2.8"
|
|
16
|
+
},
|
|
17
|
+
"devDependencies": {
|
|
18
|
+
"@tailwindcss/postcss": "4.3.3",
|
|
19
|
+
"@types/node": "26.4.0",
|
|
20
|
+
"@types/react": "19.2.18",
|
|
21
|
+
"@types/react-dom": "19.2.5",
|
|
22
|
+
"tailwindcss": "4.3.3",
|
|
23
|
+
"typescript": "6.0.3"
|
|
24
|
+
}
|
|
25
|
+
}
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
@import "tailwindcss";
|
|
@@ -0,0 +1,22 @@
|
|
|
1
|
+
import type { Metadata } from "next";
|
|
2
|
+
import type { ReactNode } from "react";
|
|
3
|
+
|
|
4
|
+
import "./globals.css";
|
|
5
|
+
|
|
6
|
+
export const metadata: Metadata = {
|
|
7
|
+
title: "__PROJECT_NAME__",
|
|
8
|
+
description: "Built with __LIBRARY_NAME__.",
|
|
9
|
+
};
|
|
10
|
+
|
|
11
|
+
export default function RootLayout({ children }: { children: ReactNode }) {
|
|
12
|
+
return (
|
|
13
|
+
/*
|
|
14
|
+
`data-theme` selects the preset and the `dark` class selects the mode;
|
|
15
|
+
they are independent, so every preset works in both. Swap the preset here,
|
|
16
|
+
or drive it from a theme switcher — no component file changes either way.
|
|
17
|
+
*/
|
|
18
|
+
<html lang="en" data-theme="__THEME__" suppressHydrationWarning>
|
|
19
|
+
<body className="bg-background text-foreground antialiased">{children}</body>
|
|
20
|
+
</html>
|
|
21
|
+
);
|
|
22
|
+
}
|
|
@@ -0,0 +1,23 @@
|
|
|
1
|
+
{
|
|
2
|
+
"compilerOptions": {
|
|
3
|
+
"target": "ES2022",
|
|
4
|
+
"lib": ["dom", "dom.iterable", "ES2022"],
|
|
5
|
+
"module": "preserve",
|
|
6
|
+
"moduleResolution": "bundler",
|
|
7
|
+
"jsx": "preserve",
|
|
8
|
+
"allowJs": true,
|
|
9
|
+
"strict": true,
|
|
10
|
+
"noEmit": true,
|
|
11
|
+
"esModuleInterop": true,
|
|
12
|
+
"resolveJsonModule": true,
|
|
13
|
+
"isolatedModules": true,
|
|
14
|
+
"incremental": true,
|
|
15
|
+
"skipLibCheck": true,
|
|
16
|
+
"plugins": [{ "name": "next" }],
|
|
17
|
+
"paths": {
|
|
18
|
+
"@/*": ["./src/*"]
|
|
19
|
+
}
|
|
20
|
+
},
|
|
21
|
+
"include": ["next-env.d.ts", "**/*.ts", "**/*.tsx", ".next/types/**/*.ts"],
|
|
22
|
+
"exclude": ["node_modules"]
|
|
23
|
+
}
|
|
@@ -0,0 +1,62 @@
|
|
|
1
|
+
"use client";
|
|
2
|
+
|
|
3
|
+
import { AnalyticsBlock, type AnalyticsPoint } from "@/components/blocks/analytics";
|
|
4
|
+
|
|
5
|
+
/*
|
|
6
|
+
A client component, because of the `format` below.
|
|
7
|
+
|
|
8
|
+
The blocks are client components, and a function cannot cross the server
|
|
9
|
+
boundary into one — React has no way to send it. So a page that hands a block
|
|
10
|
+
a formatter, a comparator or an event handler has to be a client component
|
|
11
|
+
itself. Data-only props are fine from the server; the moment a function is
|
|
12
|
+
among them, this directive is required, and the build says so rather than
|
|
13
|
+
failing at runtime.
|
|
14
|
+
*/
|
|
15
|
+
|
|
16
|
+
const DAYS = ["Mon", "Tue", "Wed", "Thu", "Fri", "Sat", "Sun"];
|
|
17
|
+
|
|
18
|
+
const SERIES: AnalyticsPoint[] = [1240, 1810, 1520, 2140, 2380, 1180, 940].map(
|
|
19
|
+
(value, index) => ({
|
|
20
|
+
at: `2026-03-0${String(index + 1)}`,
|
|
21
|
+
label: DAYS[index] ?? "",
|
|
22
|
+
value,
|
|
23
|
+
}),
|
|
24
|
+
);
|
|
25
|
+
|
|
26
|
+
export default function AnalyticsPage() {
|
|
27
|
+
return (
|
|
28
|
+
<AnalyticsBlock
|
|
29
|
+
metrics={[
|
|
30
|
+
{
|
|
31
|
+
id: "visitors",
|
|
32
|
+
label: "Visitors",
|
|
33
|
+
value: 11_210,
|
|
34
|
+
previous: 9_840,
|
|
35
|
+
comparisonLabel: "vs last week",
|
|
36
|
+
},
|
|
37
|
+
{
|
|
38
|
+
id: "signups",
|
|
39
|
+
label: "Signups",
|
|
40
|
+
value: 284,
|
|
41
|
+
previous: 231,
|
|
42
|
+
comparisonLabel: "vs last week",
|
|
43
|
+
},
|
|
44
|
+
{
|
|
45
|
+
id: "bounce",
|
|
46
|
+
label: "Bounce rate",
|
|
47
|
+
value: 38,
|
|
48
|
+
previous: 44,
|
|
49
|
+
polarity: "lower-is-better",
|
|
50
|
+
format: (value) => `${String(value)}%`,
|
|
51
|
+
},
|
|
52
|
+
{ id: "sessions", label: "Sessions", value: 18_400, previous: 16_120 },
|
|
53
|
+
]}
|
|
54
|
+
series={SERIES}
|
|
55
|
+
breakdown={[
|
|
56
|
+
{ id: "search", label: "Organic search", value: 5820 },
|
|
57
|
+
{ id: "direct", label: "Direct", value: 2940 },
|
|
58
|
+
{ id: "referral", label: "Referral", value: 1610 },
|
|
59
|
+
]}
|
|
60
|
+
/>
|
|
61
|
+
);
|
|
62
|
+
}
|
|
@@ -0,0 +1,36 @@
|
|
|
1
|
+
import { BillingBlock } from "@/components/blocks/billing";
|
|
2
|
+
|
|
3
|
+
export default function BillingPage() {
|
|
4
|
+
return (
|
|
5
|
+
<BillingBlock
|
|
6
|
+
plan={{
|
|
7
|
+
name: "Team",
|
|
8
|
+
price: "$240",
|
|
9
|
+
interval: "per month, billed annually",
|
|
10
|
+
renewsAt: "2027-03-03",
|
|
11
|
+
renewsLabel: "3 March 2027",
|
|
12
|
+
}}
|
|
13
|
+
usage={[
|
|
14
|
+
{ id: "seats", label: "Seats", used: 8, limit: 10, unit: "seats" },
|
|
15
|
+
{ id: "storage", label: "Storage", used: 128, limit: 250, unit: "GB" },
|
|
16
|
+
]}
|
|
17
|
+
paymentMethod={{ brand: "Visa", last4: "4242", expires: "04/2029" }}
|
|
18
|
+
invoices={[
|
|
19
|
+
{
|
|
20
|
+
id: "in_2",
|
|
21
|
+
at: "2026-02-01",
|
|
22
|
+
label: "1 February 2026",
|
|
23
|
+
amount: "$240.00",
|
|
24
|
+
status: "paid",
|
|
25
|
+
},
|
|
26
|
+
{
|
|
27
|
+
id: "in_1",
|
|
28
|
+
at: "2026-01-01",
|
|
29
|
+
label: "1 January 2026",
|
|
30
|
+
amount: "$240.00",
|
|
31
|
+
status: "paid",
|
|
32
|
+
},
|
|
33
|
+
]}
|
|
34
|
+
/>
|
|
35
|
+
);
|
|
36
|
+
}
|
|
@@ -0,0 +1,69 @@
|
|
|
1
|
+
import {
|
|
2
|
+
DashboardBlock,
|
|
3
|
+
type DashboardEvent,
|
|
4
|
+
type DashboardStat,
|
|
5
|
+
} from "@/components/blocks/dashboard";
|
|
6
|
+
import { OnboardingBlock, type OnboardingStep } from "@/components/blocks/onboarding";
|
|
7
|
+
|
|
8
|
+
/**
|
|
9
|
+
* Replace these with your own data.
|
|
10
|
+
*
|
|
11
|
+
* They are inline rather than fetched so the page renders the moment it is
|
|
12
|
+
* generated — a scaffold that needs a database before it shows anything is a
|
|
13
|
+
* scaffold nobody sees working.
|
|
14
|
+
*/
|
|
15
|
+
const STATS: DashboardStat[] = [
|
|
16
|
+
{
|
|
17
|
+
id: "mrr",
|
|
18
|
+
label: "Monthly revenue",
|
|
19
|
+
value: "$48,120",
|
|
20
|
+
change: 12.4,
|
|
21
|
+
comparison: "on last month",
|
|
22
|
+
},
|
|
23
|
+
{
|
|
24
|
+
id: "users",
|
|
25
|
+
label: "Active users",
|
|
26
|
+
value: "2,410",
|
|
27
|
+
change: 4.2,
|
|
28
|
+
comparison: "on last week",
|
|
29
|
+
},
|
|
30
|
+
{ id: "churn", label: "Churn", value: "1.8%", change: 0.4, higherIsBetter: false },
|
|
31
|
+
{ id: "uptime", label: "Uptime", value: "99.98%" },
|
|
32
|
+
];
|
|
33
|
+
|
|
34
|
+
const EVENTS: DashboardEvent[] = [
|
|
35
|
+
{
|
|
36
|
+
id: "1",
|
|
37
|
+
title: "Deployed to production",
|
|
38
|
+
at: "2026-03-04T09:12:00Z",
|
|
39
|
+
label: "12 minutes ago",
|
|
40
|
+
tone: "success",
|
|
41
|
+
},
|
|
42
|
+
{
|
|
43
|
+
id: "2",
|
|
44
|
+
title: "New customer: Acme Inc.",
|
|
45
|
+
at: "2026-03-04T08:40:00Z",
|
|
46
|
+
label: "44 minutes ago",
|
|
47
|
+
},
|
|
48
|
+
];
|
|
49
|
+
|
|
50
|
+
const STEPS: OnboardingStep[] = [
|
|
51
|
+
{ id: "account", title: "Create your account", status: "done" },
|
|
52
|
+
{
|
|
53
|
+
id: "team",
|
|
54
|
+
title: "Invite your team",
|
|
55
|
+
status: "current",
|
|
56
|
+
actionLabel: "Invite",
|
|
57
|
+
estimate: "2 minutes",
|
|
58
|
+
},
|
|
59
|
+
{ id: "billing", title: "Add a payment method", status: "todo", actionLabel: "Add" },
|
|
60
|
+
];
|
|
61
|
+
|
|
62
|
+
export default function AppPage() {
|
|
63
|
+
return (
|
|
64
|
+
<div className="flex flex-col gap-8">
|
|
65
|
+
<DashboardBlock stats={STATS} events={EVENTS} />
|
|
66
|
+
<OnboardingBlock steps={STEPS} />
|
|
67
|
+
</div>
|
|
68
|
+
);
|
|
69
|
+
}
|
|
@@ -0,0 +1,51 @@
|
|
|
1
|
+
"use client";
|
|
2
|
+
|
|
3
|
+
import { useState } from "react";
|
|
4
|
+
|
|
5
|
+
import {
|
|
6
|
+
SettingsBlock,
|
|
7
|
+
type SettingsNotification,
|
|
8
|
+
type SettingsProfile,
|
|
9
|
+
} from "@/components/blocks/settings";
|
|
10
|
+
|
|
11
|
+
export default function SettingsPage() {
|
|
12
|
+
const [profile, setProfile] = useState<SettingsProfile>({
|
|
13
|
+
name: "Ada Lovelace",
|
|
14
|
+
email: "ada@example.com",
|
|
15
|
+
bio: "Replace this with the signed-in user.",
|
|
16
|
+
});
|
|
17
|
+
|
|
18
|
+
const [notifications, setNotifications] = useState<SettingsNotification[]>([
|
|
19
|
+
{
|
|
20
|
+
id: "product",
|
|
21
|
+
label: "Product updates",
|
|
22
|
+
description: "New features and changes worth knowing about.",
|
|
23
|
+
enabled: true,
|
|
24
|
+
},
|
|
25
|
+
{
|
|
26
|
+
id: "billing",
|
|
27
|
+
label: "Billing",
|
|
28
|
+
description: "Invoices, failed payments and plan changes.",
|
|
29
|
+
enabled: true,
|
|
30
|
+
},
|
|
31
|
+
{
|
|
32
|
+
id: "digest",
|
|
33
|
+
label: "Weekly digest",
|
|
34
|
+
description: "A summary of activity every Monday.",
|
|
35
|
+
enabled: false,
|
|
36
|
+
},
|
|
37
|
+
]);
|
|
38
|
+
|
|
39
|
+
return (
|
|
40
|
+
<SettingsBlock
|
|
41
|
+
profile={profile}
|
|
42
|
+
notifications={notifications}
|
|
43
|
+
onSaveProfile={setProfile}
|
|
44
|
+
onToggleNotification={(id, enabled) => {
|
|
45
|
+
setNotifications((previous) =>
|
|
46
|
+
previous.map((entry) => (entry.id === id ? { ...entry, enabled } : entry)),
|
|
47
|
+
);
|
|
48
|
+
}}
|
|
49
|
+
/>
|
|
50
|
+
);
|
|
51
|
+
}
|
|
@@ -0,0 +1,24 @@
|
|
|
1
|
+
import Link from "next/link";
|
|
2
|
+
|
|
3
|
+
import { Button } from "@/components/ui/button";
|
|
4
|
+
|
|
5
|
+
export default function Home() {
|
|
6
|
+
return (
|
|
7
|
+
<main className="mx-auto flex min-h-dvh max-w-3xl flex-col justify-center gap-6 px-6 py-16">
|
|
8
|
+
<h1 className="text-3xl font-semibold tracking-tight text-balance">__PROJECT_NAME__</h1>
|
|
9
|
+
<p className="text-pretty text-muted-foreground">
|
|
10
|
+
A SaaS application scaffolded with __LIBRARY_NAME__. The dashboard, analytics, billing,
|
|
11
|
+
settings and onboarding surfaces are source files in this repository — open one and
|
|
12
|
+
change it.
|
|
13
|
+
</p>
|
|
14
|
+
<div className="flex flex-wrap gap-3">
|
|
15
|
+
<Button asChild>
|
|
16
|
+
<Link href="/app">Open the app</Link>
|
|
17
|
+
</Button>
|
|
18
|
+
<Button variant="outline" asChild>
|
|
19
|
+
<a href="__DOCS_URL__">Documentation</a>
|
|
20
|
+
</Button>
|
|
21
|
+
</div>
|
|
22
|
+
</main>
|
|
23
|
+
);
|
|
24
|
+
}
|