Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
7 changes: 7 additions & 0 deletions AGENTS.md
Original file line number Diff line number Diff line change
Expand Up @@ -24,9 +24,16 @@ Yarn workspaces monorepo, two independent apps talking only over HTTP:

- `apps/api` — Express + Prisma/Postgres, layered `router/ → controller/ → service/ → repository/` per resource (see `task.*.ts` for the pattern). Validation is zod in the controller; no DTO layer. Every response is `{ success, message, data }`, thrown via `common/errors/app-error.ts` typed errors and centralized in `common/middleware/error-handler.middleware.ts`.
- `apps/web` — Next.js App Router. Server Components fetch data (`lib/api-client.ts`, which unwraps the `{ success, data }` envelope); anything interactive needs its own `"use client"` component, and mutations should call `router.refresh()` afterward since there's no client cache.
- `src/features/<domain>/` — business-specific components grouped by feature (`auth`, `profile`, `wallet`); e.g. `features/auth/login-panel.tsx` wires the presentational form to the API and router.
- `src/components/ui/` — base/design-system primitives (button, input, select, ...), documented in `DESIGN.md`.
- `src/components/guards/auth-guard.tsx` (`AuthGuard`) — the single client-side route guard, mounted once in `app/layout.tsx`. Matches the path against a `ROUTE_RULES` table (guest-only / profile-completion-required / role-restricted per prefix) and redirects before rendering — add a route there instead of a new `layout.tsx`.
- `src/components/Navbar.tsx`, `Footer.tsx` — global app shell, not tied to one feature.
- `src/contexts/auth-context.tsx` — `AuthProvider` + `useAuth()`, the single source of truth for the signed-in user's profile (`GET /users/me`). `AuthGuard`, `Navbar`, and profile/wallet features read from it instead of each fetching independently; call `refresh()` after a mutation that can change role/profile-completion/wallet state.

## Conventions

- New API resource: add one file each to `router/`, `controller/`, `service/`, `repository/`, then register in `apps/api/src/routes.ts`.
- New shared frontend type: one file per resource under `apps/web/src/types/`.
- New frontend feature UI goes under `apps/web/src/features/<domain>/`, not `components/`; `components/` is reserved for the design system (`ui/`), route guards (`guards/`), and app-shell pieces (`Navbar`, `Footer`).
- New protected/guest page: add a prefix to `ROUTE_RULES` in `apps/web/src/components/guards/auth-guard.tsx`.
- Formatting is enforced by Prettier (100 cols, double quotes, semicolons) + ESLint, run automatically on commit via husky/lint-staged — don't bypass with `--no-verify`.
7 changes: 7 additions & 0 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -5,6 +5,13 @@ Monorepo: Next.js frontend + Express API, Prisma/Postgres, Swagger docs, Storybo
## Stack

- **apps/web** — Next.js, Tailwind CSS, shadcn/ui, lucide-react, Storybook
- `src/features/<domain>/` — feature UI grouped by business domain (`auth`,
`profile`, `wallet`)
- `src/components/ui/` — base design-system primitives, see `DESIGN.md`
- `src/components/guards/auth-guard.tsx` — the single route guard mounted in
`app/layout.tsx`; redirects per-route based on sign-in/profile-completion/role
- `src/contexts/auth-context.tsx` — `AuthProvider`/`useAuth()`, the shared
signed-in user profile consumed by the guard, navbar, and feature components
- **apps/api** — Express, Prisma, PostgreSQL, Swagger (`swagger-jsdoc` + `swagger-ui-express`)
- Layers: `router/` → `controller/` → `service/` → `repository/`. Validation
(zod) lives in the controller; no separate DTO layer.
Expand Down
2 changes: 1 addition & 1 deletion apps/web/src/app/balance/top-up/page.tsx
Original file line number Diff line number Diff line change
@@ -1,5 +1,5 @@
import type { Metadata } from "next";
import { TopUpForm } from "@/components/wallet/top-up-form";
import { TopUpForm } from "@/features/wallet/top-up-form";

export const metadata: Metadata = { title: "Top up balance | Tutorist" };

Expand Down
14 changes: 3 additions & 11 deletions apps/web/src/app/complete-profile/page.tsx
Original file line number Diff line number Diff line change
@@ -1,25 +1,17 @@
import type { Metadata } from "next";

import { ProfileCompletionForm } from "@/components/profile/profile-completion-form";
import { ProfileCompletionForm } from "@/features/profile/profile-completion-form";

export const metadata: Metadata = {
title: "Complete your profile | Tutorist",
description: "Finish setting up your Tutorist profile.",
};

type CompleteProfilePageProps = {
searchParams: Promise<{ role?: string }>;
};

