diff --git a/frontend/src/app/about/page.tsx b/frontend/src/app/about/page.tsx index c9da336..4935cbc 100644 --- a/frontend/src/app/about/page.tsx +++ b/frontend/src/app/about/page.tsx @@ -15,6 +15,7 @@ import { CheckCircleIcon, } from '@heroicons/react/24/outline'; import Link from 'next/link'; +import { ROUTES } from '@/lib/routes'; const AboutPage = () => { const [expandedSections, setExpandedSections] = useState>(new Set()); @@ -475,14 +476,14 @@ const AboutPage = () => {
Formular-Builder testen diff --git a/frontend/src/app/blog/[slug]/page.tsx b/frontend/src/app/blog/[slug]/page.tsx index 27e77e0..4d300bb 100644 --- a/frontend/src/app/blog/[slug]/page.tsx +++ b/frontend/src/app/blog/[slug]/page.tsx @@ -7,6 +7,7 @@ import { notFound } from 'next/navigation'; import { MDXContentClient } from './MDXContentClient'; import Image from 'next/image'; import Link from 'next/link'; +import { ROUTES } from '@/lib/routes'; export const generateStaticParams = async () => allPosts.map((p) => ({ slug: p.slug })); @@ -45,7 +46,7 @@ export default async function BlogPostPage({ params }: PageProps) {

Teste den Universal Form Builder selbst

Zum Builder → diff --git a/frontend/src/app/components/FormCaptureLanding.tsx b/frontend/src/app/components/FormCaptureLanding.tsx index 8f6616e..62016b9 100644 --- a/frontend/src/app/components/FormCaptureLanding.tsx +++ b/frontend/src/app/components/FormCaptureLanding.tsx @@ -12,6 +12,7 @@ import { ServerIcon, } from '@heroicons/react/24/outline'; import Link from 'next/link'; +import { ROUTES } from '@/lib/routes'; interface FormCaptureLandingProps { onStartBuilding: () => void; @@ -410,7 +411,7 @@ export function FormCaptureLanding({ onStartBuilding }: FormCaptureLandingProps) Formular erstellen Produkt-Erfassung erkunden → diff --git a/frontend/src/app/components/GlobalNavigation.tsx b/frontend/src/app/components/GlobalNavigation.tsx index 2b0ab87..0364ea1 100644 --- a/frontend/src/app/components/GlobalNavigation.tsx +++ b/frontend/src/app/components/GlobalNavigation.tsx @@ -6,11 +6,12 @@ import { usePathname, useRouter } from 'next/navigation'; import { TopNavigation } from './TopNavigation'; +import { ROUTES } from '@/lib/routes'; function mapPathToView(path: string): 'builder' | 'templates' | 'saved-forms' | 'about' { - if (path.startsWith('/templates')) return 'templates'; - if (path.startsWith('/forms')) return 'saved-forms'; - if (path.startsWith('/about')) return 'about'; + if (path.startsWith(ROUTES.templates)) return 'templates'; + if (path.startsWith(ROUTES.forms)) return 'saved-forms'; + if (path.startsWith(ROUTES.about)) return 'about'; return 'builder'; } @@ -22,7 +23,7 @@ export default function GlobalNavigation() { { - const target = view === 'saved-forms' ? '/forms' : `/${view}`; + const target = view === 'saved-forms' ? ROUTES.forms : `/${view}`; router.push(target); }} /> diff --git a/frontend/src/app/components/LoginForm.tsx b/frontend/src/app/components/LoginForm.tsx index 2d2b4ca..b1c48be 100644 --- a/frontend/src/app/components/LoginForm.tsx +++ b/frontend/src/app/components/LoginForm.tsx @@ -4,6 +4,7 @@ import React, { useState } from 'react'; import Link from 'next/link'; import { useAuth } from '../context/AuthContext'; import { http, ApiSuccess } from '../services/http'; +import { ROUTES } from '@/lib/routes'; interface LoginFormProps { onSuccess?: () => void; // Optional callback for successful login @@ -90,7 +91,10 @@ export function LoginForm({ onSuccess, showRegisterLink = true, className = '' } {showRegisterLink && (

Noch kein Konto?{' '} - + Registrieren

diff --git a/frontend/src/app/components/TopNavigation.tsx b/frontend/src/app/components/TopNavigation.tsx index 36038df..5f6628a 100644 --- a/frontend/src/app/components/TopNavigation.tsx +++ b/frontend/src/app/components/TopNavigation.tsx @@ -8,6 +8,7 @@ import Link from 'next/link'; import Image from 'next/image'; import { useAuth } from '../context/AuthContext'; import { useBrandName } from '../../hooks/useBranding'; +import { ROUTES } from '@/lib/routes'; // Define types for navigation items type NavItem = { @@ -96,11 +97,11 @@ interface TopNavigationProps { } const navigation = [ - { name: 'Builder', href: '/builder' }, - { name: 'Forms', href: '/forms' }, - { name: 'Templates', href: '/templates' }, - { name: 'About', href: '/about' }, - { name: 'Blog', href: '/blog' }, + { name: 'Builder', href: ROUTES.builder }, + { name: 'Forms', href: ROUTES.forms }, + { name: 'Templates', href: ROUTES.templates }, + { name: 'About', href: ROUTES.about }, + { name: 'Blog', href: ROUTES.blog }, ]; export function TopNavigation({ currentView, onViewChange = () => {} }: TopNavigationProps) { @@ -117,13 +118,13 @@ export function TopNavigation({ currentView, onViewChange = () => {} }: TopNavig const primaryLinks: NavItem[] = [ { name: 'Form-Tool', - href: '/builder', + href: ROUTES.builder, description: 'Builder, Bibliothek & Gespeicherte unter einem Dach.', icon: DocumentChartBarIcon, }, { name: 'Foto-Inventar', - href: '/erfassung', + href: ROUTES.erfassung, description: 'Inventar-Erfassung per Fotoscan mit KI.', icon: CameraIcon, }, @@ -133,19 +134,19 @@ export function TopNavigation({ currentView, onViewChange = () => {} }: TopNavig const formSubmenuItems: NavItem[] = [ { name: 'Form Builder', - href: '/builder', + href: ROUTES.builder, description: 'Erstelle neue Formulare mit unserem Builder.', icon: DocumentChartBarIcon, }, { name: 'Template Library', - href: '/templates', + href: ROUTES.templates, description: 'Durchsuche vorgefertigte Form-Templates.', icon: FolderIcon, }, { name: 'Saved Forms', - href: '/forms', + href: ROUTES.forms, description: 'Verwalte deine gespeicherten Formulare.', icon: DocumentDuplicateIcon, }, @@ -259,9 +260,9 @@ export function TopNavigation({ currentView, onViewChange = () => {} }: TopNavig key={subItem.name} href={subItem.href} onClick={(e) => { - if (subItem.href === '/forms' && !token) { + if (subItem.href === ROUTES.forms && !token) { e.preventDefault(); - window.location.href = '/login'; + window.location.href = ROUTES.login; return; } e.preventDefault(); @@ -324,7 +325,7 @@ export function TopNavigation({ currentView, onViewChange = () => {} }: TopNavig
{ onViewChange('about'); setMegaMenuOpen(false); @@ -356,21 +357,21 @@ export function TopNavigation({ currentView, onViewChange = () => {} }: TopNavig
onViewChange('about')} className="text-base font-medium text-gray-500 hover:text-gray-900" > Über uns onViewChange('about')} className="text-base font-medium text-gray-500 hover:text-gray-900" > FAQ onViewChange('about')} className="text-base font-medium text-gray-500 hover:text-gray-900" > @@ -402,7 +403,7 @@ export function TopNavigation({ currentView, onViewChange = () => {} }: TopNavig {({ active }) => ( Profil @@ -426,13 +427,13 @@ export function TopNavigation({ currentView, onViewChange = () => {} }: TopNavig ) : ( <> Anmelden Registrieren @@ -496,14 +497,17 @@ export function TopNavigation({ currentView, onViewChange = () => {} }: TopNavig ) : (
Registrieren

Bestehender Kunde?{' '} - + Anmelden

diff --git a/frontend/src/app/context/AuthContext.tsx b/frontend/src/app/context/AuthContext.tsx index 50f23a2..55c088a 100644 --- a/frontend/src/app/context/AuthContext.tsx +++ b/frontend/src/app/context/AuthContext.tsx @@ -3,6 +3,7 @@ import React, { createContext, useContext, ReactNode } from 'react'; import { signOut, signIn, useSession } from 'next-auth/react'; import { useRouter } from 'next/navigation'; +import { ROUTES } from '@/lib/routes'; interface User { id: string; @@ -28,7 +29,7 @@ export const AuthProvider = ({ children }: { children: ReactNode }) => { const loginWithCredentials = async (email: string, password: string) => { const res = await signIn('credentials', { redirect: false, email, password }); if (res?.ok) { - router.push('/builder'); + router.push(ROUTES.builder); return true; } return false; @@ -36,7 +37,7 @@ export const AuthProvider = ({ children }: { children: ReactNode }) => { const logout = async () => { await signOut({ redirect: false }); - router.push('/login'); + router.push(ROUTES.login); }; const value: AuthContextType = { diff --git a/frontend/src/app/databases/page.tsx b/frontend/src/app/databases/page.tsx index c14df03..9648ccf 100644 --- a/frontend/src/app/databases/page.tsx +++ b/frontend/src/app/databases/page.tsx @@ -10,6 +10,7 @@ import { ArrowDownTrayIcon, } from '@heroicons/react/24/outline'; import { useAuth } from '../context/AuthContext'; +import { ROUTES } from '@/lib/routes'; interface Database { id: string; @@ -123,7 +124,7 @@ export default function DatabasesPage() {

@@ -328,7 +329,7 @@ export default function DatabasesPage() {

diff --git a/frontend/src/app/erfassung/components/TableOverviewStep.tsx b/frontend/src/app/erfassung/components/TableOverviewStep.tsx index 9cd5251..f71af2e 100644 --- a/frontend/src/app/erfassung/components/TableOverviewStep.tsx +++ b/frontend/src/app/erfassung/components/TableOverviewStep.tsx @@ -3,6 +3,7 @@ import { DocumentChartBarIcon, PlusIcon } from '@heroicons/react/24/outline'; import Link from 'next/link'; import { WorkflowProduct } from './ErfassungWorkflow'; +import { ROUTES } from '@/lib/routes'; interface TableOverviewStepProps { product: WorkflowProduct; @@ -125,7 +126,7 @@ export function TableOverviewStep({ product, onBackToWorkflow }: TableOverviewSt {/* Action Buttons */}
@@ -141,7 +142,7 @@ export function TableOverviewStep({ product, onBackToWorkflow }: TableOverviewSt Zurück zur Übersicht diff --git a/frontend/src/app/erfassung/page.tsx b/frontend/src/app/erfassung/page.tsx index 5569690..c65f732 100644 --- a/frontend/src/app/erfassung/page.tsx +++ b/frontend/src/app/erfassung/page.tsx @@ -2,6 +2,7 @@ import { CameraIcon, DocumentChartBarIcon, ArrowRightIcon } from '@heroicons/react/24/outline'; import Link from 'next/link'; +import { ROUTES } from '@/lib/routes'; export default function ErfassungPage() { return ( @@ -22,14 +23,14 @@ export default function ErfassungPage() {

Produkt scannen @@ -216,7 +217,7 @@ export default function ErfassungPage() { KI-gestützten Katalogisierung.

diff --git a/frontend/src/app/erfassung/table/page.tsx b/frontend/src/app/erfassung/table/page.tsx index d1a7e3a..c14666d 100644 --- a/frontend/src/app/erfassung/table/page.tsx +++ b/frontend/src/app/erfassung/table/page.tsx @@ -11,6 +11,7 @@ import { EllipsisVerticalIcon, } from '@heroicons/react/24/outline'; import Link from 'next/link'; +import { ROUTES } from '@/lib/routes'; // Demo data for the table const demoProducts = [ @@ -150,7 +151,7 @@ export default function TablePage() {
@@ -166,7 +167,7 @@ export default function TablePage() {
@@ -372,7 +373,7 @@ export default function TablePage() {

diff --git a/frontend/src/app/fyi/page.tsx b/frontend/src/app/fyi/page.tsx index 456b7b4..1b45e83 100644 --- a/frontend/src/app/fyi/page.tsx +++ b/frontend/src/app/fyi/page.tsx @@ -1,6 +1,7 @@ 'use client'; import Link from 'next/link'; +import { ROUTES } from '@/lib/routes'; export default function FYIPage() { return ( @@ -60,13 +61,13 @@ export default function FYIPage() {
  • Build a form in the{' '} - + Form Builder
  • Learn how AI defines schemas in{' '} - + About
  • diff --git a/frontend/src/app/page.tsx b/frontend/src/app/page.tsx index 8e95391..2ea40c0 100644 --- a/frontend/src/app/page.tsx +++ b/frontend/src/app/page.tsx @@ -10,6 +10,7 @@ import { CloudArrowUpIcon, } from '@heroicons/react/24/outline'; import Link from 'next/link'; +import { ROUTES } from '@/lib/routes'; export default function Home() { return ( @@ -32,14 +33,14 @@ export default function Home() {
    Formular-Erfassung @@ -133,7 +134,7 @@ export default function Home() {
    Formular erstellen → @@ -169,7 +170,7 @@ export default function Home() {
    Produkt-Scan starten → @@ -205,14 +206,14 @@ export default function Home() {
    Formular-Erfassung starten diff --git a/frontend/src/app/register/page.tsx b/frontend/src/app/register/page.tsx index f0d01a7..f5561ed 100644 --- a/frontend/src/app/register/page.tsx +++ b/frontend/src/app/register/page.tsx @@ -4,6 +4,7 @@ import React, { useState } from 'react'; import Link from 'next/link'; import { useAuth } from '../context/AuthContext'; import { http, ApiSuccess } from '../services/http'; +import { ROUTES } from '@/lib/routes'; const RegisterPage = () => { const [email, setEmail] = useState(''); @@ -80,7 +81,7 @@ const RegisterPage = () => {

    Bereits registriert?{' '} - + Anmelden

    diff --git a/frontend/src/lib/routes.test.ts b/frontend/src/lib/routes.test.ts new file mode 100644 index 0000000..8ba1bac --- /dev/null +++ b/frontend/src/lib/routes.test.ts @@ -0,0 +1,74 @@ +/** + * Internal routes live in exactly one place. + * + * They were string literals spread across the app: `/builder` in ten places + * over six files, `/erfassung` in eight, `/forms` in seven. Renaming a route + * meant finding every copy, and a missed one did not fail to compile — it + * became a dead link only a visitor would discover. + * + * This test is why it stays fixed: a literal creeping back into a page or a + * nav component fails here rather than in production. + */ +import { describe, it, expect } from 'vitest'; +import { readFileSync, readdirSync, statSync } from 'node:fs'; +import { join } from 'node:path'; +import { ROUTES, blogPostPath } from './routes'; + +const SRC = join(__dirname, '..'); + +/** Paths that are allowed to appear as literals, and why. */ +const ALLOWED = new Set([ + // The routes module itself is where they are declared. + join(SRC, 'lib', 'routes.ts'), + join(SRC, 'lib', 'routes.test.ts'), +]); + +function sourceFiles(dir: string): string[] { + const out: string[] = []; + for (const entry of readdirSync(dir)) { + if (entry === 'node_modules' || entry.startsWith('.')) continue; + const full = join(dir, entry); + if (statSync(full).isDirectory()) { + out.push(...sourceFiles(full)); + } else if (/\.(ts|tsx)$/.test(entry) && !ALLOWED.has(full)) { + out.push(full); + } + } + return out; +} + +// Only the routes ROUTES actually declares. A path this module does not own +// (an API endpoint, an external URL) is not this test's business. +const OWNED = Object.values(ROUTES).filter((r) => r !== '/'); + +describe('ROUTES', () => { + it('declares no duplicate path', () => { + const values = Object.values(ROUTES); + expect(new Set(values).size).toBe(values.length); + }); + + it('builds a blog post path from the blog route', () => { + expect(blogPostPath('intake-engine')).toBe(`${ROUTES.blog}/intake-engine`); + }); + + it('is the only place those paths are written down', () => { + const offenders: string[] = []; + for (const file of sourceFiles(SRC)) { + const source = readFileSync(file, 'utf8'); + source.split('\n').forEach((text, i) => { + for (const route of OWNED) { + // Quoted and exactly this route — not a prefix of a longer one, and + // not a substring of an API path like "/api/forms". + const re = new RegExp(`["'\`]${route}["'\`]`); + if (re.test(text)) { + offenders.push(`${file.replace(SRC + '/', '')}:${i + 1} ${text.trim().slice(0, 90)}`); + break; + } + } + }); + } + expect(offenders, `import ROUTES from @/lib/routes instead:\n${offenders.join('\n')}`).toEqual( + [], + ); + }); +}); diff --git a/frontend/src/lib/routes.ts b/frontend/src/lib/routes.ts new file mode 100644 index 0000000..4420a76 --- /dev/null +++ b/frontend/src/lib/routes.ts @@ -0,0 +1,30 @@ +/** + * Every internal route, written down once. + * + * These were string literals spread across the app — `/builder` in ten places + * over six files, `/erfassung` in eight, `/forms` in seven. Renaming a route + * meant finding every copy, and a missed one did not fail to compile: it became + * a dead link only a visitor would discover. + * + * Adding a route means adding it here. + */ +export const ROUTES = { + home: '/', + builder: '/builder', + forms: '/forms', + templates: '/templates', + erfassung: '/erfassung', + erfassungNew: '/erfassung/new', + erfassungTable: '/erfassung/table', + about: '/about', + aboutFaq: '/about/faq', + blog: '/blog', + profile: '/profile', + login: '/login', + register: '/register', +} as const; + +export type Route = (typeof ROUTES)[keyof typeof ROUTES]; + +/** A blog post by slug — a template literal in a component is a copy too. */ +export const blogPostPath = (slug: string) => `${ROUTES.blog}/${slug}` as const;