feat: add sitemap / robots.txt etc etc
This commit is contained in:
+4
-1
@@ -5,6 +5,9 @@ RUN apk add --no-cache openssl
|
||||
|
||||
ARG NEXT_PUBLIC_API_URL
|
||||
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 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/package.json ./
|
||||
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-workspace.yaml ./
|
||||
|
||||
@@ -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;
|
||||
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 |
@@ -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 |
@@ -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 |
@@ -1,13 +1,34 @@
|
||||
import type { Metadata } from 'next';
|
||||
import type { Metadata, Viewport } from 'next';
|
||||
import { Inter } from 'next/font/google';
|
||||
import './globals.css';
|
||||
import { Providers } from './providers';
|
||||
import { SITE_URL } from '@/lib/site';
|
||||
|
||||
const inter = Inter({ subsets: ['latin'] });
|
||||
|
||||
const description = 'Beheer de poefs van je zaak: open poefs, betalingsherinneringen en je barterminal.';
|
||||
|
||||
export const metadata: Metadata = {
|
||||
metadataBase: new URL(SITE_URL),
|
||||
title: { default: 'KoolTap', template: '%s · KoolTap' },
|
||||
description,
|
||||
applicationName: 'KoolTap',
|
||||
openGraph: {
|
||||
type: 'website',
|
||||
siteName: 'KoolTap',
|
||||
title: 'KoolTap',
|
||||
description: 'Manage tabs remotely across your venue',
|
||||
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({
|
||||
|
||||
@@ -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' },
|
||||
],
|
||||
};
|
||||
}
|
||||
@@ -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')}
|
||||
/>
|
||||
);
|
||||
}
|
||||
@@ -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,
|
||||
};
|
||||
}
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
@@ -23,6 +23,12 @@ const translations: Record<Lang, Record<string, string>> = {
|
||||
'common.close': 'Close',
|
||||
'common.copied': 'Copied',
|
||||
'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.minutes_ago': '{count}m ago',
|
||||
'time.hours_ago': '{count}h ago',
|
||||
@@ -227,6 +233,12 @@ const translations: Record<Lang, Record<string, string>> = {
|
||||
'common.close': 'Sluiten',
|
||||
'common.copied': 'Gekopieerd',
|
||||
'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.minutes_ago': '{count} min geleden',
|
||||
'time.hours_ago': '{count} u geleden',
|
||||
|
||||
@@ -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
@@ -34,6 +34,7 @@ services:
|
||||
target: dashboard
|
||||
args:
|
||||
NEXT_PUBLIC_API_URL: ${API_URL}/api/v1
|
||||
NEXT_PUBLIC_SITE_URL: ${DASHBOARD_URL}
|
||||
healthcheck:
|
||||
test: ["CMD", "wget", "-qO-", "http://127.0.0.1:3000/"]
|
||||
interval: 15s
|
||||
|
||||
@@ -49,6 +49,7 @@ services:
|
||||
target: dashboard
|
||||
args:
|
||||
NEXT_PUBLIC_API_URL: "${API_URL:-http://localhost:3001}/api/v1"
|
||||
NEXT_PUBLIC_SITE_URL: "${DASHBOARD_URL:-https://kooltap.com}"
|
||||
ports:
|
||||
- "${DASHBOARD_PORT:-3000}:3000"
|
||||
depends_on:
|
||||
|
||||
Reference in New Issue
Block a user