@pylonsync/create-pylon 0.3.344 → 0.3.346
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/bin/create-pylon.js +35 -0
- package/package.json +1 -1
- package/templates/crm/AGENTS.md +116 -0
- package/templates/crm/CLAUDE.md +7 -0
- package/templates/crm/README.md +96 -0
- package/templates/crm/app/companies/companies-view.tsx +129 -0
- package/templates/crm/app/companies/page.tsx +17 -0
- package/templates/crm/app/contacts/contacts-view.tsx +139 -0
- package/templates/crm/app/contacts/page.tsx +17 -0
- package/templates/crm/app/deals/[id]/deal-view.tsx +189 -0
- package/templates/crm/app/deals/[id]/page.tsx +23 -0
- package/templates/crm/app/error.tsx +43 -0
- package/templates/crm/app/globals.css +201 -0
- package/templates/crm/app/layout.tsx +31 -0
- package/templates/crm/app/login/auth-form.tsx +122 -0
- package/templates/crm/app/login/page.tsx +38 -0
- package/templates/crm/app/not-found.tsx +29 -0
- package/templates/crm/app/page.tsx +38 -0
- package/templates/crm/app/pipeline-view.tsx +99 -0
- package/templates/crm/app/robots.ts +12 -0
- package/templates/crm/app/sitemap.ts +27 -0
- package/templates/crm/app/workspace.tsx +231 -0
- package/templates/crm/app.ts +171 -0
- package/templates/crm/bunfig +4 -0
- package/templates/crm/components/activity-timeline.tsx +133 -0
- package/templates/crm/components/avatar.tsx +40 -0
- package/templates/crm/components/board-skeleton.tsx +44 -0
- package/templates/crm/components/command-palette.tsx +162 -0
- package/templates/crm/components/data-table.tsx +90 -0
- package/templates/crm/components/deal-card.tsx +91 -0
- package/templates/crm/components/deal-dialog.tsx +169 -0
- package/templates/crm/components/empty-state.tsx +43 -0
- package/templates/crm/components/kbd.tsx +23 -0
- package/templates/crm/components/metrics-bar.tsx +46 -0
- package/templates/crm/components/page-header.tsx +36 -0
- package/templates/crm/components/pipeline-board.tsx +105 -0
- package/templates/crm/components/record-dialog.tsx +126 -0
- package/templates/crm/components/sidebar.tsx +112 -0
- package/templates/crm/components/stage-badge.tsx +33 -0
- package/templates/crm/components/ui/badge.tsx +37 -0
- package/templates/crm/components/ui/button.tsx +56 -0
- package/templates/crm/components/ui/card.tsx +90 -0
- package/templates/crm/components/ui/dialog.tsx +110 -0
- package/templates/crm/components/ui/input.tsx +30 -0
- package/templates/crm/components/ui/label.tsx +21 -0
- package/templates/crm/components/ui/select.tsx +39 -0
- package/templates/crm/components/ui/table.tsx +124 -0
- package/templates/crm/components/ui/textarea.tsx +24 -0
- package/templates/crm/components.json +20 -0
- package/templates/crm/functions/createDeal.ts +59 -0
- package/templates/crm/functions/logActivity.ts +49 -0
- package/templates/crm/functions/moveDeal.ts +53 -0
- package/templates/crm/functions/seedWorkspace.ts +63 -0
- package/templates/crm/gitignore +10 -0
- package/templates/crm/lib/pipeline.ts +221 -0
- package/templates/crm/lib/search.ts +78 -0
- package/templates/crm/lib/seed.ts +130 -0
- package/templates/crm/lib/utils.ts +10 -0
- package/templates/crm/package.json +39 -0
- package/templates/crm/tests/components.test.tsx +245 -0
- package/templates/crm/tests/pipeline.test.ts +219 -0
- package/templates/crm/tests/search.test.ts +83 -0
- package/templates/crm/tests/setup.ts +5 -0
- package/templates/crm/tsconfig.json +33 -0
- package/templates/helpdesk/AGENTS.md +116 -0
- package/templates/helpdesk/CLAUDE.md +7 -0
- package/templates/helpdesk/README.md +90 -0
- package/templates/helpdesk/app/customers/customers-view.tsx +140 -0
- package/templates/helpdesk/app/customers/page.tsx +17 -0
- package/templates/helpdesk/app/error.tsx +43 -0
- package/templates/helpdesk/app/globals.css +201 -0
- package/templates/helpdesk/app/inbox-view.tsx +147 -0
- package/templates/helpdesk/app/layout.tsx +31 -0
- package/templates/helpdesk/app/login/auth-form.tsx +122 -0
- package/templates/helpdesk/app/login/page.tsx +38 -0
- package/templates/helpdesk/app/not-found.tsx +29 -0
- package/templates/helpdesk/app/page.tsx +38 -0
- package/templates/helpdesk/app/robots.ts +12 -0
- package/templates/helpdesk/app/sitemap.ts +27 -0
- package/templates/helpdesk/app/tickets/[id]/page.tsx +23 -0
- package/templates/helpdesk/app/tickets/[id]/ticket-view.tsx +164 -0
- package/templates/helpdesk/app/workspace.tsx +210 -0
- package/templates/helpdesk/app.ts +142 -0
- package/templates/helpdesk/bunfig +4 -0
- package/templates/helpdesk/components/avatar.tsx +40 -0
- package/templates/helpdesk/components/command-palette.tsx +162 -0
- package/templates/helpdesk/components/data-table.tsx +90 -0
- package/templates/helpdesk/components/empty-state.tsx +43 -0
- package/templates/helpdesk/components/kbd.tsx +23 -0
- package/templates/helpdesk/components/new-ticket-dialog.tsx +147 -0
- package/templates/helpdesk/components/page-header.tsx +36 -0
- package/templates/helpdesk/components/priority-badge.tsx +61 -0
- package/templates/helpdesk/components/record-dialog.tsx +126 -0
- package/templates/helpdesk/components/sidebar.tsx +111 -0
- package/templates/helpdesk/components/sla-indicator.tsx +47 -0
- package/templates/helpdesk/components/thread.tsx +143 -0
- package/templates/helpdesk/components/ticket-list.tsx +78 -0
- package/templates/helpdesk/components/ui/badge.tsx +37 -0
- package/templates/helpdesk/components/ui/button.tsx +56 -0
- package/templates/helpdesk/components/ui/card.tsx +90 -0
- package/templates/helpdesk/components/ui/dialog.tsx +110 -0
- package/templates/helpdesk/components/ui/input.tsx +30 -0
- package/templates/helpdesk/components/ui/label.tsx +21 -0
- package/templates/helpdesk/components/ui/select.tsx +39 -0
- package/templates/helpdesk/components/ui/table.tsx +124 -0
- package/templates/helpdesk/components/ui/textarea.tsx +24 -0
- package/templates/helpdesk/components.json +20 -0
- package/templates/helpdesk/functions/createTicket.ts +62 -0
- package/templates/helpdesk/functions/replyToTicket.ts +55 -0
- package/templates/helpdesk/functions/seedWorkspace.ts +52 -0
- package/templates/helpdesk/functions/setTicketState.ts +58 -0
- package/templates/helpdesk/gitignore +10 -0
- package/templates/helpdesk/lib/format.ts +53 -0
- package/templates/helpdesk/lib/search.ts +78 -0
- package/templates/helpdesk/lib/seed.ts +185 -0
- package/templates/helpdesk/lib/tickets.ts +184 -0
- package/templates/helpdesk/lib/utils.ts +10 -0
- package/templates/helpdesk/package.json +39 -0
- package/templates/helpdesk/tests/components.test.tsx +202 -0
- package/templates/helpdesk/tests/search.test.ts +83 -0
- package/templates/helpdesk/tests/setup.ts +5 -0
- package/templates/helpdesk/tests/tickets.test.ts +215 -0
- package/templates/helpdesk/tsconfig.json +33 -0
- package/templates/inventory/AGENTS.md +116 -0
- package/templates/inventory/CLAUDE.md +7 -0
- package/templates/inventory/README.md +103 -0
- package/templates/inventory/app/error.tsx +43 -0
- package/templates/inventory/app/globals.css +201 -0
- package/templates/inventory/app/layout.tsx +31 -0
- package/templates/inventory/app/login/auth-form.tsx +122 -0
- package/templates/inventory/app/login/page.tsx +38 -0
- package/templates/inventory/app/movements/movements-view.tsx +103 -0
- package/templates/inventory/app/movements/page.tsx +17 -0
- package/templates/inventory/app/not-found.tsx +29 -0
- package/templates/inventory/app/page.tsx +34 -0
- package/templates/inventory/app/products/[id]/page.tsx +23 -0
- package/templates/inventory/app/products/[id]/product-view.tsx +178 -0
- package/templates/inventory/app/products-view.tsx +213 -0
- package/templates/inventory/app/robots.ts +12 -0
- package/templates/inventory/app/sitemap.ts +27 -0
- package/templates/inventory/app/workspace.tsx +172 -0
- package/templates/inventory/app.ts +154 -0
- package/templates/inventory/bunfig +4 -0
- package/templates/inventory/components/avatar.tsx +40 -0
- package/templates/inventory/components/command-palette.tsx +162 -0
- package/templates/inventory/components/data-table.tsx +90 -0
- package/templates/inventory/components/empty-state.tsx +43 -0
- package/templates/inventory/components/kbd.tsx +23 -0
- package/templates/inventory/components/movement-dialog.tsx +182 -0
- package/templates/inventory/components/page-header.tsx +36 -0
- package/templates/inventory/components/record-dialog.tsx +126 -0
- package/templates/inventory/components/sidebar.tsx +111 -0
- package/templates/inventory/components/stock-level.tsx +47 -0
- package/templates/inventory/components/summary-bar.tsx +56 -0
- package/templates/inventory/components/ui/badge.tsx +37 -0
- package/templates/inventory/components/ui/button.tsx +56 -0
- package/templates/inventory/components/ui/card.tsx +90 -0
- package/templates/inventory/components/ui/dialog.tsx +110 -0
- package/templates/inventory/components/ui/input.tsx +30 -0
- package/templates/inventory/components/ui/label.tsx +21 -0
- package/templates/inventory/components/ui/select.tsx +39 -0
- package/templates/inventory/components/ui/table.tsx +124 -0
- package/templates/inventory/components/ui/textarea.tsx +24 -0
- package/templates/inventory/components.json +20 -0
- package/templates/inventory/functions/recordMovement.ts +72 -0
- package/templates/inventory/functions/seedWorkspace.ts +40 -0
- package/templates/inventory/gitignore +10 -0
- package/templates/inventory/lib/format.ts +53 -0
- package/templates/inventory/lib/search.ts +78 -0
- package/templates/inventory/lib/seed.ts +102 -0
- package/templates/inventory/lib/stock.ts +207 -0
- package/templates/inventory/lib/utils.ts +10 -0
- package/templates/inventory/package.json +39 -0
- package/templates/inventory/tests/components.test.tsx +155 -0
- package/templates/inventory/tests/search.test.ts +83 -0
- package/templates/inventory/tests/setup.ts +5 -0
- package/templates/inventory/tests/stock.test.ts +255 -0
- package/templates/inventory/tsconfig.json +33 -0
- package/templates/invoices/AGENTS.md +116 -0
- package/templates/invoices/CLAUDE.md +7 -0
- package/templates/invoices/README.md +99 -0
- package/templates/invoices/app/clients/clients-view.tsx +130 -0
- package/templates/invoices/app/clients/page.tsx +17 -0
- package/templates/invoices/app/error.tsx +43 -0
- package/templates/invoices/app/globals.css +201 -0
- package/templates/invoices/app/invoices/[id]/invoice-view.tsx +253 -0
- package/templates/invoices/app/invoices/[id]/page.tsx +23 -0
- package/templates/invoices/app/invoices-view.tsx +183 -0
- package/templates/invoices/app/layout.tsx +31 -0
- package/templates/invoices/app/login/auth-form.tsx +122 -0
- package/templates/invoices/app/login/page.tsx +38 -0
- package/templates/invoices/app/not-found.tsx +29 -0
- package/templates/invoices/app/page.tsx +36 -0
- package/templates/invoices/app/robots.ts +12 -0
- package/templates/invoices/app/sitemap.ts +27 -0
- package/templates/invoices/app/workspace.tsx +174 -0
- package/templates/invoices/app.ts +169 -0
- package/templates/invoices/bunfig +4 -0
- package/templates/invoices/components/avatar.tsx +40 -0
- package/templates/invoices/components/command-palette.tsx +162 -0
- package/templates/invoices/components/data-table.tsx +90 -0
- package/templates/invoices/components/empty-state.tsx +43 -0
- package/templates/invoices/components/kbd.tsx +23 -0
- package/templates/invoices/components/line-items.tsx +172 -0
- package/templates/invoices/components/page-header.tsx +36 -0
- package/templates/invoices/components/payment-dialog.tsx +132 -0
- package/templates/invoices/components/record-dialog.tsx +126 -0
- package/templates/invoices/components/sidebar.tsx +111 -0
- package/templates/invoices/components/status-badge.tsx +35 -0
- package/templates/invoices/components/summary-bar.tsx +65 -0
- package/templates/invoices/components/totals-panel.tsx +70 -0
- package/templates/invoices/components/ui/badge.tsx +37 -0
- package/templates/invoices/components/ui/button.tsx +56 -0
- package/templates/invoices/components/ui/card.tsx +90 -0
- package/templates/invoices/components/ui/dialog.tsx +110 -0
- package/templates/invoices/components/ui/input.tsx +30 -0
- package/templates/invoices/components/ui/label.tsx +21 -0
- package/templates/invoices/components/ui/select.tsx +39 -0
- package/templates/invoices/components/ui/table.tsx +124 -0
- package/templates/invoices/components/ui/textarea.tsx +24 -0
- package/templates/invoices/components.json +20 -0
- package/templates/invoices/functions/createInvoice.ts +49 -0
- package/templates/invoices/functions/recordPayment.ts +72 -0
- package/templates/invoices/functions/seedWorkspace.ts +57 -0
- package/templates/invoices/functions/setInvoiceStatus.ts +35 -0
- package/templates/invoices/gitignore +10 -0
- package/templates/invoices/lib/billing.ts +259 -0
- package/templates/invoices/lib/format.ts +53 -0
- package/templates/invoices/lib/search.ts +78 -0
- package/templates/invoices/lib/seed.ts +190 -0
- package/templates/invoices/lib/utils.ts +10 -0
- package/templates/invoices/package.json +39 -0
- package/templates/invoices/tests/billing.test.ts +283 -0
- package/templates/invoices/tests/components.test.tsx +172 -0
- package/templates/invoices/tests/search.test.ts +83 -0
- package/templates/invoices/tests/setup.ts +5 -0
- package/templates/invoices/tsconfig.json +33 -0
- package/templates/projects/AGENTS.md +116 -0
- package/templates/projects/CLAUDE.md +7 -0
- package/templates/projects/README.md +96 -0
- package/templates/projects/app/clients/clients-view.tsx +126 -0
- package/templates/projects/app/clients/page.tsx +17 -0
- package/templates/projects/app/error.tsx +43 -0
- package/templates/projects/app/globals.css +201 -0
- package/templates/projects/app/layout.tsx +31 -0
- package/templates/projects/app/login/auth-form.tsx +122 -0
- package/templates/projects/app/login/page.tsx +38 -0
- package/templates/projects/app/not-found.tsx +29 -0
- package/templates/projects/app/page.tsx +30 -0
- package/templates/projects/app/projects/[id]/page.tsx +23 -0
- package/templates/projects/app/projects/[id]/project-view.tsx +179 -0
- package/templates/projects/app/projects-view.tsx +181 -0
- package/templates/projects/app/robots.ts +12 -0
- package/templates/projects/app/sitemap.ts +27 -0
- package/templates/projects/app/workspace.tsx +190 -0
- package/templates/projects/app.ts +186 -0
- package/templates/projects/bunfig +4 -0
- package/templates/projects/components/avatar.tsx +40 -0
- package/templates/projects/components/board-skeleton.tsx +37 -0
- package/templates/projects/components/budget-bar.tsx +61 -0
- package/templates/projects/components/command-palette.tsx +162 -0
- package/templates/projects/components/data-table.tsx +90 -0
- package/templates/projects/components/empty-state.tsx +43 -0
- package/templates/projects/components/kbd.tsx +23 -0
- package/templates/projects/components/page-header.tsx +36 -0
- package/templates/projects/components/record-dialog.tsx +126 -0
- package/templates/projects/components/sidebar.tsx +111 -0
- package/templates/projects/components/task-board.tsx +109 -0
- package/templates/projects/components/task-card.tsx +77 -0
- package/templates/projects/components/time-dialog.tsx +114 -0
- package/templates/projects/components/ui/badge.tsx +37 -0
- package/templates/projects/components/ui/button.tsx +56 -0
- package/templates/projects/components/ui/card.tsx +90 -0
- package/templates/projects/components/ui/dialog.tsx +110 -0
- package/templates/projects/components/ui/input.tsx +30 -0
- package/templates/projects/components/ui/label.tsx +21 -0
- package/templates/projects/components/ui/select.tsx +39 -0
- package/templates/projects/components/ui/table.tsx +124 -0
- package/templates/projects/components/ui/textarea.tsx +24 -0
- package/templates/projects/components.json +20 -0
- package/templates/projects/functions/logTime.ts +49 -0
- package/templates/projects/functions/moveTask.ts +40 -0
- package/templates/projects/functions/seedWorkspace.ts +59 -0
- package/templates/projects/gitignore +10 -0
- package/templates/projects/lib/format.ts +53 -0
- package/templates/projects/lib/search.ts +78 -0
- package/templates/projects/lib/seed.ts +140 -0
- package/templates/projects/lib/utils.ts +10 -0
- package/templates/projects/lib/work.ts +233 -0
- package/templates/projects/package.json +39 -0
- package/templates/projects/tests/components.test.tsx +200 -0
- package/templates/projects/tests/search.test.ts +83 -0
- package/templates/projects/tests/setup.ts +5 -0
- package/templates/projects/tests/work.test.ts +238 -0
- package/templates/projects/tsconfig.json +33 -0
|
@@ -0,0 +1,103 @@
|
|
|
1
|
+
"use client";
|
|
2
|
+
|
|
3
|
+
import React from "react";
|
|
4
|
+
import { ArrowLeftRight } from "lucide-react";
|
|
5
|
+
import { PageHeader } from "@/components/page-header";
|
|
6
|
+
import { DataTable, type ColumnDef } from "@/components/data-table";
|
|
7
|
+
import { EmptyState } from "@/components/empty-state";
|
|
8
|
+
import { relativeTime } from "@/lib/format";
|
|
9
|
+
import { reasonById, signedQuantity } from "@/lib/stock";
|
|
10
|
+
import { Workspace, type MovementRow } from "../workspace";
|
|
11
|
+
|
|
12
|
+
/**
|
|
13
|
+
* The ledger, newest first. Read-only by design: a movement is never edited or
|
|
14
|
+
* deleted — the policy forbids it — because rewriting history would change a
|
|
15
|
+
* past valuation and make the current level unexplainable. Corrections are new
|
|
16
|
+
* rows in the opposite direction.
|
|
17
|
+
*/
|
|
18
|
+
export function MovementsView({ email }: { email: string }) {
|
|
19
|
+
return (
|
|
20
|
+
<Workspace email={email} pathname="/movements">
|
|
21
|
+
{(data) => {
|
|
22
|
+
const columns: ColumnDef<MovementRow>[] = [
|
|
23
|
+
{
|
|
24
|
+
key: "when",
|
|
25
|
+
header: "When",
|
|
26
|
+
cell: (row) => (
|
|
27
|
+
<span className="text-muted-foreground">{relativeTime(row.createdAt)}</span>
|
|
28
|
+
),
|
|
29
|
+
},
|
|
30
|
+
{
|
|
31
|
+
key: "product",
|
|
32
|
+
header: "Product",
|
|
33
|
+
cell: (row) => (
|
|
34
|
+
<span className="truncate font-medium">
|
|
35
|
+
{data.productName(row.productId) ?? "—"}
|
|
36
|
+
</span>
|
|
37
|
+
),
|
|
38
|
+
},
|
|
39
|
+
{
|
|
40
|
+
key: "reason",
|
|
41
|
+
header: "Reason",
|
|
42
|
+
cell: (row) => (
|
|
43
|
+
<span className="text-muted-foreground">
|
|
44
|
+
{reasonById(row.reason)?.label ?? row.reason}
|
|
45
|
+
</span>
|
|
46
|
+
),
|
|
47
|
+
},
|
|
48
|
+
{
|
|
49
|
+
key: "delta",
|
|
50
|
+
header: "Change",
|
|
51
|
+
numeric: true,
|
|
52
|
+
cell: (row) => (
|
|
53
|
+
<span
|
|
54
|
+
className={
|
|
55
|
+
row.delta > 0 ? "text-stage-won" : row.delta < 0 ? "text-destructive" : ""
|
|
56
|
+
}
|
|
57
|
+
>
|
|
58
|
+
{signedQuantity(row.delta)}
|
|
59
|
+
</span>
|
|
60
|
+
),
|
|
61
|
+
},
|
|
62
|
+
{
|
|
63
|
+
key: "note",
|
|
64
|
+
header: "Note",
|
|
65
|
+
cell: (row) => (
|
|
66
|
+
<span className="text-muted-foreground">{row.note ?? "—"}</span>
|
|
67
|
+
),
|
|
68
|
+
},
|
|
69
|
+
{
|
|
70
|
+
key: "who",
|
|
71
|
+
header: "By",
|
|
72
|
+
cell: (row) => (
|
|
73
|
+
<span className="text-muted-foreground">
|
|
74
|
+
{data.actorName(row.actorId) ?? "—"}
|
|
75
|
+
</span>
|
|
76
|
+
),
|
|
77
|
+
},
|
|
78
|
+
];
|
|
79
|
+
|
|
80
|
+
const rows = [...data.movements].sort((a, b) =>
|
|
81
|
+
(b.createdAt ?? "").localeCompare(a.createdAt ?? ""),
|
|
82
|
+
);
|
|
83
|
+
|
|
84
|
+
return (
|
|
85
|
+
<>
|
|
86
|
+
<PageHeader title="Movements" count={rows.length} />
|
|
87
|
+
<DataTable
|
|
88
|
+
rows={rows}
|
|
89
|
+
columns={columns}
|
|
90
|
+
empty={
|
|
91
|
+
<EmptyState
|
|
92
|
+
icon={<ArrowLeftRight />}
|
|
93
|
+
title={data.loading ? "Loading…" : "No movements yet"}
|
|
94
|
+
description="Every change to stock is recorded here, permanently. On-hand is the sum of these rows."
|
|
95
|
+
/>
|
|
96
|
+
}
|
|
97
|
+
/>
|
|
98
|
+
</>
|
|
99
|
+
);
|
|
100
|
+
}}
|
|
101
|
+
</Workspace>
|
|
102
|
+
);
|
|
103
|
+
}
|
|
@@ -0,0 +1,17 @@
|
|
|
1
|
+
import React, { use } from "react";
|
|
2
|
+
import type { Metadata, PageProps } from "@pylonsync/react";
|
|
3
|
+
import { MovementsView } from "./movements-view";
|
|
4
|
+
|
|
5
|
+
export const metadata: Metadata = {
|
|
6
|
+
title: "Movements",
|
|
7
|
+
robots: "noindex",
|
|
8
|
+
};
|
|
9
|
+
|
|
10
|
+
export default function MovementsPage({ auth, response, serverData }: PageProps) {
|
|
11
|
+
if (!auth.user_id || auth.user_id.startsWith("guest_")) {
|
|
12
|
+
response.redirect("/login");
|
|
13
|
+
return null;
|
|
14
|
+
}
|
|
15
|
+
const me = use(serverData.get<{ email?: string }>("User", auth.user_id));
|
|
16
|
+
return <MovementsView email={me?.email ?? ""} />;
|
|
17
|
+
}
|
|
@@ -0,0 +1,29 @@
|
|
|
1
|
+
import React from "react";
|
|
2
|
+
import { Link, useRouter, type NotFoundProps } from "@pylonsync/react";
|
|
3
|
+
import { Button } from "@/components/ui/button";
|
|
4
|
+
|
|
5
|
+
// `app/not-found.tsx` → rendered at HTTP 404 for any unmatched URL (and when
|
|
6
|
+
// a page calls `response.notFound()`). It's HYDRATED, so it's interactive:
|
|
7
|
+
// the buttons below use the client router. Not-found boundaries receive the
|
|
8
|
+
// standard page props (and, matching Next, no `reset`).
|
|
9
|
+
export default function NotFound(_props: NotFoundProps) {
|
|
10
|
+
const router = useRouter();
|
|
11
|
+
return (
|
|
12
|
+
<div className="space-y-6">
|
|
13
|
+
<section>
|
|
14
|
+
<h1 className="text-2xl font-semibold tracking-tight">404</h1>
|
|
15
|
+
<p className="mt-2 text-muted-foreground">
|
|
16
|
+
We couldn't find that page.
|
|
17
|
+
</p>
|
|
18
|
+
</section>
|
|
19
|
+
<div className="flex items-center gap-3">
|
|
20
|
+
<Button onClick={() => router.back()} variant="outline">
|
|
21
|
+
← Go back
|
|
22
|
+
</Button>
|
|
23
|
+
<Button asChild>
|
|
24
|
+
<Link href="/">Home</Link>
|
|
25
|
+
</Button>
|
|
26
|
+
</div>
|
|
27
|
+
</div>
|
|
28
|
+
);
|
|
29
|
+
}
|
|
@@ -0,0 +1,34 @@
|
|
|
1
|
+
import React, { use } from "react";
|
|
2
|
+
import type { Metadata, PageProps } from "@pylonsync/react";
|
|
3
|
+
import { ProductsView } from "./products-view";
|
|
4
|
+
|
|
5
|
+
export const metadata: Metadata = {
|
|
6
|
+
title: "Products",
|
|
7
|
+
robots: "noindex",
|
|
8
|
+
};
|
|
9
|
+
|
|
10
|
+
/**
|
|
11
|
+
* `/` — the stock list.
|
|
12
|
+
*
|
|
13
|
+
* Server-side auth gate only. Reading `auth` opts this render out of caching,
|
|
14
|
+
* which is correct: every screen here is private.
|
|
15
|
+
*/
|
|
16
|
+
export default function ProductsPage({
|
|
17
|
+
auth,
|
|
18
|
+
response,
|
|
19
|
+
searchParams,
|
|
20
|
+
serverData,
|
|
21
|
+
}: PageProps) {
|
|
22
|
+
if (!auth.user_id || auth.user_id.startsWith("guest_")) {
|
|
23
|
+
response.redirect("/login");
|
|
24
|
+
return null;
|
|
25
|
+
}
|
|
26
|
+
const me = use(serverData.get<{ email?: string }>("User", auth.user_id));
|
|
27
|
+
return (
|
|
28
|
+
<ProductsView
|
|
29
|
+
email={me?.email ?? ""}
|
|
30
|
+
openNew={searchParams?.new === "product"}
|
|
31
|
+
initialFilter={searchParams?.filter}
|
|
32
|
+
/>
|
|
33
|
+
);
|
|
34
|
+
}
|
|
@@ -0,0 +1,23 @@
|
|
|
1
|
+
import React, { use } from "react";
|
|
2
|
+
import type { Metadata, PageProps } from "@pylonsync/react";
|
|
3
|
+
import { ProductView } from "./product-view";
|
|
4
|
+
|
|
5
|
+
export const metadata: Metadata = {
|
|
6
|
+
title: "Product",
|
|
7
|
+
robots: "noindex",
|
|
8
|
+
};
|
|
9
|
+
|
|
10
|
+
/** `app/products/[id]/page.tsx` -> `/products/:id`. */
|
|
11
|
+
export default function ProductPage({
|
|
12
|
+
auth,
|
|
13
|
+
response,
|
|
14
|
+
params,
|
|
15
|
+
serverData,
|
|
16
|
+
}: PageProps<{ id: string }>) {
|
|
17
|
+
if (!auth.user_id || auth.user_id.startsWith("guest_")) {
|
|
18
|
+
response.redirect("/login");
|
|
19
|
+
return null;
|
|
20
|
+
}
|
|
21
|
+
const me = use(serverData.get<{ email?: string }>("User", auth.user_id));
|
|
22
|
+
return <ProductView email={me?.email ?? ""} productId={params.id} />;
|
|
23
|
+
}
|
|
@@ -0,0 +1,178 @@
|
|
|
1
|
+
"use client";
|
|
2
|
+
|
|
3
|
+
import React, { useState } from "react";
|
|
4
|
+
import { Link, callFn, useRouter } from "@pylonsync/react";
|
|
5
|
+
import { ArrowLeft, ArrowLeftRight } from "lucide-react";
|
|
6
|
+
import { Button } from "@/components/ui/button";
|
|
7
|
+
import { PageHeader } from "@/components/page-header";
|
|
8
|
+
import { EmptyState } from "@/components/empty-state";
|
|
9
|
+
import { StockLevel } from "@/components/stock-level";
|
|
10
|
+
import { MovementDialog } from "@/components/movement-dialog";
|
|
11
|
+
import { relativeTime } from "@/lib/format";
|
|
12
|
+
import { money, reasonById, signedQuantity } from "@/lib/stock";
|
|
13
|
+
import { Workspace } from "../../workspace";
|
|
14
|
+
|
|
15
|
+
export function ProductView({
|
|
16
|
+
email,
|
|
17
|
+
productId,
|
|
18
|
+
}: {
|
|
19
|
+
email: string;
|
|
20
|
+
productId: string;
|
|
21
|
+
}) {
|
|
22
|
+
const [moveOpen, setMoveOpen] = useState(false);
|
|
23
|
+
|
|
24
|
+
return (
|
|
25
|
+
<Workspace email={email} pathname="/">
|
|
26
|
+
{(data) => {
|
|
27
|
+
const product = data.products.find((p) => p.id === productId);
|
|
28
|
+
|
|
29
|
+
if (!product) {
|
|
30
|
+
return (
|
|
31
|
+
<>
|
|
32
|
+
<PageHeader title="Product" />
|
|
33
|
+
<EmptyState
|
|
34
|
+
title={data.loading ? "Loading…" : "Product not found"}
|
|
35
|
+
description={
|
|
36
|
+
data.loading ? undefined : "It may have been deleted, or the link is wrong."
|
|
37
|
+
}
|
|
38
|
+
action={
|
|
39
|
+
data.loading ? undefined : (
|
|
40
|
+
<Button size="sm" variant="secondary" asChild>
|
|
41
|
+
<Link href="/">Back to products</Link>
|
|
42
|
+
</Button>
|
|
43
|
+
)
|
|
44
|
+
}
|
|
45
|
+
/>
|
|
46
|
+
</>
|
|
47
|
+
);
|
|
48
|
+
}
|
|
49
|
+
|
|
50
|
+
const quantity = data.levels.get(product.id) ?? 0;
|
|
51
|
+
// Newest first, and running backwards from the current level so each
|
|
52
|
+
// row shows what the shelf held right after it — the question you
|
|
53
|
+
// actually ask when auditing a count.
|
|
54
|
+
const history = data.movements
|
|
55
|
+
.filter((movement) => movement.productId === product.id)
|
|
56
|
+
.sort((a, b) => (b.createdAt ?? "").localeCompare(a.createdAt ?? ""));
|
|
57
|
+
let running = quantity;
|
|
58
|
+
const withBalance = history.map((movement) => {
|
|
59
|
+
const after = running;
|
|
60
|
+
running -= Math.trunc(Number(movement.delta) || 0);
|
|
61
|
+
return { movement, after };
|
|
62
|
+
});
|
|
63
|
+
|
|
64
|
+
return (
|
|
65
|
+
<>
|
|
66
|
+
<PageHeader title={product.name}>
|
|
67
|
+
<Button size="sm" onClick={() => setMoveOpen(true)}>
|
|
68
|
+
<ArrowLeftRight />
|
|
69
|
+
Record movement
|
|
70
|
+
</Button>
|
|
71
|
+
</PageHeader>
|
|
72
|
+
|
|
73
|
+
<div className="min-h-0 flex-1 overflow-y-auto">
|
|
74
|
+
<div className="mx-auto flex max-w-3xl flex-col gap-6 px-6 py-6">
|
|
75
|
+
<Link
|
|
76
|
+
href="/"
|
|
77
|
+
className="inline-flex w-fit items-center gap-1.5 text-[12px] text-muted-foreground transition-colors hover:text-foreground"
|
|
78
|
+
>
|
|
79
|
+
<ArrowLeft className="size-3.5" />
|
|
80
|
+
Products
|
|
81
|
+
</Link>
|
|
82
|
+
|
|
83
|
+
<dl className="grid grid-cols-2 gap-x-6 gap-y-4 rounded-lg border border-border bg-card p-4 sm:grid-cols-4">
|
|
84
|
+
<Field label="SKU">
|
|
85
|
+
<span className="tabular">{product.sku}</span>
|
|
86
|
+
</Field>
|
|
87
|
+
<Field label="On hand">
|
|
88
|
+
<StockLevel
|
|
89
|
+
quantity={quantity}
|
|
90
|
+
reorderPoint={product.reorderPoint}
|
|
91
|
+
/>
|
|
92
|
+
</Field>
|
|
93
|
+
<Field label="Unit cost">{money(product.unitCostCents)}</Field>
|
|
94
|
+
<Field label="Stock value">
|
|
95
|
+
{money(Math.max(0, quantity) * (Number(product.unitCostCents) || 0))}
|
|
96
|
+
</Field>
|
|
97
|
+
</dl>
|
|
98
|
+
|
|
99
|
+
<section>
|
|
100
|
+
<h2 className="mb-2 text-[13px] font-semibold">History</h2>
|
|
101
|
+
<p className="mb-3 text-[12px] text-muted-foreground">
|
|
102
|
+
On hand is the sum of these movements. Nothing here is ever
|
|
103
|
+
edited — a correction is another movement.
|
|
104
|
+
</p>
|
|
105
|
+
{withBalance.length === 0 ? (
|
|
106
|
+
<p className="py-6 text-center text-[12px] text-muted-foreground">
|
|
107
|
+
No movements yet.
|
|
108
|
+
</p>
|
|
109
|
+
) : (
|
|
110
|
+
<ol className="space-y-1.5">
|
|
111
|
+
{withBalance.map(({ movement, after }) => (
|
|
112
|
+
<li
|
|
113
|
+
key={movement.id}
|
|
114
|
+
className="flex items-center gap-3 rounded-md border border-border bg-card px-3 py-2 text-[12px]"
|
|
115
|
+
>
|
|
116
|
+
<span
|
|
117
|
+
className={
|
|
118
|
+
"tabular w-12 shrink-0 font-medium " +
|
|
119
|
+
(movement.delta > 0 ? "text-stage-won" : "text-destructive")
|
|
120
|
+
}
|
|
121
|
+
>
|
|
122
|
+
{signedQuantity(movement.delta)}
|
|
123
|
+
</span>
|
|
124
|
+
<span className="shrink-0">
|
|
125
|
+
{reasonById(movement.reason)?.label ?? movement.reason}
|
|
126
|
+
</span>
|
|
127
|
+
{movement.note ? (
|
|
128
|
+
<span className="truncate text-muted-foreground">
|
|
129
|
+
{movement.note}
|
|
130
|
+
</span>
|
|
131
|
+
) : null}
|
|
132
|
+
<span className="tabular ml-auto shrink-0 text-muted-foreground">
|
|
133
|
+
→ {after}
|
|
134
|
+
</span>
|
|
135
|
+
<time
|
|
136
|
+
className="w-20 shrink-0 text-right text-muted-foreground"
|
|
137
|
+
dateTime={movement.createdAt ?? undefined}
|
|
138
|
+
>
|
|
139
|
+
{relativeTime(movement.createdAt)}
|
|
140
|
+
</time>
|
|
141
|
+
</li>
|
|
142
|
+
))}
|
|
143
|
+
</ol>
|
|
144
|
+
)}
|
|
145
|
+
</section>
|
|
146
|
+
</div>
|
|
147
|
+
</div>
|
|
148
|
+
|
|
149
|
+
<MovementDialog
|
|
150
|
+
open={moveOpen}
|
|
151
|
+
products={data.products.filter((p) => !p.archived)}
|
|
152
|
+
currentLevel={(id) => data.levels.get(id) ?? 0}
|
|
153
|
+
defaultProductId={product.id}
|
|
154
|
+
onOpenChange={setMoveOpen}
|
|
155
|
+
onRecord={(pid, delta, reason, note) =>
|
|
156
|
+
callFn("recordMovement", {
|
|
157
|
+
productId: pid,
|
|
158
|
+
delta,
|
|
159
|
+
reason,
|
|
160
|
+
...(note ? { note } : {}),
|
|
161
|
+
})
|
|
162
|
+
}
|
|
163
|
+
/>
|
|
164
|
+
</>
|
|
165
|
+
);
|
|
166
|
+
}}
|
|
167
|
+
</Workspace>
|
|
168
|
+
);
|
|
169
|
+
}
|
|
170
|
+
|
|
171
|
+
function Field({ label, children }: { label: string; children: React.ReactNode }) {
|
|
172
|
+
return (
|
|
173
|
+
<div className="min-w-0">
|
|
174
|
+
<dt className="text-[11px] text-muted-foreground">{label}</dt>
|
|
175
|
+
<dd className="mt-1 truncate text-[13px]">{children}</dd>
|
|
176
|
+
</div>
|
|
177
|
+
);
|
|
178
|
+
}
|
|
@@ -0,0 +1,213 @@
|
|
|
1
|
+
"use client";
|
|
2
|
+
|
|
3
|
+
import React, { useEffect, useState } from "react";
|
|
4
|
+
import { callFn, db, useRouter } from "@pylonsync/react";
|
|
5
|
+
import { Boxes, Plus, ArrowLeftRight } from "lucide-react";
|
|
6
|
+
import { Button } from "@/components/ui/button";
|
|
7
|
+
import { Kbd } from "@/components/kbd";
|
|
8
|
+
import { PageHeader } from "@/components/page-header";
|
|
9
|
+
import { DataTable, type ColumnDef } from "@/components/data-table";
|
|
10
|
+
import { EmptyState } from "@/components/empty-state";
|
|
11
|
+
import { RecordDialog } from "@/components/record-dialog";
|
|
12
|
+
import { SummaryBar } from "@/components/summary-bar";
|
|
13
|
+
import { StockLevel } from "@/components/stock-level";
|
|
14
|
+
import { MovementDialog } from "@/components/movement-dialog";
|
|
15
|
+
import { money, parseAmount, parseCount, stockState } from "@/lib/stock";
|
|
16
|
+
import { Workspace, type ProductRow } from "./workspace";
|
|
17
|
+
|
|
18
|
+
export function ProductsView({
|
|
19
|
+
email,
|
|
20
|
+
openNew,
|
|
21
|
+
initialFilter,
|
|
22
|
+
}: {
|
|
23
|
+
email: string;
|
|
24
|
+
openNew?: boolean;
|
|
25
|
+
initialFilter?: string;
|
|
26
|
+
}) {
|
|
27
|
+
const router = useRouter();
|
|
28
|
+
const [newOpen, setNewOpen] = useState(Boolean(openNew));
|
|
29
|
+
const [moveOpen, setMoveOpen] = useState(false);
|
|
30
|
+
const [reorderOnly, setReorderOnly] = useState(initialFilter === "reorder");
|
|
31
|
+
|
|
32
|
+
// "m" records a movement — the thing you do twenty times a day. Ignored
|
|
33
|
+
// while typing.
|
|
34
|
+
useEffect(() => {
|
|
35
|
+
function onKey(event: KeyboardEvent) {
|
|
36
|
+
const target = event.target as HTMLElement | null;
|
|
37
|
+
const typing =
|
|
38
|
+
!!target &&
|
|
39
|
+
(target.tagName === "INPUT" ||
|
|
40
|
+
target.tagName === "TEXTAREA" ||
|
|
41
|
+
target.isContentEditable);
|
|
42
|
+
if (typing || event.metaKey || event.ctrlKey) return;
|
|
43
|
+
if (event.key === "m") {
|
|
44
|
+
event.preventDefault();
|
|
45
|
+
setMoveOpen(true);
|
|
46
|
+
} else if (event.key === "c") {
|
|
47
|
+
event.preventDefault();
|
|
48
|
+
setNewOpen(true);
|
|
49
|
+
}
|
|
50
|
+
}
|
|
51
|
+
window.addEventListener("keydown", onKey);
|
|
52
|
+
return () => window.removeEventListener("keydown", onKey);
|
|
53
|
+
}, []);
|
|
54
|
+
|
|
55
|
+
return (
|
|
56
|
+
<Workspace email={email} pathname="/">
|
|
57
|
+
{(data) => {
|
|
58
|
+
const level = (id: string) => data.levels.get(id) ?? 0;
|
|
59
|
+
|
|
60
|
+
const columns: ColumnDef<ProductRow>[] = [
|
|
61
|
+
{
|
|
62
|
+
key: "sku",
|
|
63
|
+
header: "SKU",
|
|
64
|
+
cell: (row) => <span className="tabular text-muted-foreground">{row.sku}</span>,
|
|
65
|
+
},
|
|
66
|
+
{
|
|
67
|
+
key: "name",
|
|
68
|
+
header: "Product",
|
|
69
|
+
cell: (row) => <span className="truncate font-medium">{row.name}</span>,
|
|
70
|
+
},
|
|
71
|
+
{
|
|
72
|
+
key: "category",
|
|
73
|
+
header: "Category",
|
|
74
|
+
cell: (row) => (
|
|
75
|
+
<span className="text-muted-foreground">{row.category ?? "—"}</span>
|
|
76
|
+
),
|
|
77
|
+
},
|
|
78
|
+
{
|
|
79
|
+
key: "onhand",
|
|
80
|
+
header: "On hand",
|
|
81
|
+
numeric: true,
|
|
82
|
+
cell: (row) => (
|
|
83
|
+
<StockLevel quantity={level(row.id)} reorderPoint={row.reorderPoint} />
|
|
84
|
+
),
|
|
85
|
+
},
|
|
86
|
+
{
|
|
87
|
+
key: "reorder",
|
|
88
|
+
header: "Reorder at",
|
|
89
|
+
numeric: true,
|
|
90
|
+
cell: (row) => (
|
|
91
|
+
<span className="text-muted-foreground">{row.reorderPoint || "—"}</span>
|
|
92
|
+
),
|
|
93
|
+
},
|
|
94
|
+
{
|
|
95
|
+
key: "value",
|
|
96
|
+
header: "Value",
|
|
97
|
+
numeric: true,
|
|
98
|
+
cell: (row) =>
|
|
99
|
+
money(Math.max(0, level(row.id)) * (Number(row.unitCostCents) || 0)),
|
|
100
|
+
},
|
|
101
|
+
];
|
|
102
|
+
|
|
103
|
+
const rows = data.products
|
|
104
|
+
.filter((product) => !product.archived)
|
|
105
|
+
.filter((product) =>
|
|
106
|
+
reorderOnly
|
|
107
|
+
? stockState(level(product.id), product.reorderPoint) !== "ok"
|
|
108
|
+
: true,
|
|
109
|
+
)
|
|
110
|
+
.sort((a, b) => a.sku.localeCompare(b.sku));
|
|
111
|
+
|
|
112
|
+
return (
|
|
113
|
+
<>
|
|
114
|
+
<PageHeader title="Products" count={rows.length}>
|
|
115
|
+
<Button
|
|
116
|
+
size="sm"
|
|
117
|
+
variant={reorderOnly ? "default" : "secondary"}
|
|
118
|
+
onClick={() => setReorderOnly((on) => !on)}
|
|
119
|
+
>
|
|
120
|
+
Needs reorder
|
|
121
|
+
</Button>
|
|
122
|
+
<Button size="sm" variant="secondary" onClick={() => setMoveOpen(true)}>
|
|
123
|
+
<ArrowLeftRight />
|
|
124
|
+
Movement
|
|
125
|
+
<Kbd className="ml-1">m</Kbd>
|
|
126
|
+
</Button>
|
|
127
|
+
<Button size="sm" onClick={() => setNewOpen(true)}>
|
|
128
|
+
<Plus />
|
|
129
|
+
New product
|
|
130
|
+
</Button>
|
|
131
|
+
</PageHeader>
|
|
132
|
+
|
|
133
|
+
<SummaryBar products={data.products} movements={data.movements} />
|
|
134
|
+
|
|
135
|
+
<DataTable
|
|
136
|
+
rows={rows}
|
|
137
|
+
columns={columns}
|
|
138
|
+
onRowClick={(row) => router.push(`/products/${row.id}`)}
|
|
139
|
+
empty={
|
|
140
|
+
<EmptyState
|
|
141
|
+
icon={<Boxes />}
|
|
142
|
+
title={
|
|
143
|
+
data.loading
|
|
144
|
+
? "Loading…"
|
|
145
|
+
: reorderOnly
|
|
146
|
+
? "Nothing needs reordering"
|
|
147
|
+
: "No products yet"
|
|
148
|
+
}
|
|
149
|
+
description={
|
|
150
|
+
reorderOnly
|
|
151
|
+
? "Every line is above its reorder point."
|
|
152
|
+
: "Add a product, then record what you receive. On-hand is the sum of those movements — there is no quantity to keep in sync."
|
|
153
|
+
}
|
|
154
|
+
action={
|
|
155
|
+
reorderOnly ? undefined : (
|
|
156
|
+
<Button size="sm" onClick={() => setNewOpen(true)}>
|
|
157
|
+
<Plus />
|
|
158
|
+
New product
|
|
159
|
+
</Button>
|
|
160
|
+
)
|
|
161
|
+
}
|
|
162
|
+
/>
|
|
163
|
+
}
|
|
164
|
+
/>
|
|
165
|
+
|
|
166
|
+
<RecordDialog
|
|
167
|
+
open={newOpen}
|
|
168
|
+
title="New product"
|
|
169
|
+
submitLabel="Create product"
|
|
170
|
+
onOpenChange={setNewOpen}
|
|
171
|
+
fields={[
|
|
172
|
+
{ name: "sku", label: "SKU", required: true, placeholder: "CFE-001" },
|
|
173
|
+
{ name: "name", label: "Name", required: true, placeholder: "House Blend, 1kg" },
|
|
174
|
+
{ name: "category", label: "Category", placeholder: "Coffee" },
|
|
175
|
+
{ name: "unitCost", label: "Unit cost", placeholder: "9.40" },
|
|
176
|
+
{ name: "unitPrice", label: "Unit price", placeholder: "18.00" },
|
|
177
|
+
{ name: "reorderPoint", label: "Reorder at", placeholder: "12" },
|
|
178
|
+
]}
|
|
179
|
+
onCreate={async (values) => {
|
|
180
|
+
// Money and counts are parsed here, at the edge — everything
|
|
181
|
+
// below this line is integers.
|
|
182
|
+
await db.insert("Product", {
|
|
183
|
+
sku: values.sku,
|
|
184
|
+
name: values.name,
|
|
185
|
+
category: values.category ?? null,
|
|
186
|
+
unitCostCents: parseAmount(values.unitCost ?? "") ?? 0,
|
|
187
|
+
unitPriceCents: parseAmount(values.unitPrice ?? "") ?? 0,
|
|
188
|
+
reorderPoint: parseCount(values.reorderPoint ?? "") ?? 0,
|
|
189
|
+
archived: false,
|
|
190
|
+
});
|
|
191
|
+
}}
|
|
192
|
+
/>
|
|
193
|
+
|
|
194
|
+
<MovementDialog
|
|
195
|
+
open={moveOpen}
|
|
196
|
+
products={data.products.filter((p) => !p.archived)}
|
|
197
|
+
currentLevel={level}
|
|
198
|
+
onOpenChange={setMoveOpen}
|
|
199
|
+
onRecord={(productId, delta, reason, note) =>
|
|
200
|
+
callFn("recordMovement", {
|
|
201
|
+
productId,
|
|
202
|
+
delta,
|
|
203
|
+
reason,
|
|
204
|
+
...(note ? { note } : {}),
|
|
205
|
+
})
|
|
206
|
+
}
|
|
207
|
+
/>
|
|
208
|
+
</>
|
|
209
|
+
);
|
|
210
|
+
}}
|
|
211
|
+
</Workspace>
|
|
212
|
+
);
|
|
213
|
+
}
|
|
@@ -0,0 +1,12 @@
|
|
|
1
|
+
import type { Robots } from "@pylonsync/react";
|
|
2
|
+
|
|
3
|
+
// app/robots.ts → served at /robots.txt. The default export may also be async.
|
|
4
|
+
const SITE = process.env.SITE_URL ?? "http://localhost:4321";
|
|
5
|
+
|
|
6
|
+
export default function robots(): Robots {
|
|
7
|
+
return {
|
|
8
|
+
// Keep the authenticated app and the API out of the index.
|
|
9
|
+
rules: { userAgent: "*", allow: "/", disallow: ["/dashboard", "/api/"] },
|
|
10
|
+
sitemap: `${SITE}/sitemap.xml`,
|
|
11
|
+
};
|
|
12
|
+
}
|
|
@@ -0,0 +1,27 @@
|
|
|
1
|
+
import type { Sitemap } from "@pylonsync/react";
|
|
2
|
+
|
|
3
|
+
// app/sitemap.ts → served at /sitemap.xml. The default export can be async, so
|
|
4
|
+
// it can enumerate dynamic pages from your database. Point SITE_URL at your
|
|
5
|
+
// domain in production.
|
|
6
|
+
const SITE = process.env.SITE_URL ?? "http://localhost:4321";
|
|
7
|
+
|
|
8
|
+
export default async function sitemap(): Promise<Sitemap> {
|
|
9
|
+
// Only public pages belong here — /dashboard is private (and noindex), so
|
|
10
|
+
// it's intentionally left out.
|
|
11
|
+
const staticRoutes: Sitemap = [
|
|
12
|
+
{ url: `${SITE}/`, changeFrequency: "weekly", priority: 1 },
|
|
13
|
+
{ url: `${SITE}/login`, changeFrequency: "yearly", priority: 0.3 },
|
|
14
|
+
{ url: `${SITE}/signup`, changeFrequency: "yearly", priority: 0.5 },
|
|
15
|
+
];
|
|
16
|
+
|
|
17
|
+
// The export is async, so you can enumerate dynamic pages from a DB read:
|
|
18
|
+
//
|
|
19
|
+
// const posts = await fetchPublishedPosts();
|
|
20
|
+
// const postRoutes: Sitemap = posts.map((p) => ({
|
|
21
|
+
// url: `${SITE}/blog/${p.slug}`,
|
|
22
|
+
// lastModified: p.updatedAt,
|
|
23
|
+
// }));
|
|
24
|
+
// return [...staticRoutes, ...postRoutes];
|
|
25
|
+
|
|
26
|
+
return staticRoutes;
|
|
27
|
+
}
|