This commit is contained in:
2026-09-28 23:15:47 +05:30
commit 605c6a2e58
41 changed files with 4299 additions and 0 deletions
+40
View File
@@ -0,0 +1,40 @@
import { BILL_BODY_LIMIT, readLimited } from "../../../../lib/body";
import { BillStoreError, readBillPayload, storePublishedBill } from "../../../../lib/bills";
import { isUuid } from "../../../../lib/ids";
export const dynamic = "force-dynamic";
type RouteContext = { params: Promise<{ token: string }> };
export async function GET(_request: Request, context: RouteContext) {
const { token } = await context.params;
if (!isUuid(token)) return Response.json({ error: "not found" }, { status: 404 });
try {
const payload = await readBillPayload(token);
if (!payload) return Response.json({ error: "not found" }, { status: 404 });
return new Response(payload, {
status: 200,
headers: { "content-type": "application/json; charset=utf-8" },
});
} catch {
console.error("bill read failed");
return Response.json({ error: "not found" }, { status: 500 });
}
}
export async function POST(request: Request, context: RouteContext) {
const { token } = await context.params;
if (!isUuid(token)) return Response.json({ error: "invalid token" }, { status: 400 });
const raw = await readLimited(request, BILL_BODY_LIMIT);
if (raw === null) return Response.json({ error: "body too large" }, { status: 413 });
try {
await storePublishedBill(token, raw);
return Response.json({ ok: true }, { status: 201 });
} catch (error) {
if (error instanceof BillStoreError) {
return Response.json({ error: error.message }, { status: error.status });
}
console.error("bill publish failed");
return Response.json({ error: "publish failed" }, { status: 500 });
}
}
+30
View File
@@ -0,0 +1,30 @@
import { isAuthorized } from "../../../../lib/auth";
import { pullChanges, SyncInputError } from "../../../../lib/sync";
export const dynamic = "force-dynamic";
export async function POST(request: Request) {
if (!isAuthorized(request.headers.get("authorization"))) {
return Response.json({ error: "unauthorized" }, { status: 401 });
}
let body: unknown;
try {
body = await request.json();
} catch {
return Response.json({ error: "invalid json" }, { status: 400 });
}
if (!body || typeof body !== "object" || Array.isArray(body)) {
return Response.json({ error: "invalid body" }, { status: 400 });
}
const cursors = (body as { cursors?: unknown }).cursors;
try {
const result = await pullChanges(cursors);
return Response.json(result);
} catch (error) {
if (error instanceof SyncInputError) {
return Response.json({ error: error.message }, { status: 400 });
}
console.error("sync pull failed");
return Response.json({ error: "sync failed" }, { status: 500 });
}
}
+30
View File
@@ -0,0 +1,30 @@
import { isAuthorized } from "../../../../lib/auth";
import { pushChanges, SyncInputError } from "../../../../lib/sync";
export const dynamic = "force-dynamic";
export async function POST(request: Request) {
if (!isAuthorized(request.headers.get("authorization"))) {
return Response.json({ error: "unauthorized" }, { status: 401 });
}
let body: unknown;
try {
body = await request.json();
} catch {
return Response.json({ error: "invalid json" }, { status: 400 });
}
if (!body || typeof body !== "object" || Array.isArray(body)) {
return Response.json({ error: "invalid body" }, { status: 400 });
}
const tables = (body as { tables?: unknown }).tables ?? {};
try {
const result = await pushChanges(tables);
return Response.json(result);
} catch (error) {
if (error instanceof SyncInputError) {
return Response.json({ error: error.message }, { status: 400 });
}
console.error("sync push failed");
return Response.json({ error: "sync failed" }, { status: 500 });
}
}
+79
View File
@@ -0,0 +1,79 @@
import { cache } from "react";
import type { Metadata } from "next";
import { readBillPayload } from "../../../lib/bills";
import { BILL_NOT_FOUND, formatBillDate, parseBill, type BillView } from "../../../lib/bill-view";
import { money } from "../../../lib/money";
export const dynamic = "force-dynamic";
const loadBill = cache(async (token: string): Promise<BillView | null> => {
const raw = await readBillPayload(token);
if (!raw) return null;
return parseBill(raw);
});
export async function generateMetadata({
params,
}: {
params: Promise<{ token: string }>;
}): Promise<Metadata> {
const { token } = await params;
const bill = await loadBill(token);
if (!bill) return { title: "100kmph invoice" };
return { title: `${bill.number || "Invoice"} · 100kmph` };
}
export default async function BillPage({ params }: { params: Promise<{ token: string }> }) {
const { token } = await params;
const bill = await loadBill(token);
if (!bill) return <main id="bill">{BILL_NOT_FOUND}</main>;
const when = formatBillDate(bill.createdAt);
return (
<main id="bill">
<h1>{bill.shopName || "100kmph"}</h1>
<p className="muted">{bill.address}</p>
<p className="muted">{bill.phone ? `Ph: ${bill.phone}` : ""}</p>
<hr />
<p>
<strong>Invoice {bill.number}</strong>
<br />
{when}
</p>
<p>
Customer: {bill.customerName || "-"}
<br />
Phone: {bill.customerPhone || "-"}
{bill.vehicleReg ? (
<>
<br />
Vehicle: {bill.vehicleReg} {bill.vehicleModel}
</>
) : null}
</p>
<table>
<thead>
<tr>
<th>Item</th>
<th className="num">Qty</th>
<th className="num">Rate</th>
<th className="num">Amount</th>
</tr>
</thead>
<tbody>
{bill.lines.map((line, index) => (
<tr key={index}>
<td>{line.description}</td>
<td className="num">{line.qty ?? ""}</td>
<td className="num">{money(line.unitPrice)}</td>
<td className="num">{money(line.lineTotal)}</td>
</tr>
))}
</tbody>
</table>
<p className="total">Total {money(bill.total)}</p>
<p>Paid by: {bill.paymentMethod.toUpperCase()}</p>
<p className="foot">Digital invoice from 100kmph. You can print this page if you need paper.</p>
</main>
);
}
+109
View File
@@ -0,0 +1,109 @@
:root {
font-family: Arial, Helvetica, sans-serif;
color: #111;
}
body {
margin: 0;
background: #eee;
}
.home {
padding: 24px;
}
#bill {
max-width: 720px;
margin: 16px auto;
background: #fff;
padding: 20px;
}
#bill h1 {
text-align: center;
margin: 0 0 4px;
}
.muted {
text-align: center;
color: #444;
margin: 0;
}
#bill table {
width: 100%;
border-collapse: collapse;
margin-top: 12px;
}
#bill th,
#bill td {
padding: 6px 0;
border-bottom: 1px solid #ddd;
}
#bill th {
text-align: left;
}
.num {
text-align: right;
}
.total {
font-size: 20px;
font-weight: 800;
}
.foot {
text-align: center;
color: #666;
font-size: 13px;
margin-top: 18px;
}
.status {
max-width: 720px;
margin: 16px auto;
background: #fff;
padding: 20px;
}
.status h1 {
margin: 0 0 16px;
font-size: 20px;
}
.status label {
display: block;
margin-bottom: 8px;
}
.status input {
width: 100%;
box-sizing: border-box;
padding: 8px;
margin-top: 4px;
}
.status button {
margin-top: 12px;
}
.status .error {
color: #900;
}
.status li {
margin: 12px 0;
}
@media print {
body {
background: #fff;
}
#bill {
margin: 0;
}
}
+14
View File
@@ -0,0 +1,14 @@
import type { Metadata } from "next";
import "./globals.css";
export const metadata: Metadata = {
title: "100kmph",
};
export default function RootLayout({ children }: { children: React.ReactNode }) {
return (
<html lang="en">
<body>{children}</body>
</html>
);
}
+3
View File
@@ -0,0 +1,3 @@
export default function HomePage() {
return <main className="home">100kmph</main>;
}
+24
View File
@@ -0,0 +1,24 @@
"use server";
import { headers } from "next/headers";
import { allowRequest, clientIp } from "../../lib/rate-limit";
import { lookupBikes } from "../../lib/status";
import type { StatusResult } from "../../lib/workshop";
export async function lookupStatus(_prev: StatusResult | null, formData: FormData): Promise<StatusResult> {
const headerList = await headers();
const ip = clientIp(headerList.get("x-forwarded-for"));
if (!allowRequest(ip)) {
return {
message: null,
error: "Too many lookups. Wait a minute and try again.",
bikes: [],
};
}
try {
return await lookupBikes(String(formData.get("phone") ?? ""));
} catch {
console.error("status lookup failed");
return { message: null, error: "Lookup failed. Try again.", bikes: [] };
}
}
+12
View File
@@ -0,0 +1,12 @@
import { StatusForm } from "./status-form";
export const dynamic = "force-dynamic";
export default function StatusPage() {
return (
<main className="status">
<h1>100kmph</h1>
<StatusForm />
</main>
);
}
+53
View File
@@ -0,0 +1,53 @@
"use client";
import { useActionState } from "react";
import { lookupStatus } from "./actions";
import { NOT_IN_WORKSHOP, type StatusResult } from "../../lib/workshop";
function formatWhen(iso: string | null): string {
if (!iso) return "";
const date = new Date(iso);
if (Number.isNaN(date.getTime())) return "";
return date.toLocaleString("en-LK", { timeZone: "Asia/Colombo" });
}
export function StatusForm() {
const [state, action, pending] = useActionState<StatusResult | null, FormData>(lookupStatus, null);
return (
<form action={action}>
<label>
Phone
<input name="phone" inputMode="tel" autoComplete="tel" required />
</label>
<button type="submit" disabled={pending}>
Check
</button>
{state?.error ? <p className="error">{state.error}</p> : null}
{state?.message ? <p>{state.message}</p> : null}
{state && state.bikes.length > 0 ? (
<ul>
{state.bikes.map((bike, index) => {
const when = formatWhen(bike.stageAt);
return (
<li key={`${bike.registration}-${index}`}>
<div>{bike.registration}</div>
<div>
{bike.make} {bike.model}
</div>
{bike.inWorkshop ? (
<div>
{bike.stageLabel}
{when ? ` · ${when}` : ""}
</div>
) : (
<div>{NOT_IN_WORKSHOP}</div>
)}
</li>
);
})}
</ul>
) : null}
</form>
);
}