@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.
- package/README.md +85 -22
- package/bin/create-pyreon-app.js +2 -0
- package/lib/index.js +1254 -191
- package/package.json +5 -2
- package/templates/{default → app}/src/routes/_layout.tsx +5 -2
- package/templates/blog/.mcp.json +8 -0
- package/templates/blog/CLAUDE.md +59 -0
- package/templates/blog/index.html +18 -0
- package/templates/blog/public/favicon.svg +4 -0
- package/templates/blog/src/content/posts/static-vs-ssr.tsx +54 -0
- package/templates/blog/src/content/posts/welcome.tsx +70 -0
- package/templates/blog/src/content/posts/why-signals.tsx +57 -0
- package/templates/blog/src/entry-client.ts +5 -0
- package/templates/blog/src/global.css +292 -0
- package/templates/blog/src/lib/posts.ts +45 -0
- package/templates/blog/src/routes/_layout.tsx +40 -0
- package/templates/blog/src/routes/about.tsx +28 -0
- package/templates/blog/src/routes/api/rss.ts +55 -0
- package/templates/blog/src/routes/blog/[slug].tsx +67 -0
- package/templates/blog/src/routes/blog/index.tsx +43 -0
- package/templates/blog/src/routes/index.tsx +52 -0
- package/templates/blog/tsconfig.json +16 -0
- package/templates/dashboard/.mcp.json +8 -0
- package/templates/dashboard/CLAUDE.md +50 -0
- package/templates/dashboard/index.html +16 -0
- package/templates/dashboard/public/favicon.svg +4 -0
- package/templates/dashboard/src/entry-client.ts +5 -0
- package/templates/dashboard/src/global.css +451 -0
- package/templates/dashboard/src/lib/auth.ts +106 -0
- package/templates/dashboard/src/lib/db.ts +118 -0
- package/templates/dashboard/src/routes/_layout.tsx +28 -0
- package/templates/dashboard/src/routes/api/signout.ts +15 -0
- package/templates/dashboard/src/routes/app/_layout.tsx +76 -0
- package/templates/dashboard/src/routes/app/dashboard.tsx +92 -0
- package/templates/dashboard/src/routes/app/invoices/[id].tsx +197 -0
- package/templates/dashboard/src/routes/app/invoices/index.tsx +61 -0
- package/templates/dashboard/src/routes/app/settings/account.tsx +31 -0
- package/templates/dashboard/src/routes/app/settings/billing.tsx +28 -0
- package/templates/dashboard/src/routes/app/settings/index.tsx +29 -0
- package/templates/dashboard/src/routes/app/users.tsx +50 -0
- package/templates/dashboard/src/routes/index.tsx +40 -0
- package/templates/dashboard/src/routes/login.tsx +79 -0
- package/templates/dashboard/src/routes/signup.tsx +78 -0
- package/templates/dashboard/tsconfig.json +16 -0
- package/lib/index.js.map +0 -1
- /package/templates/{default → app}/.mcp.json +0 -0
- /package/templates/{default → app}/CLAUDE.md +0 -0
- /package/templates/{default → app}/index.html +0 -0
- /package/templates/{default → app}/public/favicon.svg +0 -0
- /package/templates/{default → app}/src/entry-client.ts +0 -0
- /package/templates/{default → app}/src/features/posts.ts +0 -0
- /package/templates/{default → app}/src/global.css +0 -0
- /package/templates/{default → app}/src/routes/(admin)/dashboard.tsx +0 -0
- /package/templates/{default → app}/src/routes/_error.tsx +0 -0
- /package/templates/{default → app}/src/routes/_loading.tsx +0 -0
- /package/templates/{default → app}/src/routes/about.tsx +0 -0
- /package/templates/{default → app}/src/routes/api/health.ts +0 -0
- /package/templates/{default → app}/src/routes/api/posts.ts +0 -0
- /package/templates/{default → app}/src/routes/counter.tsx +0 -0
- /package/templates/{default → app}/src/routes/index.tsx +0 -0
- /package/templates/{default → app}/src/routes/posts/[id].tsx +0 -0
- /package/templates/{default → app}/src/routes/posts/index.tsx +0 -0
- /package/templates/{default → app}/src/routes/posts/new.tsx +0 -0
- /package/templates/{default → app}/src/stores/app.ts +0 -0
- /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
|
+
}
|