export default async function CompleteProfilePage({ searchParams }: CompleteProfilePageProps) {
const { role } = await searchParams;
const normalizedRole = role?.trim().toLowerCase();
const profileRole = normalizedRole === "tutor" ? "tutor" : "student";

export default function CompleteProfilePage() {
return (
<main className="flex min-h-[calc(100vh-64px)] items-center bg-[#faf9fc] px-4 py-6 sm:px-6 sm:py-10 lg:px-10">
<div className="mx-auto w-full max-w-6xl">
<ProfileCompletionForm role={profileRole} />
<ProfileCompletionForm />
</div>
</main>
);
Expand Down
9 changes: 6 additions & 3 deletions apps/web/src/app/layout.tsx
Original file line number Diff line number Diff line change
@@ -1,8 +1,9 @@
import type { Metadata } from "next";
import "./globals.css";
import { AuthBoundary } from "@/components/auth/auth-boundary";
import { AuthGuard } from "@/components/guards/auth-guard";
import { Footer } from "@/components/Footer";
import { Navbar } from "@/components/Navbar";
import { AuthProvider } from "@/contexts/auth-context";

export const metadata: Metadata = {
title: "SE Project",
Expand All @@ -13,8 +14,10 @@ export default function RootLayout({ children }: { children: React.ReactNode })
return (
<html lang="en">
<body>
<Navbar />
<AuthBoundary>{children}</AuthBoundary>
<AuthProvider>

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Why Footer isn't in the AuthGuard?

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

cause it static for both guest and authenticated user.

<Navbar />
<AuthGuard>{children}</AuthGuard>
</AuthProvider>
<Footer />
</body>
</html>
Expand Down
2 changes: 1 addition & 1 deletion apps/web/src/app/login/page.tsx
Original file line number Diff line number Diff line change
@@ -1,5 +1,5 @@
import type { Metadata } from "next";
import { LoginPanel } from "@/components/auth/login-panel";
import { LoginPanel } from "@/features/auth/login-panel";

export const metadata: Metadata = {
title: "Log in | Tutorist",
Expand Down
2 changes: 1 addition & 1 deletion apps/web/src/app/profile/edit/page.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -2,7 +2,7 @@ import type { Metadata } from "next";
import Link from "next/link";
import { ArrowLeft } from "lucide-react";

import { ProfileEditForm } from "@/components/profile/profile-edit-form";
import { ProfileEditForm } from "@/features/profile/profile-edit-form";

export const metadata: Metadata = {
title: "Edit profile | Tutorist",
Expand Down
2 changes: 1 addition & 1 deletion apps/web/src/app/profile/page.tsx
Original file line number Diff line number Diff line change
@@ -1,6 +1,6 @@
import type { Metadata } from "next";

import { ProfilePanel } from "@/components/profile/profile-panel";
import { ProfilePanel } from "@/features/profile/profile-panel";

export const metadata: Metadata = {
title: "My profile | Tutorist",
Expand Down
2 changes: 1 addition & 1 deletion apps/web/src/app/register/page.tsx
Original file line number Diff line number Diff line change
@@ -1,6 +1,6 @@
import type { Metadata } from "next";

import { RegistrationPanel } from "@/components/auth/registration-panel";
import { RegistrationPanel } from "@/features/auth/registration-panel";

export const metadata: Metadata = {
title: "Sign up | Tutorist",
Expand Down
55 changes: 8 additions & 47 deletions apps/web/src/components/Navbar.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -7,16 +7,12 @@ import Link from "next/link";
import { usePathname, useRouter } from "next/navigation";

import { Button } from "@/components/ui/button";
import { getToken, getUser } from "@/lib/auth-storage";
import { apiClient } from "@/lib/api-client";
import type { WalletBalance } from "@/types/wallet";
import type { LoginResponse } from "@/types/auth";
import { useAuth } from "@/contexts/auth-context";

const NAV_LINKS = [
{ label: "Find tutors", href: "/tutors" },
{ label: "My classroom", href: "/classroom" },
{ label: "Message", href: "/message" },
{ label: "My profile", href: "/profile" },
];

const FOCUSABLE_SELECTOR =
Expand All @@ -34,51 +30,16 @@ export function Navbar({ isLoggedIn, userName, userMoney, profileUrl }: NavbarPr
const pathname = usePathname();
const router = useRouter();
const [isSidebarOpen, setIsSidebarOpen] = useState(false);
const [session, setSession] = useState<LoginResponse["user"] | null>(null);
const [balance, setBalance] = useState<string>();
const { profile } = useAuth();
const menuToggleRef = useRef<HTMLButtonElement>(null);
const sidebarRef = useRef<HTMLDivElement>(null);

// The session lives in localStorage, so it can only be read after mount —
// the first paint always renders signed-out. Re-reading on navigation is what
// makes logging in or out update the header, since this component never
// unmounts during client-side routing.
useEffect(() => {
let active = true;
let accountVersion = 0;
function updateAccount() {
const version = ++accountVersion;
const user = getToken() ? getUser() : null;
setSession(user);
setBalance(undefined);
if (user?.role === "student") {
apiClient.get<WalletBalance>("/wallet").then(
(wallet) => {
if (active && version === accountVersion) {
setBalance(Number(wallet.walletBalance).toFixed(2));
}
},
() => {
/* The top-up page displays wallet loading errors. */
}
);
}
}
updateAccount();
window.addEventListener("wallet-updated", updateAccount);
window.addEventListener("storage", updateAccount);
return () => {
active = false;
window.removeEventListener("wallet-updated", updateAccount);
window.removeEventListener("storage", updateAccount);
};
}, [pathname]);

const signedIn = isLoggedIn ?? session !== null;
const displayName = userName ?? session?.name ?? session?.email;
const displayPhoto = profileUrl ?? session?.profileUrl ?? undefined;
const displayBalance = userMoney ?? balance;
const isStudent = session?.role === "student";
const signedIn = isLoggedIn ?? profile !== null;
const displayName = userName ?? profile?.name ?? profile?.email;
const displayPhoto = profileUrl ?? profile?.profileUrl ?? undefined;
const displayBalance =
userMoney ?? (profile?.role === "student" ? profile.walletBalance.toFixed(2) : undefined);
const isStudent = profile?.role === "student";

useEffect(() => {
if (!isSidebarOpen) return;
Expand Down
151 changes: 0 additions & 151 deletions apps/web/src/components/auth/auth-boundary.tsx

This file was deleted.

Loading
Loading