This commit is contained in:
2026-06-02 10:23:09 +03:00
parent e08d555b23
commit 0c87eaef46
136 changed files with 16069 additions and 94 deletions

202
app/actions/auth.ts Normal file
View File

@@ -0,0 +1,202 @@
'use server';
import { redirect } from "next/navigation";
import { prisma } from "@/lib/prisma";
import { hashPassword, verifyPassword, generateToken } from "@/lib/crypto";
import {
createSession,
deleteSession,
requireCurrentUser,
} from "@/lib/auth";
import { Resend } from "resend";
import type { FormState } from "@/lib/types";
export async function login(
prevState: FormState | undefined,
formData: FormData,
): Promise<FormState> {
const email = formData.get("email") as string;
const password = formData.get("password") as string;
if (!email || !password) {
return { message: "Email and password are required" };
}
const user = await prisma.user.findUnique({ where: { email } });
if (!user) {
return { message: "Invalid email or password" };
}
const valid = await verifyPassword(password, user.passwordHash);
if (!valid) {
return { message: "Invalid email or password" };
}
await createSession(user.id);
redirect("/");
}
export async function logout(): Promise<never> {
await deleteSession();
redirect("/login");
}
export async function changePassword(
prevState: FormState | undefined,
formData: FormData,
): Promise<FormState> {
const currentUser = await requireCurrentUser();
const currentPassword = formData.get("currentPassword") as string;
const newPassword = formData.get("newPassword") as string;
const confirmPassword = formData.get("confirmPassword") as string;
if (!currentPassword || !newPassword || !confirmPassword) {
return { message: "All fields are required" };
}
if (newPassword.length < 8) {
return {
errors: { newPassword: ["Password must be at least 8 characters"] },
message: "Password must be at least 8 characters",
};
}
if (newPassword !== confirmPassword) {
return {
errors: { confirmPassword: ["Passwords do not match"] },
message: "Passwords do not match",
};
}
const user = await prisma.user.findUnique({
where: { id: currentUser.id },
});
if (!user) {
return { message: "User not found" };
}
const valid = await verifyPassword(currentPassword, user.passwordHash);
if (!valid) {
return {
errors: { currentPassword: ["Current password is incorrect"] },
message: "Current password is incorrect",
};
}
const newHash = await hashPassword(newPassword);
await prisma.user.update({
where: { id: user.id },
data: { passwordHash: newHash },
});
return { message: "Password changed successfully", success: true };
}
export async function forgotPassword(
prevState: FormState | undefined,
formData: FormData,
): Promise<FormState> {
const email = formData.get("email") as string;
if (!email) {
return { message: "Email is required" };
}
const user = await prisma.user.findUnique({ where: { email } });
if (user) {
const token = generateToken();
await prisma.passwordResetToken.create({
data: {
token,
userId: user.id,
expiresAt: new Date(Date.now() + 60 * 60 * 1000),
},
});
const resetLink = `${process.env.NEXT_PUBLIC_APP_URL}/reset-password?token=${token}`;
if (process.env.RESEND_API_KEY) {
try {
const resend = new Resend(process.env.RESEND_API_KEY);
await resend.emails.send({
from: "Order Loop <onboarding@resend.dev>",
to: email,
subject: "Reset your password",
html: `<p>Click <a href="${resetLink}">here</a> to reset your password. This link expires in 1 hour.</p>`,
});
} catch (error) {
console.error("Failed to send reset email:", error);
console.log("Password reset link:", resetLink);
}
} else {
console.log("Password reset link:", resetLink);
}
}
return {
message: "If an account exists, a reset link has been sent",
success: true,
};
}
export async function resetPassword(
prevState: FormState | undefined,
formData: FormData,
): Promise<FormState> {
const token = formData.get("token") as string;
const password = formData.get("password") as string;
const confirmPassword = formData.get("confirmPassword") as string;
if (!token || !password || !confirmPassword) {
return { message: "All fields are required" };
}
if (password.length < 8) {
return {
errors: { password: ["Password must be at least 8 characters"] },
message: "Password must be at least 8 characters",
};
}
if (password !== confirmPassword) {
return {
errors: { confirmPassword: ["Passwords do not match"] },
message: "Passwords do not match",
};
}
const resetToken = await prisma.passwordResetToken.findUnique({
where: { token },
});
if (!resetToken || resetToken.used || resetToken.expiresAt < new Date()) {
return { message: "Invalid or expired reset token" };
}
const newHash = await hashPassword(password);
await prisma.user.update({
where: { id: resetToken.userId },
data: { passwordHash: newHash },
});
await prisma.passwordResetToken.update({
where: { id: resetToken.id },
data: { used: true },
});
return {
message: "Password reset successfully. You can now log in.",
success: true,
};
}

375
app/actions/orders.ts Normal file
View File

@@ -0,0 +1,375 @@
'use server'
import { revalidatePath } from "next/cache";
import { redirect } from "next/navigation";
import { prisma } from "@/lib/prisma";
import { requireCurrentUser } from "@/lib/auth";
import { Prisma } from "@prisma/client";
import type {
OrderWithDetails,
DashboardStats,
FormState,
OrderStatus,
} from "@/lib/types";
// ---------------------------------------------------------------------------
// Types for parsed JSON payloads from hidden form inputs
// ---------------------------------------------------------------------------
interface ParsedCustomer {
id: string;
name: string;
}
interface ParsedItem {
id: string;
customerId: string | null;
itemName: string;
initPrice: number;
myPrice: number;
taxRatio: number;
quantity: number;
}
// ---------------------------------------------------------------------------
// Helpers
// ---------------------------------------------------------------------------
function computePrices(item: ParsedItem) {
const netPrice = (item.initPrice + item.initPrice * item.taxRatio) * item.quantity;
const myNetPrice = (item.myPrice + item.myPrice * item.taxRatio) * item.quantity;
const finalPrice = netPrice;
return { netPrice, myNetPrice, finalPrice };
}
/**
* Resolve a parsed customer to a database Customer id.
* First tries to find an existing customer by id, then by name.
* If neither exists, creates a new Customer record.
*/
async function resolveCustomer(
customer: ParsedCustomer,
tx: Parameters<Parameters<typeof prisma.$transaction>[0]>[0],
): Promise<string> {
// Try by id first
const existing = await tx.customer.findUnique({
where: { id: customer.id },
});
if (existing) return existing.id;
// Try by name (prevents duplicates from re-adding)
const byName = await tx.customer.findUnique({
where: { name: customer.name },
});
if (byName) return byName.id;
// Create new
const created = await tx.customer.create({
data: { name: customer.name },
});
return created.id;
}
// ---------------------------------------------------------------------------
// createOrder
// ---------------------------------------------------------------------------
export async function createOrder(
prevState: FormState | undefined,
formData: FormData,
): Promise<FormState> {
await requireCurrentUser();
const title = (formData.get("title") as string) ?? "";
const status = ((formData.get("status") as string) ?? "pending") as OrderStatus;
const notes = (formData.get("notes") as string) || null;
const customersRaw = formData.get("customers") as string | null;
const itemsRaw = formData.get("items") as string | null;
const customers: ParsedCustomer[] = customersRaw ? JSON.parse(customersRaw) : [];
const items: ParsedItem[] = itemsRaw ? JSON.parse(itemsRaw) : [];
// Validation
const errors: Record<string, string[]> = {};
if (!title.trim()) {
errors.title = ["Title is required"];
}
if (items.length === 0) {
errors.items = ["At least one item is required"];
}
if (Object.keys(errors).length > 0) {
return { errors };
}
// Transaction
const order = await prisma.$transaction(async (tx) => {
const created = await tx.order.create({
data: { title: title.trim(), status, notes },
});
// Resolve customer IDs (maps client-side "new-xxx" IDs to real DB IDs)
const customerIdMap: Record<string, string> = {};
for (const c of customers) {
const dbId = await resolveCustomer(c, tx);
customerIdMap[c.id] = dbId;
await tx.orderCustomer.create({
data: { orderId: created.id, customerId: dbId },
});
}
for (const item of items) {
const { netPrice, myNetPrice, finalPrice } = computePrices(item);
// Resolve item's customerId through the map (handles "new-xxx" references)
const resolvedCustomerId = item.customerId
? (customerIdMap[item.customerId] ?? item.customerId)
: null;
await tx.orderItem.create({
data: {
orderId: created.id,
customerId: resolvedCustomerId,
itemName: item.itemName,
initPrice: item.initPrice,
myPrice: item.myPrice,
taxRatio: item.taxRatio,
quantity: item.quantity,
netPrice,
myNetPrice,
finalPrice,
},
});
}
return created;
});
revalidatePath("/");
redirect(`/orders/${order.id}`);
}
// ---------------------------------------------------------------------------
// updateOrder
// ---------------------------------------------------------------------------
export async function updateOrder(
orderId: string,
prevState: FormState | undefined,
formData: FormData,
): Promise<FormState> {
await requireCurrentUser();
// Fetch existing order and verify it is not closed
const existing = await prisma.order.findUnique({ where: { id: orderId } });
if (!existing) {
return { message: "Order not found" };
}
if (existing.status === "closed") {
return { message: "Cannot edit a closed order" };
}
const title = (formData.get("title") as string) ?? "";
const status = ((formData.get("status") as string) ?? existing.status) as OrderStatus;
const notes = (formData.get("notes") as string) || null;
const customersRaw = formData.get("customers") as string | null;
const itemsRaw = formData.get("items") as string | null;
const customers: ParsedCustomer[] = customersRaw ? JSON.parse(customersRaw) : [];
const items: ParsedItem[] = itemsRaw ? JSON.parse(itemsRaw) : [];
// Validation
const errors: Record<string, string[]> = {};
if (!title.trim()) {
errors.title = ["Title is required"];
}
if (items.length === 0) {
errors.items = ["At least one item is required"];
}
if (Object.keys(errors).length > 0) {
return { errors };
}
// Transaction: delete old relations, update order, recreate relations
await prisma.$transaction(async (tx) => {
await tx.orderCustomer.deleteMany({ where: { orderId } });
await tx.orderItem.deleteMany({ where: { orderId } });
await tx.order.update({
where: { id: orderId },
data: { title: title.trim(), status, notes },
});
// Resolve customer IDs (maps client-side "new-xxx" IDs to real DB IDs)
const customerIdMap: Record<string, string> = {};
for (const c of customers) {
const dbId = await resolveCustomer(c, tx);
customerIdMap[c.id] = dbId;
await tx.orderCustomer.create({
data: { orderId, customerId: dbId },
});
}
for (const item of items) {
const { netPrice, myNetPrice, finalPrice } = computePrices(item);
// Resolve item's customerId through the map (handles "new-xxx" references)
const resolvedCustomerId = item.customerId
? (customerIdMap[item.customerId] ?? item.customerId)
: null;
await tx.orderItem.create({
data: {
orderId,
customerId: resolvedCustomerId,
itemName: item.itemName,
initPrice: item.initPrice,
myPrice: item.myPrice,
taxRatio: item.taxRatio,
quantity: item.quantity,
netPrice,
myNetPrice,
finalPrice,
},
});
}
});
revalidatePath("/");
revalidatePath(`/orders/${orderId}`);
return { message: "Order saved successfully", success: true };
}
// ---------------------------------------------------------------------------
// getOrders
// ---------------------------------------------------------------------------
export async function getOrders(
search?: string,
statusFilter?: string,
): Promise<OrderWithDetails[]> {
await requireCurrentUser();
const where: Prisma.OrderWhereInput = {};
if (search) {
where.OR = [
{ title: { contains: search } },
{ customers: { some: { customer: { name: { contains: search } } } } },
];
}
if (statusFilter) {
where.status = statusFilter;
}
const orders = await prisma.order.findMany({
where,
include: {
customers: { include: { customer: true } },
items: { include: { customer: true } },
},
orderBy: { updatedAt: "desc" },
});
return orders.map((order) => ({
id: order.id,
title: order.title,
status: order.status as OrderStatus,
notes: order.notes,
createdAt: order.createdAt,
updatedAt: order.updatedAt,
customers: order.customers.map((oc) => ({
customer: { id: oc.customer.id, name: oc.customer.name },
})),
items: order.items.map((item) => ({
id: item.id,
orderId: item.orderId,
customerId: item.customerId,
customerName: item.customer?.name ?? null,
itemName: item.itemName,
initPrice: item.initPrice,
myPrice: item.myPrice,
taxRatio: item.taxRatio,
quantity: item.quantity,
netPrice: item.netPrice,
myNetPrice: item.myNetPrice,
finalPrice: item.finalPrice,
})),
}));
}
// ---------------------------------------------------------------------------
// getOrderById
// ---------------------------------------------------------------------------
export async function getOrderById(
orderId: string,
): Promise<OrderWithDetails | null> {
await requireCurrentUser();
const order = await prisma.order.findUnique({
where: { id: orderId },
include: {
customers: { include: { customer: true } },
items: { include: { customer: true } },
},
});
if (!order) return null;
return {
id: order.id,
title: order.title,
status: order.status as OrderStatus,
notes: order.notes,
createdAt: order.createdAt,
updatedAt: order.updatedAt,
customers: order.customers.map((oc) => ({
customer: { id: oc.customer.id, name: oc.customer.name },
})),
items: order.items.map((item) => ({
id: item.id,
orderId: item.orderId,
customerId: item.customerId,
customerName: item.customer?.name ?? null,
itemName: item.itemName,
initPrice: item.initPrice,
myPrice: item.myPrice,
taxRatio: item.taxRatio,
quantity: item.quantity,
netPrice: item.netPrice,
myNetPrice: item.myNetPrice,
finalPrice: item.finalPrice,
})),
};
}
// ---------------------------------------------------------------------------
// getDashboardStats
// ---------------------------------------------------------------------------
export async function getDashboardStats(): Promise<DashboardStats> {
await requireCurrentUser();
const groups = await prisma.order.groupBy({
by: ["status"],
_count: { id: true },
});
const counts: Record<string, number> = {};
for (const g of groups) {
counts[g.status] = g._count.id;
}
return {
pending: counts["pending"] ?? 0,
purchased: counts["purchased"] ?? 0,
delivered: counts["delivered"] ?? 0,
closed: counts["closed"] ?? 0,
totalOrders: Object.values(counts).reduce((sum, n) => sum + n, 0),
};
}

