feat: add sitemap / robots.txt etc etc

This commit is contained in:
2026-10-13 22:18:09 +02:00
parent db06276a4f
commit df90cf8a18
20 changed files with 226 additions and 7 deletions
+4 -1
View File
@@ -5,6 +5,9 @@ RUN apk add --no-cache openssl
ARG NEXT_PUBLIC_API_URL ARG NEXT_PUBLIC_API_URL
ENV NEXT_PUBLIC_API_URL=${NEXT_PUBLIC_API_URL:-http://localhost:3001/api/v1} ENV NEXT_PUBLIC_API_URL=${NEXT_PUBLIC_API_URL:-http://localhost:3001/api/v1}
# Public address of the dashboard, for the sitemap, robots.txt and link previews
ARG NEXT_PUBLIC_SITE_URL
ENV NEXT_PUBLIC_SITE_URL=${NEXT_PUBLIC_SITE_URL:-https://kooltap.com}
COPY pnpm-workspace.yaml package.json tsconfig.base.json pnpm-lock.yaml ./ COPY pnpm-workspace.yaml package.json tsconfig.base.json pnpm-lock.yaml ./
COPY packages/shared/package.json packages/shared/ COPY packages/shared/package.json packages/shared/
@@ -50,7 +53,7 @@ WORKDIR /app
COPY --from=builder /app/apps/dashboard/.next ./.next COPY --from=builder /app/apps/dashboard/.next ./.next
COPY --from=builder /app/apps/dashboard/package.json ./ COPY --from=builder /app/apps/dashboard/package.json ./
COPY --from=builder /app/apps/dashboard/next.config.js ./ COPY --from=builder /app/apps/dashboard/next.config.js ./
RUN mkdir -p public COPY --from=builder /app/apps/dashboard/public ./public
COPY --from=builder /app/pnpm-lock.yaml ./ COPY --from=builder /app/pnpm-lock.yaml ./
COPY --from=builder /app/pnpm-workspace.yaml ./ COPY --from=builder /app/pnpm-workspace.yaml ./
+20 -1
View File
@@ -1,3 +1,22 @@
const nextConfig = {}; const securityHeaders = [
{ key: 'X-Content-Type-Options', value: 'nosniff' },
{ key: 'Referrer-Policy', value: 'strict-origin-when-cross-origin' },
// The dashboard is never meant to be shown inside another site
{ key: 'X-Frame-Options', value: 'DENY' },
{ key: 'Permissions-Policy', value: 'camera=(), microphone=(), geolocation=(), payment=()' },
{ key: 'Strict-Transport-Security', value: 'max-age=31536000' },
];
/** @type {import('next').NextConfig} */
const nextConfig = {
poweredByHeader: false,
async headers() {
return [
{ source: '/:path*', headers: securityHeaders },
// Private pages: also keep them out of search engines that ignore robots.txt
{ source: '/dashboard/:path*', headers: [{ key: 'X-Robots-Tag', value: 'noindex, nofollow' }] },
];
},
};
module.exports = nextConfig; module.exports = nextConfig;
Binary file not shown.

After

Width:  |  Height:  |  Size: 4.5 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 12 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 4.2 KiB

+29
View File
@@ -0,0 +1,29 @@
'use client';
import { useEffect } from 'react';
import { StatusPage } from '@/components/status-page';
import { Button } from '@/components/ui/button';
import { useLang } from '@/lib/i18n';
/** Shown instead of a blank screen when a page crashes. */
export default function ErrorPage({ error, reset }: { error: Error & { digest?: string }; reset: () => void }) {
const { t } = useLang();
useEffect(() => {
console.error(error);
}, [error]);
return (
<StatusPage
code="500"
title={t('error_page.title')}
description={t('error_page.desc')}
homeLabel={t('status_page.home')}
extraAction={
<Button variant="outline" onClick={reset}>
{t('error_page.retry')}
</Button>
}
/>
);
}
Binary file not shown.

After

Width:  |  Height:  |  Size: 2.6 KiB

+27
View File
@@ -0,0 +1,27 @@
'use client';
/**
* Last resort when the root layout itself fails. The language and styles may not have loaded,
* so this is plain markup with both languages.
*/
export default function GlobalError({ reset }: { error: Error & { digest?: string }; reset: () => void }) {
return (
<html lang="nl">
<body style={{ margin: 0, fontFamily: 'system-ui, sans-serif', background: '#f1f5f9', color: '#0f172a' }}>
<main style={{ minHeight: '100vh', display: 'flex', alignItems: 'center', justifyContent: 'center', padding: 16 }}>
<div style={{ background: '#fff', borderRadius: 12, padding: 32, maxWidth: 420, textAlign: 'center', boxShadow: '0 1px 3px rgba(0,0,0,.1)' }}>
<h1 style={{ fontSize: 20, margin: '0 0 8px' }}>Er is iets misgegaan</h1>
<p style={{ margin: '0 0 4px', color: '#64748b', fontSize: 14 }}>Probeer het opnieuw of herlaad de pagina.</p>
<p lang="en" style={{ margin: '0 0 20px', color: '#64748b', fontSize: 14 }}>Something went wrong. Please try again or reload the page.</p>
<button
onClick={reset}
style={{ background: '#0f172a', color: '#fff', border: 0, borderRadius: 6, padding: '8px 16px', fontSize: 14, cursor: 'pointer' }}
>
Opnieuw proberen / Try again
</button>
</div>
</main>
</body>
</html>
);
}
Binary file not shown.

After

Width:  |  Height:  |  Size: 4.5 KiB

+24 -3
View File
@@ -1,13 +1,34 @@
import type { Metadata } from 'next'; import type { Metadata, Viewport } from 'next';
import { Inter } from 'next/font/google'; import { Inter } from 'next/font/google';
import './globals.css'; import './globals.css';
import { Providers } from './providers'; import { Providers } from './providers';
import { SITE_URL } from '@/lib/site';
const inter = Inter({ subsets: ['latin'] }); const inter = Inter({ subsets: ['latin'] });
const description = 'Beheer de poefs van je zaak: open poefs, betalingsherinneringen en je barterminal.';
export const metadata: Metadata = { export const metadata: Metadata = {
title: 'KoolTap', metadataBase: new URL(SITE_URL),
description: 'Manage tabs remotely across your venue', title: { default: 'KoolTap', template: '%s · KoolTap' },
description,
applicationName: 'KoolTap',
openGraph: {
type: 'website',
siteName: 'KoolTap',
title: 'KoolTap',
description,
url: '/',
images: [{ url: '/icons/icon-512.png', width: 512, height: 512, alt: 'KoolTap' }],
},
appleWebApp: { capable: true, title: 'KoolTap', statusBarStyle: 'default' },
formatDetection: { telephone: false },
};
export const viewport: Viewport = {
themeColor: '#ffffff',
width: 'device-width',
initialScale: 1,
}; };
export default function RootLayout({ export default function RootLayout({
+20
View File
@@ -0,0 +1,20 @@
import type { MetadataRoute } from 'next';
// Lets staff add the dashboard to their phone's home screen as an app
export default function manifest(): MetadataRoute.Manifest {
return {
name: 'KoolTap',
short_name: 'KoolTap',
description: 'Beheer de poefs van je zaak',
start_url: '/dashboard',
scope: '/',
display: 'standalone',
background_color: '#ffffff',
theme_color: '#ffffff',
icons: [
{ src: '/icons/icon-192.png', sizes: '192x192', type: 'image/png' },
{ src: '/icons/icon-512.png', sizes: '512x512', type: 'image/png' },
{ src: '/icons/icon-512.png', sizes: '512x512', type: 'image/png', purpose: 'maskable' },
],
};
}
+16
View File
@@ -0,0 +1,16 @@
'use client';
import { StatusPage } from '@/components/status-page';
import { useLang } from '@/lib/i18n';
export default function NotFound() {
const { t } = useLang();
return (
<StatusPage
code="404"
title={t('not_found.title')}
description={t('not_found.desc')}
homeLabel={t('status_page.home')}
/>
);
}
+15
View File
@@ -0,0 +1,15 @@
import type { MetadataRoute } from 'next';
import { PUBLIC_PATHS, SITE_URL } from '@/lib/site';
// The dashboard is private: only the login page may be indexed
export default function robots(): MetadataRoute.Robots {
return {
rules: {
userAgent: '*',
allow: PUBLIC_PATHS,
disallow: ['/dashboard', '/dashboard/'],
},
sitemap: `${SITE_URL}/sitemap.xml`,
host: SITE_URL,
};
}
+11
View File
@@ -0,0 +1,11 @@
import type { MetadataRoute } from 'next';
import { PUBLIC_PATHS, SITE_URL } from '@/lib/site';
// Only public pages belong here; dashboard pages need a login and are kept out of search engines
export default function sitemap(): MetadataRoute.Sitemap {
return PUBLIC_PATHS.map((path) => ({
url: `${SITE_URL}${path}`,
changeFrequency: 'yearly',
priority: 1,
}));
}
@@ -0,0 +1,39 @@
import { ReactNode } from 'react';
import Link from 'next/link';
import { Button } from '@/components/ui/button';
import { Card, CardContent } from '@/components/ui/card';
/** Full-screen message used by the 404 and error pages, styled like the login page. */
export function StatusPage({
code,
title,
description,
homeLabel,
extraAction,
}: {
code: string;
title: string;
description: string;
homeLabel: string;
extraAction?: ReactNode;
}) {
return (
<main className="min-h-screen flex items-center justify-center bg-gradient-to-br from-slate-100 to-slate-200 p-4">
<Card className="w-full max-w-md">
<CardContent className="flex flex-col items-center text-center pt-8 pb-8 gap-3">
{/* eslint-disable-next-line @next/next/no-img-element */}
<img src="/icons/icon-192.png" alt="" width={64} height={64} className="rounded-xl" />
<p className="text-5xl font-bold tracking-tight text-muted-foreground/60" aria-hidden="true">{code}</p>
<h1 className="text-xl font-semibold">{title}</h1>
<p className="text-sm text-muted-foreground">{description}</p>
<div className="flex flex-wrap justify-center gap-2 mt-3">
{extraAction}
<Button asChild>
<Link href="/">{homeLabel}</Link>
</Button>
</div>
</CardContent>
</Card>
</main>
);
}
+12
View File
@@ -23,6 +23,12 @@ const translations: Record<Lang, Record<string, string>> = {
'common.close': 'Close', 'common.close': 'Close',
'common.copied': 'Copied', 'common.copied': 'Copied',
'common.logged_out': 'Logged out', 'common.logged_out': 'Logged out',
'status_page.home': 'Back to KoolTap',
'not_found.title': 'Page not found',
'not_found.desc': "This page doesn't exist or has been moved.",
'error_page.title': 'Something went wrong',
'error_page.desc': 'This page could not be shown. Try again, or go back to the start.',
'error_page.retry': 'Try again',
'time.just_now': 'just now', 'time.just_now': 'just now',
'time.minutes_ago': '{count}m ago', 'time.minutes_ago': '{count}m ago',
'time.hours_ago': '{count}h ago', 'time.hours_ago': '{count}h ago',
@@ -227,6 +233,12 @@ const translations: Record<Lang, Record<string, string>> = {
'common.close': 'Sluiten', 'common.close': 'Sluiten',
'common.copied': 'Gekopieerd', 'common.copied': 'Gekopieerd',
'common.logged_out': 'Uitgelogd', 'common.logged_out': 'Uitgelogd',
'status_page.home': 'Terug naar KoolTap',
'not_found.title': 'Pagina niet gevonden',
'not_found.desc': 'Deze pagina bestaat niet of is verplaatst.',
'error_page.title': 'Er is iets misgegaan',
'error_page.desc': 'Deze pagina kon niet getoond worden. Probeer het opnieuw of ga terug naar het begin.',
'error_page.retry': 'Opnieuw proberen',
'time.just_now': 'zonet', 'time.just_now': 'zonet',
'time.minutes_ago': '{count} min geleden', 'time.minutes_ago': '{count} min geleden',
'time.hours_ago': '{count} u geleden', 'time.hours_ago': '{count} u geleden',
+5
View File
@@ -0,0 +1,5 @@
/** Public address of the dashboard, used for absolute URLs (sitemap, robots.txt, link previews). */
export const SITE_URL = (process.env.NEXT_PUBLIC_SITE_URL || 'https://kooltap.com').replace(/\/+$/, '');
/** Pages anyone may visit and search engines may index; everything else needs a login. */
export const PUBLIC_PATHS = ['/login'];
File diff suppressed because one or more lines are too long
+1
View File
@@ -34,6 +34,7 @@ services:
target: dashboard target: dashboard
args: args:
NEXT_PUBLIC_API_URL: ${API_URL}/api/v1 NEXT_PUBLIC_API_URL: ${API_URL}/api/v1
NEXT_PUBLIC_SITE_URL: ${DASHBOARD_URL}
healthcheck: healthcheck:
test: ["CMD", "wget", "-qO-", "http://127.0.0.1:3000/"] test: ["CMD", "wget", "-qO-", "http://127.0.0.1:3000/"]
interval: 15s interval: 15s
+1
View File
@@ -49,6 +49,7 @@ services:
target: dashboard target: dashboard
args: args:
NEXT_PUBLIC_API_URL: "${API_URL:-http://localhost:3001}/api/v1" NEXT_PUBLIC_API_URL: "${API_URL:-http://localhost:3001}/api/v1"
NEXT_PUBLIC_SITE_URL: "${DASHBOARD_URL:-https://kooltap.com}"
ports: ports:
- "${DASHBOARD_PORT:-3000}:3000" - "${DASHBOARD_PORT:-3000}:3000"
depends_on: depends_on: