feat: close remaining gaps — middleware, demo wiring, error UX, env validation
CI / lint + typecheck + test + build (push) Canceled after 0s
CI / lint + typecheck + test + build (push) Canceled after 0s
- Auth: middleware.ts guards /protected via better-auth session cookie; protected page reads session server-side (auth.api.getSession) - Demo wiring (app/demo + components/demo): react-markdown + remark-gfm + shiki CodeBlock, RHF+zod form with react-query mutation + sonner toast, react-table items list (legacy API, table v9), /api/items GET+POST (zod v4 coercion, schema in lib/items.ts shared with unit tests) - Error UX: app/error.tsx (retry + digest), not-found.tsx (404), loading.tsx - Env: lib/env.ts zod-validated (no import-time throw), .env.example sync (NEXT_PUBLIC_BETTER_AUTH_URL), auth-client uses env - Tooling: @vitest/coverage-v8 (test:coverage works), tsconfig cleanup (drop .next/dev/dev/types artifact), README rewrite, demo e2e specs
This commit is contained in:
@@ -0,0 +1,18 @@
|
||||
"use client";
|
||||
|
||||
import { ItemForm } from "@/components/demo/item-form";
|
||||
import { ItemList } from "@/components/demo/item-list";
|
||||
|
||||
export function DemoClient() {
|
||||
return (
|
||||
<section className="flex flex-col gap-6">
|
||||
<div className="grid gap-6 md:grid-cols-2">
|
||||
<ItemForm />
|
||||
<div className="flex flex-col gap-2">
|
||||
<h2 className="text-sm font-semibold">Items (react-query + react-table)</h2>
|
||||
<ItemList />
|
||||
</div>
|
||||
</div>
|
||||
</section>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,101 @@
|
||||
"use client";
|
||||
|
||||
import { useForm } from "react-hook-form";
|
||||
import { zodResolver } from "@hookform/resolvers/zod";
|
||||
import { z } from "zod";
|
||||
import { useMutation, useQueryClient } from "@tanstack/react-query";
|
||||
import { toast } from "sonner";
|
||||
|
||||
const itemSchema = z.object({
|
||||
name: z.string().min(1, "Name is required").max(100),
|
||||
quantity: z.coerce
|
||||
.number({ error: "Quantity must be a number" })
|
||||
.int("Must be a whole number")
|
||||
.min(0, "Minimum 0")
|
||||
.max(10000, "Maximum 10000"),
|
||||
});
|
||||
|
||||
type ItemFormValues = z.input<typeof itemSchema>;
|
||||
type ItemSubmitValues = z.output<typeof itemSchema>;
|
||||
|
||||
export function ItemForm() {
|
||||
const queryClient = useQueryClient();
|
||||
const {
|
||||
register,
|
||||
handleSubmit,
|
||||
reset,
|
||||
formState: { errors, isSubmitting },
|
||||
} = useForm<ItemFormValues, unknown, ItemSubmitValues>({
|
||||
resolver: zodResolver(itemSchema),
|
||||
defaultValues: { name: "", quantity: 1 },
|
||||
});
|
||||
|
||||
const mutation = useMutation({
|
||||
mutationFn: async (values: ItemSubmitValues) => {
|
||||
const res = await fetch("/api/items", {
|
||||
method: "POST",
|
||||
headers: { "Content-Type": "application/json" },
|
||||
body: JSON.stringify(values),
|
||||
});
|
||||
if (!res.ok) {
|
||||
const err = await res.json().catch(() => null);
|
||||
throw new Error(err?.error ?? "Failed to create item");
|
||||
}
|
||||
return res.json();
|
||||
},
|
||||
onSuccess: () => {
|
||||
toast.success("Item created");
|
||||
reset();
|
||||
queryClient.invalidateQueries({ queryKey: ["items"] });
|
||||
},
|
||||
onError: (err: Error) => {
|
||||
toast.error(err.message);
|
||||
},
|
||||
});
|
||||
|
||||
return (
|
||||
<form
|
||||
onSubmit={handleSubmit((values) => mutation.mutate(values))}
|
||||
className="flex flex-col gap-3 rounded-lg border p-4"
|
||||
noValidate
|
||||
>
|
||||
<h2 className="text-sm font-semibold">Add item (RHF + zod + react-query)</h2>
|
||||
|
||||
<div className="flex flex-col gap-1">
|
||||
<label htmlFor="name" className="text-xs font-medium">
|
||||
Name
|
||||
</label>
|
||||
<input
|
||||
id="name"
|
||||
{...register("name")}
|
||||
className="rounded-md border bg-background px-3 py-1.5 text-sm outline-none focus:border-ring"
|
||||
placeholder="e.g. USB-C cable"
|
||||
/>
|
||||
{errors.name && <span className="text-xs text-destructive">{errors.name.message}</span>}
|
||||
</div>
|
||||
|
||||
<div className="flex flex-col gap-1">
|
||||
<label htmlFor="quantity" className="text-xs font-medium">
|
||||
Quantity
|
||||
</label>
|
||||
<input
|
||||
id="quantity"
|
||||
type="number"
|
||||
{...register("quantity")}
|
||||
className="rounded-md border bg-background px-3 py-1.5 text-sm outline-none focus:border-ring"
|
||||
/>
|
||||
{errors.quantity && (
|
||||
<span className="text-xs text-destructive">{errors.quantity.message}</span>
|
||||
)}
|
||||
</div>
|
||||
|
||||
<button
|
||||
type="submit"
|
||||
disabled={isSubmitting || mutation.isPending}
|
||||
className="rounded-md bg-primary px-3 py-1.5 text-sm font-medium text-primary-foreground disabled:opacity-50"
|
||||
>
|
||||
{mutation.isPending ? "Creating…" : "Create item"}
|
||||
</button>
|
||||
</form>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,98 @@
|
||||
"use client";
|
||||
|
||||
import { useQuery } from "@tanstack/react-query";
|
||||
import { flexRender } from "@tanstack/react-table";
|
||||
import {
|
||||
legacyCreateColumnHelper as createColumnHelper,
|
||||
getCoreRowModel,
|
||||
useLegacyTable,
|
||||
type LegacyColumnDef,
|
||||
} from "@tanstack/react-table/legacy";
|
||||
import { format } from "date-fns";
|
||||
import type { Item } from "@/lib/items";
|
||||
|
||||
const columnHelper = createColumnHelper<Item>();
|
||||
|
||||
const columns = [
|
||||
columnHelper.accessor("name", {
|
||||
header: "Name",
|
||||
}),
|
||||
columnHelper.accessor("quantity", {
|
||||
header: "Qty",
|
||||
}),
|
||||
columnHelper.accessor("createdAt", {
|
||||
header: "Created",
|
||||
cell: (info) => format(new Date(info.getValue<string>()), "MMM d, HH:mm"),
|
||||
}),
|
||||
] as LegacyColumnDef<Item>[];
|
||||
|
||||
export function ItemList() {
|
||||
const { data, isLoading, isError, error, refetch } = useQuery({
|
||||
queryKey: ["items"],
|
||||
queryFn: async () => {
|
||||
const res = await fetch("/api/items");
|
||||
if (!res.ok) throw new Error("Failed to load items");
|
||||
const body = await res.json();
|
||||
return body.items as Item[];
|
||||
},
|
||||
});
|
||||
|
||||
const table = useLegacyTable({
|
||||
data: data ?? [],
|
||||
columns,
|
||||
getCoreRowModel: getCoreRowModel(),
|
||||
});
|
||||
|
||||
if (isLoading) {
|
||||
return <p className="text-sm text-muted-foreground">Loading items…</p>;
|
||||
}
|
||||
|
||||
if (isError) {
|
||||
return (
|
||||
<div className="flex items-center gap-3 text-sm text-destructive">
|
||||
<span>{error.message}</span>
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => refetch()}
|
||||
className="rounded-md border px-2 py-1 hover:bg-muted"
|
||||
>
|
||||
Retry
|
||||
</button>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="overflow-hidden rounded-lg border">
|
||||
<table className="w-full text-sm">
|
||||
<thead className="bg-muted/50 text-left">
|
||||
{table.getHeaderGroups().map((headerGroup) => (
|
||||
<tr key={headerGroup.id}>
|
||||
{headerGroup.headers.map((header) => (
|
||||
<th key={header.id} className="px-4 py-2 font-medium">
|
||||
{header.isPlaceholder
|
||||
? null
|
||||
: flexRender(header.column.columnDef.header, header.getContext())}
|
||||
</th>
|
||||
))}
|
||||
</tr>
|
||||
))}
|
||||
</thead>
|
||||
<tbody>
|
||||
{table.getRowModel().rows.map((row) => (
|
||||
<tr key={row.id} className="border-t hover:bg-muted/30">
|
||||
{row.getVisibleCells().map((cell) => (
|
||||
<td key={cell.id} className="px-4 py-2">
|
||||
{flexRender(cell.column.columnDef.cell, cell.getContext())}
|
||||
</td>
|
||||
))}
|
||||
</tr>
|
||||
))}
|
||||
</tbody>
|
||||
</table>
|
||||
{data?.length === 0 ? (
|
||||
<p className="px-4 py-3 text-sm text-muted-foreground">No items yet.</p>
|
||||
) : null}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
Reference in New Issue
Block a user