View File

@@ -0,0 +1,89 @@
'use client';
import { useActionState } from "react";
import { useFormStatus } from "react-dom";
import Link from "next/link";
import { changePassword } from "@/app/actions/auth";
function SubmitButton() {
const { pending } = useFormStatus();
return (
<button
type="submit"
disabled={pending}
className="bg-accent text-accent-on px-6 py-3 rounded-xl font-bold text-sm border-b-4 border-accent-active hover:bg-accent-hover active:translate-y-1 active:border-b-0 transition-all disabled:opacity-50 cursor-pointer shadow-[0_4px_0_0_var(--accent-active)]"
>
{pending ? "Changing..." : "Change password"}
</button>
);
}
export default function ChangePasswordPage() {
const [state, formAction] = useActionState(changePassword, undefined);
return (
<div className="min-h-screen flex flex-col bg-bg">
<nav className="sticky top-0 z-50 backdrop-blur-md bg-white/80 border-b-2 border-border">
<div className="max-w-[1080px] mx-auto px-6 h-16 flex items-center">
<Link href="/" className="flex items-center gap-3">
<div className="w-9 h-9 rounded-xl bg-accent flex items-center justify-center shadow-card">
<span className="text-accent-on font-bold text-sm">OL</span>
</div>
<span className="font-display text-lg font-bold text-fg">Order Loop</span>
</Link>
</div>
</nav>
<main className="flex-1 max-w-md mx-auto px-6 py-8 w-full">
<h1 className="font-display text-2xl font-bold text-fg mb-6">Change Password</h1>
<div className="bg-white border-2 border-border rounded-2xl p-6 shadow-card">
<form action={formAction} className="space-y-4">
{state?.message && (
<div className={`p-3 rounded-xl text-sm font-medium ${state.success ? "bg-green-50 text-green-800 border border-green-200" : "bg-red-50 text-red-800 border border-red-200"}`}>
{state.message}
</div>
)}
<div className="flex flex-col gap-1">
<label htmlFor="currentPassword" className="text-sm font-medium text-muted">Current Password</label>
<input
id="currentPassword"
name="currentPassword"
type="password"
required
className="border-2 border-border rounded-xl px-4 py-2.5 text-sm bg-white focus:border-accent focus:ring-2 focus:ring-accent/20 outline-none"
/>
</div>
<div className="flex flex-col gap-1">
<label htmlFor="newPassword" className="text-sm font-medium text-muted">New Password</label>
<input
id="newPassword"
name="newPassword"
type="password"
required
minLength={8}
className="border-2 border-border rounded-xl px-4 py-2.5 text-sm bg-white focus:border-accent focus:ring-2 focus:ring-accent/20 outline-none"
/>
</div>
<div className="flex flex-col gap-1">
<label htmlFor="confirmPassword" className="text-sm font-medium text-muted">Confirm New Password</label>
<input
id="confirmPassword"
name="confirmPassword"
type="password"
required
minLength={8}
className="border-2 border-border rounded-xl px-4 py-2.5 text-sm bg-white focus:border-accent focus:ring-2 focus:ring-accent/20 outline-none"
/>
</div>
<SubmitButton />
</form>
</div>
</main>
</div>
);
}

