init app
This commit is contained in:
202
app/actions/auth.ts
Normal file
202
app/actions/auth.ts
Normal 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
375
app/actions/orders.ts
Normal 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),
|
||||
};
|
||||
}
|
||||
89
app/change-password/page.tsx
Normal file
89
app/change-password/page.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
68
app/forgot-password/page.tsx
Normal file
68
app/forgot-password/page.tsx
Normal 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'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>
|
||||
);
|
||||
}
|
||||
108
app/globals.css
108
app/globals.css
@@ -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;
|
||||
}
|
||||
|
||||
@@ -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
78
app/login/page.tsx
Normal 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
53
app/orders/[id]/page.tsx
Normal 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
23
app/orders/new/page.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
97
app/page.tsx
97
app/page.tsx
@@ -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>
|
||||
|
||||
16
app/reset-password/page.tsx
Normal file
16
app/reset-password/page.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
108
app/reset-password/reset-password-form.tsx
Normal file
108
app/reset-password/reset-password-form.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
Reference in New Issue
Block a user