@pyreon/create-zero 0.14.0 → 0.15.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/blog/.mcp.json +8 -0
  7. package/templates/blog/CLAUDE.md +59 -0
  8. package/templates/blog/index.html +18 -0
  9. package/templates/blog/public/favicon.svg +4 -0
  10. package/templates/blog/src/content/posts/static-vs-ssr.tsx +54 -0
  11. package/templates/blog/src/content/posts/welcome.tsx +70 -0
  12. package/templates/blog/src/content/posts/why-signals.tsx +57 -0
  13. package/templates/blog/src/entry-client.ts +5 -0
  14. package/templates/blog/src/global.css +292 -0
  15. package/templates/blog/src/lib/posts.ts +45 -0
  16. package/templates/blog/src/routes/_layout.tsx +40 -0
  17. package/templates/blog/src/routes/about.tsx +28 -0
  18. package/templates/blog/src/routes/api/rss.ts +55 -0
  19. package/templates/blog/src/routes/blog/[slug].tsx +67 -0
  20. package/templates/blog/src/routes/blog/index.tsx +43 -0
  21. package/templates/blog/src/routes/index.tsx +52 -0
  22. package/templates/blog/tsconfig.json +16 -0
  23. package/templates/dashboard/.mcp.json +8 -0
  24. package/templates/dashboard/CLAUDE.md +50 -0
  25. package/templates/dashboard/index.html +16 -0
  26. package/templates/dashboard/public/favicon.svg +4 -0
  27. package/templates/dashboard/src/entry-client.ts +5 -0
  28. package/templates/dashboard/src/global.css +451 -0
  29. package/templates/dashboard/src/lib/auth.ts +106 -0
  30. package/templates/dashboard/src/lib/db.ts +118 -0
  31. package/templates/dashboard/src/routes/_layout.tsx +28 -0
  32. package/templates/dashboard/src/routes/api/signout.ts +15 -0
  33. package/templates/dashboard/src/routes/app/_layout.tsx +76 -0
  34. package/templates/dashboard/src/routes/app/dashboard.tsx +92 -0
  35. package/templates/dashboard/src/routes/app/invoices/[id].tsx +197 -0
  36. package/templates/dashboard/src/routes/app/invoices/index.tsx +61 -0
  37. package/templates/dashboard/src/routes/app/settings/account.tsx +31 -0
  38. package/templates/dashboard/src/routes/app/settings/billing.tsx +28 -0
  39. package/templates/dashboard/src/routes/app/settings/index.tsx +29 -0
  40. package/templates/dashboard/src/routes/app/users.tsx +50 -0
  41. package/templates/dashboard/src/routes/index.tsx +40 -0
  42. package/templates/dashboard/src/routes/login.tsx +79 -0
  43. package/templates/dashboard/src/routes/signup.tsx +78 -0
  44. package/templates/dashboard/tsconfig.json +16 -0
  45. package/lib/index.js.map +0 -1
  46. /package/templates/{default → app}/.mcp.json +0 -0
  47. /package/templates/{default → app}/CLAUDE.md +0 -0
  48. /package/templates/{default → app}/index.html +0 -0
  49. /package/templates/{default → app}/public/favicon.svg +0 -0
  50. /package/templates/{default → app}/src/entry-client.ts +0 -0
  51. /package/templates/{default → app}/src/features/posts.ts +0 -0
  52. /package/templates/{default → app}/src/global.css +0 -0
  53. /package/templates/{default → app}/src/routes/(admin)/dashboard.tsx +0 -0
  54. /package/templates/{default → app}/src/routes/_error.tsx +0 -0
  55. /package/templates/{default → app}/src/routes/_loading.tsx +0 -0
  56. /package/templates/{default → app}/src/routes/about.tsx +0 -0
  57. /package/templates/{default → app}/src/routes/api/health.ts +0 -0
  58. /package/templates/{default → app}/src/routes/api/posts.ts +0 -0
  59. /package/templates/{default → app}/src/routes/counter.tsx +0 -0
  60. /package/templates/{default → app}/src/routes/index.tsx +0 -0
  61. /package/templates/{default → app}/src/routes/posts/[id].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,197 @@
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 { Link } from "@pyreon/zero/link"
6
+ import {
7
+ DocDocument,
8
+ DocPage,
9
+ DocSection,
10
+ DocHeading,
11
+ DocText,
12
+ DocTable,
13
+ DocSpacer,
14
+ DocDivider,
15
+ extractDocNode,
16
+ } from "@pyreon/document-primitives"
17
+ import { render } from "@pyreon/document"
18
+ import { type Invoice, invoiceById, invoiceTotal } from "../../../lib/db"
19
+
20
+ export const meta = { title: "Invoice" }
21
+
22
+ /**
23
+ * The headline demo of `@pyreon/document-primitives`: this template renders
24
+ * directly in the browser preview AND exports to PDF/email/etc. without
25
+ * being re-authored. `extractDocNode(template)` walks the JSX and produces
26
+ * a renderer-agnostic `DocNode`; `@pyreon/document`'s `render()` then
27
+ * formats it for whatever output the user clicks.
28
+ */
29
+ function InvoiceTemplate(inv: Invoice) {
30
+ return () => (
31
+ <DocDocument
32
+ title={`Invoice ${inv.number}`}
33
+ author="Your Company"
34
+ subject={`Invoice for ${inv.customer.name}`}
35
+ >
36
+ <DocPage>
37
+ <DocSection>
38
+ <DocHeading level="h1">Invoice {inv.number}</DocHeading>
39
+ <DocText>Issued {inv.issuedAt.toLocaleDateString()}</DocText>
40
+ </DocSection>
41
+
42
+ <DocSpacer />
43
+
44
+ <DocSection>
45
+ <DocHeading level="h3">Bill to</DocHeading>
46
+ <DocText>{inv.customer.name}</DocText>
47
+ <DocText>{inv.customer.email}</DocText>
48
+ <DocText>{inv.customer.address}</DocText>
49
+ </DocSection>
50
+
51
+ <DocDivider />
52
+
53
+ <DocTable
54
+ rows={[
55
+ ["Description", "Qty", "Unit price", "Line total"],
56
+ ...inv.items.map((it) => [
57
+ it.description,
58
+ String(it.qty),
59
+ `$${it.unitPrice.toLocaleString()}`,
60
+ `$${(it.qty * it.unitPrice).toLocaleString()}`,
61
+ ]),
62
+ ]}
63
+ />
64
+
65
+ <DocSpacer />
66
+
67
+ <DocSection>
68
+ <DocHeading level="h3">Total: ${invoiceTotal(inv).toLocaleString()}</DocHeading>
69
+ </DocSection>
70
+ </DocPage>
71
+ </DocDocument>
72
+ )
73
+ }
74
+
75
+ export default function InvoiceDetail() {
76
+ const route = useRoute()
77
+ const inv = signal<Invoice | null>(null)
78
+ const notFound = signal(false)
79
+
80
+ onMount(() => {
81
+ const id = route().params.id
82
+ void invoiceById(id).then((found) => {
83
+ if (!found) notFound.set(true)
84
+ else inv.set(found)
85
+ })
86
+ })
87
+
88
+ useHead({ title: meta.title })
89
+
90
+ async function exportPdf() {
91
+ const found = inv()
92
+ if (!found) return
93
+ const node = extractDocNode(InvoiceTemplate(found))
94
+ const result = await render(node, "pdf")
95
+ if (typeof window === "undefined") return
96
+ const blob = result instanceof Blob ? result : new Blob([result as BlobPart], { type: "application/pdf" })
97
+ const url = URL.createObjectURL(blob)
98
+ const a = document.createElement("a")
99
+ a.href = url
100
+ a.download = `${found.number}.pdf`
101
+ a.click()
102
+ URL.revokeObjectURL(url)
103
+ }
104
+
105
+ function sendEmail() {
106
+ // Real impl: POST the rendered email HTML to /api/email and let
107
+ // the email scaffold (lib/email.ts + emails/welcome.tsx) handle the
108
+ // SMTP transport via Resend. The point of the demo is that the SAME
109
+ // `InvoiceTemplate` renders both the PDF above and the email body —
110
+ // no re-authoring per channel.
111
+ alert(
112
+ "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.",
113
+ )
114
+ }
115
+
116
+ return () => {
117
+ if (notFound()) {
118
+ return (
119
+ <>
120
+ <h1>Invoice not found</h1>
121
+ <p>
122
+ <Link href="/app/invoices">← Back to invoices</Link>
123
+ </p>
124
+ </>
125
+ )
126
+ }
127
+
128
+ const found = inv()
129
+ if (!found) {
130
+ return (
131
+ <>
132
+ <div class="app-page-header">
133
+ <h1>Loading…</h1>
134
+ </div>
135
+ </>
136
+ )
137
+ }
138
+
139
+ return (
140
+ <>
141
+ <div class="app-page-header">
142
+ <h1>{found.number}</h1>
143
+ <span class={`pill ${found.status}`}>{found.status}</span>
144
+ </div>
145
+
146
+ <div class="invoice-detail">
147
+ <div class="invoice-preview">
148
+ <h2>Invoice {found.number}</h2>
149
+ <p>Issued {found.issuedAt.toLocaleDateString()}</p>
150
+ <p style="margin-top: 1.5rem;">
151
+ <strong>Bill to</strong>
152
+ </p>
153
+ <p>{found.customer.name}</p>
154
+ <p>{found.customer.email}</p>
155
+ <p>{found.customer.address}</p>
156
+ <table>
157
+ <thead>
158
+ <tr>
159
+ <th>Description</th>
160
+ <th>Qty</th>
161
+ <th>Unit price</th>
162
+ <th>Line total</th>
163
+ </tr>
164
+ </thead>
165
+ <tbody>
166
+ {found.items.map((it) => (
167
+ <tr>
168
+ <td>{it.description}</td>
169
+ <td>{it.qty}</td>
170
+ <td>${it.unitPrice.toLocaleString()}</td>
171
+ <td>${(it.qty * it.unitPrice).toLocaleString()}</td>
172
+ </tr>
173
+ ))}
174
+ </tbody>
175
+ </table>
176
+ <div class="total">Total: ${invoiceTotal(found).toLocaleString()}</div>
177
+ </div>
178
+
179
+ <aside class="invoice-actions">
180
+ <strong>Actions</strong>
181
+ <button class="btn btn-primary" onClick={exportPdf}>
182
+ Export to PDF
183
+ </button>
184
+ <button class="btn btn-secondary" onClick={sendEmail}>
185
+ Send by email
186
+ </button>
187
+ <p style="font-size: 0.8125rem; color: var(--c-text-muted); margin-top: 1rem;">
188
+ The preview above and the PDF export are rendered from the SAME{" "}
189
+ <code>InvoiceTemplate</code> component tree. That's what{" "}
190
+ <code>@pyreon/document-primitives</code> buys you.
191
+ </p>
192
+ </aside>
193
+ </div>
194
+ </>
195
+ )
196
+ }
197
+ }
@@ -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
+ }
@@ -0,0 +1,79 @@
1
+ import { signal } from "@pyreon/reactivity"
2
+ import { useHead } from "@pyreon/head"
3
+ import { Link } from "@pyreon/zero/link"
4
+ import { useRouter } from "@pyreon/router"
5
+ import { signIn } from "../lib/auth"
6
+
7
+ export const meta = { title: "Sign in" }
8
+
9
+ export default function Login() {
10
+ useHead({ title: meta.title })
11
+
12
+ const email = signal("demo@example.com")
13
+ const password = signal("demo1234")
14
+ const error = signal<string | null>(null)
15
+ const submitting = signal(false)
16
+
17
+ const router = useRouter()
18
+
19
+ async function handleSubmit(e: Event) {
20
+ e.preventDefault()
21
+ error.set(null)
22
+ submitting.set(true)
23
+
24
+ const result = signIn(email(), password())
25
+ submitting.set(false)
26
+
27
+ if ("error" in result) {
28
+ error.set(result.error)
29
+ return
30
+ }
31
+
32
+ // Persist the stub session id client-side. Real impl uses an HttpOnly cookie
33
+ // set by the auth handler. See @pyreon/auth-lucia for the production path.
34
+ if (typeof document !== "undefined") {
35
+ document.cookie = `sid=${result.sessionId}; path=/; max-age=${7 * 24 * 60 * 60}`
36
+ }
37
+ await router.push("/app/dashboard")
38
+ }
39
+
40
+ return (
41
+ <div class="auth-shell">
42
+ <form class="auth-card" onSubmit={handleSubmit}>
43
+ <h1>Sign in</h1>
44
+
45
+ <div class="field">
46
+ <label for="email">Email</label>
47
+ <input
48
+ id="email"
49
+ type="email"
50
+ value={email}
51
+ onInput={(e) => email.set((e.currentTarget as HTMLInputElement).value)}
52
+ required
53
+ />
54
+ </div>
55
+
56
+ <div class="field">
57
+ <label for="password">Password</label>
58
+ <input
59
+ id="password"
60
+ type="password"
61
+ value={password}
62
+ onInput={(e) => password.set((e.currentTarget as HTMLInputElement).value)}
63
+ required
64
+ />
65
+ </div>
66
+
67
+ {() => (error() ? <div class="error">{error()}</div> : null)}
68
+
69
+ <button type="submit" class="btn btn-primary" disabled={submitting} style="width: 100%; justify-content: center; margin-top: 1rem;">
70
+ {() => (submitting() ? "Signing in…" : "Sign in")}
71
+ </button>
72
+
73
+ <p style="margin-top: 1rem; font-size: 0.875rem; color: var(--c-text-muted); text-align: center;">
74
+ No account? <Link href="/signup">Create one</Link>
75
+ </p>
76
+ </form>
77
+ </div>
78
+ )
79
+ }