View File

@@ -0,0 +1,68 @@
"use client";
import { useActionState } from "react";
import { useFormStatus } from "react-dom";
import Link from "next/link";
import { forgotPassword } from "@/app/actions/auth";
function SubmitButton() {
const { pending } = useFormStatus();
return (
<button
type="submit"
disabled={pending}
className="w-full bg-accent text-accent-on font-bold py-3 rounded-xl border-b-4 border-accent-active hover:bg-accent-hover active:translate-y-0.5 active:border-b-0 transition-all disabled:opacity-50 cursor-pointer"
>
{pending ? "Sending..." : "Send reset link"}
</button>
);
}
export default function ForgotPasswordPage() {
const [state, formAction] = useActionState(forgotPassword, undefined);
return (
<div className="min-h-screen flex items-center justify-center bg-bg px-4">
<div className="w-full max-w-md">
<div className="text-center mb-8">
<div className="w-14 h-14 rounded-2xl bg-accent flex items-center justify-center mx-auto mb-4 shadow-[0_4px_0_0_var(--accent-active)]">
<span className="text-accent-on font-bold text-xl">OL</span>
</div>
<h1 className="font-display text-2xl font-bold text-fg">Order Loop</h1>
</div>
<div className="bg-surface border-2 border-border rounded-2xl p-6 shadow-card">
<h2 className="font-display text-xl font-bold text-fg mb-2">Forgot your password?</h2>
<p className="text-sm text-muted mb-6">Enter your email and we&apos;ll send you a reset link.</p>
{state?.message && (
<div className={`mb-4 p-3 rounded-xl text-sm font-medium ${state.success ? "bg-green-50 border border-green-200 text-green-700" : "bg-danger/10 border border-danger/20 text-danger"}`}>
{state.message}
</div>
)}
<form action={formAction} className="space-y-4">
<div>
<label htmlFor="email" className="block text-sm font-medium text-fg mb-1">Email</label>
<input
id="email"
name="email"
type="email"
required
className="w-full border-2 border-border rounded-xl px-4 py-2.5 text-sm bg-bg text-fg focus:outline-none focus:border-accent"
placeholder="your@email.com"
/>
</div>
<SubmitButton />
</form>
<div className="mt-4 text-center">
<Link href="/login" className="text-sm text-accent hover:text-accent-hover">
Back to login
</Link>
</div>
</div>
</div>
</div>
);
}

View File

@@ -1,26 +1,102 @@
@import "tailwindcss";
:root {
--background: #ffffff;
--foreground: #171717;
/* Core tokens */
--bg: #ffffff;
--surface: #f8f9fa;
--fg: #1a1a2e;
--muted: #6b7280;
--meta: #9ca3af;
--border: #d1d5db;
/* Accent — Duolingo green */
--accent: #58cc02;
--accent-on: #ffffff;
--accent-hover: #46a302;
--accent-active: #3a8a01;
/* Status */
--success: #00c853;
--warn: #ff9800;
--danger: #ff4444;
/* Shadows */
--shadow-chunky: 0 4px 0 0 var(--accent-active);
--shadow-card: 0 2px 0 0 rgba(0, 0, 0, 0.08);
}
@theme inline {
--color-background: var(--background);
--color-foreground: var(--foreground);
--font-sans: var(--font-geist-sans);
--font-mono: var(--font-geist-mono);
}
@media (prefers-color-scheme: dark) {
:root {
--background: #0a0a0a;
--foreground: #ededed;
}
--color-bg: var(--bg);
--color-surface: var(--surface);
--color-fg: var(--fg);
--color-muted: var(--muted);
--color-meta: var(--meta);
--color-border: var(--border);
--color-accent: var(--accent);
--color-accent-on: var(--accent-on);
--color-accent-hover: var(--accent-hover);
--color-accent-active: var(--accent-active);
--color-success: var(--success);
--color-warn: var(--warn);
--color-danger: var(--danger);
--font-display: "Feather Bold", "DIN Round Pro", "Helvetica Neue", sans-serif;
--font-body: "Mona Sans", "Helvetica Neue", system-ui, sans-serif;
--font-mono: "JetBrains Mono", ui-monospace, Menlo, Monaco, Consolas, monospace;
}
body {
background: var(--background);
color: var(--foreground);
font-family: Arial, Helvetica, sans-serif;
background: var(--bg);
color: var(--fg);
font-family: var(--font-body);
}
/* Button press-down animation */
.btn-press:active {
transform: translateY(2px);
box-shadow: none !important;
}
/* Smooth transitions for interactive elements */
input, select, textarea, button {
transition: border-color 0.15s ease, box-shadow 0.15s ease;
}
/* Customer color palette for item grouping */
.customer-color-0 { --customer-border: #3b82f6; --customer-bg: #eff6ff; --customer-chip: #dbeafe; --customer-chip-text: #1e40af; }
.customer-color-1 { --customer-border: #10b981; --customer-bg: #ecfdf5; --customer-chip: #d1fae5; --customer-chip-text: #065f46; }
.customer-color-2 { --customer-border: #8b5cf6; --customer-bg: #f5f3ff; --customer-chip: #ede9fe; --customer-chip-text: #5b21b6; }
.customer-color-3 { --customer-border: #f97316; --customer-bg: #fff7ed; --customer-chip: #ffedd5; --customer-chip-text: #9a3412; }
.customer-color-4 { --customer-border: #14b8a6; --customer-bg: #f0fdfa; --customer-chip: #ccfbf1; --customer-chip-text: #115e59; }
.customer-color-5 { --customer-border: #ec4899; --customer-bg: #fdf2f8; --customer-chip: #fce7f3; --customer-chip-text: #9d174d; }
/* Customer color presets */
.customer-color-0 {
--customer-border: #3b82f6;
--customer-bg: #eff6ff;
--customer-text: #1e40af;
}
.customer-color-1 {
--customer-border: #10b981;
--customer-bg: #ecfdf5;
--customer-text: #065f46;
}
.customer-color-2 {
--customer-border: #8b5cf6;
--customer-bg: #f5f3ff;
--customer-text: #5b21b6;
}
.customer-color-3 {
--customer-border: #f97316;
--customer-bg: #fff7ed;
--customer-text: #9a3412;
}
.customer-color-4 {
--customer-border: #14b8a6;
--customer-bg: #f0fdfa;
--customer-text: #0f766e;
}
.customer-color-5 {
--customer-border: #ec4899;
--customer-bg: #fdf2f8;
--customer-text: #9d174d;
}

View File

@@ -1,20 +1,15 @@
import type { Metadata } from "next";
import { Geist, Geist_Mono } from "next/font/google";
import { Inter } from "next/font/google";
import "./globals.css";
const geistSans = Geist({
variable: "--font-geist-sans",
subsets: ["latin"],
});
const geistMono = Geist_Mono({
variable: "--font-geist-mono",
const inter = Inter({
subsets: ["latin"],
variable: "--font-inter",
});
export const metadata: Metadata = {
title: "Create Next App",
description: "Generated by create next app",
title: "Order Loop",
description: "Order management for fast-moving teams",
};
export default function RootLayout({
@@ -23,11 +18,10 @@ export default function RootLayout({
children: React.ReactNode;
}>) {
return (
<html
lang="en"
className={`${geistSans.variable} ${geistMono.variable} h-full antialiased`}
>
<body className="min-h-full flex flex-col">{children}</body>
<html lang="en" className={`${inter.variable} h-full`}>
<body className="min-h-full flex flex-col antialiased">
{children}
</body>
</html>
);
}

78
app/login/page.tsx Normal file
View File

@@ -0,0 +1,78 @@
"use client";
import { useActionState } from "react";
import { useFormStatus } from "react-dom";
import Link from "next/link";
import { login } from "@/app/actions/auth";
function SubmitButton() {
const { pending } = useFormStatus();
return (
<button
type="submit"
disabled={pending}
className="w-full bg-accent text-accent-on font-bold py-3 rounded-xl border-b-4 border-accent-active hover:bg-accent-hover active:translate-y-0.5 active:border-b-0 transition-all disabled:opacity-50 cursor-pointer"
>
{pending ? "Logging in..." : "Log in"}
</button>
);
}
export default function LoginPage() {
const [state, formAction] = useActionState(login, undefined);
return (
<div className="min-h-screen flex items-center justify-center bg-bg px-4">
<div className="w-full max-w-md">
<div className="text-center mb-8">
<div className="w-14 h-14 rounded-2xl bg-accent flex items-center justify-center mx-auto mb-4 shadow-[0_4px_0_0_var(--accent-active)]">
<span className="text-accent-on font-bold text-xl">OL</span>
</div>
<h1 className="font-display text-2xl font-bold text-fg">Order Loop</h1>
</div>
<div className="bg-surface border-2 border-border rounded-2xl p-6 shadow-card">
<h2 className="font-display text-xl font-bold text-fg mb-6">Welcome back</h2>
{state?.message && (
<div className="mb-4 p-3 rounded-xl bg-danger/10 border border-danger/20 text-danger text-sm font-medium">
{state.message}
</div>
)}
<form action={formAction} className="space-y-4">
<div>
<label htmlFor="email" className="block text-sm font-medium text-fg mb-1">Email</label>
<input
id="email"
name="email"
type="email"
required
className="w-full border-2 border-border rounded-xl px-4 py-2.5 text-sm bg-bg text-fg focus:outline-none focus:border-accent"
placeholder="admin@example.com"
/>
</div>
<div>
<label htmlFor="password" className="block text-sm font-medium text-fg mb-1">Password</label>
<input
id="password"
name="password"
type="password"
required
className="w-full border-2 border-border rounded-xl px-4 py-2.5 text-sm bg-bg text-fg focus:outline-none focus:border-accent"
placeholder="Enter password"
/>
</div>
<SubmitButton />
</form>
<div className="mt-4 text-center">
<Link href="/forgot-password" className="text-sm text-accent hover:text-accent-hover">
Forgot your password?
</Link>
</div>
</div>
</div>
</div>
);
}

53
app/orders/[id]/page.tsx Normal file
View File

@@ -0,0 +1,53 @@
import { notFound } from "next/navigation";
import { requireCurrentUser } from "@/lib/auth";
import { getOrderById, updateOrder } from "@/app/actions/orders";
import { Navbar } from "@/components/Navbar";
import { OrderForm } from "@/components/OrderForm";
import { LockBanner } from "@/components/LockBanner";
export default async function OrderDetailPage({
params,
}: {
params: Promise<{ id: string }>;
}) {
await requireCurrentUser();
const { id } = await params;
const order = await getOrderById(id);
if (!order) {
notFound();
}
const isClosed = order.status === "closed";
// Bind orderId to the update action
const updateOrderWithId = updateOrder.bind(null, order.id);
return (
<div className="min-h-screen bg-bg">
<Navbar />
<main className="max-w-[1080px] mx-auto px-6 py-8">
{isClosed && (
<div className="mb-6">
<LockBanner />
</div>
)}
<div className="mb-8">
<h1 className="font-display text-3xl font-bold text-fg">{order.title}</h1>
<p className="text-muted text-sm mt-1">
{isClosed ? "This order is read-only" : "Edit order details below"}
</p>
</div>
<OrderForm
mode="edit"
initialData={order}
serverAction={updateOrderWithId}
disabled={isClosed}
/>
</main>
</div>
);
}

23
app/orders/new/page.tsx Normal file
View File

@@ -0,0 +1,23 @@
import { requireCurrentUser } from "@/lib/auth";
import { createOrder } from "@/app/actions/orders";
import { Navbar } from "@/components/Navbar";
import { OrderForm } from "@/components/OrderForm";
export default async function NewOrderPage() {
await requireCurrentUser();
return (
<div className="min-h-screen bg-bg">
<Navbar />
<main className="max-w-[1080px] mx-auto px-6 py-8">
<div className="mb-8">
<h1 className="font-display text-3xl font-bold text-fg">New Order</h1>
<p className="text-muted text-sm mt-1">Create a new order with customers and items</p>
</div>
<OrderForm mode="create" serverAction={createOrder} />
</main>
</div>
);
}

View File

@@ -1,63 +1,46 @@
import Image from "next/image";
import Link from "next/link";
import { requireCurrentUser } from "@/lib/auth";
import { getDashboardStats, getOrders } from "@/app/actions/orders";
import { Navbar } from "@/components/Navbar";
import { StatCard } from "@/components/StatCard";
import { OrderTable } from "@/components/OrderTable";
export default async function DashboardPage() {
await requireCurrentUser();
const stats = await getDashboardStats();
const orders = await getOrders();
export default function Home() {
return (
<div className="flex flex-col flex-1 items-center justify-center bg-zinc-50 font-sans dark:bg-black">
<main className="flex flex-1 w-full max-w-3xl flex-col items-center justify-between py-32 px-16 bg-white dark:bg-black sm:items-start">
<Image
className="dark:invert"
src="/next.svg"
alt="Next.js logo"
width={100}
height={20}
priority
/>
<div className="flex flex-col items-center gap-6 text-center sm:items-start sm:text-left">
<h1 className="max-w-xs text-3xl font-semibold leading-10 tracking-tight text-black dark:text-zinc-50">
To get started, edit the page.tsx file.
</h1>
<p className="max-w-md text-lg leading-8 text-zinc-600 dark:text-zinc-400">
Looking for a starting point or more instructions? Head over to{" "}
<a
href="https://vercel.com/templates?framework=next.js&utm_source=create-next-app&utm_medium=appdir-template-tw&utm_campaign=create-next-app"
className="font-medium text-zinc-950 dark:text-zinc-50"
>
Templates
</a>{" "}
or the{" "}
<a
href="https://nextjs.org/learn?utm_source=create-next-app&utm_medium=appdir-template-tw&utm_campaign=create-next-app"
className="font-medium text-zinc-950 dark:text-zinc-50"
>
Learning
</a>{" "}
center.
</p>
<div className="min-h-screen bg-bg">
<Navbar />
<main className="max-w-[1080px] mx-auto px-6 py-8">
{/* Hero */}
<div className="flex items-center justify-between mb-8">
<div>
<h1 className="font-display text-3xl font-bold text-fg">Dashboard</h1>
<p className="text-muted text-sm mt-1">Your order overview at a glance</p>
</div>
<Link
href="/orders/new"
className="bg-accent text-accent-on px-5 py-2.5 rounded-xl font-bold text-sm border-b-4 border-accent-active hover:bg-accent-hover active:translate-y-1 active:border-b-0 transition-all shadow-[0_4px_0_0_var(--accent-active)]"
>
+ New Order
</Link>
</div>
<div className="flex flex-col gap-4 text-base font-medium sm:flex-row">
<a
className="flex h-12 w-full items-center justify-center gap-2 rounded-full bg-foreground px-5 text-background transition-colors hover:bg-[#383838] dark:hover:bg-[#ccc] md:w-[158px]"
href="https://vercel.com/new?utm_source=create-next-app&utm_medium=appdir-template-tw&utm_campaign=create-next-app"
target="_blank"
rel="noopener noreferrer"
>
<Image
className="dark:invert"
src="/vercel.svg"
alt="Vercel logomark"
width={16}
height={16}
/>
Deploy Now
</a>
<a
className="flex h-12 w-full items-center justify-center rounded-full border border-solid border-black/[.08] px-5 transition-colors hover:border-transparent hover:bg-black/[.04] dark:border-white/[.145] dark:hover:bg-[#1a1a1a] md:w-[158px]"
href="https://nextjs.org/docs?utm_source=create-next-app&utm_medium=appdir-template-tw&utm_campaign=create-next-app"
target="_blank"
rel="noopener noreferrer"
>
Documentation
</a>
{/* Stat Cards */}
<div className="grid grid-cols-2 lg:grid-cols-4 gap-4 mb-8">
<StatCard label="Pending" value={stats.pending} color="text-yellow-600" />
<StatCard label="Purchased" value={stats.purchased} color="text-blue-600" />
<StatCard label="Delivered" value={stats.delivered} color="text-green-600" />
<StatCard label="Closed" value={stats.closed} color="text-gray-500" />
</div>
{/* Order Table */}
<div>
<h2 className="font-display text-xl font-bold text-fg mb-4">Order History</h2>
<OrderTable orders={orders} />
</div>
</main>
</div>

View File

@@ -0,0 +1,16 @@
import { Suspense } from "react";
import ResetPasswordForm from "./reset-password-form";
export default function ResetPasswordPage() {
return (
<Suspense
fallback={
<div className="min-h-screen flex items-center justify-center">
<p className="text-muted">Loading...</p>
</div>
}
>
<ResetPasswordForm />
</Suspense>
);
}

View File

@@ -0,0 +1,108 @@
"use client";
import { useActionState } from "react";
import { useFormStatus } from "react-dom";
import { useSearchParams } from "next/navigation";
import Link from "next/link";
import { resetPassword } from "@/app/actions/auth";
function SubmitButton() {
const { pending } = useFormStatus();
return (
<button
type="submit"
disabled={pending}
className="w-full bg-accent text-accent-on font-bold py-3 rounded-xl border-b-4 border-accent-active hover:bg-accent-hover active:translate-y-0.5 active:border-b-0 transition-all disabled:opacity-50 cursor-pointer"
>
{pending ? "Resetting..." : "Reset password"}
</button>
);
}
export default function ResetPasswordForm() {
const searchParams = useSearchParams();
const token = searchParams.get("token") ?? "";
const [state, formAction] = useActionState(resetPassword, undefined);
return (
<div className="min-h-screen flex items-center justify-center bg-bg px-4">
<div className="w-full max-w-md">
<div className="text-center mb-8">
<div className="w-14 h-14 rounded-2xl bg-accent flex items-center justify-center mx-auto mb-4 shadow-[0_4px_0_0_var(--accent-active)]">
<span className="text-accent-on font-bold text-xl">OL</span>
</div>
<h1 className="font-display text-2xl font-bold text-fg">Order Loop</h1>
</div>
<div className="bg-surface border-2 border-border rounded-2xl p-6 shadow-card">
<h2 className="font-display text-xl font-bold text-fg mb-2">
Reset your password
</h2>
<p className="text-sm text-muted mb-6">
Enter your new password below.
</p>
{state?.message && (
<div
className={`mb-4 p-3 rounded-xl text-sm font-medium ${
state.success
? "bg-green-50 border border-green-200 text-green-700"
: "bg-danger/10 border border-danger/20 text-danger"
}`}
>
{state.message}
</div>
)}
{state?.success ? (
<Link
href="/login"
className="block w-full text-center bg-accent text-accent-on font-bold py-3 rounded-xl"
>
Go to login
</Link>
) : (
<form action={formAction} className="space-y-4">
<input type="hidden" name="token" value={token} />
<div>
<label
htmlFor="password"
className="block text-sm font-medium text-fg mb-1"
>
New password
</label>
<input
id="password"
name="password"
type="password"
required
minLength={8}
className="w-full border-2 border-border rounded-xl px-4 py-2.5 text-sm bg-bg text-fg focus:outline-none focus:border-accent"
placeholder="At least 8 characters"
/>
</div>
<div>
<label
htmlFor="confirmPassword"
className="block text-sm font-medium text-fg mb-1"
>
Confirm password
</label>
<input
id="confirmPassword"
name="confirmPassword"
type="password"
required
minLength={8}
className="w-full border-2 border-border rounded-xl px-4 py-2.5 text-sm bg-bg text-fg focus:outline-none focus:border-accent"
placeholder="Repeat password"
/>
</div>
<SubmitButton />
</form>
)}
</div>
</div>
</div>
);
}