Base app architecture:

    Route groups for customer, staff and owner.
* Reusable design system with around 20 UI components.
* Complete Prisma schema.
* Authentication with email/password and Google.
* Role and permission model.
* Protected routes.
* Clean separation between features, services, components and shared utilities.
This commit is contained in:
Stefan Lazic committed 2026-08-02 17:47:23 +01:00
1 parent 468d163299
commit 090646fdfd
201 files changed
+31984

No files matched your search

+73
View File
@@ -0,0 +1,73 @@
"use server";
import bcrypt from "bcryptjs";
import { signIn } from "@/auth";
import { prisma } from "@/lib/db/prisma";
import { registerSchema } from "@/features/auth/schemas";
export interface RegisterResult {
success: boolean;
error?: string;
/** Per-field errors, keyed by field name, for the form to surface inline. */
fieldErrors?: Record<string, string[] | undefined>;
}
/**
* Creates a User + CustomerProfile in one transaction, then signs the user
* straight in — no email-verification gate yet (that needs the
* Notifications system, which is out of scope for Phase 2; see
* docs/implementation-plan.md §15, Question 3-adjacent).
*/
export async function registerAction(formData: FormData): Promise<RegisterResult> {
const parsed = registerSchema.safeParse({
firstName: formData.get("firstName"),
lastName: formData.get("lastName"),
email: formData.get("email"),
phone: formData.get("phone"),
password: formData.get("password"),
confirmPassword: formData.get("confirmPassword"),
});
if (!parsed.success) {
return {
success: false,
error: "Please fix the highlighted fields.",
fieldErrors: parsed.error.flatten().fieldErrors,
};
}
const { firstName, lastName, email, phone, password } = parsed.data;
const existing = await prisma.user.findUnique({ where: { email } });
if (existing) {
return {
success: false,
error: "An account with this email already exists.",
fieldErrors: { email: ["An account with this email already exists."] },
};
}
const passwordHash = await bcrypt.hash(password, 12);
await prisma.user.create({
data: {
email,
phone: phone || null,
name: `${firstName} ${lastName}`,
passwordHash,
customerProfile: {
create: {
firstName,
lastName,
email,
phone: phone || null,
isGuest: false,
},
},
},
});
await signIn("credentials", { email, password, redirectTo: "/home" });
return { success: true };
}
@@ -0,0 +1,103 @@
"use client";
import { zodResolver } from "@hookform/resolvers/zod";
import Link from "next/link";
import { useState } from "react";
import { useForm } from "react-hook-form";
import { Button } from "@/components/ui/button";
import { Input } from "@/components/ui/input";
import { registerAction } from "@/features/auth/actions/register";
import { registerSchema, type RegisterInput } from "@/features/auth/schemas";
export function RegisterForm() {
const [formError, setFormError] = useState<string | null>(null);
const {
register,
handleSubmit,
setError,
formState: { errors, isSubmitting },
} = useForm<RegisterInput>({
resolver: zodResolver(registerSchema),
});
const onSubmit = async (data: RegisterInput) => {
setFormError(null);
const formData = new FormData();
Object.entries(data).forEach(([key, value]) => formData.set(key, value ?? ""));
const result = await registerAction(formData);
// A successful call redirects server-side and never resolves here with
// success: true in practice, but we still handle it for completeness.
if (!result.success) {
setFormError(result.error ?? "Something went wrong. Please try again.");
if (result.fieldErrors) {
for (const [field, messages] of Object.entries(result.fieldErrors)) {
if (messages?.[0]) {
setError(field as keyof RegisterInput, { message: messages[0] });
}
}
}
}
};
return (
<form onSubmit={handleSubmit(onSubmit)} noValidate className="space-y-4">
<div className="grid grid-cols-2 gap-4">
<Input label="First Name" placeholder="Alex" error={errors.firstName?.message} {...register("firstName")} />
<Input label="Last Name" placeholder="Chen" error={errors.lastName?.message} {...register("lastName")} />
</div>
<Input
label="Email Address"
type="email"
placeholder="name@example.com"
error={errors.email?.message}
{...register("email")}
/>
<Input
label="Phone Number (optional)"
type="tel"
placeholder="+44 7700 900000"
error={errors.phone?.message}
{...register("phone")}
/>
<Input
label="Password"
type="password"
placeholder="••••••••"
error={errors.password?.message}
{...register("password")}
/>
<Input
label="Confirm Password"
type="password"
placeholder="••••••••"
error={errors.confirmPassword?.message}
{...register("confirmPassword")}
/>
{formError && (
<p role="alert" className="text-sm font-medium text-red-500">
{formError}
</p>
)}
<p className="px-1 text-[10px] leading-relaxed text-muted">
By creating an account, you agree to our <a href="#" className="underline">Terms of Service</a> and{" "}
<a href="#" className="underline">Privacy Policy</a>.
</p>
<Button type="submit" className="mt-4 w-full" isLoading={isSubmitting}>
Sign Up
</Button>
<p className="mt-10 text-center text-sm text-muted">
Already have an account?{" "}
<Link href="/sign-in" className="border-b border-ink font-bold text-ink">
Log in
</Link>
</p>
</form>
);
}
@@ -0,0 +1,100 @@
"use client";
import { zodResolver } from "@hookform/resolvers/zod";
import { Mail, Lock } from "lucide-react";
import { signIn } from "next-auth/react";
import Link from "next/link";
import { useSearchParams } from "next/navigation";
import { useState } from "react";
import { useForm } from "react-hook-form";
import { Button } from "@/components/ui/button";
import { Input } from "@/components/ui/input";
import { signInSchema, type SignInInput } from "@/features/auth/schemas";
export function SignInForm() {
const searchParams = useSearchParams();
const callbackUrl = searchParams.get("callbackUrl") ?? "/home";
const [formError, setFormError] = useState<string | null>(null);
const {
register,
handleSubmit,
formState: { errors, isSubmitting },
} = useForm<SignInInput>({
resolver: zodResolver(signInSchema),
});
const onSubmit = async (data: SignInInput) => {
setFormError(null);
const result = await signIn("credentials", { ...data, redirect: false, callbackUrl });
if (result?.error) {
setFormError("Incorrect email or password.");
return;
}
window.location.assign(result?.url ?? callbackUrl);
};
return (
<div className="w-full">
<form onSubmit={handleSubmit(onSubmit)} noValidate className="space-y-4">
<Input
label="Email Address"
type="email"
icon={<Mail className="h-4 w-4" aria-hidden="true" />}
placeholder="name@example.com"
error={errors.email?.message}
{...register("email")}
/>
<Input
label="Password"
id="password"
type="password"
icon={<Lock className="h-4 w-4" aria-hidden="true" />}
placeholder="••••••••"
error={errors.password?.message}
{...register("password")}
/>
{/* Password reset needs the Notifications system (email delivery)
to actually send a reset link — out of scope for this phase.
The "Forgot Password" template (8) is a design reference for
when that lands; no dead link to it here in the meantime. */}
{formError && (
<p role="alert" className="text-sm font-medium text-red-500">
{formError}
</p>
)}
<Button type="submit" className="mt-2 w-full" isLoading={isSubmitting}>
Sign In
</Button>
</form>
<div className="mt-10">
<div className="relative mb-8 flex items-center justify-center">
<div className="absolute inset-0 flex items-center">
<div className="w-full border-t border-line" />
</div>
<span className="relative bg-stone px-4 text-[10px] font-bold uppercase tracking-widest text-muted">
Or continue with
</span>
</div>
<Button
type="button"
variant="secondary"
className="w-full normal-case tracking-normal"
onClick={() => signIn("google", { callbackUrl })}
>
Continue with Google
</Button>
</div>
<p className="mt-10 text-center text-sm text-muted">
Don&apos;t have an account?{" "}
<Link href="/register" className="border-b-2 border-ink font-bold text-ink">
Create one
</Link>
</p>
</div>
);
}
@@ -0,0 +1,18 @@
"use client";
import { LogOut } from "lucide-react";
import { signOut } from "next-auth/react";
import { Button } from "@/components/ui/button";
export function SignOutButton() {
return (
<Button
variant="secondary"
className="w-full text-red-500 hover:bg-red-50"
onClick={() => signOut({ callbackUrl: "/" })}
>
<LogOut className="h-4 w-4" aria-hidden="true" />
Log Out
</Button>
);
}
+28
View File
@@ -0,0 +1,28 @@
import { z } from "zod";
/**
* Single source of truth for auth form shapes — shared between the client
* form (react-hook-form + zodResolver) and the server action / Credentials
* `authorize()` callback, so validation never drifts between the two.
*/
export const signInSchema = z.object({
email: z.email("Enter a valid email address"),
password: z.string().min(1, "Password is required"),
});
export type SignInInput = z.infer<typeof signInSchema>;
export const registerSchema = z
.object({
firstName: z.string().trim().min(1, "First name is required"),
lastName: z.string().trim().min(1, "Last name is required"),
email: z.email("Enter a valid email address"),
phone: z.string().trim().optional().or(z.literal("")),
password: z.string().min(8, "Password must be at least 8 characters"),
confirmPassword: z.string(),
})
.refine((data) => data.password === data.confirmPassword, {
message: "Passwords don't match",
path: ["confirmPassword"],
});
export type RegisterInput = z.infer<typeof registerSchema>;