@pyreon/create-zero 0.14.0 → 0.16.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.
Files changed (65) hide show
  1. package/README.md +85 -22
  2. package/bin/create-pyreon-app.js +2 -0
  3. package/lib/index.js +1254 -191
  4. package/package.json +5 -2
  5. package/templates/{default → app}/src/routes/_layout.tsx +5 -2
  6. package/templates/{default → app}/src/routes/posts/[id].tsx +14 -0
  7. package/templates/blog/.mcp.json +8 -0
  8. package/templates/blog/CLAUDE.md +59 -0
  9. package/templates/blog/index.html +18 -0
  10. package/templates/blog/public/favicon.svg +4 -0
  11. package/templates/blog/src/content/posts/static-vs-ssr.tsx +54 -0
  12. package/templates/blog/src/content/posts/welcome.tsx +70 -0
  13. package/templates/blog/src/content/posts/why-signals.tsx +57 -0
  14. package/templates/blog/src/entry-client.ts +5 -0
  15. package/templates/blog/src/global.css +292 -0
  16. package/templates/blog/src/lib/posts.ts +45 -0
  17. package/templates/blog/src/routes/_layout.tsx +40 -0
  18. package/templates/blog/src/routes/about.tsx +28 -0
  19. package/templates/blog/src/routes/api/rss.ts +55 -0
  20. package/templates/blog/src/routes/blog/[slug].tsx +73 -0
  21. package/templates/blog/src/routes/blog/index.tsx +43 -0
  22. package/templates/blog/src/routes/index.tsx +52 -0
  23. package/templates/blog/tsconfig.json +16 -0
  24. package/templates/dashboard/.mcp.json +8 -0
  25. package/templates/dashboard/CLAUDE.md +50 -0
  26. package/templates/dashboard/index.html +16 -0
  27. package/templates/dashboard/public/favicon.svg +4 -0
  28. package/templates/dashboard/src/entry-client.ts +5 -0
  29. package/templates/dashboard/src/global.css +451 -0
  30. package/templates/dashboard/src/lib/auth.ts +106 -0
  31. package/templates/dashboard/src/lib/db.ts +118 -0
  32. package/templates/dashboard/src/routes/_layout.tsx +28 -0
  33. package/templates/dashboard/src/routes/api/signout.ts +15 -0
  34. package/templates/dashboard/src/routes/app/_layout.tsx +76 -0
  35. package/templates/dashboard/src/routes/app/dashboard.tsx +92 -0
  36. package/templates/dashboard/src/routes/app/invoices/[id].tsx +214 -0
  37. package/templates/dashboard/src/routes/app/invoices/index.tsx +61 -0
  38. package/templates/dashboard/src/routes/app/settings/account.tsx +31 -0
  39. package/templates/dashboard/src/routes/app/settings/billing.tsx +28 -0
  40. package/templates/dashboard/src/routes/app/settings/index.tsx +29 -0
  41. package/templates/dashboard/src/routes/app/users.tsx +50 -0
  42. package/templates/dashboard/src/routes/index.tsx +40 -0
  43. package/templates/dashboard/src/routes/login.tsx +79 -0
  44. package/templates/dashboard/src/routes/signup.tsx +78 -0
  45. package/templates/dashboard/tsconfig.json +16 -0
  46. package/lib/index.js.map +0 -1
  47. /package/templates/{default → app}/.mcp.json +0 -0
  48. /package/templates/{default → app}/CLAUDE.md +0 -0
  49. /package/templates/{default → app}/index.html +0 -0
  50. /package/templates/{default → app}/public/favicon.svg +0 -0
  51. /package/templates/{default → app}/src/entry-client.ts +0 -0
  52. /package/templates/{default → app}/src/features/posts.ts +0 -0
  53. /package/templates/{default → app}/src/global.css +0 -0
  54. /package/templates/{default → app}/src/routes/(admin)/dashboard.tsx +0 -0
  55. /package/templates/{default → app}/src/routes/_error.tsx +0 -0
  56. /package/templates/{default → app}/src/routes/_loading.tsx +0 -0
  57. /package/templates/{default → app}/src/routes/about.tsx +0 -0
  58. /package/templates/{default → app}/src/routes/api/health.ts +0 -0
  59. /package/templates/{default → app}/src/routes/api/posts.ts +0 -0
  60. /package/templates/{default → app}/src/routes/counter.tsx +0 -0
  61. /package/templates/{default → app}/src/routes/index.tsx +0 -0
  62. /package/templates/{default → app}/src/routes/posts/index.tsx +0 -0
  63. /package/templates/{default → app}/src/routes/posts/new.tsx +0 -0
  64. /package/templates/{default → app}/src/stores/app.ts +0 -0
  65. /package/templates/{default → app}/tsconfig.json +0 -0
