883 lines
25 KiB
Markdown
883 lines
25 KiB
Markdown
# URL-Driven Section Routing Implementation Plan
|
|
|
|
> **For Claude:** REQUIRED SUB-SKILL: Use superpowers:executing-plans to implement this plan task-by-task.
|
|
|
|
**Goal:** Replace the single-page client-state accordion with URL-driven routing — each section gets its own static URL (`/intro`, `/bio`, etc.), clicking a section heading navigates between pages.
|
|
|
|
**Architecture:** `app/[[...slug]]/page.tsx` is the single RSC that handles all routes. It resolves the active slug from URL params (defaulting to first section at `/`), then passes `activeSlug` down to `SectionsAccordion` (now a server component). `SectionAccordion` entity renders inactive sections as `<Link>` elements. `SidebarNav` uses `usePathname()` for active state.
|
|
|
|
**Tech Stack:** Next.js 16 App Router, React 19, Vitest + RTL, TypeScript strict, Biome, `next/link`, `next/navigation`.
|
|
|
|
---
|
|
|
|
## Task 0: Commit next.config.ts fix
|
|
|
|
The `output: 'export'` was already made conditional on `NODE_ENV === 'production'` to allow route handlers in dev. Commit it.
|
|
|
|
**Files:**
|
|
- Already modified: `next.config.ts`
|
|
|
|
**Step 1: Verify file is correct**
|
|
|
|
`next.config.ts` should read:
|
|
```ts
|
|
import type { NextConfig } from 'next';
|
|
|
|
const isExport = process.env.NODE_ENV === 'production';
|
|
|
|
const nextConfig: NextConfig = {
|
|
/* output: 'export' only applies at build time — enabling it in dev mode
|
|
* breaks route handlers (incompatible with force-dynamic in Next.js 16) */
|
|
...(isExport ? { output: 'export' } : {}),
|
|
images: { unoptimized: true },
|
|
};
|
|
|
|
export default nextConfig;
|
|
```
|
|
|
|
**Step 2: Commit**
|
|
|
|
```bash
|
|
git add next.config.ts
|
|
git commit -m "fix: make output export build-only so dev route handlers work"
|
|
```
|
|
|
|
---
|
|
|
|
## Task 1: Update SectionAccordion entity — onClick → href (TDD)
|
|
|
|
Replace the inactive `<button onClick>` with `<Link href>`. The entity already has tests — update them first.
|
|
|
|
**Files:**
|
|
- Modify: `src/entities/Section/ui/SectionAccordion/SectionAccordion.test.tsx`
|
|
- Modify: `src/entities/Section/ui/SectionAccordion/SectionAccordion.tsx`
|
|
|
|
**Step 1: Update the tests**
|
|
|
|
Replace `src/entities/Section/ui/SectionAccordion/SectionAccordion.test.tsx` entirely:
|
|
|
|
```tsx
|
|
import { render, screen } from '@testing-library/react';
|
|
import { SectionAccordion } from './SectionAccordion';
|
|
|
|
const defaultProps = {
|
|
number: '01',
|
|
title: 'About',
|
|
id: 'about',
|
|
isActive: false,
|
|
href: '/about',
|
|
children: <p>Content here</p>,
|
|
};
|
|
|
|
describe('SectionAccordion', () => {
|
|
describe('collapsed state (isActive=false)', () => {
|
|
it('renders a section element with the given id', () => {
|
|
const { container } = render(<SectionAccordion {...defaultProps} />);
|
|
expect(container.querySelector('section#about')).toBeInTheDocument();
|
|
});
|
|
|
|
it('renders a link with number and title', () => {
|
|
render(<SectionAccordion {...defaultProps} />);
|
|
expect(screen.getByRole('link', { name: /01.*About/i })).toBeInTheDocument();
|
|
});
|
|
|
|
it('link points to the correct href', () => {
|
|
render(<SectionAccordion {...defaultProps} />);
|
|
expect(screen.getByRole('link', { name: /01.*About/i })).toHaveAttribute('href', '/about');
|
|
});
|
|
|
|
it('does not render children', () => {
|
|
render(<SectionAccordion {...defaultProps} />);
|
|
expect(screen.queryByText('Content here')).not.toBeInTheDocument();
|
|
});
|
|
|
|
it('does not render a button', () => {
|
|
render(<SectionAccordion {...defaultProps} />);
|
|
expect(screen.queryByRole('button')).not.toBeInTheDocument();
|
|
});
|
|
});
|
|
|
|
describe('active state (isActive=true)', () => {
|
|
const activeProps = { ...defaultProps, isActive: true };
|
|
|
|
it('renders an h1 with number and title', () => {
|
|
render(<SectionAccordion {...activeProps} />);
|
|
expect(screen.getByRole('heading', { level: 1, name: /01.*About/i })).toBeInTheDocument();
|
|
});
|
|
|
|
it('renders children', () => {
|
|
render(<SectionAccordion {...activeProps} />);
|
|
expect(screen.getByText('Content here')).toBeInTheDocument();
|
|
});
|
|
|
|
it('does not render a link', () => {
|
|
render(<SectionAccordion {...activeProps} />);
|
|
expect(screen.queryByRole('link')).not.toBeInTheDocument();
|
|
});
|
|
|
|
it('content wrapper has animate-fadeIn class', () => {
|
|
const { container } = render(<SectionAccordion {...activeProps} />);
|
|
expect(container.querySelector('.animate-fadeIn')).toBeInTheDocument();
|
|
});
|
|
});
|
|
});
|
|
```
|
|
|
|
**Step 2: Run tests — verify they fail**
|
|
|
|
```bash
|
|
yarn test src/entities/Section/ui/SectionAccordion --run
|
|
```
|
|
|
|
Expected: FAIL — tests expecting `link` role but component still renders `button`.
|
|
|
|
**Step 3: Update the component**
|
|
|
|
Replace `src/entities/Section/ui/SectionAccordion/SectionAccordion.tsx`:
|
|
|
|
```tsx
|
|
import Link from 'next/link';
|
|
import type { ReactNode } from 'react';
|
|
|
|
interface SectionAccordionProps {
|
|
/**
|
|
* Display number prefix (e.g. "01")
|
|
*/
|
|
number: string;
|
|
/**
|
|
* Section title
|
|
*/
|
|
title: string;
|
|
/**
|
|
* HTML id for anchor navigation
|
|
*/
|
|
id: string;
|
|
/**
|
|
* Whether this section is expanded
|
|
*/
|
|
isActive: boolean;
|
|
/**
|
|
* Navigation URL for the collapsed heading link
|
|
*/
|
|
href: string;
|
|
/**
|
|
* Section content, shown when active
|
|
*/
|
|
children: ReactNode;
|
|
}
|
|
|
|
/**
|
|
* Accordion-style section that collapses to a navigation link when inactive.
|
|
*/
|
|
export function SectionAccordion({ number, title, id, isActive, href, children }: SectionAccordionProps) {
|
|
return (
|
|
<section id={id} className="scroll-mt-8">
|
|
{isActive ? (
|
|
<div className="mb-12">
|
|
<div className="mb-16">
|
|
<h1
|
|
className="font-heading font-black text-5xl leading-[1.2] mb-0"
|
|
style={{ fontVariationSettings: "'WONK' 1, 'SOFT' 0" }}
|
|
>
|
|
{number}. {title}
|
|
</h1>
|
|
</div>
|
|
<div className="animate-fadeIn">{children}</div>
|
|
</div>
|
|
) : (
|
|
<Link
|
|
href={href}
|
|
className="block w-full text-left mb-3 py-3 transition-all duration-200 hover:opacity-60 group"
|
|
>
|
|
<h2
|
|
className="font-heading font-black text-2xl sm:text-3xl opacity-30 group-hover:opacity-50 transition-opacity"
|
|
style={{ fontVariationSettings: "'WONK' 1, 'SOFT' 0" }}
|
|
>
|
|
{number}. {title}
|
|
</h2>
|
|
</Link>
|
|
)}
|
|
</section>
|
|
);
|
|
}
|
|
```
|
|
|
|
**Step 4: Run tests — verify they pass**
|
|
|
|
```bash
|
|
yarn test src/entities/Section/ui/SectionAccordion --run
|
|
```
|
|
|
|
Expected: all PASS.
|
|
|
|
**Step 5: Commit**
|
|
|
|
```bash
|
|
git add src/entities/Section/ui/SectionAccordion/
|
|
git commit -m "feat: SectionAccordion inactive state uses Link href instead of button onClick"
|
|
```
|
|
|
|
---
|
|
|
|
## Task 2: Update SectionsAccordion widget — drop client state, add activeSlug prop (TDD)
|
|
|
|
The widget becomes a server component. `activeSlug` is passed as a prop from the page. `children` is a single RSC slot for the active section content only.
|
|
|
|
**Files:**
|
|
- Modify: `src/widgets/SectionsAccordion/ui/SectionsAccordion/SectionsAccordion.test.tsx`
|
|
- Modify: `src/widgets/SectionsAccordion/ui/SectionsAccordion/SectionsAccordion.tsx`
|
|
|
|
**Step 1: Rewrite the tests**
|
|
|
|
Replace `src/widgets/SectionsAccordion/ui/SectionsAccordion/SectionsAccordion.test.tsx`:
|
|
|
|
```tsx
|
|
import { render, screen } from '@testing-library/react';
|
|
import type { SectionRecord } from '$entities/Section';
|
|
import { SectionsAccordion } from './SectionsAccordion';
|
|
|
|
const baseRecord = { collectionId: 'c1', collectionName: 'sections', created: '', updated: '' };
|
|
|
|
const sections: SectionRecord[] = [
|
|
{ ...baseRecord, id: '1', slug: 'intro', title: 'Intro', number: '01', order: 1 },
|
|
{ ...baseRecord, id: '2', slug: 'bio', title: 'Bio', number: '02', order: 2 },
|
|
{ ...baseRecord, id: '3', slug: 'skills', title: 'Skills', number: '03', order: 3 },
|
|
];
|
|
|
|
describe('SectionsAccordion', () => {
|
|
describe('active section rendering', () => {
|
|
it('renders the active section as h1', () => {
|
|
render(
|
|
<SectionsAccordion sections={sections} activeSlug="bio">
|
|
<div>Bio content</div>
|
|
</SectionsAccordion>,
|
|
);
|
|
expect(screen.getByRole('heading', { level: 1 })).toHaveTextContent('02. Bio');
|
|
});
|
|
|
|
it('renders active section children', () => {
|
|
render(
|
|
<SectionsAccordion sections={sections} activeSlug="bio">
|
|
<div>Bio content</div>
|
|
</SectionsAccordion>,
|
|
);
|
|
expect(screen.getByText('Bio content')).toBeInTheDocument();
|
|
});
|
|
});
|
|
|
|
describe('inactive section rendering', () => {
|
|
it('renders inactive sections as links', () => {
|
|
render(
|
|
<SectionsAccordion sections={sections} activeSlug="bio">
|
|
<div>Bio content</div>
|
|
</SectionsAccordion>,
|
|
);
|
|
const links = screen.getAllByRole('link');
|
|
expect(links).toHaveLength(2);
|
|
});
|
|
|
|
it('inactive links point to correct hrefs', () => {
|
|
render(
|
|
<SectionsAccordion sections={sections} activeSlug="bio">
|
|
<div>Bio content</div>
|
|
</SectionsAccordion>,
|
|
);
|
|
expect(screen.getByRole('link', { name: /01.*Intro/i })).toHaveAttribute('href', '/intro');
|
|
expect(screen.getByRole('link', { name: /03.*Skills/i })).toHaveAttribute('href', '/skills');
|
|
});
|
|
|
|
it('does not render children for inactive sections', () => {
|
|
render(
|
|
<SectionsAccordion sections={sections} activeSlug="bio">
|
|
<div>Bio content</div>
|
|
</SectionsAccordion>,
|
|
);
|
|
expect(screen.getAllByText('Bio content')).toHaveLength(1);
|
|
});
|
|
});
|
|
|
|
describe('first section default', () => {
|
|
it('shows first section as active when activeSlug matches first', () => {
|
|
render(
|
|
<SectionsAccordion sections={sections} activeSlug="intro">
|
|
<div>Intro content</div>
|
|
</SectionsAccordion>,
|
|
);
|
|
expect(screen.getByRole('heading', { level: 1 })).toHaveTextContent('01. Intro');
|
|
});
|
|
});
|
|
});
|
|
```
|
|
|
|
**Step 2: Run tests — verify they fail**
|
|
|
|
```bash
|
|
yarn test src/widgets/SectionsAccordion --run
|
|
```
|
|
|
|
Expected: FAIL — component still uses `useState` and doesn't accept `activeSlug` prop.
|
|
|
|
**Step 3: Rewrite the component**
|
|
|
|
Replace `src/widgets/SectionsAccordion/ui/SectionsAccordion/SectionsAccordion.tsx`:
|
|
|
|
```tsx
|
|
import type { ReactNode } from 'react';
|
|
import type { SectionRecord } from '$entities/Section';
|
|
import { SectionAccordion } from '$entities/Section';
|
|
|
|
type Props = {
|
|
/**
|
|
* Ordered section metadata — drives navigation labels and IDs
|
|
*/
|
|
sections: SectionRecord[];
|
|
/**
|
|
* Slug of the currently active section
|
|
*/
|
|
activeSlug: string;
|
|
/**
|
|
* Content for the active section — rendered inside the expanded accordion item
|
|
*/
|
|
children: ReactNode;
|
|
};
|
|
|
|
/**
|
|
* Renders all portfolio sections as an accordion list.
|
|
* Active section is determined by URL (activeSlug from page params).
|
|
* Inactive sections render as navigation links.
|
|
*/
|
|
export function SectionsAccordion({ sections, activeSlug, children }: Props) {
|
|
return (
|
|
<div className="space-y-2">
|
|
{sections.map((section) => (
|
|
<SectionAccordion
|
|
key={section.slug}
|
|
id={section.slug}
|
|
number={section.number}
|
|
title={section.title}
|
|
isActive={activeSlug === section.slug}
|
|
href={`/${section.slug}`}
|
|
>
|
|
{activeSlug === section.slug ? children : null}
|
|
</SectionAccordion>
|
|
))}
|
|
</div>
|
|
);
|
|
}
|
|
```
|
|
|
|
**Step 4: Run tests — verify they pass**
|
|
|
|
```bash
|
|
yarn test src/widgets/SectionsAccordion --run
|
|
```
|
|
|
|
Expected: all PASS.
|
|
|
|
**Step 5: Commit**
|
|
|
|
```bash
|
|
git add src/widgets/SectionsAccordion/
|
|
git commit -m "refactor: SectionsAccordion server component, activeSlug prop replaces useState"
|
|
```
|
|
|
|
---
|
|
|
|
## Task 3: Update SidebarNav — IntersectionObserver → usePathname (TDD)
|
|
|
|
Items become `<Link>` elements. Active state driven by `usePathname()`. The first section is also active at `/`.
|
|
|
|
**Files:**
|
|
- Modify: `src/widgets/Navigation/ui/SidebarNav.test.tsx`
|
|
- Modify: `src/widgets/Navigation/ui/SidebarNav.tsx`
|
|
|
|
**Step 1: Rewrite the tests**
|
|
|
|
Replace `src/widgets/Navigation/ui/SidebarNav.test.tsx`:
|
|
|
|
```tsx
|
|
import { render, screen } from '@testing-library/react';
|
|
import type { NavItem } from '../model/types';
|
|
import { SidebarNav } from './SidebarNav';
|
|
|
|
vi.mock('next/navigation', () => ({
|
|
usePathname: vi.fn(),
|
|
}));
|
|
|
|
import { usePathname } from 'next/navigation';
|
|
|
|
const ITEMS: NavItem[] = [
|
|
{ id: 'bio', label: 'Bio', number: '01' },
|
|
{ id: 'work', label: 'Work', number: '02' },
|
|
];
|
|
|
|
describe('SidebarNav', () => {
|
|
describe('rendering', () => {
|
|
beforeEach(() => {
|
|
vi.mocked(usePathname).mockReturnValue('/bio');
|
|
});
|
|
|
|
it('renders a nav element', () => {
|
|
render(<SidebarNav items={ITEMS} />);
|
|
expect(screen.getByRole('navigation')).toBeInTheDocument();
|
|
});
|
|
|
|
it('renders "Index" heading', () => {
|
|
render(<SidebarNav items={ITEMS} />);
|
|
expect(screen.getByText('Index')).toBeInTheDocument();
|
|
});
|
|
|
|
it('renders "Digital Monograph" subtitle', () => {
|
|
render(<SidebarNav items={ITEMS} />);
|
|
expect(screen.getByText('Digital Monograph')).toBeInTheDocument();
|
|
});
|
|
|
|
it('renders each item label and number', () => {
|
|
render(<SidebarNav items={ITEMS} />);
|
|
expect(screen.getByText('Bio')).toBeInTheDocument();
|
|
expect(screen.getByText('01')).toBeInTheDocument();
|
|
expect(screen.getByText('Work')).toBeInTheDocument();
|
|
expect(screen.getByText('02')).toBeInTheDocument();
|
|
});
|
|
|
|
it('renders "Quick Links" section', () => {
|
|
render(<SidebarNav items={ITEMS} />);
|
|
expect(screen.getByText('Quick Links')).toBeInTheDocument();
|
|
});
|
|
|
|
it('renders Email quick link', () => {
|
|
render(<SidebarNav items={ITEMS} />);
|
|
expect(screen.getByRole('link', { name: 'Email' })).toBeInTheDocument();
|
|
});
|
|
|
|
it('renders a link for each item', () => {
|
|
render(<SidebarNav items={ITEMS} />);
|
|
expect(screen.getByRole('link', { name: /Bio/i })).toBeInTheDocument();
|
|
expect(screen.getByRole('link', { name: /Work/i })).toBeInTheDocument();
|
|
});
|
|
});
|
|
|
|
describe('active state', () => {
|
|
it('marks matching pathname item as active', () => {
|
|
vi.mocked(usePathname).mockReturnValue('/bio');
|
|
render(<SidebarNav items={ITEMS} />);
|
|
const activeLink = screen.getByRole('link', { name: /Bio/i });
|
|
expect(activeLink).not.toHaveClass('opacity-40');
|
|
});
|
|
|
|
it('marks non-matching item as inactive', () => {
|
|
vi.mocked(usePathname).mockReturnValue('/bio');
|
|
render(<SidebarNav items={ITEMS} />);
|
|
const inactiveLink = screen.getByRole('link', { name: /Work/i });
|
|
expect(inactiveLink).toHaveClass('opacity-40');
|
|
});
|
|
|
|
it('marks first item active at root path', () => {
|
|
vi.mocked(usePathname).mockReturnValue('/');
|
|
render(<SidebarNav items={ITEMS} />);
|
|
const firstLink = screen.getByRole('link', { name: /Bio/i });
|
|
expect(firstLink).not.toHaveClass('opacity-40');
|
|
});
|
|
});
|
|
});
|
|
```
|
|
|
|
**Step 2: Run tests — verify they fail**
|
|
|
|
```bash
|
|
yarn test src/widgets/Navigation/ui/SidebarNav.test.tsx --run
|
|
```
|
|
|
|
Expected: FAIL — component still uses `IntersectionObserver` and renders buttons, not links.
|
|
|
|
**Step 3: Rewrite the component**
|
|
|
|
Replace `src/widgets/Navigation/ui/SidebarNav.tsx`:
|
|
|
|
```tsx
|
|
'use client';
|
|
|
|
import Link from 'next/link';
|
|
import { usePathname } from 'next/navigation';
|
|
import { CONTACT_LINKS, cn } from '$shared/lib';
|
|
import type { NavItem } from '../model/types';
|
|
|
|
interface Props {
|
|
/**
|
|
* Navigation items to render
|
|
*/
|
|
items: NavItem[];
|
|
}
|
|
|
|
/**
|
|
* Fixed sidebar navigation, visible on lg+ screens.
|
|
* Active section determined by current URL pathname.
|
|
*/
|
|
export function SidebarNav({ items }: Props) {
|
|
const pathname = usePathname();
|
|
|
|
/**
|
|
* An item is active when its slug matches the current pathname,
|
|
* or when the pathname is root and it is the first item.
|
|
*/
|
|
function isActive(item: NavItem): boolean {
|
|
if (pathname === `/${item.id}`) return true;
|
|
if (pathname === '/' && items[0]?.id === item.id) return true;
|
|
return false;
|
|
}
|
|
|
|
return (
|
|
<nav className="fixed top-0 left-0 h-screen w-full lg:w-1/3 bg-ochre-clay brutal-border-right hidden lg:block overflow-y-auto z-50">
|
|
<div className="px-8 py-12 space-y-2">
|
|
<div className="mb-12">
|
|
<h2>Index</h2>
|
|
<div className="brutal-border-top pt-4">
|
|
<p className="text-sm opacity-60">Digital Monograph</p>
|
|
</div>
|
|
</div>
|
|
|
|
{items.map((item) => (
|
|
<Link
|
|
key={item.id}
|
|
href={`/${item.id}`}
|
|
className={cn(
|
|
'block w-full text-left brutal-border bg-ochre-clay px-6 py-4 transition-all duration-300',
|
|
isActive(item)
|
|
? 'shadow-[12px_12px_0_var(--carbon-black)] opacity-100 translate-x-0'
|
|
: 'opacity-40 shadow-none hover:opacity-60',
|
|
)}
|
|
>
|
|
<div className="flex items-baseline gap-4">
|
|
<span className="text-sm opacity-60">{item.number}</span>
|
|
<span className="font-heading text-xl font-black">{item.label}</span>
|
|
</div>
|
|
</Link>
|
|
))}
|
|
|
|
<div className="mt-12 pt-12 brutal-border-top">
|
|
<p className="text-sm uppercase tracking-wider mb-4 opacity-60">Quick Links</p>
|
|
<div className="space-y-3">
|
|
<a href={`mailto:${CONTACT_LINKS.email}`} className="block">
|
|
Email
|
|
</a>
|
|
<a href={CONTACT_LINKS.linkedin} className="block">
|
|
LinkedIn
|
|
</a>
|
|
<a href={CONTACT_LINKS.instagram} className="block">
|
|
Instagram
|
|
</a>
|
|
<a href={CONTACT_LINKS.arena} className="block">
|
|
Are.na
|
|
</a>
|
|
</div>
|
|
</div>
|
|
</div>
|
|
</nav>
|
|
);
|
|
}
|
|
```
|
|
|
|
**Step 4: Run tests — verify they pass**
|
|
|
|
```bash
|
|
yarn test src/widgets/Navigation/ui/SidebarNav.test.tsx --run
|
|
```
|
|
|
|
Expected: all PASS.
|
|
|
|
**Step 5: Commit**
|
|
|
|
```bash
|
|
git add src/widgets/Navigation/ui/SidebarNav.tsx src/widgets/Navigation/ui/SidebarNav.test.tsx
|
|
git commit -m "refactor: SidebarNav uses usePathname and Link instead of IntersectionObserver"
|
|
```
|
|
|
|
---
|
|
|
|
## Task 4: Update MobileNav — section buttons → Link (TDD)
|
|
|
|
Section items become `<Link>` elements that close the menu `onClick`. The `scrollToSection` function is removed.
|
|
|
|
**Files:**
|
|
- Modify: `src/widgets/Navigation/ui/MobileNav.test.tsx`
|
|
- Modify: `src/widgets/Navigation/ui/MobileNav.tsx`
|
|
|
|
**Step 1: Update the tests**
|
|
|
|
Replace `src/widgets/Navigation/ui/MobileNav.test.tsx`:
|
|
|
|
```tsx
|
|
import { render, screen } from '@testing-library/react';
|
|
import userEvent from '@testing-library/user-event';
|
|
import type { NavItem } from '../model/types';
|
|
import { MobileNav } from './MobileNav';
|
|
|
|
const ITEMS: NavItem[] = [{ id: 'about', label: 'About', number: '01' }];
|
|
|
|
describe('MobileNav', () => {
|
|
describe('rendering', () => {
|
|
it('renders title "allmy.work"', () => {
|
|
render(<MobileNav items={ITEMS} />);
|
|
expect(screen.getByText('allmy.work')).toBeInTheDocument();
|
|
});
|
|
|
|
it('renders toggle button with text "Menu" initially', () => {
|
|
render(<MobileNav items={ITEMS} />);
|
|
expect(screen.getByRole('button', { name: 'Menu' })).toBeInTheDocument();
|
|
});
|
|
|
|
it('menu items are hidden initially', () => {
|
|
render(<MobileNav items={ITEMS} />);
|
|
expect(screen.queryByRole('link', { name: /About/i })).not.toBeInTheDocument();
|
|
});
|
|
});
|
|
|
|
describe('interactions', () => {
|
|
it('click toggle shows item links and changes label to "Close"', async () => {
|
|
render(<MobileNav items={ITEMS} />);
|
|
await userEvent.click(screen.getByRole('button', { name: 'Menu' }));
|
|
expect(screen.getByRole('button', { name: 'Close' })).toBeInTheDocument();
|
|
expect(screen.getByRole('link', { name: /About/i })).toBeInTheDocument();
|
|
});
|
|
|
|
it('item links point to correct href', async () => {
|
|
render(<MobileNav items={ITEMS} />);
|
|
await userEvent.click(screen.getByRole('button', { name: 'Menu' }));
|
|
expect(screen.getByRole('link', { name: /About/i })).toHaveAttribute('href', '/about');
|
|
});
|
|
|
|
it('click item link closes the menu', async () => {
|
|
render(<MobileNav items={ITEMS} />);
|
|
await userEvent.click(screen.getByRole('button', { name: 'Menu' }));
|
|
await userEvent.click(screen.getByRole('link', { name: /About/i }));
|
|
expect(screen.queryByText('Close')).not.toBeInTheDocument();
|
|
expect(screen.getByRole('button', { name: 'Menu' })).toBeInTheDocument();
|
|
});
|
|
});
|
|
});
|
|
```
|
|
|
|
**Step 2: Run tests — verify they fail**
|
|
|
|
```bash
|
|
yarn test src/widgets/Navigation/ui/MobileNav.test.tsx --run
|
|
```
|
|
|
|
Expected: FAIL — component still uses `button` for items, not `link`.
|
|
|
|
**Step 3: Update the component**
|
|
|
|
Replace `src/widgets/Navigation/ui/MobileNav.tsx`:
|
|
|
|
```tsx
|
|
'use client';
|
|
|
|
import Link from 'next/link';
|
|
import { useState } from 'react';
|
|
import { cn } from '$shared/lib';
|
|
import type { NavItem } from '../model/types';
|
|
|
|
interface Props {
|
|
/**
|
|
* Navigation items to render
|
|
*/
|
|
items: NavItem[];
|
|
}
|
|
|
|
/**
|
|
* Mobile navigation overlay, hidden on lg+ screens.
|
|
* Section items are links that close the menu on navigate.
|
|
*/
|
|
export function MobileNav({ items }: Props) {
|
|
const [isOpen, setIsOpen] = useState(false);
|
|
|
|
return (
|
|
<div className="lg:hidden fixed top-0 left-0 right-0 bg-ochre-clay brutal-border-bottom z-50">
|
|
<div className="px-6 py-4 flex items-center justify-between">
|
|
<h4>allmy.work</h4>
|
|
<button
|
|
type="button"
|
|
onClick={() => setIsOpen((prev) => !prev)}
|
|
className="brutal-border px-4 py-2 bg-carbon-black text-ochre-clay"
|
|
>
|
|
{isOpen ? 'Close' : 'Menu'}
|
|
</button>
|
|
</div>
|
|
{isOpen && (
|
|
<div className="px-6 py-6 brutal-border-top space-y-2 max-h-[80vh] overflow-y-auto">
|
|
{items.map((item) => (
|
|
<Link
|
|
key={item.id}
|
|
href={`/${item.id}`}
|
|
onClick={() => setIsOpen(false)}
|
|
className="block w-full text-left brutal-border bg-ochre-clay px-4 py-3"
|
|
>
|
|
<div className={cn('flex items-baseline gap-3')}>
|
|
<span className="text-sm opacity-60 font-body">{item.number}</span>
|
|
<span
|
|
className="font-heading text-lg font-black"
|
|
style={{ fontVariationSettings: '"WONK" 1, "SOFT" 0' }}
|
|
>
|
|
{item.label}
|
|
</span>
|
|
</div>
|
|
</Link>
|
|
))}
|
|
</div>
|
|
)}
|
|
</div>
|
|
);
|
|
}
|
|
```
|
|
|
|
**Step 4: Run tests — verify they pass**
|
|
|
|
```bash
|
|
yarn test src/widgets/Navigation/ui/MobileNav.test.tsx --run
|
|
```
|
|
|
|
Expected: all PASS.
|
|
|
|
**Step 5: Commit**
|
|
|
|
```bash
|
|
git add src/widgets/Navigation/ui/MobileNav.tsx src/widgets/Navigation/ui/MobileNav.test.tsx
|
|
git commit -m "refactor: MobileNav section items use Link instead of scrollToSection"
|
|
```
|
|
|
|
---
|
|
|
|
## Task 5: Create [[...slug]]/page.tsx and delete app/page.tsx
|
|
|
|
Wire everything together with `generateStaticParams` for SSG.
|
|
|
|
**Files:**
|
|
- Create: `app/[[...slug]]/page.tsx`
|
|
- Delete: `app/page.tsx`
|
|
|
|
**Step 1: Create the route file**
|
|
|
|
Create `app/[[...slug]]/page.tsx`:
|
|
|
|
```tsx
|
|
import { notFound } from 'next/navigation';
|
|
import type { SectionRecord } from '$entities/Section';
|
|
import { getCollection } from '$shared/api';
|
|
import type { NavItem } from '$widgets/Navigation';
|
|
import { MobileNav, SidebarNav } from '$widgets/Navigation';
|
|
import { SectionFactory } from '$widgets/SectionFactory';
|
|
import { SectionsAccordion } from '$widgets/SectionsAccordion';
|
|
|
|
/**
|
|
* Generates static params for all section pages plus the root.
|
|
*/
|
|
export async function generateStaticParams() {
|
|
const { items: sections } = await getCollection<SectionRecord>('sections', {
|
|
sort: 'order',
|
|
});
|
|
return [
|
|
{},
|
|
...sections.map((s) => ({ slug: [s.slug] })),
|
|
];
|
|
}
|
|
|
|
/**
|
|
* Portfolio page — handles all section routes via optional catchall.
|
|
*
|
|
* `/` → first section shown as active
|
|
* `/{slug}` → that section shown as active
|
|
*/
|
|
export default async function SectionPage({
|
|
params,
|
|
}: {
|
|
params: Promise<{ slug?: string[] }>;
|
|
}) {
|
|
const { slug } = await params;
|
|
|
|
const { items: sections } = await getCollection<SectionRecord>('sections', {
|
|
sort: 'order',
|
|
});
|
|
|
|
if (!sections.length) notFound();
|
|
|
|
const activeSlug = slug?.[0] ?? sections[0].slug;
|
|
|
|
if (!sections.find((s) => s.slug === activeSlug)) notFound();
|
|
|
|
const navItems: NavItem[] = sections.map((s) => ({
|
|
id: s.slug,
|
|
label: s.title,
|
|
number: s.number,
|
|
}));
|
|
|
|
return (
|
|
<div className="min-h-screen lg:flex">
|
|
<SidebarNav items={navItems} />
|
|
<main className="flex-1 lg:ml-[33.333%] px-8 py-12 lg:py-16 lg:px-16">
|
|
<MobileNav items={navItems} />
|
|
<SectionsAccordion sections={sections} activeSlug={activeSlug}>
|
|
<SectionFactory slug={activeSlug} />
|
|
</SectionsAccordion>
|
|
</main>
|
|
</div>
|
|
);
|
|
}
|
|
```
|
|
|
|
**Step 2: Delete app/page.tsx**
|
|
|
|
```bash
|
|
rm app/page.tsx
|
|
```
|
|
|
|
**Step 3: TypeScript check**
|
|
|
|
```bash
|
|
yarn tsc --noEmit
|
|
```
|
|
|
|
Expected: no errors.
|
|
|
|
**Step 4: Commit**
|
|
|
|
```bash
|
|
git add app/
|
|
git commit -m "feat: URL-driven section routing via optional catchall with generateStaticParams"
|
|
```
|
|
|
|
---
|
|
|
|
## Task 6: Final Verification
|
|
|
|
**Step 1: Full test suite**
|
|
|
|
```bash
|
|
yarn test --run
|
|
```
|
|
|
|
Expected: all tests PASS.
|
|
|
|
**Step 2: TypeScript check**
|
|
|
|
```bash
|
|
yarn tsc --noEmit
|
|
```
|
|
|
|
Expected: no errors.
|
|
|
|
**Step 3: Lint check**
|
|
|
|
```bash
|
|
yarn check
|
|
```
|
|
|
|
Expected: no errors. If auto-fixable issues: `yarn check:fix` then re-run.
|
|
|
|
**Step 4: Dev server smoke test**
|
|
|
|
```bash
|
|
yarn dev
|
|
```
|
|
|
|
Open `http://localhost:3000` — should show first section (intro) as active, others as links. Clicking a section link should change the URL and show that section's content.
|