diff --git a/apps/api/src/server.ts b/apps/api/src/server.ts index 852c6d3..97dd69e 100644 --- a/apps/api/src/server.ts +++ b/apps/api/src/server.ts @@ -135,17 +135,72 @@ export async function buildServer() { // Must NOT be `false`: the SPA fallback below calls reply.sendFile, which only exists when // @fastify/static decorates the reply. With it disabled, every deep-link/refresh to a // non-/api route (e.g. /dashboard, emailed /reset-password links) 500s in production. + // Only paths WITH a file extension are served by the plugin (hashed assets, favicons, + // sitemap.xml, ...). Page-shaped requests — '/', '/blog/', '/blog//' — fall + // through to the not-found handler below (allowedPath:false → reply.callNotFound()), + // which serves the matching prerendered index.html or the app shell with + // Cache-Control: no-cache. Without this, the plugin's directory-index handling serves + // prerendered HTML itself, stamped with the 1y immutable header above — meant only for + // hashed assets — so browsers/crawlers would pin a year-stale page after every deploy. + index: false, + allowedPath: (pathName) => path.extname(pathName) !== '', }); + // Public SPA route prefixes — keep in sync with apps/web/src/App.tsx routes. + const KNOWN_SPA_PREFIXES = ['/login', '/signup', '/forgot-password', '/reset-password', '/billing/', '/tools', '/blog', '/legal', '/app', '/admin']; + // SPA fallback: any non-/api path returns index.html. index.html itself must not be cached // for a year (unlike the hashed assets) or clients keep a stale app shell after each deploy. + // Known SPA paths get 200; anything else (including missing static files) still gets + // index.html — so the client renders its NotFound page — but with a 404 status so + // crawlers don't index junk URLs as soft-200s. app.setNotFoundHandler((req, reply) => { if (req.raw.url?.startsWith('/api/')) { return reply.code(404).send({ error: 'not_found' }); } + const pathname = (req.raw.url ?? '').split('?')[0] ?? ''; + + // Prerendered pages: an extensionless path like /blog/some-post misses @fastify/static + // (no trailing slash → no directory index lookup) and lands here. If the build produced + // dist/blog/some-post/index.html, serve THAT file — crawlers must get the route-specific + // head tags, not the root app shell. Decode + resolve and require the result to stay + // inside webDist so encoded traversal (/..%2f..) can never escape the dist root. + let decodedPath: string | null = null; + try { + decodedPath = decodeURIComponent(pathname); + } catch { + decodedPath = null; // malformed percent-encoding → fall through to the SPA fallback + } + if (decodedPath && !decodedPath.includes('\0')) { + const relDir = decodedPath.replace(/^\/+/, '').replace(/\/+$/, ''); + const distRoot = path.resolve(webDist); + const candidate = path.resolve(distRoot, relDir, 'index.html'); + if ( + relDir.length > 0 && + candidate.startsWith(distRoot + path.sep) && + fs.existsSync(candidate) + ) { + return reply + .code(200) + .header('Cache-Control', 'no-cache') + .type('text/html') + .sendFile(path.relative(distRoot, candidate).split(path.sep).join('/'), webDist, { + cacheControl: false, + }); + } + } + + const isKnown = + pathname === '/' || + KNOWN_SPA_PREFIXES.some((prefix) => + prefix.endsWith('/') + ? pathname.startsWith(prefix) + : pathname === prefix || pathname.startsWith(`${prefix}/`), + ); // cacheControl:false stops @fastify/static from stamping its own 1y immutable header // (which would otherwise override the no-cache below and pin a stale app shell). return reply + .code(isKnown ? 200 : 404) .header('Cache-Control', 'no-cache') .type('text/html') .sendFile('index.html', webDist, { cacheControl: false }); diff --git a/apps/web/index.html b/apps/web/index.html index 90a6140..20d86df 100644 --- a/apps/web/index.html +++ b/apps/web/index.html @@ -2,19 +2,30 @@ + - eLegal Software - All-in-One Practice Management for Law Firms + eLegal Software — Practice Management for Law Firms & Attorneys + + + + + + + diff --git a/apps/web/package.json b/apps/web/package.json index 6ffe1b8..849dd73 100644 --- a/apps/web/package.json +++ b/apps/web/package.json @@ -5,7 +5,7 @@ "type": "module", "scripts": { "dev": "vite", - "build": "tsc -b && vite build", + "build": "tsc -b && vite build && npx tsx scripts/prerender.mts && npx tsx scripts/generate-sitemap.mts", "preview": "vite preview", "typecheck": "tsc -b --noEmit" }, diff --git a/apps/web/public/robots.txt b/apps/web/public/robots.txt new file mode 100644 index 0000000..2330141 --- /dev/null +++ b/apps/web/public/robots.txt @@ -0,0 +1,10 @@ +User-agent: * +Allow: / +Disallow: /app +Disallow: /admin +Disallow: /api/ +Disallow: /billing/ +Disallow: /forgot-password +Disallow: /reset-password + +Sitemap: https://elegalsoftware.com/sitemap.xml diff --git a/apps/web/scripts/generate-sitemap.mts b/apps/web/scripts/generate-sitemap.mts new file mode 100644 index 0000000..0d449f9 --- /dev/null +++ b/apps/web/scripts/generate-sitemap.mts @@ -0,0 +1,37 @@ +// Generates dist/sitemap.xml from the public, indexable routes in src/seo/routes-meta.ts. +// Run after `vite build` (see the "build" script in package.json): npx tsx scripts/generate-sitemap.mts +// Paths are resolved from import.meta.url so the script works regardless of cwd. +import fs from 'node:fs'; +import path from 'node:path'; +import { fileURLToPath } from 'node:url'; +import { SITEMAP_ROUTES, SITE_URL } from '../src/seo/routes-meta'; + +const __dirname = path.dirname(fileURLToPath(import.meta.url)); +const distDir = path.resolve(__dirname, '../dist'); +const outFile = path.join(distDir, 'sitemap.xml'); + +const lastmod = new Date().toISOString().slice(0, 10); // YYYY-MM-DD + +const urlEntries = SITEMAP_ROUTES.map((route) => { + // Root must be the origin with a trailing slash: https://elegalsoftware.com/ + const loc = route.path === '/' ? `${SITE_URL}/` : `${SITE_URL}${route.path}`; + return [ + ' ', + ` ${loc}`, + ` ${lastmod}`, + ' ', + ].join('\n'); +}); + +const xml = [ + '', + '', + ...urlEntries, + '', + '', +].join('\n'); + +fs.mkdirSync(distDir, { recursive: true }); +fs.writeFileSync(outFile, xml, 'utf8'); + +console.log(`sitemap: wrote ${SITEMAP_ROUTES.length} URLs to ${outFile}`); diff --git a/apps/web/scripts/prerender.mts b/apps/web/scripts/prerender.mts new file mode 100644 index 0000000..488f98d --- /dev/null +++ b/apps/web/scripts/prerender.mts @@ -0,0 +1,103 @@ +// Prerenders every route in PRERENDER_ROUTES to static HTML under dist/, so crawlers +// receive full markup with per-route head tags without executing JS. +// Run AFTER `vite build` (see the "build" script in package.json): npx tsx scripts/prerender.mts +// +// How it works: a Vite dev server in middleware mode gives us ssrLoadModule — TSX, +// the '@/' alias, and CSS imports all resolve exactly as in the app build, so no +// separate SSR bundle is needed. The built dist/index.html is the template: its +// default /description/og:/twitter: fallback tags are stripped (the per-route +// tags from renderHeadTags() would otherwise duplicate them), the route's head tags +// are injected before </head>, and the rendered app HTML is placed inside #root. +// +// NOTE: the client does NOT hydrate — main.tsx keeps createRoot().render(), which +// replaces the prerendered DOM on load. That is intentional (no mismatch risk). +import fs from 'node:fs'; +import path from 'node:path'; +import { fileURLToPath } from 'node:url'; +import { createServer } from 'vite'; +import type { RouteMeta } from '../src/seo/routes-meta'; + +const __dirname = path.dirname(fileURLToPath(import.meta.url)); +const webRoot = path.resolve(__dirname, '..'); +const distDir = path.join(webRoot, 'dist'); +const templatePath = path.join(distDir, 'index.html'); + +function fail(msg: string): never { + console.error(`prerender: FAILED — ${msg}`); + process.exit(1); +} + +if (!fs.existsSync(templatePath)) { + fail(`${templatePath} not found — run \`vite build\` first`); +} +const rawTemplate = fs.readFileSync(templatePath, 'utf8'); + +// Strip the template's default SEO fallback tags (title, meta description, og:*, +// twitter:*). renderHeadTags() emits the per-route versions of all of them; leaving +// the defaults in would give crawlers duplicate/conflicting tags. [^>]* also matches +// newlines, covering the multi-line <meta> formatting in index.html. +const template = rawTemplate + .replace(/[ \t]*<title>[\s\S]*?<\/title>\s*\n?/i, '') + .replace(/[ \t]*<meta[^>]*name="description"[^>]*>\s*\n?/gi, '') + .replace(/[ \t]*<meta[^>]*property="og:[^"]*"[^>]*>\s*\n?/gi, '') + .replace(/[ \t]*<meta[^>]*name="twitter:[^"]*"[^>]*>\s*\n?/gi, ''); + +if (/<title>|property="og:|name="twitter:/i.test(template)) { + fail('template still contains default <title>/og:/twitter: tags after stripping — index.html format changed?'); +} +if (!template.includes('<div id="root"></div>')) { + fail('template is missing `<div id="root"></div>` — cannot inject app HTML'); +} + +// Middleware mode + appType 'custom' = no HTTP server, no HTML middlewares — we only +// want ssrLoadModule. Root is apps/web so vite.config.ts (alias, envDir) applies. +const vite = await createServer({ + root: webRoot, + logLevel: 'error', + server: { middlewareMode: true }, + appType: 'custom', +}); + +let exitCode = 0; +try { + const { render } = (await vite.ssrLoadModule('/src/entry-server.tsx')) as { + render: (url: string) => string; + }; + const { PRERENDER_ROUTES, metaForPath, renderHeadTags } = (await vite.ssrLoadModule( + '/src/seo/routes-meta.ts', + )) as { + PRERENDER_ROUTES: string[]; + metaForPath: (p: string) => RouteMeta | undefined; + renderHeadTags: (m: RouteMeta) => string; + }; + + if (PRERENDER_ROUTES.length === 0) fail('PRERENDER_ROUTES is empty'); + + for (const route of PRERENDER_ROUTES) { + try { + const meta = metaForPath(route); + if (!meta) throw new Error(`no meta registered for ${route}`); + + const appHtml = render(route); + if (!appHtml.trim()) throw new Error('rendered app HTML is empty'); + + const doc = template + .replace('</head>', ` ${renderHeadTags(meta)}\n </head>`) + .replace('<div id="root"></div>', `<div id="root">${appHtml}</div>`); + + const outFile = + route === '/' ? templatePath : path.join(distDir, ...route.slice(1).split('/'), 'index.html'); + fs.mkdirSync(path.dirname(outFile), { recursive: true }); + fs.writeFileSync(outFile, doc, 'utf8'); + console.log(`prerender: ok ${route} -> ${path.relative(webRoot, outFile)}`); + } catch (err) { + exitCode = 1; + console.error(`prerender: ERROR rendering ${route}:`, err); + } + } +} finally { + await vite.close(); +} + +if (exitCode !== 0) fail('one or more routes failed (see errors above)'); +console.log('prerender: all routes rendered'); diff --git a/apps/web/src/App.tsx b/apps/web/src/App.tsx index ae7cfb5..b6fd5f0 100644 --- a/apps/web/src/App.tsx +++ b/apps/web/src/App.tsx @@ -1,5 +1,7 @@ import { Route, Routes } from 'react-router-dom'; +import { Seo } from '@/components/Seo'; import LandingPage from './pages/LandingPage'; +import NotFoundPage from './pages/NotFoundPage'; import LoginPage from './pages/LoginPage'; import SignupPage from './pages/SignupPage'; import ForgotPasswordPage from './pages/ForgotPasswordPage'; @@ -44,6 +46,7 @@ import DpaPage from './pages/legal/DpaPage'; export default function App() { return ( <> + <Seo /> <Routes> <Route path="/" element={<LandingPage />} /> <Route path="/login" element={<LoginPage />} /> @@ -94,7 +97,7 @@ export default function App() { <Route path="audit" element={<AdminAuditPage />} /> </Route> - <Route path="*" element={<LandingPage />} /> + <Route path="*" element={<NotFoundPage />} /> </Routes> <CookieBanner /> </> diff --git a/apps/web/src/components/Seo.tsx b/apps/web/src/components/Seo.tsx new file mode 100644 index 0000000..0cfefd4 --- /dev/null +++ b/apps/web/src/components/Seo.tsx @@ -0,0 +1,80 @@ +import { useEffect } from 'react'; +import { useLocation } from 'react-router-dom'; +import { + metaForPath, + canonicalUrl, + NOT_FOUND_META, + DEFAULT_OG_IMAGE, + SITE_NAME, + type RouteMeta, +} from '@/seo/routes-meta'; + +// Mounted ONCE in App.tsx, above <Routes>. Keeps the document head in sync with the +// current route from the routes-meta map. Prerendered pages ship the same tags +// (stamped data-seo) baked into their static HTML; this component replaces them on +// client-side navigation so the two systems never fight. + +function upsertMeta(attr: 'name' | 'property', key: string, content: string) { + let el = document.head.querySelector<HTMLMetaElement>(`meta[${attr}="${key}"]`); + if (!el) { + el = document.createElement('meta'); + el.setAttribute(attr, key); + el.setAttribute('data-seo', '1'); + document.head.appendChild(el); + } + el.setAttribute('content', content); +} + +function removeMeta(attr: 'name' | 'property', key: string) { + document.head.querySelector(`meta[${attr}="${key}"]`)?.remove(); +} + +export function Seo() { + const { pathname } = useLocation(); + + useEffect(() => { + const meta: RouteMeta = metaForPath(pathname) ?? NOT_FOUND_META; + const url = canonicalUrl(meta.path || pathname); + + document.title = meta.title; + upsertMeta('name', 'description', meta.description); + + // Canonical for indexable pages; robots noindex otherwise (never both). + if (meta.noindex) { + document.head.querySelector('link[rel="canonical"]')?.remove(); + upsertMeta('name', 'robots', 'noindex, nofollow'); + } else { + removeMeta('name', 'robots'); + let link = document.head.querySelector<HTMLLinkElement>('link[rel="canonical"]'); + if (!link) { + link = document.createElement('link'); + link.setAttribute('rel', 'canonical'); + link.setAttribute('data-seo', '1'); + document.head.appendChild(link); + } + link.setAttribute('href', url); + } + + upsertMeta('property', 'og:site_name', SITE_NAME); + upsertMeta('property', 'og:type', meta.ogType ?? 'website'); + upsertMeta('property', 'og:url', url); + upsertMeta('property', 'og:title', meta.title); + upsertMeta('property', 'og:description', meta.description); + upsertMeta('property', 'og:image', DEFAULT_OG_IMAGE); + upsertMeta('name', 'twitter:card', 'summary'); + upsertMeta('name', 'twitter:title', meta.title); + upsertMeta('name', 'twitter:description', meta.description); + + // JSON-LD: replace wholesale (covers both prerendered and prior-route scripts). + document.head.querySelectorAll('script[type="application/ld+json"]').forEach((s) => s.remove()); + for (const ld of meta.jsonLd ?? []) { + const script = document.createElement('script'); + script.type = 'application/ld+json'; + script.setAttribute('data-seo', '1'); + script.textContent = JSON.stringify(ld); + document.head.appendChild(script); + } + }, [pathname]); + + return null; +} diff --git a/apps/web/src/components/marketing/BlogTeaser.tsx b/apps/web/src/components/marketing/BlogTeaser.tsx index 3e60154..71f3a53 100644 --- a/apps/web/src/components/marketing/BlogTeaser.tsx +++ b/apps/web/src/components/marketing/BlogTeaser.tsx @@ -35,7 +35,7 @@ export function BlogTeaser() { {p.coverImage ? ( <img src={p.coverImage} - alt="" + alt={`Cover image for article: ${p.title}`} loading="lazy" className="absolute inset-0 h-full w-full object-cover transition duration-300 group-hover:scale-[1.03]" /> diff --git a/apps/web/src/components/marketing/Faq.tsx b/apps/web/src/components/marketing/Faq.tsx index 2e76d9c..71ea23e 100644 --- a/apps/web/src/components/marketing/Faq.tsx +++ b/apps/web/src/components/marketing/Faq.tsx @@ -1,33 +1,8 @@ import { useState } from 'react'; import { ChevronDown } from 'lucide-react'; import { cn } from '@/lib/cn'; - -const ITEMS = [ - { - q: 'What makes eLegal Software different from other legal software?', - a: 'eLegal Software is built exclusively for legal professionals and bundles case management, billable-hours tracking, document storage, and invoicing into a single, secure platform — no jumping between tools, no per-feature add-ons.', - }, - { - q: 'Can I try eLegal Software before committing?', - a: 'Yes. The Starter plan is free forever and lets you manage one active case and two clients so you can experience the workflow end-to-end before upgrading.', - }, - { - q: 'How does client billing and payment processing work?', - a: 'You can convert any unbilled time entry into a polished invoice in seconds, send it to your client, and track its status from sent to paid. Payment processing is wired through Stripe.', - }, - { - q: 'Can I import my existing cases and client data?', - a: 'Yes. eLegal Software supports CSV imports for clients and cases. For larger migrations our team will assist directly during onboarding.', - }, - { - q: 'Is my client data secure and compliant?', - a: 'All data is encrypted at rest and in transit, hosted on enterprise-grade infrastructure with daily backups and audit logging. Access is gated by role-based permissions.', - }, - { - q: 'What happens if I need to cancel?', - a: 'You can cancel anytime from your billing settings. Your data remains accessible during the current billing period and can be exported in standard formats.', - }, -]; +// FAQ content lives in routes-meta so the FAQPage JSON-LD stays in lockstep with the UI. +import { FAQ_ITEMS as ITEMS } from '@/seo/routes-meta'; export function Faq() { const [open, setOpen] = useState<number | null>(0); diff --git a/apps/web/src/content/posts.ts b/apps/web/src/content/posts.ts index e2fef8d..99f4bff 100644 --- a/apps/web/src/content/posts.ts +++ b/apps/web/src/content/posts.ts @@ -28,8 +28,6 @@ export const POSTS: Post[] = [ publishedAt: '2026-04-08', readMinutes: 8, author: 'eLegal Software Team', - coverImage: - 'https://elegalsoftware.nyc3.digitaloceanspaces.com/media/blog/maximize-billable-hours-without-burnout.jpg', body: [ { type: 'p', @@ -92,8 +90,6 @@ export const POSTS: Post[] = [ publishedAt: '2026-03-21', readMinutes: 12, author: 'eLegal Software Team', - coverImage: - 'https://elegalsoftware.nyc3.digitaloceanspaces.com/media/blog/client-intake-best-practices-2026.jpg', body: [ { type: 'p', @@ -162,8 +158,6 @@ export const POSTS: Post[] = [ publishedAt: '2026-02-14', readMinutes: 10, author: 'eLegal Software Team', - coverImage: - 'https://elegalsoftware.nyc3.digitaloceanspaces.com/media/blog/legal-billing-software-comparison-2026.jpg', body: [ { type: 'p', diff --git a/apps/web/src/entry-server.tsx b/apps/web/src/entry-server.tsx new file mode 100644 index 0000000..9c80603 --- /dev/null +++ b/apps/web/src/entry-server.tsx @@ -0,0 +1,32 @@ +// Server-side rendering entry, used ONLY by scripts/prerender.mts at build time +// (loaded through Vite's ssrLoadModule — never shipped to the browser). +// Deliberately does NOT import main.tsx: that file initializes Sentry and calls +// createRoot() at module scope, both of which are browser-only concerns. +import ReactDOMServer from 'react-dom/server'; +import { StaticRouter } from 'react-router-dom/server'; +import { QueryClient, QueryClientProvider } from '@tanstack/react-query'; +import App from './App'; + +/** Render the app for a given URL to an HTML string (no effects run, no data fetching). */ +export function render(url: string): string { + // Fresh client per render so no cache state leaks between prerendered routes. + const queryClient = new QueryClient({ + defaultOptions: { + queries: { + retry: false, + staleTime: Infinity, + }, + }, + }); + try { + return ReactDOMServer.renderToString( + <QueryClientProvider client={queryClient}> + <StaticRouter location={url}> + <App /> + </StaticRouter> + </QueryClientProvider>, + ); + } finally { + queryClient.clear(); + } +} diff --git a/apps/web/src/pages/NotFoundPage.tsx b/apps/web/src/pages/NotFoundPage.tsx new file mode 100644 index 0000000..79d1bef --- /dev/null +++ b/apps/web/src/pages/NotFoundPage.tsx @@ -0,0 +1,32 @@ +import { Link } from 'react-router-dom'; +import { PublicLayout } from '@/components/public/PublicLayout'; + +export default function NotFoundPage() { + return ( + <PublicLayout> + <section className="container py-24 max-w-2xl text-center"> + <p className="text-xs uppercase tracking-wider text-brand-600 font-semibold">404</p> + <h1 className="mt-2 text-3xl md:text-4xl font-bold text-ink-950 font-display"> + Page not found + </h1> + <p className="mt-4 text-ink-600"> + The page you're looking for doesn't exist or has moved. + </p> + <div className="mt-8 flex flex-wrap items-center justify-center gap-3"> + <Link to="/" className="btn-primary text-sm"> + Home + </Link> + <Link to="/tools" className="btn-secondary text-sm"> + Free tools + </Link> + <Link to="/blog" className="btn-secondary text-sm"> + Blog + </Link> + <Link to="/login" className="btn-ghost text-sm"> + Sign in + </Link> + </div> + </section> + </PublicLayout> + ); +} diff --git a/apps/web/src/pages/blog/BlogIndexPage.tsx b/apps/web/src/pages/blog/BlogIndexPage.tsx index 2e860b9..2ae39f4 100644 --- a/apps/web/src/pages/blog/BlogIndexPage.tsx +++ b/apps/web/src/pages/blog/BlogIndexPage.tsx @@ -38,7 +38,9 @@ export default function BlogIndexPage() { )} </div> <div className="p-6 flex flex-col flex-1"> - <p className="text-xs text-ink-500">{formatDate(p.publishedAt)}</p> + <p className="text-xs text-ink-500"> + <time dateTime={p.publishedAt}>{formatDate(p.publishedAt)}</time> + </p> <h3 className="mt-2 text-lg font-semibold text-ink-900 group-hover:text-brand-700 transition leading-snug"> {p.title} </h3> diff --git a/apps/web/src/pages/blog/BlogPostPage.tsx b/apps/web/src/pages/blog/BlogPostPage.tsx index 65567f8..89feb05 100644 --- a/apps/web/src/pages/blog/BlogPostPage.tsx +++ b/apps/web/src/pages/blog/BlogPostPage.tsx @@ -39,7 +39,7 @@ export default function BlogPostPage() { <div className="mt-6 flex items-center gap-3 text-sm text-ink-500"> <span>{post.author}</span> <span className="text-ink-300">·</span> - <span>{formatDate(post.publishedAt)}</span> + <time dateTime={post.publishedAt}>{formatDate(post.publishedAt)}</time> <span className="text-ink-300">·</span> <span className="inline-flex items-center gap-1.5"> <Clock className="h-3.5 w-3.5" /> diff --git a/apps/web/src/seo/routes-meta.ts b/apps/web/src/seo/routes-meta.ts new file mode 100644 index 0000000..c80d58b --- /dev/null +++ b/apps/web/src/seo/routes-meta.ts @@ -0,0 +1,316 @@ +// Single source of truth for public-route SEO metadata. +// Consumed by three things — keep them in mind when editing: +// 1. <Seo /> (client) — updates document head on navigation +// 2. scripts/prerender.mts — injects head tags into static HTML at build time +// 3. scripts/generate-sitemap.mts — emits sitemap.xml for indexable routes +// IMPORTANT: imports here must stay RELATIVE (no '@/' alias) and side-effect-free, +// because the build scripts execute this module under tsx/node outside Vite. +import { POSTS } from '../content/posts'; + +export const SITE_URL = 'https://elegalsoftware.com'; +export const SITE_NAME = 'eLegal Software'; +export const DEFAULT_OG_IMAGE = `${SITE_URL}/logo-dark.png`; + +// FAQ content lives here (not in Faq.tsx) so the FAQPage JSON-LD and the rendered +// accordion can never drift apart. Faq.tsx imports this. +export const FAQ_ITEMS = [ + { + q: 'What makes eLegal Software different from other legal software?', + a: 'eLegal Software is built exclusively for legal professionals and bundles case management, billable-hours tracking, document storage, and invoicing into a single, secure platform — no jumping between tools, no per-feature add-ons.', + }, + { + q: 'Can I try eLegal Software before committing?', + a: 'Yes. The Starter plan is free forever and lets you manage one active case and two clients so you can experience the workflow end-to-end before upgrading.', + }, + { + q: 'How does client billing and payment processing work?', + a: 'You can convert any unbilled time entry into a polished invoice in seconds, send it to your client, and track its status from sent to paid. Payment processing is wired through Stripe.', + }, + { + q: 'Can I import my existing cases and client data?', + a: 'Yes. eLegal Software supports CSV imports for clients and cases. For larger migrations our team will assist directly during onboarding.', + }, + { + q: 'Is my client data secure and compliant?', + a: 'All data is encrypted at rest and in transit, hosted on enterprise-grade infrastructure with daily backups and audit logging. Access is gated by role-based permissions.', + }, + { + q: 'What happens if I need to cancel?', + a: 'You can cancel anytime from your billing settings. Your data remains accessible during the current billing period and can be exported in standard formats.', + }, +]; + +export interface RouteMeta { + path: string; + title: string; + description: string; + noindex?: boolean; + ogType?: 'website' | 'article'; + jsonLd?: Record<string, unknown>[]; +} + +const ORGANIZATION_LD = { + '@context': 'https://schema.org', + '@type': 'Organization', + name: SITE_NAME, + url: SITE_URL, + logo: `${SITE_URL}/logo-dark.png`, + contactPoint: { + '@type': 'ContactPoint', + email: 'contact@elegalsoftware.com', + contactType: 'customer support', + }, +}; + +const SOFTWARE_LD = { + '@context': 'https://schema.org', + '@type': 'SoftwareApplication', + name: SITE_NAME, + applicationCategory: 'BusinessApplication', + operatingSystem: 'Web', + url: SITE_URL, + description: + 'All-in-one practice management for law firms: case management, billable-hours tracking, secure document storage, and invoicing.', + offers: [ + { '@type': 'Offer', name: 'Starter', price: '0', priceCurrency: 'USD' }, + { '@type': 'Offer', name: 'Professional', price: '25', priceCurrency: 'USD' }, + { '@type': 'Offer', name: 'Lifetime', price: '129', priceCurrency: 'USD' }, + ], +}; + +const FAQ_LD = { + '@context': 'https://schema.org', + '@type': 'FAQPage', + mainEntity: FAQ_ITEMS.map((item) => ({ + '@type': 'Question', + name: item.q, + acceptedAnswer: { '@type': 'Answer', text: item.a }, + })), +}; + +const STATIC_ROUTES: RouteMeta[] = [ + { + path: '/', + title: 'eLegal Software — Practice Management for Law Firms & Attorneys', + description: + 'Streamline case management, billable hours, legal documents and invoicing in one secure platform built for attorneys. Free to start — no credit card required.', + jsonLd: [ORGANIZATION_LD, SOFTWARE_LD, FAQ_LD], + }, + { + path: '/login', + title: 'Sign In — eLegal Software', + description: 'Log in to eLegal Software to manage your cases, billable hours, and invoices.', + }, + { + path: '/signup', + title: 'Start Your Free Trial — eLegal Software', + description: + 'Create your free eLegal Software account in under a minute. Manage cases, track billable hours, and send invoices — no credit card required.', + }, + { + path: '/forgot-password', + title: 'Reset Your Password — eLegal Software', + description: 'Request a password reset link for your eLegal Software account.', + noindex: true, + }, + { + path: '/reset-password', + title: 'Choose a New Password — eLegal Software', + description: 'Set a new password for your eLegal Software account.', + noindex: true, + }, + { + path: '/billing/success', + title: 'Payment Successful — eLegal Software', + description: 'Your eLegal Software subscription is active.', + noindex: true, + }, + { + path: '/billing/cancel', + title: 'Checkout Canceled — eLegal Software', + description: 'Your checkout was canceled — no charge was made.', + noindex: true, + }, + { + path: '/tools', + title: 'Free Tools for Attorneys & Law Firms — eLegal Software', + description: + 'Free calculators and tools for legal professionals: hourly rate calculator, case profitability analyzer, billable hours tracker, and document templates.', + }, + { + path: '/tools/hourly-rate-calculator', + title: 'Attorney Hourly Rate Calculator (Free) — eLegal Software', + description: + 'Work out the hourly rate your practice actually needs — factoring target income, billable utilization, overhead, and taxes. Free, no signup required.', + }, + { + path: '/tools/case-profitability', + title: 'Case Profitability Analyzer for Law Firms (Free) — eLegal Software', + description: + 'Analyze whether a case or matter is profitable: fees, hours, effective rate, and margin — before and after write-offs. Free tool for attorneys.', + }, + { + path: '/tools/billable-hours-tracker', + title: 'Free Billable Hours Tracker for Attorneys — eLegal Software', + description: + 'Track billable time in your browser with a running timer and daily target — then see what those hours are worth. Free, no signup required.', + }, + { + path: '/tools/document-templates', + title: 'Free Legal Document Templates for Small Firms — eLegal Software', + description: + 'Starting points for engagement letters, intake forms, demand letters, and more. Copy, adapt with your attorney, and use in your practice.', + }, + { + path: '/blog', + title: 'Legal Practice Management Blog — eLegal Software', + description: + 'Practical guides for running a profitable law practice: billable hours, client intake, billing software, and firm operations.', + }, + { + path: '/legal', + title: 'Legal Center — eLegal Software', + description: + 'Every document that governs your use of eLegal Software: terms, privacy, billing, acceptable use, DMCA, and data processing.', + }, + { + path: '/legal/terms', + title: 'Terms of Service — eLegal Software', + description: 'The agreement that governs your use of eLegal Software.', + }, + { + path: '/legal/privacy', + title: 'Privacy Policy — eLegal Software', + description: 'What eLegal Software collects, why, where it lives, and the rights you have over it.', + }, + { + path: '/legal/cookies', + title: 'Cookie Policy — eLegal Software', + description: 'The essential-only cookies eLegal Software sets and how to control them.', + }, + { + path: '/legal/acceptable-use', + title: 'Acceptable Use Policy — eLegal Software', + description: 'What you may not do on the eLegal Software platform.', + }, + { + path: '/legal/refunds', + title: 'Billing & Refund Policy — eLegal Software', + description: 'How subscriptions, renewals, cancellations, and refunds work at eLegal Software.', + }, + { + path: '/legal/disclaimer', + title: 'Legal Disclaimer — eLegal Software', + description: 'eLegal Software is software, not a law firm — no legal advice, no attorney-client relationship.', + }, + { + path: '/legal/dmca', + title: 'DMCA & Copyright Policy — eLegal Software', + description: 'How to report copyright infringement on eLegal Software, and how counter-notices work.', + }, + { + path: '/legal/dpa', + title: 'Data Processing Addendum — eLegal Software', + description: 'How eLegal Software processes practice data on your behalf: security, subprocessors, breach notice.', + }, + { + path: '/app', + title: 'Dashboard — eLegal Software', + description: 'Your eLegal Software workspace.', + noindex: true, + }, + { + path: '/admin', + title: 'Admin — eLegal Software', + description: 'eLegal Software administration.', + noindex: true, + }, +]; + +const BLOG_ROUTES: RouteMeta[] = POSTS.map((post) => ({ + path: `/blog/${post.slug}`, + title: `${post.title} — ${SITE_NAME}`, + description: post.description, + ogType: 'article' as const, + jsonLd: [ + { + '@context': 'https://schema.org', + '@type': 'BlogPosting', + headline: post.title, + description: post.description, + datePublished: post.publishedAt, + author: { '@type': 'Organization', name: SITE_NAME, url: SITE_URL }, + publisher: { '@type': 'Organization', name: SITE_NAME, logo: { '@type': 'ImageObject', url: `${SITE_URL}/logo-dark.png` } }, + mainEntityOfPage: `${SITE_URL}/blog/${post.slug}`, + }, + ], +})); + +export const ALL_ROUTES: RouteMeta[] = [...STATIC_ROUTES, ...BLOG_ROUTES]; + +/** Routes to prerender to static HTML at build time (everything public & static). */ +export const PRERENDER_ROUTES: string[] = ALL_ROUTES.filter( + (r) => r.path !== '/app' && r.path !== '/admin' && r.path !== '/reset-password', +).map((r) => r.path); + +/** Routes that belong in sitemap.xml (public and indexable). */ +export const SITEMAP_ROUTES: RouteMeta[] = ALL_ROUTES.filter((r) => !r.noindex); + +export function metaForPath(pathname: string): RouteMeta | undefined { + const clean = pathname !== '/' && pathname.endsWith('/') ? pathname.slice(0, -1) : pathname; + const exact = ALL_ROUTES.find((r) => r.path === clean); + if (exact) return exact; + // Authed sections: any nested path inherits the section's noindex meta. + if (clean.startsWith('/app/')) return ALL_ROUTES.find((r) => r.path === '/app'); + if (clean.startsWith('/admin/')) return ALL_ROUTES.find((r) => r.path === '/admin'); + return undefined; // unknown → <Seo /> falls back to a noindex not-found meta +} + +export const NOT_FOUND_META: RouteMeta = { + path: '', + title: 'Page Not Found — eLegal Software', + description: 'The page you were looking for does not exist.', + noindex: true, +}; + +// ── Static head rendering (used by scripts/prerender.mts) ────────────────── +// Every generated tag carries data-seo so the client <Seo /> can replace them +// wholesale on navigation without duplicating. + +function escapeHtml(s: string): string { + return s + .replace(/&/g, '&') + .replace(/</g, '<') + .replace(/>/g, '>') + .replace(/"/g, '"'); +} + +export function canonicalUrl(path: string): string { + return path === '/' ? `${SITE_URL}/` : `${SITE_URL}${path}`; +} + +export function renderHeadTags(meta: RouteMeta): string { + const url = canonicalUrl(meta.path); + const t = escapeHtml(meta.title); + const d = escapeHtml(meta.description); + const tags = [ + `<title>${t}`, + ``, + meta.noindex + ? `` + : ``, + ``, + ``, + ``, + ``, + ``, + ``, + ``, + ``, + ``, + ...(meta.jsonLd ?? []).map( + (ld) => ``, + ), + ]; + return tags.join('\n '); +}