@@ -0,0 +1,76 @@
1
+ import { signal } from "@pyreon/reactivity"
2
+ import { onMount } from "@pyreon/core"
3
+ import { RouterView, useRouter } from "@pyreon/router"
4
+ import { Link } from "@pyreon/zero/link"
5
+ import { ThemeToggle } from "@pyreon/zero/theme"
6
+ import { getSession, type SessionInfo } from "../../lib/auth"
7
+
8
+ /**
9
+ * Auth-gated route group. Every `/app/*` route runs through this layout,
10
+ * which checks the session client-side on mount and redirects to `/login`
11
+ * if missing. Pyreon doesn't ship a loader-side `throw redirect()` pattern
12
+ * (unlike Remix / React Router), so the gate runs after first paint —
13
+ * brief flash of the layout shell is acceptable since unauthorized users
14
+ * see no real data (the data-fetching helpers in `lib/db.ts` should
15
+ * additionally enforce authorization on the server).
16
+ *
17
+ * For SSR-side enforcement, set the session cookie on the server-rendered
18
+ * response and add a route middleware that 302s anonymous requests at the
19
+ * adapter layer (vercel.json rewrites, Cloudflare Pages function, etc.).
20
+ */
21
+ export function layout() {
22
+ const session = signal<SessionInfo | null>(null)
23
+ const router = useRouter()
24
+
25
+ onMount(() => {
26
+ const sid = readSessionCookie()
27
+ void getSession(sid).then((info) => {
28
+ if (!info) {
29
+ void router.push("/login")
30
+ return
31
+ }
32
+ session.set(info)
33
+ })
34
+ })
35
+
36
+ return (
37
+ <div class="app-shell">
38
+ <aside class="app-sidebar">
39
+ <div class="brand">Dashboard</div>
40
+ <Link href="/app/dashboard" prefetch="hover" exactActiveClass="nav-active">
41
+ Overview
42
+ </Link>
43
+ <Link href="/app/users" prefetch="hover" exactActiveClass="nav-active">
44
+ Users
45
+ </Link>
46
+ <Link href="/app/invoices" prefetch="hover" activeClass="nav-active">
47
+ Invoices
48
+ </Link>
49
+ <Link href="/app/settings" prefetch="hover" activeClass="nav-active">
50
+ Settings
51
+ </Link>
52
+
53
+ <div class="app-sidebar-footer">
54
+ {() => {
55
+ const s = session()
56
+ return s ? <div>{s.email}</div> : <div>Loading…</div>
57
+ }}
58
+ <div style="margin-top: 0.5rem; display: flex; gap: 0.5rem; align-items: center;">
59
+ <a href="/api/signout">Sign out</a>
60
+ <ThemeToggle />
61
+ </div>
62
+ </div>
63
+ </aside>
64
+
65
+ <main class="app-content">
66
+ <RouterView />
67
+ </main>
68
+ </div>
69
+ )
70
+ }
71
+
72
+ function readSessionCookie(): string | undefined {
73
+ if (typeof document === "undefined") return undefined
74
+ const m = /(?:^|;\s*)sid=([^;]+)/.exec(document.cookie)
75
+ return m?.[1]
76
+ }
@@ -0,0 +1,92 @@
1
+ import { computed, signal } from "@pyreon/reactivity"
2
+ import { onMount } from "@pyreon/core"
3
+ import { useHead } from "@pyreon/head"
4
+ import { type Invoice, invoiceTotal, listInvoices, listUsers, type User } from "../../lib/db"
5
+
6
+ export const meta = { title: "Overview" }
7
+
8
+ export default function Dashboard() {
9
+ useHead({ title: meta.title })
10
+
11
+ const users = signal<User[]>([])
12
+ const invoices = signal<Invoice[]>([])
13
+
14
+ onMount(() => {
15
+ void Promise.all([listUsers(), listInvoices()]).then(([u, i]) => {
16
+ users.set(u)
17
+ invoices.set(i)
18
+ })
19
+ })
20
+
21
+ const revenue = computed(() =>
22
+ invoices()
23
+ .filter((i) => i.status === "paid")
24
+ .reduce((sum, i) => sum + invoiceTotal(i), 0),
25
+ )
26
+ const outstanding = computed(() =>
27
+ invoices()
28
+ .filter((i) => i.status === "pending")
29
+ .reduce((sum, i) => sum + invoiceTotal(i), 0),
30
+ )
31
+
32
+ return (
33
+ <>
34
+ <div class="app-page-header">
35
+ <h1>Overview</h1>
36
+ </div>
37
+
38
+ <div class="stats-grid">
39
+ <div class="stat-card">
40
+ <div class="label">Users</div>
41
+ <div class="value">{() => users().length}</div>
42
+ <div class="delta">+2 this month</div>
43
+ </div>
44
+ <div class="stat-card">
45
+ <div class="label">Invoices</div>
46
+ <div class="value">{() => invoices().length}</div>
47
+ <div class="delta">{() => invoices().filter((i) => i.status === "paid").length} paid</div>
48
+ </div>
49
+ <div class="stat-card">
50
+ <div class="label">Revenue</div>
51
+ <div class="value">{() => `$${revenue().toLocaleString()}`}</div>
52
+ <div class="delta">YTD</div>
53
+ </div>
54
+ <div class="stat-card">
55
+ <div class="label">Outstanding</div>
56
+ <div class="value">{() => `$${outstanding().toLocaleString()}`}</div>
57
+ <div class="delta" style="color: var(--c-warning);">
58
+ {() => invoices().filter((i) => i.status === "pending").length} pending
59
+ </div>
60
+ </div>
61
+ </div>
62
+
63
+ <h2 style="font-size: 1.125rem; margin-bottom: 0.75rem;">Recent invoices</h2>
64
+ <table class="data-table">
65
+ <thead>
66
+ <tr>
67
+ <th>Number</th>
68
+ <th>Customer</th>
69
+ <th>Total</th>
70
+ <th>Status</th>
71
+ </tr>
72
+ </thead>
73
+ <tbody>
74
+ {() =>
75
+ invoices()
76
+ .slice(0, 5)
77
+ .map((inv) => (
78
+ <tr>
79
+ <td>{inv.number}</td>
80
+ <td>{inv.customer.name}</td>
81
+ <td>${invoiceTotal(inv).toLocaleString()}</td>
82
+ <td>
83
+ <span class={`pill ${inv.status}`}>{inv.status}</span>
84
+ </td>
85
+ </tr>
86
+ ))
87
+ }
88
+ </tbody>
89
+ </table>
90
+ </>
91
+ )
92
+ }
@@ -0,0 +1,214 @@
1
+ import { signal } from "@pyreon/reactivity"
2
+ import { onMount } from "@pyreon/core"
3
+ import { useHead } from "@pyreon/head"
4
+ import { useRoute } from "@pyreon/router"
5
+ import type { GetStaticPaths } from "@pyreon/zero/server"
6
+ import { Link } from "@pyreon/zero/link"
7
+ import {
8
+ DocDocument,
9
+ DocPage,
10
+ DocSection,
11
+ DocHeading,
12
+ DocText,
13
+ DocTable,
14
+ DocSpacer,
15
+ DocDivider,
16
+ extractDocNode,
17
+ } from "@pyreon/document-primitives"
18
+ import { render } from "@pyreon/document"
19
+ import { type Invoice, invoiceById, invoiceTotal } from "../../../lib/db"
20
+
21
+ export const meta = { title: "Invoice" }
22
+
23
+ /**
24
+ * Enumerate the dynamic `:id` values for SSG prerendering. The dashboard
25
+ * scaffolds in `mode: 'ssr'` by default (invoices are tenant-scoped and
26
+ * fetched per-request), so this export is unused at runtime. It exists
27
+ * so that `pyreon doctor --check-ssg` doesn't warn AND so the route still
28
+ * works under `mode: 'ssg'` if you swap to a fully-static deploy with
29
+ * known invoice IDs.
30
+ *
31
+ * Replace the placeholder IDs with your real invoice enumeration (DB
32
+ * query / API fetch) when configuring SSG deploys.
33
+ */
34
+ export const getStaticPaths: GetStaticPaths<{ id: string }> = () => [
35
+ { params: { id: "demo-001" } },
36
+ { params: { id: "demo-002" } },
37
+ ]
38
+
39
+ /**
40
+ * The headline demo of `@pyreon/document-primitives`: this template renders
41
+ * directly in the browser preview AND exports to PDF/email/etc. without
42
+ * being re-authored. `extractDocNode(template)` walks the JSX and produces
43
+ * a renderer-agnostic `DocNode`; `@pyreon/document`'s `render()` then
44
+ * formats it for whatever output the user clicks.
45
+ */
46
+ function InvoiceTemplate(inv: Invoice) {
47
+ return () => (
48
+ <DocDocument
49
+ title={`Invoice ${inv.number}`}
50
+ author="Your Company"
51
+ subject={`Invoice for ${inv.customer.name}`}
52
+ >
53
+ <DocPage>
54
+ <DocSection>
55
+ <DocHeading level="h1">Invoice {inv.number}</DocHeading>
56
+ <DocText>Issued {inv.issuedAt.toLocaleDateString()}</DocText>
57
+ </DocSection>
58
+
59
+ <DocSpacer />
60
+
61
+ <DocSection>
62
+ <DocHeading level="h3">Bill to</DocHeading>
63
+ <DocText>{inv.customer.name}</DocText>
64
+ <DocText>{inv.customer.email}</DocText>
65
+ <DocText>{inv.customer.address}</DocText>
66
+ </DocSection>
67
+
68
+ <DocDivider />
69
+
70
+ <DocTable
71
+ rows={[
72
+ ["Description", "Qty", "Unit price", "Line total"],
73
+ ...inv.items.map((it) => [
74
+ it.description,
75
+ String(it.qty),
76
+ `$${it.unitPrice.toLocaleString()}`,
77
+ `$${(it.qty * it.unitPrice).toLocaleString()}`,
78
+ ]),
79
+ ]}
80
+ />
81
+
82
+ <DocSpacer />
83
+
84
+ <DocSection>
85
+ <DocHeading level="h3">Total: ${invoiceTotal(inv).toLocaleString()}</DocHeading>
86
+ </DocSection>
87
+ </DocPage>
88
+ </DocDocument>
89
+ )
90
+ }
91
+
92
+ export default function InvoiceDetail() {
93
+ const route = useRoute()
94
+ const inv = signal<Invoice | null>(null)
95
+ const notFound = signal(false)
96
+
97
+ onMount(() => {
98
+ const id = route().params.id
99
+ void invoiceById(id).then((found) => {
100
+ if (!found) notFound.set(true)
101
+ else inv.set(found)
102
+ })
103
+ })
104
+
105
+ useHead({ title: meta.title })
106
+
107
+ async function exportPdf() {
108
+ const found = inv()
109
+ if (!found) return
110
+ const node = extractDocNode(InvoiceTemplate(found))
111
+ const result = await render(node, "pdf")
112
+ if (typeof window === "undefined") return
113
+ const blob = result instanceof Blob ? result : new Blob([result as BlobPart], { type: "application/pdf" })
114
+ const url = URL.createObjectURL(blob)
115
+ const a = document.createElement("a")
116
+ a.href = url
117
+ a.download = `${found.number}.pdf`
118
+ a.click()
119
+ URL.revokeObjectURL(url)
120
+ }
121
+
122
+ function sendEmail() {
123
+ // Real impl: POST the rendered email HTML to /api/email and let
124
+ // the email scaffold (lib/email.ts + emails/welcome.tsx) handle the
125
+ // SMTP transport via Resend. The point of the demo is that the SAME
126
+ // `InvoiceTemplate` renders both the PDF above and the email body —
127
+ // no re-authoring per channel.
128
+ alert(
129
+ "Email send is wired up via Resend (lib/email.ts). The same template renders to email HTML — try the PDF export above to see the document-primitives output.",
130
+ )
131
+ }
132
+
133
+ return () => {
134
+ if (notFound()) {
135
+ return (
136
+ <>
137
+ <h1>Invoice not found</h1>
138
+ <p>
139
+ <Link href="/app/invoices">← Back to invoices</Link>
140
+ </p>
141
+ </>
142
+ )
143
+ }
144
+
145
+ const found = inv()
146
+ if (!found) {
147
+ return (
148
+ <>
149
+ <div class="app-page-header">
150
+ <h1>Loading…</h1>
151
+ </div>
152
+ </>
153
+ )
154
+ }
155
+
156
+ return (
157
+ <>
158
+ <div class="app-page-header">
159
+ <h1>{found.number}</h1>
160
+ <span class={`pill ${found.status}`}>{found.status}</span>
161
+ </div>
162
+
163
+ <div class="invoice-detail">
164
+ <div class="invoice-preview">
165
+ <h2>Invoice {found.number}</h2>
166
+ <p>Issued {found.issuedAt.toLocaleDateString()}</p>
167
+ <p style="margin-top: 1.5rem;">
168
+ <strong>Bill to</strong>
169
+ </p>
170
+ <p>{found.customer.name}</p>
171
+ <p>{found.customer.email}</p>
172
+ <p>{found.customer.address}</p>
173
+ <table>
174
+ <thead>
175
+ <tr>
176
+ <th>Description</th>
177
+ <th>Qty</th>
178
+ <th>Unit price</th>
179
+ <th>Line total</th>
180
+ </tr>
181
+ </thead>
182
+ <tbody>
183
+ {found.items.map((it) => (
184
+ <tr>
185
+ <td>{it.description}</td>
186
+ <td>{it.qty}</td>
187
+ <td>${it.unitPrice.toLocaleString()}</td>
188
+ <td>${(it.qty * it.unitPrice).toLocaleString()}</td>
189
+ </tr>
190
+ ))}
191
+ </tbody>
192
+ </table>
193
+ <div class="total">Total: ${invoiceTotal(found).toLocaleString()}</div>
194
+ </div>
195
+
196
+ <aside class="invoice-actions">
197
+ <strong>Actions</strong>
198
+ <button class="btn btn-primary" onClick={exportPdf}>
199
+ Export to PDF
200
+ </button>
201
+ <button class="btn btn-secondary" onClick={sendEmail}>
202
+ Send by email
203
+ </button>
204
+ <p style="font-size: 0.8125rem; color: var(--c-text-muted); margin-top: 1rem;">
205
+ The preview above and the PDF export are rendered from the SAME{" "}
206
+ <code>InvoiceTemplate</code> component tree. That's what{" "}
207
+ <code>@pyreon/document-primitives</code> buys you.
208
+ </p>
209
+ </aside>
210
+ </div>
211
+ </>
212
+ )
213
+ }
214
+ }
@@ -0,0 +1,61 @@
1
+ import { signal } from "@pyreon/reactivity"
2
+ import { onMount } from "@pyreon/core"
3
+ import { useHead } from "@pyreon/head"
4
+ import { Link } from "@pyreon/zero/link"
5
+ import { type Invoice, invoiceTotal, listInvoices } from "../../../lib/db"
6
+
7
+ export const meta = { title: "Invoices" }
8
+
9
+ export default function Invoices() {
10
+ useHead({ title: meta.title })
11
+
12
+ const invoices = signal<Invoice[]>([])
13
+
14
+ onMount(() => {
15
+ void listInvoices().then((i) => invoices.set(i))
16
+ })
17
+
18
+ return (
19
+ <>
20
+ <div class="app-page-header">
21
+ <h1>Invoices</h1>
22
+ <button class="btn btn-primary">New invoice</button>
23
+ </div>
24
+
25
+ <table class="data-table">
26
+ <thead>
27
+ <tr>
28
+ <th>Number</th>
29
+ <th>Customer</th>
30
+ <th>Total</th>
31
+ <th>Status</th>
32
+ <th>Issued</th>
33
+ <th></th>
34
+ </tr>
35
+ </thead>
36
+ <tbody>
37
+ {() =>
38
+ invoices().map((inv) => (
39
+ <tr>
40
+ <td>
41
+ <Link href={`/app/invoices/${inv.id}`}>{inv.number}</Link>
42
+ </td>
43
+ <td>{inv.customer.name}</td>
44
+ <td>${invoiceTotal(inv).toLocaleString()}</td>
45
+ <td>
46
+ <span class={`pill ${inv.status}`}>{inv.status}</span>
47
+ </td>
48
+ <td>{inv.issuedAt.toLocaleDateString()}</td>
49
+ <td>
50
+ <Link href={`/app/invoices/${inv.id}`} class="btn btn-secondary">
51
+ Open →
52
+ </Link>
53
+ </td>
54
+ </tr>
55
+ ))
56
+ }
57
+ </tbody>
58
+ </table>
59
+ </>
60
+ )
61
+ }
@@ -0,0 +1,31 @@
1
+ import { useHead } from "@pyreon/head"
2
+
3
+ export const meta = { title: "Account settings" }
4
+
5
+ export default function AccountSettings() {
6
+ useHead({ title: meta.title })
7
+
8
+ return (
9
+ <>
10
+ <div class="app-page-header">
11
+ <h1>Account</h1>
12
+ </div>
13
+
14
+ <form style="max-width: 480px; display: grid; gap: 1rem;">
15
+ <div class="field">
16
+ <label for="name">Name</label>
17
+ <input id="name" type="text" placeholder="Your name" />
18
+ </div>
19
+ <div class="field">
20
+ <label for="email">Email</label>
21
+ <input id="email" type="email" placeholder="you@example.com" />
22
+ </div>
23
+ <div>
24
+ <button type="submit" class="btn btn-primary">
25
+ Save changes
26
+ </button>
27
+ </div>
28
+ </form>
29
+ </>
30
+ )
31
+ }
@@ -0,0 +1,28 @@
1
+ import { useHead } from "@pyreon/head"
2
+
3
+ export const meta = { title: "Billing" }
4
+
5
+ export default function Billing() {
6
+ useHead({ title: meta.title })
7
+
8
+ return (
9
+ <>
10
+ <div class="app-page-header">
11
+ <h1>Billing</h1>
12
+ </div>
13
+
14
+ <div class="stat-card" style="max-width: 480px;">
15
+ <div class="label">Plan</div>
16
+ <div class="value" style="font-size: 1.125rem; margin-bottom: 1rem;">
17
+ Free trial
18
+ </div>
19
+ <button class="btn btn-primary">Upgrade to Pro</button>
20
+ <p style="margin-top: 1rem; font-size: 0.8125rem; color: var(--c-text-muted);">
21
+ Wire Stripe Checkout into <code>/api/billing/checkout</code> in a follow-up. The
22
+ dashboard template ships the UI shell — payment integration is intentionally left
23
+ to the user since pricing models vary widely.
24
+ </p>
25
+ </div>
26
+ </>
27
+ )
28
+ }
@@ -0,0 +1,29 @@
1
+ import { useHead } from "@pyreon/head"
2
+ import { Link } from "@pyreon/zero/link"
3
+
4
+ export const meta = { title: "Settings" }
5
+
6
+ export default function Settings() {
7
+ useHead({ title: meta.title })
8
+
9
+ return (
10
+ <>
11
+ <div class="app-page-header">
12
+ <h1>Settings</h1>
13
+ </div>
14
+
15
+ <ul style="list-style: none; display: grid; gap: 1rem; padding: 0;">
16
+ <li>
17
+ <Link href="/app/settings/account" class="btn btn-secondary" style="width: 100%; justify-content: space-between;">
18
+ Account <span>→</span>
19
+ </Link>
20
+ </li>
21
+ <li>
22
+ <Link href="/app/settings/billing" class="btn btn-secondary" style="width: 100%; justify-content: space-between;">
23
+ Billing <span>→</span>
24
+ </Link>
25
+ </li>
26
+ </ul>
27
+ </>
28
+ )
29
+ }
@@ -0,0 +1,50 @@
1
+ import { signal } from "@pyreon/reactivity"
2
+ import { onMount } from "@pyreon/core"
3
+ import { useHead } from "@pyreon/head"
4
+ import { listUsers, type User } from "../../lib/db"
5
+
6
+ export const meta = { title: "Users" }
7
+
8
+ export default function Users() {
9
+ useHead({ title: meta.title })
10
+
11
+ const users = signal<User[]>([])
12
+
13
+ onMount(() => {
14
+ void listUsers().then((u) => users.set(u))
15
+ })
16
+
17
+ return (
18
+ <>
19
+ <div class="app-page-header">
20
+ <h1>Users</h1>
21
+ <button class="btn btn-primary">Invite user</button>
22
+ </div>
23
+
24
+ <table class="data-table">
25
+ <thead>
26
+ <tr>
27
+ <th>Name</th>
28
+ <th>Email</th>
29
+ <th>Role</th>
30
+ <th>Created</th>
31
+ </tr>
32
+ </thead>
33
+ <tbody>
34
+ {() =>
35
+ users().map((u) => (
36
+ <tr>
37
+ <td>{u.name}</td>
38
+ <td>{u.email}</td>
39
+ <td>
40
+ <span class="pill">{u.role}</span>
41
+ </td>
42
+ <td>{u.createdAt.toLocaleDateString()}</td>
43
+ </tr>
44
+ ))
45
+ }
46
+ </tbody>
47
+ </table>
48
+ </>
49
+ )
50
+ }
@@ -0,0 +1,40 @@
1
+ import { useHead } from "@pyreon/head"
2
+ import { Link } from "@pyreon/zero/link"
3
+ import { MarketingHeader } from "./_layout"
4
+
5
+ export const meta = {
6
+ title: "Dashboard — your SaaS in a box",
7
+ description: "A SaaS-shape Pyreon Zero starter. Auth, invoices, users, settings — out of the box.",
8
+ }
9
+
10
+ export default function Home() {
11
+ useHead({
12
+ title: meta.title,
13
+ meta: [{ name: "description", content: meta.description }],
14
+ })
15
+
16
+ return (
17
+ <>
18
+ <MarketingHeader />
19
+
20
+ <section class="hero">
21
+ <h1>Your SaaS in a box.</h1>
22
+ <p>
23
+ A Pyreon Zero starter with auth, table views, settings, and an invoice export
24
+ pipeline that renders the same component tree to PDF and email.
25
+ </p>
26
+ <div class="hero-actions">
27
+ <Link href="/signup" class="btn btn-primary">
28
+ Create an account
29
+ </Link>
30
+ <Link href="/login" class="btn btn-secondary">
31
+ Sign in
32
+ </Link>
33
+ </div>
34
+ <p style="margin-top: 1.5rem; font-size: 0.875rem; color: var(--c-text-muted);">
35
+ Demo login: <code>demo@example.com</code> / <code>demo1234</code>
36
+ </p>
37
+ </section>
38
+ </>
39
+ )
40
+ }